feat(macos): add macOS menu-bar app and harden cross-device iCloud sync
Introduce a standalone macOS menu-bar app (OSGKeyboardMac) that reuses the platform-agnostic OSGKeyboardShared core: record -> cloud/local ASR -> polish -> insert. Local mode uses Qwen3-ASR via mlx-swift-asr (macOS 15+, Apple Silicon); iOS targets stay zero-SPM. Harden iCloud sync for multi-device correctness: - Per-field settings merge (appSettings.v2) so concurrent edits no longer clobber each other's unrelated fields. - Per-device usage statistics (G-Counter) that sum instead of max(). - Tombstoned dictionary/history merge so deletes propagate and entries can't resurrect. - API keys replicate via iCloud Keychain, never iCloud KVS JSON; pulling a legacy blob without key fields no longer wipes local Keychain entries. - Add a low-risk "Sync Now" action in Settings. Fix Flow keyboard mic state: stay orange until the host publishes a real ready contract, share a single MicVoiceAvailability gate, and self-heal stale cross-process heartbeat jitter instead of getting stuck. Extract shared storage (SpeechHistoryStore/UsageStatisticsStore, ConfigurationStore) into OSGKeyboardShared and add tests for the new sync/merge logic.
This commit is contained in:
@@ -123,6 +123,26 @@ private final class FlowLevelStore: @unchecked Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
/// Last observed audio tap timestamp. This lets the host publish "ready"
|
||||
/// only after the microphone pipeline has produced real frames.
|
||||
private final class FlowAudioProofStore: @unchecked Sendable {
|
||||
private let lock = OSAllocatedUnfairLock(initialState: TimeInterval(0))
|
||||
|
||||
func markFrameReceived() {
|
||||
lock.withLock { $0 = Date().timeIntervalSince1970 }
|
||||
}
|
||||
|
||||
func reset() {
|
||||
lock.withLock { $0 = 0 }
|
||||
}
|
||||
|
||||
func hasRecentFrame(maxAge: TimeInterval) -> Bool {
|
||||
let timestamp = lock.withLock { $0 }
|
||||
guard timestamp > 0 else { return false }
|
||||
return Date().timeIntervalSince1970 - timestamp <= maxAge
|
||||
}
|
||||
}
|
||||
|
||||
/// Route-adaptive downsampling converter, safe to call from the realtime tap.
|
||||
///
|
||||
/// `AVAudioEngine.installTap(format:)` traps with an **uncatchable** NSException
|
||||
@@ -207,6 +227,7 @@ public final class FlowContinuousCapture {
|
||||
private let streamRelay = FlowCaptureStreamRelay()
|
||||
private let prerollStore = FlowPrerollStore()
|
||||
private let levelStore = FlowLevelStore(barCount: FlowCaptureConstants.levelBarCount)
|
||||
private let audioProofStore = FlowAudioProofStore()
|
||||
private let gate = OSAllocatedUnfairLock(initialState: UtteranceGatePhase.idle)
|
||||
private let drainTracker = FlowCaptureDrainTracker()
|
||||
private let tailSampleCounter = OSAllocatedUnfairLock(initialState: 0)
|
||||
@@ -229,12 +250,28 @@ public final class FlowContinuousCapture {
|
||||
|
||||
public var running: Bool { isRunning }
|
||||
|
||||
/// True when the capture session flag, tap, and audio engine are all live.
|
||||
public var engineIsLive: Bool {
|
||||
isRunning && didInstallTap && audioEngine.isRunning
|
||||
}
|
||||
|
||||
/// True only when the engine is live and the input tap has recently
|
||||
/// delivered an actual audio frame.
|
||||
public func engineHasRecentAudio(maxAge: TimeInterval = 1) -> Bool {
|
||||
engineIsLive && audioProofStore.hasRecentFrame(maxAge: maxAge)
|
||||
}
|
||||
|
||||
/// Called on the main actor when `engineIsLive` may have changed.
|
||||
public var onEngineLiveChanged: ((Bool) -> Void)?
|
||||
|
||||
/// Configure `.playAndRecord`, install a permanent input tap, start the engine.
|
||||
public func start() throws {
|
||||
guard !isRunning else { return }
|
||||
audioProofStore.reset()
|
||||
try activateEngine()
|
||||
isRunning = true
|
||||
installSessionObservers()
|
||||
notifyEngineLiveChanged()
|
||||
}
|
||||
|
||||
/// Bring up the audio session + engine for the *current* hardware route.
|
||||
@@ -289,6 +326,7 @@ public final class FlowContinuousCapture {
|
||||
let relay = streamRelay
|
||||
let preroll = prerollStore
|
||||
let levels = levelStore
|
||||
let proof = audioProofStore
|
||||
let tracker = drainTracker
|
||||
let tailCounter = tailSampleCounter
|
||||
let policy = drainPolicy
|
||||
@@ -296,6 +334,7 @@ public final class FlowContinuousCapture {
|
||||
downsampler: downsampler,
|
||||
gate: gateLock,
|
||||
levelStore: levels,
|
||||
audioProofStore: proof,
|
||||
prerollStore: preroll,
|
||||
streamRelay: relay,
|
||||
drainTracker: tracker,
|
||||
@@ -332,6 +371,7 @@ public final class FlowContinuousCapture {
|
||||
audioEngine.stop()
|
||||
}
|
||||
isRunning = false
|
||||
audioProofStore.reset()
|
||||
downsampler = nil
|
||||
targetFormat = nil
|
||||
hwFormat = nil
|
||||
@@ -339,6 +379,7 @@ public final class FlowContinuousCapture {
|
||||
false,
|
||||
options: .notifyOthersOnDeactivation
|
||||
)
|
||||
notifyEngineLiveChanged()
|
||||
}
|
||||
|
||||
/// Re-activate capture after returning from background without
|
||||
@@ -355,6 +396,21 @@ public final class FlowContinuousCapture {
|
||||
if !audioEngine.isRunning {
|
||||
try? audioEngine.start()
|
||||
}
|
||||
notifyEngineLiveChanged()
|
||||
}
|
||||
|
||||
public func awaitAudioFlowing(
|
||||
timeout: TimeInterval,
|
||||
recentFrameMaxAge: TimeInterval = 1
|
||||
) async -> Bool {
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
while Date() < deadline {
|
||||
if engineHasRecentAudio(maxAge: recentFrameMaxAge) {
|
||||
return true
|
||||
}
|
||||
try? await Task.sleep(nanoseconds: 50_000_000)
|
||||
}
|
||||
return engineHasRecentAudio(maxAge: recentFrameMaxAge)
|
||||
}
|
||||
|
||||
// MARK: - Route / interruption recovery
|
||||
@@ -434,6 +490,7 @@ public final class FlowContinuousCapture {
|
||||
switch type {
|
||||
case .began:
|
||||
log.info("Audio interruption began")
|
||||
notifyEngineLiveChanged()
|
||||
case .ended:
|
||||
guard isRunning else { return }
|
||||
let shouldResume: Bool
|
||||
@@ -462,11 +519,17 @@ public final class FlowContinuousCapture {
|
||||
}
|
||||
do {
|
||||
try activateEngine()
|
||||
notifyEngineLiveChanged()
|
||||
} catch {
|
||||
log.error("Engine rebuild failed: \(error.localizedDescription, privacy: .public)")
|
||||
notifyEngineLiveChanged()
|
||||
}
|
||||
}
|
||||
|
||||
private func notifyEngineLiveChanged() {
|
||||
onEngineLiveChanged?(engineIsLive)
|
||||
}
|
||||
|
||||
/// Begin forwarding downsampled buffers to ASR for one utterance.
|
||||
public func beginUtterance() -> AsyncStream<AudioBufferSnapshot> {
|
||||
let (stream, continuation) = AsyncStream<AudioBufferSnapshot>.makeStream()
|
||||
@@ -546,6 +609,7 @@ public final class FlowContinuousCapture {
|
||||
downsampler: AdaptiveDownsampler,
|
||||
gate: OSAllocatedUnfairLock<UtteranceGatePhase>,
|
||||
levelStore: FlowLevelStore,
|
||||
audioProofStore: FlowAudioProofStore,
|
||||
prerollStore: FlowPrerollStore,
|
||||
streamRelay: FlowCaptureStreamRelay,
|
||||
drainTracker: FlowCaptureDrainTracker,
|
||||
@@ -553,6 +617,7 @@ public final class FlowContinuousCapture {
|
||||
drainPolicy: FlowCaptureTailDrainPolicy
|
||||
) -> @Sendable (AVAudioPCMBuffer, AVAudioTime) -> Void {
|
||||
return { buffer, _ in
|
||||
audioProofStore.markFrameReceived()
|
||||
levelStore.update(from: buffer, barCount: FlowCaptureConstants.levelBarCount)
|
||||
|
||||
// Derive the converter from the *live* buffer format so a mid-session
|
||||
|
||||
Reference in New Issue
Block a user