diff --git a/Fluid.xcodeproj/project.pbxproj b/Fluid.xcodeproj/project.pbxproj index 1155efef..6eeeabce 100644 --- a/Fluid.xcodeproj/project.pbxproj +++ b/Fluid.xcodeproj/project.pbxproj @@ -14,6 +14,7 @@ 7C91B0012F42AA0100C0DEF0 /* HotkeyShortcutTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C91B0022F42AA0100C0DEF0 /* HotkeyShortcutTests.swift */; }; 7CDB0A2D2F3C4D5600FB7CAD /* DictationE2ETests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CDB0A292F3C4D5600FB7CAD /* DictationE2ETests.swift */; }; 7CDB0A2E2F3C4D5600FB7CAD /* AudioFixtureLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CDB0A2A2F3C4D5600FB7CAD /* AudioFixtureLoader.swift */; }; + 7CFA10012F54000100C0DEF0 /* StartCueCaptureReadinessTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CFA10022F54000100C0DEF0 /* StartCueCaptureReadinessTests.swift */; }; 86CAA2D4EF18433096185602 /* LLMClientRequestBodyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 343B29013F4441D6A797D12D /* LLMClientRequestBodyTests.swift */; }; 7CDB0A2F2F3C4D5600FB7CAD /* dictation_fixture.wav in Resources */ = {isa = PBXBuildFile; fileRef = 7CDB0A2B2F3C4D5600FB7CAD /* dictation_fixture.wav */; }; 7CDB0A302F3C4D5600FB7CAD /* XCTest.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 7CDB0A2C2F3C4D5600FB7CAD /* XCTest.framework */; }; @@ -36,6 +37,7 @@ 7C91B0022F42AA0100C0DEF0 /* HotkeyShortcutTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotkeyShortcutTests.swift; sourceTree = ""; }; 7CDB0A292F3C4D5600FB7CAD /* DictationE2ETests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DictationE2ETests.swift; sourceTree = ""; }; 343B29013F4441D6A797D12D /* LLMClientRequestBodyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LLMClientRequestBodyTests.swift; sourceTree = ""; }; + 7CFA10022F54000100C0DEF0 /* StartCueCaptureReadinessTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StartCueCaptureReadinessTests.swift; sourceTree = ""; }; 7CDB0A2A2F3C4D5600FB7CAD /* AudioFixtureLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioFixtureLoader.swift; sourceTree = ""; }; 7CDB0A2B2F3C4D5600FB7CAD /* dictation_fixture.wav */ = {isa = PBXFileReference; lastKnownFileType = audio.wav; path = dictation_fixture.wav; sourceTree = ""; }; 7CDB0A2C2F3C4D5600FB7CAD /* XCTest.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = XCTest.framework; path = Platforms/MacOSX.platform/Developer/Library/Frameworks/XCTest.framework; sourceTree = DEVELOPER_DIR; }; @@ -107,6 +109,7 @@ 7CDB0A292F3C4D5600FB7CAD /* DictationE2ETests.swift */, 7C91B0022F42AA0100C0DEF0 /* HotkeyShortcutTests.swift */, 343B29013F4441D6A797D12D /* LLMClientRequestBodyTests.swift */, + 7CFA10022F54000100C0DEF0 /* StartCueCaptureReadinessTests.swift */, ); path = FluidDictationIntegrationTests; sourceTree = ""; @@ -262,6 +265,7 @@ 7CDB0A2D2F3C4D5600FB7CAD /* DictationE2ETests.swift in Sources */, 7C91B0012F42AA0100C0DEF0 /* HotkeyShortcutTests.swift in Sources */, 86CAA2D4EF18433096185602 /* LLMClientRequestBodyTests.swift in Sources */, + 7CFA10012F54000100C0DEF0 /* StartCueCaptureReadinessTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Sources/Fluid/ContentView.swift b/Sources/Fluid/ContentView.swift index 1a5d3ec3..755e01e6 100644 --- a/Sources/Fluid/ContentView.swift +++ b/Sources/Fluid/ContentView.swift @@ -2830,15 +2830,14 @@ struct ContentView: View { self.menuBarManager.showRecordingOverlayImmediately() } - if !self.isRecordingForCommand, !self.isRecordingForRewrite { - TranscriptionSoundPlayer.shared.playStartSound() - } - Task { await self.asr.start() if !self.asr.isRunning { self.menuBarManager.hideRecordingOverlayImmediately(reason: "asr_start_failed") + return } + guard let sessionID = self.asr.currentRecordingSessionID else { return } + await self.playStartCueWhenCaptureReady(sessionID: sessionID) } // Pre-load model in background while recording (avoids 10s freeze on stop) @@ -3077,9 +3076,12 @@ struct ContentView: View { "Starting voice recording for command", source: "ContentView" ) - TranscriptionSoundPlayer.shared.playStartSound() Task { await self.asr.start() + guard self.asr.isRunning, + let sessionID = self.asr.currentRecordingSessionID + else { return } + await self.playStartCueWhenCaptureReady(sessionID: sessionID) } }, rewriteModeCallback: { @@ -3112,9 +3114,12 @@ struct ContentView: View { // Start recording immediately for the edit instruction DebugLogger.shared.info("Starting voice recording for edit mode", source: "ContentView") - TranscriptionSoundPlayer.shared.playStartSound() Task { await self.asr.start() + guard self.asr.isRunning, + let sessionID = self.asr.currentRecordingSessionID + else { return } + await self.playStartCueWhenCaptureReady(sessionID: sessionID) } }, isDictateRecordingProvider: { @@ -3430,24 +3435,49 @@ extension ContentView { self.appBench("asr_start_skipped reason=already_running") return } - if SettingsStore.shared.enableTranscriptionSounds { - TranscriptionSoundPlayer.shared.playStartSound() - } Task { let asrStartStartedAt = ProcessInfo.processInfo.systemUptime DebugLogger.shared.benchmark("APP_BENCH", message: "asr_start_call", source: "AppBenchmark") await self.asr.start() if !self.asr.isRunning { self.menuBarManager.hideRecordingOverlayImmediately(reason: "asr_start_failed") + DebugLogger.shared.benchmark( + "APP_BENCH", + message: "asr_start_return elapsedMs=\(Int(((ProcessInfo.processInfo.systemUptime - asrStartStartedAt) * 1000).rounded()))", + source: "AppBenchmark" + ) + return } DebugLogger.shared.benchmark( "APP_BENCH", message: "asr_start_return elapsedMs=\(Int(((ProcessInfo.processInfo.systemUptime - asrStartStartedAt) * 1000).rounded()))", source: "AppBenchmark" ) + guard let sessionID = self.asr.currentRecordingSessionID else { return } + await self.playStartCueWhenCaptureReady(sessionID: sessionID) } } + private func playStartCueWhenCaptureReady(sessionID: UInt64) async { + let cueWaitStartedAt = ProcessInfo.processInfo.systemUptime + let ready = await self.asr.waitForCaptureReadyForStartCue(sessionID: sessionID) + DebugLogger.shared.benchmark( + "APP_BENCH", + message: "start_cue_ready ready=\(ready) elapsedMs=\(Int(((ProcessInfo.processInfo.systemUptime - cueWaitStartedAt) * 1000).rounded()))", + source: "AppBenchmark" + ) + + guard ready, + self.asr.isRunning, + self.asr.currentRecordingSessionID == sessionID + else { + DebugLogger.shared.debug("Start cue skipped because capture is no longer active", source: "ContentView") + return + } + + TranscriptionSoundPlayer.shared.playStartSound() + } + private func beginDictationRecording(for selection: SettingsStore.DictationPromptSelection, mode: ActiveRecordingMode) { let settings = SettingsStore.shared settings.setDictationPromptSelection(selection, for: .secondary) diff --git a/Sources/Fluid/Services/ASRService.swift b/Sources/Fluid/Services/ASRService.swift index 29d4d7b2..6e391f95 100644 --- a/Sources/Fluid/Services/ASRService.swift +++ b/Sources/Fluid/Services/ASRService.swift @@ -549,7 +549,9 @@ final class ASRService: ObservableObject { (self.transcriptionProvider as? FluidAudioProvider)?.underlyingManager } #else - var asrManager: Any? { nil } + var asrManager: Any? { + nil + } #endif // Thread-safe buffer to prevent "Array mutation while enumerating" and memory corruption crashes @@ -576,6 +578,19 @@ final class ASRService: ObservableObject { private var engineConfigurationChangeObserver: NSObjectProtocol? private var audioRouteRecoveryTask: Task? private let audioRouteRecoveryDelayNanoseconds: UInt64 = 1_000_000_000 + private let startupRouteRecoveryDelayNs: UInt64 = 100_000_000 + private let startupRouteRecoveryWindowSeconds: TimeInterval = 2.0 + private let startupCaptureReadyStableDelaySeconds: TimeInterval = 0.45 + private let startupCapturePostRecoveryDelaySeconds: TimeInterval = 0.10 + private let startupCaptureReadyTimeoutSeconds: TimeInterval = 1.75 + private let startupCaptureReadyPollNanoseconds: UInt64 = 25_000_000 + private let startupCaptureReadyMinimumSamples = 2048 + private let stoppedEngineReuseGraceNanoseconds: UInt64 = 20_000_000_000 + private var initialEngineStartCompletedAt: TimeInterval? + private var startupRouteRecoveryTracker = StartupRouteRecoveryTracker() + private var recordingSessionTracker = RecordingSessionTracker() + private var stoppedEngineRetainedAt: TimeInterval? + private var stoppedEngineReleaseTask: Task? private var isRecoveringAudioRoute = false private let fastPreviewStopGraceNanoseconds: UInt64 = 200_000_000 private let fastPreviewSampleRate = 16_000 @@ -589,9 +604,16 @@ final class ASRService: ObservableObject { private var didPauseMediaForThisSession: Bool = false private var audioLevelSubject = PassthroughSubject() - var audioLevelPublisher: AnyPublisher { self.audioLevelSubject.eraseToAnyPublisher() } + var audioLevelPublisher: AnyPublisher { + self.audioLevelSubject.eraseToAnyPublisher() + } + private var lastAudioLevelSentAt: TimeInterval = 0 + var currentRecordingSessionID: UInt64? { + self.recordingSessionTracker.currentID + } + func consumeLastCompletedAudioSnapshot() -> DictationAudioSnapshot? { let snapshot = self.lastCompletedAudioSnapshot self.lastCompletedAudioSnapshot = nil @@ -649,6 +671,7 @@ final class ASRService: ObservableObject { } deinit { + self.stoppedEngineReleaseTask?.cancel() if let observer = self.vocabularyChangeObserver { NotificationCenter.default.removeObserver(observer) } @@ -853,6 +876,7 @@ final class ASRService: ObservableObject { // Reset media pause state for this session self.didPauseMediaForThisSession = false + self.recordingSessionTracker.beginSession() self.audioRouteRecoveryTask?.cancel() self.audioRouteRecoveryTask = nil self.isRecoveringAudioRoute = false @@ -867,10 +891,18 @@ final class ASRService: ObservableObject { self.isProcessingChunk = false self.skipNextChunk = false self.benchmarkSessionID += 1 + let reusedStoppedEngineAgeMs = self.stoppedEngineAgeMilliseconds() + self.cancelStoppedEngineRelease(reason: "start") + self.benchmarkLog( + "engine_reuse_start hit=\(reusedStoppedEngineAgeMs != nil) ageMs=\(reusedStoppedEngineAgeMs.map(String.init) ?? "nil")" + ) + self.stoppedEngineRetainedAt = nil self.benchmarkRecordingStartedAt = Date().timeIntervalSince1970 self.benchmarkStreamingChunkIndex = 0 self.benchmarkCompletedStreamingChunks = 0 self.benchmarkLastChunkSampleCount = 0 + self.initialEngineStartCompletedAt = nil + self.startupRouteRecoveryTracker.clear() self.streamingChunkAnalyticsSuccessCount = 0 self.lastStreamingChunkFailureAnalyticsAt = nil (self.transcriptionProvider as? FluidAudioProvider)?.resetStreamingPreviewCache() @@ -889,7 +921,7 @@ final class ASRService: ObservableObject { DebugLogger.shared.debug("✅ configureSession() completed", source: "ASRService") DebugLogger.shared.debug("🚀 Calling startEngine()...", source: "ASRService") - try self.startEngine() + try self.startEngine(context: .initialRecording) DebugLogger.shared.debug("✅ startEngine() completed", source: "ASRService") DebugLogger.shared.debug("🎧 Setting up engine tap...", source: "ASRService") @@ -928,6 +960,8 @@ final class ASRService: ObservableObject { } DebugLogger.shared.info("✅ START() completed successfully", source: "ASRService") } catch { + self.recordingSessionTracker.clearSession() + self.clearStartupCaptureReadiness(reason: "start_failed") DebugLogger.shared.error("Failed to start ASR session: \(error)", source: "ASRService") // Resume media if we paused it before the failure @@ -963,6 +997,152 @@ final class ASRService: ObservableObject { } } + func waitForCaptureReadyForStartCue(sessionID: UInt64) async -> Bool { + let waitStartedAt = Date().timeIntervalSince1970 + let configuration = StartCueCaptureReadiness.Configuration( + stableDelaySeconds: self.startupCaptureReadyStableDelaySeconds, + afterRecoveryDelaySeconds: self.startupCapturePostRecoveryDelaySeconds, + timeoutSeconds: self.startupCaptureReadyTimeoutSeconds, + minimumSamples: self.startupCaptureReadyMinimumSamples + ) + + while true { + let now = Date().timeIntervalSince1970 + let sampleCount = self.audioBuffer.count + let routeRecoveryIdle = self.isRecoveringAudioRoute == false && self.audioRouteRecoveryTask == nil + let evaluation = StartCueCaptureReadiness.evaluate( + StartCueCaptureReadiness.Snapshot( + isRunning: self.isRunning, + activeSessionID: self.recordingSessionTracker.currentID, + requestedSessionID: sessionID, + now: now, + waitStartedAt: waitStartedAt, + sampleCount: sampleCount, + routeRecoveryIdle: routeRecoveryIdle, + startupRecoveryScheduled: self.startupRouteRecoveryTracker.isScheduled, + startupRecoveryCompletedAt: self.startupRouteRecoveryTracker.completedAt, + startupRecoveryCompletedSampleCount: self.startupRouteRecoveryTracker.completedSampleCount, + engineStartedAt: self.initialEngineStartCompletedAt + ), + configuration: configuration + ) + + switch evaluation.decision { + case .ready: + DebugLogger.shared.info( + "Capture ready for start cue (samples=\(sampleCount), readySamples=\(evaluation.readySampleCount), waitedMs=\(Int(((now - waitStartedAt) * 1000).rounded())))", + source: "ASRService" + ) + return true + + case let .timedOut(ready): + DebugLogger.shared.warning( + "Timed out waiting for capture-ready start cue (ready=\(ready), samples=\(sampleCount), readySamples=\(evaluation.readySampleCount), routeRecoveryIdle=\(routeRecoveryIdle), stableEnough=\(evaluation.stableEnough))", + source: "ASRService" + ) + return ready + + case .inactive: + DebugLogger.shared.debug("Start cue wait cancelled because ASR is no longer running", source: "ASRService") + return false + + case .wait: + break + } + + do { + try await Task.sleep(nanoseconds: self.startupCaptureReadyPollNanoseconds) + } catch { + return false + } + } + } + + private func stoppedEngineAgeMilliseconds() -> Int? { + guard let stoppedEngineRetainedAt else { return nil } + return self.elapsedMilliseconds(since: stoppedEngineRetainedAt) + } + + private func cancelStoppedEngineRelease(reason: String) { + guard let stoppedEngineReleaseTask else { return } + stoppedEngineReleaseTask.cancel() + self.stoppedEngineReleaseTask = nil + self.benchmarkLog("engine_reuse_release_cancelled reason=\(reason)") + } + + private func releaseStoppedEngine(reason: String) { + self.stoppedEngineReleaseTask?.cancel() + self.stoppedEngineReleaseTask = nil + self.stoppedEngineRetainedAt = nil + + let oldEngine = self.engineStorage + self.engineStorage = nil + self.benchmarkLog("engine_reuse_release reason=\(reason) hadEngine=\(oldEngine != nil)") + + if let oldEngine { + DispatchQueue.global(qos: .utility).async { _ = oldEngine } + } + } + + private func retainStoppedEngineForReuse(reason: String) { + guard self.engineStorage != nil else { + self.benchmarkLog("engine_reuse_retained hit=false reason=\(reason)") + return + } + guard self.canRetainStoppedEngineForReuse() else { + self.releaseStoppedEngine(reason: "\(reason)_unsafe_route") + return + } + + self.cancelStoppedEngineRelease(reason: "reschedule") + self.stoppedEngineRetainedAt = Date().timeIntervalSince1970 + let reuseGraceNanoseconds = self.stoppedEngineReuseGraceNanoseconds + self.benchmarkLog( + "engine_reuse_retained hit=true reason=\(reason) graceMs=\(reuseGraceNanoseconds / 1_000_000)" + ) + + self.stoppedEngineReleaseTask = Task { [weak self] in + do { + try await Task.sleep(nanoseconds: reuseGraceNanoseconds) + } catch { + return + } + + await MainActor.run { [weak self] in + guard let self else { return } + guard self.isRunning == false, + self.isStarting == false, + self.isRecoveringAudioRoute == false + else { + self.benchmarkLog("engine_reuse_release_deferred reason=busy") + return + } + + self.releaseStoppedEngine(reason: "reuse_grace_expired") + } + } + } + + private func canRetainStoppedEngineForReuse() -> Bool { + guard SettingsStore.shared.syncAudioDevicesWithSystem else { + self.benchmarkLog("engine_reuse_retained hit=false reason=independent_device_binding") + return false + } + + let routeDevices = [ + self.getCurrentlyBoundInputDevice(), + AudioDevice.getDefaultInputDevice(), + AudioDevice.getDefaultOutputDevice(), + ].compactMap { $0 } + + if let bluetoothDevice = routeDevices.first(where: { AudioDevice.isBluetoothDevice($0) }) { + self.benchmarkLog("engine_reuse_retained hit=false reason=bluetooth_device device=\(bluetoothDevice.name)") + return false + } + + return true + } + /// Stops the recording session and returns the transcribed text. /// /// This method performs the complete transcription process: @@ -1002,14 +1182,17 @@ final class ASRService: ObservableObject { self.benchmarkLog("stop_start ageMs=\(self.elapsedMilliseconds(since: self.benchmarkRecordingStartedAt)) bufferedSamples=\(self.audioBuffer.count)") guard self.isRunning else { + self.recordingSessionTracker.clearSession() DebugLogger.shared.warning("âš ī¸ STOP() - not running, returning empty string", source: "ASRService") return "" } defer { self.applyPendingParakeetVocabularyReloadIfNeeded() } + self.recordingSessionTracker.clearSession() self.audioRouteRecoveryTask?.cancel() self.audioRouteRecoveryTask = nil self.isRecoveringAudioRoute = false + self.clearStartupCaptureReadiness(reason: "stop") // Capture media pause state before we reset it, for resuming at the end let shouldResumeMedia = SettingsStore.shared.pauseMediaDuringTranscription && self.didPauseMediaForThisSession @@ -1047,12 +1230,7 @@ final class ASRService: ObservableObject { // (potentially slow) final transcription pass. await MainActor.run { onCaptureStopped?() } - // Recreate the engine instance instead of calling reset() to prevent format corruption - // VoiceInk approach: tearing down and rebuilding ensures fresh, valid audio format on restart - DebugLogger.shared.debug("đŸ—‘ī¸ Deallocating old engine and creating fresh instance...", source: "ASRService") - self.engineStorage = nil // Explicitly release old engine - // New engine will be lazily created on next access via computed property - DebugLogger.shared.debug("✅ Engine instance recreated", source: "ASRService") + self.retainStoppedEngineForReuse(reason: "normal_stop") // CRITICAL FIX: Await completion of streaming task AND any pending transcriptions // This prevents use-after-free crashes (EXC_BAD_ACCESS) when clearing buffer @@ -1306,6 +1484,8 @@ final class ASRService: ObservableObject { } func stopWithoutTranscription() async { + self.recordingSessionTracker.clearSession() + self.clearStartupCaptureReadiness(reason: "stop_without_transcription") guard self.isRunning else { return } defer { self.applyPendingParakeetVocabularyReloadIfNeeded() } @@ -1366,26 +1546,28 @@ final class ASRService: ObservableObject { private func configureSession() throws { DebugLogger.shared.debug("🔧 configureSession() - ENTERED", source: "ASRService") + let hadExistingEngine = self.engineStorage != nil + let engine = self.engine - if self.engine.isRunning { + if engine.isRunning { DebugLogger.shared.debug("âš ī¸ Engine is running, stopping before configuration", source: "ASRService") - self.engine.stop() + engine.stop() DebugLogger.shared.debug("✅ Engine stopped", source: "ASRService") } - // No need to call engine.reset() here - we created a fresh engine in stop() - // Accessing the engine property will either return the existing fresh engine, - // or create a new one if this is the first start - DebugLogger.shared.debug("â„šī¸ Using fresh engine instance (created lazily)", source: "ASRService") + DebugLogger.shared.debug( + hadExistingEngine ? "â„šī¸ Using retained engine instance" : "â„šī¸ Created fresh engine instance lazily", + source: "ASRService" + ) // Force input node instantiation (ensures the underlying AUHAL AudioUnit exists) DebugLogger.shared.debug("📍 Forcing input node instantiation...", source: "ASRService") - _ = self.engine.inputNode + _ = engine.inputNode DebugLogger.shared.debug("Input node instantiated", source: "ASRService") // Force output node instantiation for output device binding DebugLogger.shared.debug("📍 Forcing output node instantiation...", source: "ASRService") - _ = self.engine.outputNode + _ = engine.outputNode DebugLogger.shared.debug("✅ Output node instantiated", source: "ASRService") // NOTE: Device binding occurs in startEngine() BEFORE engine.prepare() @@ -1703,7 +1885,7 @@ final class ASRService: ObservableObject { } } - private func startEngine() throws { + private func startEngine(context: EngineStartContext) throws { DebugLogger.shared.debug("🚀 startEngine() - ENTERED", source: "ASRService") var attempts = 0 var lastError: Error? @@ -1746,6 +1928,9 @@ final class ASRService: ObservableObject { ) try self.engine.start() + if context == .initialRecording { + self.initialEngineStartCompletedAt = Date().timeIntervalSince1970 + } DebugLogger.shared.info("AVAudioEngine started successfully on attempt \(attempts + 1)", source: "ASRService") return } catch { @@ -1864,12 +2049,20 @@ final class ASRService: ObservableObject { return } - DebugLogger.shared.warning("Audio route changed while recording; scheduling recovery (\(reason))", source: "ASRService") - self.audioCapturePipeline.setRecordingEnabled(false) - self.audioLevelSubject.send(0.0) - self.audioRouteRecoveryTask?.cancel() - let recoveryDelayNanoseconds = self.audioRouteRecoveryDelayNanoseconds + let isStartupEngineConfigurationRecovery = self.isStartupEngineConfigurationRecovery(reason: reason) + self.markStartupRouteRecoveryPending(reason: reason) + if isStartupEngineConfigurationRecovery == false { + self.audioCapturePipeline.setRecordingEnabled(false) + self.audioLevelSubject.send(0.0) + } + let recoveryDelayNanoseconds = self.recoveryDelayNanoseconds( + isStartupEngineConfigurationRecovery: isStartupEngineConfigurationRecovery + ) + DebugLogger.shared.warning( + "Audio route changed while recording; scheduling recovery (\(reason), delayMs=\(recoveryDelayNanoseconds / 1_000_000))", + source: "ASRService" + ) self.audioRouteRecoveryTask = Task { [weak self] in do { try await Task.sleep(nanoseconds: recoveryDelayNanoseconds) @@ -1880,6 +2073,47 @@ final class ASRService: ObservableObject { } } + private func recoveryDelayNanoseconds(isStartupEngineConfigurationRecovery: Bool) -> UInt64 { + StartupRouteRecoveryDelay.nanoseconds( + isStartupEngineConfigurationRecovery: isStartupEngineConfigurationRecovery, + startupDelayNanoseconds: self.startupRouteRecoveryDelayNs, + defaultDelayNanoseconds: self.audioRouteRecoveryDelayNanoseconds + ) + } + + private func isStartupEngineConfigurationRecovery(reason: String) -> Bool { + StartupEngineConfigurationRecoveryPolicy.isStartupRecovery( + reason: reason, + initialEngineStartedAt: self.initialEngineStartCompletedAt, + now: Date().timeIntervalSince1970, + windowSeconds: self.startupRouteRecoveryWindowSeconds + ) + } + + private func markStartupRouteRecoveryPending(reason: String) { + self.startupRouteRecoveryTracker.markScheduled() + self.benchmarkLog("startup_capture_readiness_recovery_pending reason=\(reason)") + } + + private func completeStartupRouteRecoveryIfNeeded(reason: String) { + guard self.startupRouteRecoveryTracker.isScheduled else { return } + let sampleCount = self.audioBuffer.count + self.startupRouteRecoveryTracker.markCompleted( + at: Date().timeIntervalSince1970, + sampleCount: sampleCount + ) + self.benchmarkLog( + "startup_capture_readiness_recovery_completed reason=\(reason) sampleBaseline=\(sampleCount)" + ) + } + + private func clearStartupCaptureReadiness(reason: String) { + if self.startupRouteRecoveryTracker.hasState { + self.benchmarkLog("startup_capture_readiness_reset reason=\(reason)") + } + self.startupRouteRecoveryTracker.clear() + } + @MainActor private func recoverAudioRoute(reason: String) async { guard self.isRunning else { return } @@ -1906,7 +2140,7 @@ final class ASRService: ObservableObject { do { try self.configureSession() - try self.startEngine() + try self.startEngine(context: .routeRecovery) try self.setupEngineTap() self.audioCapturePipeline.setRecordingEnabled(true) @@ -1915,6 +2149,7 @@ final class ASRService: ObservableObject { } DebugLogger.shared.info("Audio route recovery succeeded", source: "ASRService") + self.completeStartupRouteRecoveryIfNeeded(reason: reason) } catch { DebugLogger.shared.error("Audio route recovery failed: \(error)", source: "ASRService") await self.stopWithoutTranscription() @@ -2260,7 +2495,7 @@ final class ASRService: ObservableObject { return nil } - // Device caching for change detection + /// Device caching for change detection private var cachedDeviceUIDs: Set = [] private func cacheCurrentDeviceList(_ devices: [AudioDevice.Device]) { @@ -3176,8 +3411,168 @@ private extension ASRService { } } +private enum EngineStartContext { + case initialRecording + case routeRecovery +} + // MARK: - Audio capture pipeline +struct RecordingSessionTracker { + private var counter: UInt64 = 0 + private(set) var currentID: UInt64? + + @discardableResult + mutating func beginSession() -> UInt64 { + self.counter &+= 1 + self.currentID = self.counter + return self.counter + } + + mutating func clearSession() { + self.currentID = nil + } + + func isActive(_ sessionID: UInt64) -> Bool { + self.currentID == sessionID + } +} + +struct StartupRouteRecoveryTracker { + private(set) var isScheduled = false + private(set) var completedAt: TimeInterval? + private(set) var completedSampleCount: Int? + + var hasState: Bool { + self.isScheduled || self.completedAt != nil || self.completedSampleCount != nil + } + + mutating func markScheduled() { + self.isScheduled = true + self.completedAt = nil + self.completedSampleCount = nil + } + + mutating func markCompleted(at completedAt: TimeInterval, sampleCount: Int) { + self.isScheduled = false + self.completedAt = completedAt + self.completedSampleCount = sampleCount + } + + mutating func clear() { + self.isScheduled = false + self.completedAt = nil + self.completedSampleCount = nil + } +} + +enum StartupEngineConfigurationRecoveryPolicy { + static func isStartupRecovery( + reason: String, + initialEngineStartedAt: TimeInterval?, + now: TimeInterval, + windowSeconds: TimeInterval + ) -> Bool { + guard reason == "engine configuration changed", + let initialEngineStartedAt + else { return false } + + let startAge = now - initialEngineStartedAt + return startAge >= 0 && startAge <= windowSeconds + } +} + +enum StartupRouteRecoveryDelay { + static func nanoseconds( + isStartupEngineConfigurationRecovery: Bool, + startupDelayNanoseconds: UInt64, + defaultDelayNanoseconds: UInt64 + ) -> UInt64 { + isStartupEngineConfigurationRecovery ? startupDelayNanoseconds : defaultDelayNanoseconds + } +} + +enum StartCueCaptureReadiness { + struct Configuration { + let stableDelaySeconds: TimeInterval + let afterRecoveryDelaySeconds: TimeInterval + let timeoutSeconds: TimeInterval + let minimumSamples: Int + } + + struct Snapshot { + let isRunning: Bool + let activeSessionID: UInt64? + let requestedSessionID: UInt64 + let now: TimeInterval + let waitStartedAt: TimeInterval + let sampleCount: Int + let routeRecoveryIdle: Bool + let startupRecoveryScheduled: Bool + let startupRecoveryCompletedAt: TimeInterval? + let startupRecoveryCompletedSampleCount: Int? + let engineStartedAt: TimeInterval? + } + + enum Decision: Equatable { + case ready + case wait + case inactive + case timedOut(ready: Bool) + } + + struct Evaluation: Equatable { + let decision: Decision + let stableEnough: Bool + let readySampleCount: Int + } + + static func evaluate(_ snapshot: Snapshot, configuration: Configuration) -> Evaluation { + let stableEnough = self.isStableEnough(snapshot, configuration: configuration) + let readySampleCount = self.readySampleCount(for: snapshot) + + guard snapshot.isRunning, snapshot.activeSessionID == snapshot.requestedSessionID else { + return Evaluation(decision: .inactive, stableEnough: stableEnough, readySampleCount: readySampleCount) + } + + if snapshot.routeRecoveryIdle, + stableEnough, + readySampleCount >= configuration.minimumSamples + { + return Evaluation(decision: .ready, stableEnough: stableEnough, readySampleCount: readySampleCount) + } + + if snapshot.now - snapshot.waitStartedAt >= configuration.timeoutSeconds { + let ready = snapshot.routeRecoveryIdle && readySampleCount > 0 + return Evaluation(decision: .timedOut(ready: ready), stableEnough: stableEnough, readySampleCount: readySampleCount) + } + + return Evaluation(decision: .wait, stableEnough: stableEnough, readySampleCount: readySampleCount) + } + + private static func isStableEnough(_ snapshot: Snapshot, configuration: Configuration) -> Bool { + if let completedAt = snapshot.startupRecoveryCompletedAt { + return snapshot.now - completedAt >= configuration.afterRecoveryDelaySeconds + } + if snapshot.startupRecoveryScheduled { + return false + } + if let engineStartedAt = snapshot.engineStartedAt { + return snapshot.now - engineStartedAt >= configuration.stableDelaySeconds + } + return false + } + + private static func readySampleCount(for snapshot: Snapshot) -> Int { + if snapshot.startupRecoveryCompletedAt != nil, + let recoveryCompletedSampleCount = snapshot.startupRecoveryCompletedSampleCount + { + return max(0, snapshot.sampleCount - recoveryCompletedSampleCount) + } + return snapshot.sampleCount + } +} + // // AVAudioEngine's tap runs on a realtime audio thread. ASRService is @MainActor, so we must NOT // touch its state directly inside the tap callback. This pipeline keeps all tap-side state diff --git a/Sources/Fluid/Services/AudioDeviceService.swift b/Sources/Fluid/Services/AudioDeviceService.swift index 8e899d76..925dc4b3 100644 --- a/Sources/Fluid/Services/AudioDeviceService.swift +++ b/Sources/Fluid/Services/AudioDeviceService.swift @@ -115,6 +115,26 @@ enum AudioDevice { return self.listAllDevices().first { $0.uid == uid }?.id } + static func isBluetoothDevice(_ device: Device) -> Bool { + let nameLooksBluetooth = device.name.localizedCaseInsensitiveContains("airpods") || + device.name.localizedCaseInsensitiveContains("bluetooth") || + device.name.localizedCaseInsensitiveContains("beats") + if nameLooksBluetooth { + return true + } + + guard device.id != kAudioObjectUnknown else { return false } + + guard let transportType = self.getUInt32Property( + device.id, + selector: kAudioDevicePropertyTransportType, + scope: kAudioObjectPropertyScopeGlobal + ) else { return false } + + return transportType == kAudioDeviceTransportTypeBluetooth || + transportType == kAudioDeviceTransportTypeBluetoothLE + } + private static func getDefaultDeviceId(selector: AudioObjectPropertySelector) -> AudioObjectID? { var address = AudioObjectPropertyAddress( mSelector: selector, @@ -173,6 +193,24 @@ enum AudioDevice { return value?.takeRetainedValue() as String? } + private static func getUInt32Property( + _ devId: AudioObjectID, + selector: AudioObjectPropertySelector, + scope: AudioObjectPropertyScope + ) -> UInt32? { + var address = AudioObjectPropertyAddress( + mSelector: selector, + mScope: scope, + mElement: kAudioObjectPropertyElementMain + ) + + var value: UInt32 = 0 + var dataSize = UInt32(MemoryLayout.size) + let status = AudioObjectGetPropertyData(devId, &address, 0, nil, &dataSize, &value) + guard status == noErr else { return nil } + return value + } + private static func hasChannels(_ devId: AudioObjectID, scope: AudioObjectPropertyScope) -> Bool { var address = AudioObjectPropertyAddress( mSelector: kAudioDevicePropertyStreamConfiguration, diff --git a/Sources/Fluid/Services/GlobalHotkeyManager.swift b/Sources/Fluid/Services/GlobalHotkeyManager.swift index 2e1cef4b..a483ca2f 100644 --- a/Sources/Fluid/Services/GlobalHotkeyManager.swift +++ b/Sources/Fluid/Services/GlobalHotkeyManager.swift @@ -271,9 +271,11 @@ final class GlobalHotkeyManager: NSObject { private var isInitialized = false private var initializationTask: Task? private var healthCheckTask: Task? + private var tapRecoveryTask: Task? private var maxRetryAttempts = 5 private var retryDelay: TimeInterval = 0.5 private var healthCheckInterval: TimeInterval = 30.0 + private let eventTapRecoveryDelayNanoseconds: UInt64 = 250_000_000 init( asrService: ASRService, @@ -993,26 +995,65 @@ final class GlobalHotkeyManager: NSObject { private func handleTapDisableEvent(type: CGEventType, event: CGEvent) -> Unmanaged? { // macOS can temporarily disable event taps (e.g. timeouts, user input protection). - // If we don't immediately re-enable here, hotkeys will silently stop working until our - // periodic health check kicks in, and the OS may handle the key (e.g. system dictation). + // Keep this path fail-open: doing synchronous recovery work inside the event tap callback + // can make keyboard input feel stuck while macOS is already trying to protect user input. guard type == .tapDisabledByTimeout || type == .tapDisabledByUserInput else { return nil } let reason = (type == .tapDisabledByTimeout) ? "timeout" : "user input" - DebugLogger.shared.warning("Event tap disabled by \(reason) — attempting immediate re-enable", source: "GlobalHotkeyManager") + Task { @MainActor [weak self] in + self?.scheduleEventTapRecovery(reason: reason) + } + + return Unmanaged.passUnretained(event) + } + + private func scheduleEventTapRecovery(reason: String) { + DebugLogger.shared.warning( + "Event tap disabled by \(reason) — scheduling async recovery and passing input through", + source: "GlobalHotkeyManager" + ) + self.resetModifierOnlyShortcutTracking(reason: .tapDisabled) - if let tap = self.eventTap { - CGEvent.tapEnable(tap: tap, enable: true) + guard self.tapRecoveryTask == nil else { + DebugLogger.shared.debug( + "Event tap recovery already scheduled; ignoring duplicate disabled event (\(reason))", + source: "GlobalHotkeyManager" + ) + return } - if !self.isEventTapEnabled() { - DebugLogger.shared.warning("Event tap re-enable failed — recreating tap", source: "GlobalHotkeyManager") + self.isInitialized = false + self.tapRecoveryTask = Task { @MainActor [weak self] in + guard let self else { return } + defer { self.tapRecoveryTask = nil } + + do { + try await Task.sleep(nanoseconds: self.eventTapRecoveryDelayNanoseconds) + } catch { + return + } + + guard AXIsProcessTrusted() else { + DebugLogger.shared.warning( + "Event tap async recovery skipped because Accessibility is not trusted", + source: "GlobalHotkeyManager" + ) + return + } + self.setupGlobalHotkeyWithRetry() + if self.isInitialized { + DebugLogger.shared.info("Event tap async recovery successful", source: "GlobalHotkeyManager") + } else { + DebugLogger.shared.warning( + "Event tap async recovery initial attempt failed; retry loop scheduled", + source: "GlobalHotkeyManager" + ) + } } - - return Unmanaged.passUnretained(event) } private func synchronizedPressedModifierKeyCodes( @@ -1809,6 +1850,8 @@ final class GlobalHotkeyManager: NSObject { self.initializationTask?.cancel() self.healthCheckTask?.cancel() + self.tapRecoveryTask?.cancel() + self.tapRecoveryTask = nil self.resetModifierOnlyShortcutTracking(reason: .reinitialize) self.isInitialized = false self.initializeWithDelay() @@ -1823,6 +1866,7 @@ final class GlobalHotkeyManager: NSObject { guard !Task.isCancelled else { break } await MainActor.run { + guard self.tapRecoveryTask == nil else { return } if !self.validateEventTapHealth() { DebugLogger.shared.warning("Health check failed, attempting to recover", source: "GlobalHotkeyManager") @@ -1842,6 +1886,7 @@ final class GlobalHotkeyManager: NSObject { deinit { initializationTask?.cancel() healthCheckTask?.cancel() + tapRecoveryTask?.cancel() cleanupEventTap() } } diff --git a/Sources/Fluid/Services/TranscriptionSoundPlayer.swift b/Sources/Fluid/Services/TranscriptionSoundPlayer.swift index 5c7ced21..9b0a3cd7 100644 --- a/Sources/Fluid/Services/TranscriptionSoundPlayer.swift +++ b/Sources/Fluid/Services/TranscriptionSoundPlayer.swift @@ -12,33 +12,45 @@ final class TranscriptionSoundPlayer { private init() {} func playStartSound() { - guard SettingsStore.shared.enableTranscriptionSounds else { return } + guard SettingsStore.shared.enableTranscriptionSounds else { + DebugLogger.shared.debug("Start sound skipped because transcription sounds are disabled", source: "TranscriptionSoundPlayer") + return + } let selected = SettingsStore.shared.transcriptionStartSound - guard let soundName = selected.startSoundFileName else { return } - self.play(soundName: soundName) + guard let soundName = selected.startSoundFileName else { + DebugLogger.shared.debug("Start sound skipped because selected sound is none", source: "TranscriptionSoundPlayer") + return + } + self.play(soundName: soundName, purpose: "start") } func playStopSound() { - guard SettingsStore.shared.enableTranscriptionSounds else { return } + guard SettingsStore.shared.enableTranscriptionSounds else { + DebugLogger.shared.debug("Stop sound skipped because transcription sounds are disabled", source: "TranscriptionSoundPlayer") + return + } let selected = SettingsStore.shared.transcriptionStartSound - guard let soundName = selected.stopSoundFileName else { return } - self.play(soundName: soundName) + guard let soundName = selected.stopSoundFileName else { + DebugLogger.shared.debug("Stop sound skipped because selected sound has no stop sound", source: "TranscriptionSoundPlayer") + return + } + self.play(soundName: soundName, purpose: "stop") } /// Preview a specific sound at the current volume setting (used in Settings UI). func playPreview(sound: SettingsStore.TranscriptionStartSound) { guard let soundName = sound.startSoundFileName else { return } - self.play(soundName: soundName) + self.play(soundName: soundName, purpose: "preview") } /// Preview current sound at a specific volume (used when slider is released). func playPreviewAtVolume(_ volume: Float) { let selected = SettingsStore.shared.transcriptionStartSound guard let soundName = selected.startSoundFileName else { return } - self.play(soundName: soundName, overrideVolume: volume) + self.play(soundName: soundName, overrideVolume: volume, purpose: "preview") } - private func play(soundName: String, overrideVolume: Float? = nil) { + private func play(soundName: String, overrideVolume: Float? = nil, purpose: String) { guard let url = Bundle.main.url(forResource: soundName, withExtension: "m4a") else { DebugLogger.shared.error("Missing sound resource: \(soundName).m4a", source: "TranscriptionSoundPlayer") return @@ -71,7 +83,11 @@ final class TranscriptionSoundPlayer { } else { player.volume = desiredVolume } - player.play() + let started = player.play() + DebugLogger.shared.info( + "Played \(purpose) sound \(soundName).m4a started=\(started) volume=\(player.volume)", + source: "TranscriptionSoundPlayer" + ) // Restore system volume after the sound finishes if settings.transcriptionSoundIndependentVolume, let saved = self.savedSystemVolume { diff --git a/Tests/FluidDictationIntegrationTests/StartCueCaptureReadinessTests.swift b/Tests/FluidDictationIntegrationTests/StartCueCaptureReadinessTests.swift new file mode 100644 index 00000000..23736623 --- /dev/null +++ b/Tests/FluidDictationIntegrationTests/StartCueCaptureReadinessTests.swift @@ -0,0 +1,276 @@ +import CoreAudio +@testable import FluidVoice_Debug +import XCTest + +final class StartCueCaptureReadinessTests: XCTestCase { + private let configuration = StartCueCaptureReadiness.Configuration( + stableDelaySeconds: 0.45, + afterRecoveryDelaySeconds: 0.10, + timeoutSeconds: 1.75, + minimumSamples: 2048 + ) + + func testRestartInvalidatesPreviousRecordingSession() { + var tracker = RecordingSessionTracker() + + let firstSessionID = tracker.beginSession() + let secondSessionID = tracker.beginSession() + + XCTAssertFalse(tracker.isActive(firstSessionID)) + XCTAssertTrue(tracker.isActive(secondSessionID)) + + tracker.clearSession() + + XCTAssertNil(tracker.currentID) + XCTAssertFalse(tracker.isActive(secondSessionID)) + } + + func testStaleSessionWaiterIsInactiveEvenWhenCaptureIsOtherwiseReady() { + let evaluation = StartCueCaptureReadiness.evaluate( + self.snapshot( + activeSessionID: 2, + requestedSessionID: 1, + sampleCount: 4096, + engineStartedAt: 9.0 + ), + configuration: self.configuration + ) + + XCTAssertEqual(evaluation.decision, .inactive) + } + + func testPostRecoveryReadinessRequiresSamplesAfterRecovery() { + let waiting = StartCueCaptureReadiness.evaluate( + self.snapshot( + sampleCount: 4096, + startupRecoveryCompletedAt: 10.0, + startupRecoveryCompletedSampleCount: 4096 + ), + configuration: self.configuration + ) + + XCTAssertEqual(waiting.decision, .wait) + XCTAssertEqual(waiting.readySampleCount, 0) + + let ready = StartCueCaptureReadiness.evaluate( + self.snapshot( + sampleCount: 6144, + startupRecoveryCompletedAt: 10.0, + startupRecoveryCompletedSampleCount: 4096 + ), + configuration: self.configuration + ) + + XCTAssertEqual(ready.decision, .ready) + XCTAssertEqual(ready.readySampleCount, 2048) + } + + func testTimeoutFallbackStillRequiresCurrentSessionSamplesAndIdleRouteRecovery() { + let staleSession = StartCueCaptureReadiness.evaluate( + self.snapshot( + activeSessionID: 2, + requestedSessionID: 1, + now: 12.0, + waitStartedAt: 10.0, + sampleCount: 4096, + engineStartedAt: 11.9 + ), + configuration: self.configuration + ) + + XCTAssertEqual(staleSession.decision, .inactive) + + let activeSessionWithSamples = StartCueCaptureReadiness.evaluate( + self.snapshot( + now: 12.0, + waitStartedAt: 10.0, + sampleCount: 128, + engineStartedAt: 11.9 + ), + configuration: self.configuration + ) + + XCTAssertEqual(activeSessionWithSamples.decision, .timedOut(ready: true)) + + let routeRecoveryPending = StartCueCaptureReadiness.evaluate( + self.snapshot( + now: 12.0, + waitStartedAt: 10.0, + sampleCount: 4096, + routeRecoveryIdle: false, + engineStartedAt: 9.0 + ), + configuration: self.configuration + ) + + XCTAssertEqual(routeRecoveryPending.decision, .timedOut(ready: false)) + } + + func testRouteRecoveryTrackerRequiresFreshSamplesAfterRecovery() { + var tracker = StartupRouteRecoveryTracker() + tracker.markScheduled() + tracker.markCompleted(at: 10.0, sampleCount: 4096) + + XCTAssertFalse(tracker.isScheduled) + XCTAssertTrue(tracker.hasState) + + let waiting = StartCueCaptureReadiness.evaluate( + self.snapshot( + sampleCount: 4096, + startupRecoveryCompletedAt: tracker.completedAt, + startupRecoveryCompletedSampleCount: tracker.completedSampleCount + ), + configuration: self.configuration + ) + + XCTAssertEqual(waiting.decision, .wait) + XCTAssertEqual(waiting.readySampleCount, 0) + } + + func testTimeoutAfterRecoveryDoesNotUsePreRecoverySamples() { + let evaluation = StartCueCaptureReadiness.evaluate( + self.snapshot( + now: 12.0, + waitStartedAt: 10.0, + sampleCount: 4096, + startupRecoveryCompletedAt: 10.1, + startupRecoveryCompletedSampleCount: 4096 + ), + configuration: self.configuration + ) + + XCTAssertEqual(evaluation.decision, .timedOut(ready: false)) + XCTAssertEqual(evaluation.readySampleCount, 0) + } + + func testRouteRecoveryTrackerClearsStaleBaselineWhenRecoveryIsReplaced() { + var tracker = StartupRouteRecoveryTracker() + tracker.markScheduled() + tracker.markCompleted(at: 9.0, sampleCount: 2048) + tracker.markScheduled() + + XCTAssertTrue(tracker.isScheduled) + XCTAssertNil(tracker.completedAt) + XCTAssertNil(tracker.completedSampleCount) + } + + func testStartupEngineConfigurationPolicyAcceptsInitialEngineWindow() { + XCTAssertTrue( + StartupEngineConfigurationRecoveryPolicy.isStartupRecovery( + reason: "engine configuration changed", + initialEngineStartedAt: 10.0, + now: 10.5, + windowSeconds: 2.0 + ) + ) + } + + func testStartupEngineConfigurationPolicyRejectsNonStartupEvents() { + XCTAssertFalse( + StartupEngineConfigurationRecoveryPolicy.isStartupRecovery( + reason: "input device changed", + initialEngineStartedAt: 10.0, + now: 10.5, + windowSeconds: 2.0 + ) + ) + XCTAssertFalse( + StartupEngineConfigurationRecoveryPolicy.isStartupRecovery( + reason: "engine configuration changed", + initialEngineStartedAt: nil, + now: 10.5, + windowSeconds: 2.0 + ) + ) + XCTAssertFalse( + StartupEngineConfigurationRecoveryPolicy.isStartupRecovery( + reason: "engine configuration changed", + initialEngineStartedAt: 10.0, + now: 12.1, + windowSeconds: 2.0 + ) + ) + } + + func testStartupEngineConfigurationPolicyDoesNotUseRouteRecoveryStartTime() { + XCTAssertFalse( + StartupEngineConfigurationRecoveryPolicy.isStartupRecovery( + reason: "engine configuration changed", + initialEngineStartedAt: 0.0, + now: 10.1, + windowSeconds: 2.0 + ) + ) + } + + func testRouteRecoveryDelayUsesCachedStartupDecision() { + XCTAssertEqual( + StartupRouteRecoveryDelay.nanoseconds( + isStartupEngineConfigurationRecovery: true, + startupDelayNanoseconds: 100, + defaultDelayNanoseconds: 1000 + ), + 100 + ) + XCTAssertEqual( + StartupRouteRecoveryDelay.nanoseconds( + isStartupEngineConfigurationRecovery: false, + startupDelayNanoseconds: 100, + defaultDelayNanoseconds: 1000 + ), + 1000 + ) + } + + func testBluetoothDeviceDetectionUsesNameFallbackForAggregateRoutes() { + let airPods = AudioDevice.Device( + id: AudioObjectID(kAudioObjectUnknown), + uid: "airpods", + name: "Jon's AirPods Pro", + hasInput: true, + hasOutput: true + ) + + XCTAssertTrue(AudioDevice.isBluetoothDevice(airPods)) + } + + func testBluetoothDeviceDetectionDoesNotFlagOrdinaryUnknownDeviceNames() { + let builtInMic = AudioDevice.Device( + id: AudioObjectID(kAudioObjectUnknown), + uid: "builtin", + name: "MacBook Pro Microphone", + hasInput: true, + hasOutput: false + ) + + XCTAssertFalse(AudioDevice.isBluetoothDevice(builtInMic)) + } + + private func snapshot( + isRunning: Bool = true, + activeSessionID: UInt64? = 1, + requestedSessionID: UInt64 = 1, + now: TimeInterval = 10.2, + waitStartedAt: TimeInterval = 10.0, + sampleCount: Int, + routeRecoveryIdle: Bool = true, + startupRecoveryScheduled: Bool = false, + startupRecoveryCompletedAt: TimeInterval? = nil, + startupRecoveryCompletedSampleCount: Int? = nil, + engineStartedAt: TimeInterval? = nil + ) -> StartCueCaptureReadiness.Snapshot { + StartCueCaptureReadiness.Snapshot( + isRunning: isRunning, + activeSessionID: activeSessionID, + requestedSessionID: requestedSessionID, + now: now, + waitStartedAt: waitStartedAt, + sampleCount: sampleCount, + routeRecoveryIdle: routeRecoveryIdle, + startupRecoveryScheduled: startupRecoveryScheduled, + startupRecoveryCompletedAt: startupRecoveryCompletedAt, + startupRecoveryCompletedSampleCount: startupRecoveryCompletedSampleCount, + engineStartedAt: engineStartedAt + ) + } +}