From 9417cfca143724d11f6c34fbfdaec16ae7b3ad77 Mon Sep 17 00:00:00 2001 From: altic-dev Date: Wed, 12 Aug 2026 22:04:17 -0700 Subject: [PATCH 1/9] fix bluetooth microphone startup churn --- Sources/Fluid/Services/ASRService.swift | 151 +++++++++++++++--- .../Services/AudioCaptureIdlePolicy.swift | 34 ++++ .../HotkeyShortcutTests.swift | 48 ++++++ 3 files changed, 213 insertions(+), 20 deletions(-) diff --git a/Sources/Fluid/Services/ASRService.swift b/Sources/Fluid/Services/ASRService.swift index 845e58c6..3e40d0e2 100644 --- a/Sources/Fluid/Services/ASRService.swift +++ b/Sources/Fluid/Services/ASRService.swift @@ -710,6 +710,7 @@ final class ASRService: ObservableObject { private var activeAudioCaptureBackend: AudioCaptureBackend = .none private var audioStartAttemptInputUID: String? private var audioStartAttemptInputName: String? + private var audioStartAttemptIsBluetooth = false private var hasPreparedAudioCapture: Bool { self.directAudioLifecycleController.snapshot.isPrepared || self.hasWarmAudioEngine @@ -919,19 +920,21 @@ final class ASRService: ObservableObject { } private func startConfiguredAudioCapture( - excluding excludedInputUIDs: Set = [] + excluding excludedInputUIDs: Set = [], + forcingInputUID: String? = nil ) async throws { self.audioStartAttemptInputUID = nil self.audioStartAttemptInputName = nil + self.audioStartAttemptIsBluetooth = false if SettingsStore.shared.experimentalDirectAudioCaptureEnabled { // A non-route path may have scheduled a fire-and-forget retirement. // Do not let direct capture startup overlap a queued AVAudioEngine // release. await self.audioEngineRetirementDrain.waitForScheduledReleases() do { - guard let selection = self.directCoreAudioDeviceSelection( - excluding: excludedInputUIDs - ) else { + let selection = forcingInputUID.map(DirectCoreAudioDeviceSelection.preferredUID) ?? + self.directCoreAudioDeviceSelection(excluding: excludedInputUIDs) + guard let selection else { throw NSError( domain: "ASRService", code: -4, @@ -947,6 +950,7 @@ final class ASRService: ObservableObject { ) self.audioStartAttemptInputUID = device.uid self.audioStartAttemptInputName = device.name + self.audioStartAttemptIsBluetooth = device.isBluetooth AppServices.shared.microphonePreferenceCoordinator.reportResolvedSelection( uid: device.uid, name: device.name @@ -1716,23 +1720,54 @@ final class ASRService: ObservableObject { ? max(AudioDevice.listInputDevices().count, 1) + 1 : 1 var startAttempt = 1 + var fallbackAttempt = 1 var failedInputUIDs = Set() var immediatelyRetriedInputUID: String? + var bluetoothStabilization = AudioCaptureIdlePolicy.BluetoothInputStabilization() + var forcedInputUID: String? self.audioStartAttemptInputUID = nil while true { let routeGenerationAtStart = self.audioRouteRecoveryGeneration do { - try await self.startConfiguredAudioCapture(excluding: failedInputUIDs) + try await self.startConfiguredAudioCapture( + excluding: failedInputUIDs, + forcingInputUID: forcedInputUID + ) } catch { guard let failedUID = self.audioStartAttemptInputUID else { throw error } - let retrySameInput = immediatelyRetriedInputUID == nil - if retrySameInput { + let now = ProcessInfo.processInfo.systemUptime + let retryBluetoothInput = bluetoothStabilization.shouldRetry( + inputUID: failedUID, + isBluetoothInput: self.audioStartAttemptIsBluetooth, + now: now + ) + let retrySameInput = retryBluetoothInput || immediatelyRetriedInputUID == nil + if retryBluetoothInput { + forcedInputUID = failedUID immediatelyRetriedInputUID = failedUID + self.logBluetoothStartupRetry( + uid: failedUID, + attempt: startAttempt + 1, + elapsed: bluetoothStabilization.elapsed(at: now), + reason: error.localizedDescription + ) + } else { + forcedInputUID = nil + if bluetoothStabilization.inputUID == failedUID { + self.logBluetoothStartupStabilizationEnded( + uid: failedUID, + elapsed: bluetoothStabilization.elapsed(at: now), + outcome: "budget_exhausted" + ) + } + if immediatelyRetriedInputUID == nil { + immediatelyRetriedInputUID = failedUID + } else { + failedInputUIDs.insert(failedUID) + } + fallbackAttempt += 1 } - if retrySameInput == false { - failedInputUIDs.insert(failedUID) - } - guard startAttempt < maximumStartAttempts, + guard retryBluetoothInput || fallbackAttempt <= maximumStartAttempts, startGeneration == self.audioCaptureStartGeneration, self.isTerminating == false else { @@ -1743,7 +1778,7 @@ final class ASRService: ObservableObject { startGeneration: startGeneration, completedAttempt: startAttempt, reason: "backend_start_error:\(error.localizedDescription)", - waitForTopologyQuiet: retrySameInput == false + waitForTopologyQuiet: retryBluetoothInput || retrySameInput == false ) startAttempt += 1 continue @@ -1770,6 +1805,15 @@ final class ASRService: ObservableObject { self.pendingAudioRouteRecovery == nil && self.isRecoveringAudioRoute == false if readiness == .ready, routeStayedStable { + if let stabilizedUID = bluetoothStabilization.inputUID { + self.logBluetoothStartupStabilizationEnded( + uid: stabilizedUID, + elapsed: bluetoothStabilization.elapsed( + at: ProcessInfo.processInfo.systemUptime + ), + outcome: "first_pcm" + ) + } AppServices.shared.microphonePreferenceCoordinator.confirmActiveSelection( uid: self.audioStartAttemptInputUID, name: self.audioStartAttemptInputName @@ -1791,17 +1835,44 @@ final class ASRService: ObservableObject { throw CancellationError() } var retrySameInput = false + var retryBluetoothInput = false if let failedUID = self.audioStartAttemptInputUID { - retrySameInput = readiness == .formatInvalidated && - immediatelyRetriedInputUID == nil - if retrySameInput { + let now = ProcessInfo.processInfo.systemUptime + retryBluetoothInput = bluetoothStabilization.shouldRetry( + inputUID: failedUID, + isBluetoothInput: self.audioStartAttemptIsBluetooth, + now: now + ) + retrySameInput = retryBluetoothInput || ( + readiness == .formatInvalidated && immediatelyRetriedInputUID == nil + ) + if retryBluetoothInput { + forcedInputUID = failedUID immediatelyRetriedInputUID = failedUID - } - if retrySameInput == false { - failedInputUIDs.insert(failedUID) + self.logBluetoothStartupRetry( + uid: failedUID, + attempt: startAttempt + 1, + elapsed: bluetoothStabilization.elapsed(at: now), + reason: "readiness_\(readiness)" + ) + } else { + forcedInputUID = nil + if bluetoothStabilization.inputUID == failedUID { + self.logBluetoothStartupStabilizationEnded( + uid: failedUID, + elapsed: bluetoothStabilization.elapsed(at: now), + outcome: "budget_exhausted" + ) + } + if retrySameInput { + immediatelyRetriedInputUID = failedUID + } else { + failedInputUIDs.insert(failedUID) + fallbackAttempt += 1 + } } } - guard startAttempt < maximumStartAttempts else { + guard retryBluetoothInput || fallbackAttempt <= maximumStartAttempts else { let message: String switch readiness { case .timedOut: @@ -1827,7 +1898,7 @@ final class ASRService: ObservableObject { startGeneration: startGeneration, completedAttempt: startAttempt, reason: "readiness_\(readiness)_routeStable_\(routeStayedStable)", - waitForTopologyQuiet: retrySameInput == false + waitForTopologyQuiet: retryBluetoothInput || retrySameInput == false ) startAttempt += 1 } @@ -2012,6 +2083,33 @@ final class ASRService: ObservableObject { return attemptID } + private func logBluetoothStartupRetry( + uid: String, + attempt: Int, + elapsed: TimeInterval, + reason: String + ) { + let elapsedMilliseconds = Int((elapsed * 1000).rounded()) + let maximumMilliseconds = Int( + (AudioCaptureIdlePolicy.BluetoothInputStabilization.maximumDuration * 1000).rounded() + ) + self.benchmarkLog( + "bluetooth_start_retry uid=\(uid) attempt=\(attempt) " + + "elapsedMs=\(elapsedMilliseconds) budgetMs=\(maximumMilliseconds) reason=\(reason)" + ) + } + + private func logBluetoothStartupStabilizationEnded( + uid: String, + elapsed: TimeInterval, + outcome: String + ) { + self.benchmarkLog( + "bluetooth_start_stabilization_end uid=\(uid) outcome=\(outcome) " + + "elapsedMs=\(Int((elapsed * 1000).rounded()))" + ) + } + func cancelPendingAudioCaptureStart(reason: String) async { guard self.isStarting, self.isRunning == false else { return } self.audioCaptureStartGeneration &+= 1 @@ -2978,6 +3076,19 @@ final class ASRService: ObservableObject { self.benchmarkLog("route_recovery_ignored reason=app_terminating event=\(reason)") return } + if AudioCaptureIdlePolicy.shouldDeferRouteRecoveryToBluetoothStart( + directCaptureEnabled: SettingsStore.shared.experimentalDirectAudioCaptureEnabled, + isStarting: self.isStarting, + isRunning: self.isRunning, + attemptedInputIsBluetooth: self.audioStartAttemptIsBluetooth + ) { + // AirPods and other Bluetooth inputs can replace their streams more + // than once while entering microphone mode. The active start owns + // its bounded retry loop; a second recovery owner would exclude the + // same healthy device before the Bluetooth route settles. + self.benchmarkLog("bluetooth_start_route_change_deferred event=\(reason)") + return + } self.audioRouteRecoveryGeneration &+= 1 let requiresPrewarmAfterRecovery = requiresIdlePrewarm || self.pendingAudioRouteRecovery?.requiresIdlePrewarm == true diff --git a/Sources/Fluid/Services/AudioCaptureIdlePolicy.swift b/Sources/Fluid/Services/AudioCaptureIdlePolicy.swift index 37f2d6c1..c4aac2bb 100644 --- a/Sources/Fluid/Services/AudioCaptureIdlePolicy.swift +++ b/Sources/Fluid/Services/AudioCaptureIdlePolicy.swift @@ -1,6 +1,31 @@ import CoreAudio enum AudioCaptureIdlePolicy { + struct BluetoothInputStabilization { + static let maximumDuration: TimeInterval = 5 + + private(set) var inputUID: String? + private(set) var startedAt: TimeInterval? + + mutating func shouldRetry( + inputUID: String, + isBluetoothInput: Bool, + now: TimeInterval + ) -> Bool { + guard isBluetoothInput || self.inputUID == inputUID else { return false } + if self.inputUID != inputUID || self.startedAt == nil { + self.inputUID = inputUID + self.startedAt = now + } + return self.elapsed(at: now) < Self.maximumDuration + } + + func elapsed(at now: TimeInterval) -> TimeInterval { + guard let startedAt else { return 0 } + return max(now - startedAt, 0) + } + } + static func shouldPrewarmCapture(experimentalDirectAudioCaptureEnabled: Bool) -> Bool { experimentalDirectAudioCaptureEnabled } @@ -53,4 +78,13 @@ enum AudioCaptureIdlePolicy { ) -> Bool { isRunning || isStarting } + + static func shouldDeferRouteRecoveryToBluetoothStart( + directCaptureEnabled: Bool, + isStarting: Bool, + isRunning: Bool, + attemptedInputIsBluetooth: Bool + ) -> Bool { + directCaptureEnabled && isStarting && isRunning == false && attemptedInputIsBluetooth + } } diff --git a/Tests/FluidDictationIntegrationTests/HotkeyShortcutTests.swift b/Tests/FluidDictationIntegrationTests/HotkeyShortcutTests.swift index 0320ce47..8c6d8b1b 100644 --- a/Tests/FluidDictationIntegrationTests/HotkeyShortcutTests.swift +++ b/Tests/FluidDictationIntegrationTests/HotkeyShortcutTests.swift @@ -655,6 +655,54 @@ final class HotkeyShortcutTests: XCTestCase { XCTAssertFalse(builtInDevice.isBluetooth) } + func testBluetoothStartupRetriesSameInputWithinBoundedWindow() { + var stabilization = AudioCaptureIdlePolicy.BluetoothInputStabilization() + + XCTAssertTrue(stabilization.shouldRetry( + inputUID: "airpods", + isBluetoothInput: true, + now: 10 + )) + XCTAssertTrue(stabilization.shouldRetry( + inputUID: "airpods", + isBluetoothInput: false, + now: 14.999 + )) + XCTAssertFalse(stabilization.shouldRetry( + inputUID: "airpods", + isBluetoothInput: false, + now: 15 + )) + } + + func testBluetoothStartupPolicyDoesNotAffectOtherInputsOrActiveRecovery() { + var stabilization = AudioCaptureIdlePolicy.BluetoothInputStabilization() + + XCTAssertFalse(stabilization.shouldRetry( + inputUID: "usb", + isBluetoothInput: false, + now: 10 + )) + XCTAssertTrue(AudioCaptureIdlePolicy.shouldDeferRouteRecoveryToBluetoothStart( + directCaptureEnabled: true, + isStarting: true, + isRunning: false, + attemptedInputIsBluetooth: true + )) + XCTAssertFalse(AudioCaptureIdlePolicy.shouldDeferRouteRecoveryToBluetoothStart( + directCaptureEnabled: true, + isStarting: true, + isRunning: true, + attemptedInputIsBluetooth: true + )) + XCTAssertFalse(AudioCaptureIdlePolicy.shouldDeferRouteRecoveryToBluetoothStart( + directCaptureEnabled: true, + isStarting: true, + isRunning: false, + attemptedInputIsBluetooth: false + )) + } + @MainActor func testLegacySystemModeSeedsPriorityFromCurrentDefault() throws { try self.withRestoredDefaults(keys: [ From 89b799b1e32e062be8e6d4f65ade2cccaa792a8d Mon Sep 17 00:00:00 2001 From: altic-dev Date: Thu, 13 Aug 2026 11:50:18 -0700 Subject: [PATCH 2/9] log microphone capture health --- Sources/Fluid/Services/ASRService.swift | 98 ++++++++++++++++++++++--- 1 file changed, 89 insertions(+), 9 deletions(-) diff --git a/Sources/Fluid/Services/ASRService.swift b/Sources/Fluid/Services/ASRService.swift index 3e40d0e2..5d2e19ed 100644 --- a/Sources/Fluid/Services/ASRService.swift +++ b/Sources/Fluid/Services/ASRService.swift @@ -1134,6 +1134,19 @@ final class ASRService: ObservableObject { DispatchQueue.main.async { [weak self] in self?.audioLevelSubject.send(level) } + }, + onCaptureHealth: { [weak self] sessionID, attemptID, audioMs, sampleCount, rms, peak in + DispatchQueue.main.async { [weak self] in + guard let self else { return } + let silent = rms < 0.002 && peak < 0.01 + self.benchmarkLog( + "capture_health attempt=\(attemptID) audioMs=\(audioMs) " + + "samples=\(sampleCount) rms=\(String(format: "%.6f", rms)) " + + "peak=\(String(format: "%.6f", peak)) silent=\(silent) " + + "inputUID=\(self.audioStartAttemptInputUID ?? "unknown")", + sessionID: sessionID + ) + } } ) }() @@ -4915,6 +4928,7 @@ private final nonisolated class AudioCapturePipeline: @unchecked Sendable { private let audioBuffer: ThreadSafeAudioBuffer private let onFirstAudio: (Int, UInt64, Int, Int, Double, Int, Int) -> Void private let onLevel: (CGFloat) -> Void + private let onCaptureHealth: (Int, UInt64, Int, Int, Float, Float) -> Void private let lock = NSLock() private var recordingEnabled: Bool = false @@ -4929,6 +4943,10 @@ private final nonisolated class AudioCapturePipeline: @unchecked Sendable { private var resampleNextSourcePosition: Double = 0 private var resamplePreviousSample: Float? private var lastInputSampleEnd: Int64? + private var captureHealthSampleCount: Int = 0 + private var captureHealthTotalSampleCount: Int = 0 + private var captureHealthSquareSum: Double = 0 + private var captureHealthPeak: Float = 0 // Smoothing state (kept off ASRService/@MainActor) private var levelHistory: [CGFloat] = [] @@ -4946,11 +4964,13 @@ private final nonisolated class AudioCapturePipeline: @unchecked Sendable { init( audioBuffer: ThreadSafeAudioBuffer, onFirstAudio: @escaping (Int, UInt64, Int, Int, Double, Int, Int) -> Void, - onLevel: @escaping (CGFloat) -> Void + onLevel: @escaping (CGFloat) -> Void, + onCaptureHealth: @escaping (Int, UInt64, Int, Int, Float, Float) -> Void ) { self.audioBuffer = audioBuffer self.onFirstAudio = onFirstAudio self.onLevel = onLevel + self.onCaptureHealth = onCaptureHealth } func setRecordingEnabled( @@ -4968,6 +4988,7 @@ private final nonisolated class AudioCapturePipeline: @unchecked Sendable { self.recordingStopHostTime = nil self.resetResamplerLocked() self.lastInputSampleEnd = nil + self.resetCaptureHealthLocked() self.recordingEnabled = true } if enabled == false { @@ -4978,6 +4999,7 @@ private final nonisolated class AudioCapturePipeline: @unchecked Sendable { self.recordingStopHostTime = nil self.resetResamplerLocked() self.lastInputSampleEnd = nil + self.resetCaptureHealthLocked() self.levelHistory.removeAll(keepingCapacity: true) self.smoothedLevel = 0.0 } @@ -5077,7 +5099,7 @@ private final nonisolated class AudioCapturePipeline: @unchecked Sendable { } if recordingEnabled == false { self.lock.unlock() - self.onLevel(self.calculateAudioLevel(samples)) + self.onLevel(self.measureAudioLevel(samples).level) return } let startHostTime = self.recordingStartHostTime @@ -5163,8 +5185,24 @@ private final nonisolated class AudioCapturePipeline: @unchecked Sendable { ) } self.lock.unlock() - let level = self.calculateAudioLevel(mono16k) - self.onLevel(level) + let measurement = self.measureAudioLevel(mono16k) + self.onLevel(measurement.level) + if let health = self.captureHealthDiagnostic( + sampleCount: mono16k.count, + rms: measurement.rms, + peak: measurement.peak, + sessionID: recordingSessionID, + attemptID: recordingAttemptID + ) { + self.onCaptureHealth( + recordingSessionID, + recordingAttemptID, + health.audioMs, + health.sampleCount, + health.rms, + health.peak + ) + } } private static func acceptedFrameRange( @@ -5226,6 +5264,13 @@ private final nonisolated class AudioCapturePipeline: @unchecked Sendable { self.resamplePreviousSample = nil } + private func resetCaptureHealthLocked() { + self.captureHealthSampleCount = 0 + self.captureHealthTotalSampleCount = 0 + self.captureHealthSquareSum = 0 + self.captureHealthPeak = 0 + } + /// Stateful linear resampling keeps fractional phase across small hardware /// callbacks. Stateless per-packet conversion silently shortens 44.1 kHz /// recordings and introduces a discontinuity at every device cycle. @@ -5282,23 +5327,58 @@ private final nonisolated class AudioCapturePipeline: @unchecked Sendable { return output } - private func calculateAudioLevel(_ samples: [Float]) -> CGFloat { - guard samples.isEmpty == false else { return 0.0 } + private func measureAudioLevel(_ samples: [Float]) -> (level: CGFloat, rms: Float, peak: Float) { + guard samples.isEmpty == false else { return (0, 0, 0) } - // RMS var sum: Float = 0.0 vDSP_svesq(samples, 1, &sum, vDSP_Length(samples.count)) let rms = sqrt(sum / Float(samples.count)) + var peak: Float = 0 + vDSP_maxmgv(samples, 1, &peak, vDSP_Length(samples.count)) // Noise gate if rms < 0.002 { - return self.applySmoothingAndThreshold(0.0) + return (self.applySmoothingAndThreshold(0), rms, peak) } // dB -> normalized [0, 1] let dbLevel = 20 * log10(max(rms, 1e-10)) let normalizedLevel = max(0, min(1, (dbLevel + 55) / 55)) - return self.applySmoothingAndThreshold(CGFloat(normalizedLevel)) + return (self.applySmoothingAndThreshold(CGFloat(normalizedLevel)), rms, peak) + } + + private func captureHealthDiagnostic( + sampleCount: Int, + rms: Float, + peak: Float, + sessionID: Int, + attemptID: UInt64 + ) -> (audioMs: Int, sampleCount: Int, rms: Float, peak: Float)? { + self.lock.lock() + defer { self.lock.unlock() } + guard self.recordingEnabled, + self.recordingSessionID == sessionID, + self.recordingAttemptID == attemptID + else { return nil } + + self.captureHealthSampleCount += sampleCount + self.captureHealthTotalSampleCount += sampleCount + self.captureHealthSquareSum += Double(rms * rms) * Double(sampleCount) + self.captureHealthPeak = max(self.captureHealthPeak, peak) + guard self.captureHealthSampleCount >= 16_000 else { return nil } + + let windowSampleCount = self.captureHealthSampleCount + let windowRMS = Float(sqrt(self.captureHealthSquareSum / Double(windowSampleCount))) + let result = ( + audioMs: Int((Double(self.captureHealthTotalSampleCount) / 16_000 * 1000).rounded()), + sampleCount: windowSampleCount, + rms: windowRMS, + peak: self.captureHealthPeak + ) + self.captureHealthSampleCount = 0 + self.captureHealthSquareSum = 0 + self.captureHealthPeak = 0 + return result } private func applySmoothingAndThreshold(_ newLevel: CGFloat) -> CGFloat { From 405e0332f8822ce421aaa882d31e44728df920ef Mon Sep 17 00:00:00 2001 From: altic-dev Date: Thu, 13 Aug 2026 12:00:25 -0700 Subject: [PATCH 3/9] fix(audio): recover stalled built-in capture --- Sources/Fluid/Services/ASRService.swift | 22 +++++++- .../Services/AudioCaptureIdlePolicy.swift | 30 +++++++++++ .../HotkeyShortcutTests.swift | 54 +++++++++++++++++++ 3 files changed, 104 insertions(+), 2 deletions(-) diff --git a/Sources/Fluid/Services/ASRService.swift b/Sources/Fluid/Services/ASRService.swift index 5d2e19ed..37e5a33b 100644 --- a/Sources/Fluid/Services/ASRService.swift +++ b/Sources/Fluid/Services/ASRService.swift @@ -711,6 +711,8 @@ final class ASRService: ObservableObject { private var audioStartAttemptInputUID: String? private var audioStartAttemptInputName: String? private var audioStartAttemptIsBluetooth = false + private var audioStartAttemptIsBuiltIn = false + private var silentPCMRecoveryWatchdog = AudioCaptureIdlePolicy.SilentPCMRecoveryWatchdog() private var hasPreparedAudioCapture: Bool { self.directAudioLifecycleController.snapshot.isPrepared || self.hasWarmAudioEngine @@ -926,6 +928,7 @@ final class ASRService: ObservableObject { self.audioStartAttemptInputUID = nil self.audioStartAttemptInputName = nil self.audioStartAttemptIsBluetooth = false + self.audioStartAttemptIsBuiltIn = false if SettingsStore.shared.experimentalDirectAudioCaptureEnabled { // A non-route path may have scheduled a fire-and-forget retirement. // Do not let direct capture startup overlap a queued AVAudioEngine @@ -951,6 +954,7 @@ final class ASRService: ObservableObject { self.audioStartAttemptInputUID = device.uid self.audioStartAttemptInputName = device.name self.audioStartAttemptIsBluetooth = device.isBluetooth + self.audioStartAttemptIsBuiltIn = device.isBuiltIn AppServices.shared.microphonePreferenceCoordinator.reportResolvedSelection( uid: device.uid, name: device.name @@ -1138,14 +1142,27 @@ final class ASRService: ObservableObject { onCaptureHealth: { [weak self] sessionID, attemptID, audioMs, sampleCount, rms, peak in DispatchQueue.main.async { [weak self] in guard let self else { return } + guard sessionID == self.benchmarkSessionID, self.isRunning else { return } let silent = rms < 0.002 && peak < 0.01 self.benchmarkLog( "capture_health attempt=\(attemptID) audioMs=\(audioMs) " + "samples=\(sampleCount) rms=\(String(format: "%.6f", rms)) " + "peak=\(String(format: "%.6f", peak)) silent=\(silent) " + - "inputUID=\(self.audioStartAttemptInputUID ?? "unknown")", - sessionID: sessionID + "inputUID=\(self.audioStartAttemptInputUID ?? "unknown")" ) + if self.silentPCMRecoveryWatchdog.shouldRecover( + isBuiltInInput: self.audioStartAttemptIsBuiltIn, + isDirectCapture: self.activeAudioCaptureBackend == .directCoreAudio, + rms: rms, + peak: peak + ) { + self.benchmarkLog( + "capture_health_recovery_triggered attempt=\(attemptID) " + + "audioMs=\(audioMs) rms=\(String(format: "%.6f", rms)) " + + "peak=\(String(format: "%.6f", peak))" + ) + self.scheduleAudioRouteRecovery(reason: "sustained silent PCM") + } } } ) @@ -1702,6 +1719,7 @@ final class ASRService: ObservableObject { self.isProcessingChunk = false self.skipNextChunk = false self.benchmarkSessionID += 1 + self.silentPCMRecoveryWatchdog = AudioCaptureIdlePolicy.SilentPCMRecoveryWatchdog() let captureSessionID = self.benchmarkSessionID self.audioCaptureAttemptID &+= 1 var readinessAttemptID = self.audioCaptureAttemptID diff --git a/Sources/Fluid/Services/AudioCaptureIdlePolicy.swift b/Sources/Fluid/Services/AudioCaptureIdlePolicy.swift index c4aac2bb..c67ae148 100644 --- a/Sources/Fluid/Services/AudioCaptureIdlePolicy.swift +++ b/Sources/Fluid/Services/AudioCaptureIdlePolicy.swift @@ -1,6 +1,36 @@ import CoreAudio enum AudioCaptureIdlePolicy { + struct SilentPCMRecoveryWatchdog { + static let requiredSilentWindows = 3 + static let maximumSilentRMS: Float = 0.00_001 + static let maximumSilentPeak: Float = 0.00_005 + + private var hasSeenSignal = false + private var consecutiveSilentWindows = 0 + private var hasRequestedRecovery = false + + mutating func shouldRecover( + isBuiltInInput: Bool, + isDirectCapture: Bool, + rms: Float, + peak: Float + ) -> Bool { + guard isBuiltInInput, isDirectCapture, self.hasRequestedRecovery == false else { return false } + let isEffectivelySilent = rms <= Self.maximumSilentRMS && peak <= Self.maximumSilentPeak + if isEffectivelySilent == false { + self.hasSeenSignal = true + self.consecutiveSilentWindows = 0 + return false + } + guard self.hasSeenSignal else { return false } + self.consecutiveSilentWindows += 1 + guard self.consecutiveSilentWindows >= Self.requiredSilentWindows else { return false } + self.hasRequestedRecovery = true + return true + } + } + struct BluetoothInputStabilization { static let maximumDuration: TimeInterval = 5 diff --git a/Tests/FluidDictationIntegrationTests/HotkeyShortcutTests.swift b/Tests/FluidDictationIntegrationTests/HotkeyShortcutTests.swift index 8c6d8b1b..28ddde9f 100644 --- a/Tests/FluidDictationIntegrationTests/HotkeyShortcutTests.swift +++ b/Tests/FluidDictationIntegrationTests/HotkeyShortcutTests.swift @@ -703,6 +703,60 @@ final class HotkeyShortcutTests: XCTestCase { )) } + func testSilentPCMWatchdogRecoversBuiltInDirectCaptureOnceAfterRealSignal() { + var watchdog = AudioCaptureIdlePolicy.SilentPCMRecoveryWatchdog() + + XCTAssertFalse(watchdog.shouldRecover( + isBuiltInInput: true, isDirectCapture: true, rms: 0, peak: 0 + )) + XCTAssertFalse(watchdog.shouldRecover( + isBuiltInInput: true, isDirectCapture: true, rms: 0.02, peak: 0.08 + )) + for _ in 0..<(AudioCaptureIdlePolicy.SilentPCMRecoveryWatchdog.requiredSilentWindows - 1) { + XCTAssertFalse(watchdog.shouldRecover( + isBuiltInInput: true, isDirectCapture: true, rms: 0, peak: 0 + )) + } + XCTAssertTrue(watchdog.shouldRecover( + isBuiltInInput: true, isDirectCapture: true, rms: 0, peak: 0 + )) + XCTAssertFalse(watchdog.shouldRecover( + isBuiltInInput: true, isDirectCapture: true, rms: 0, peak: 0 + )) + } + + func testSilentPCMWatchdogIgnoresExternalAndLowAmbientInputs() { + var externalWatchdog = AudioCaptureIdlePolicy.SilentPCMRecoveryWatchdog() + XCTAssertFalse(externalWatchdog.shouldRecover( + isBuiltInInput: false, isDirectCapture: true, rms: 0.02, peak: 0.08 + )) + for _ in 0...AudioCaptureIdlePolicy.SilentPCMRecoveryWatchdog.requiredSilentWindows { + XCTAssertFalse(externalWatchdog.shouldRecover( + isBuiltInInput: false, isDirectCapture: true, rms: 0, peak: 0 + )) + } + + var legacyCaptureWatchdog = AudioCaptureIdlePolicy.SilentPCMRecoveryWatchdog() + XCTAssertFalse(legacyCaptureWatchdog.shouldRecover( + isBuiltInInput: true, isDirectCapture: false, rms: 0.02, peak: 0.08 + )) + for _ in 0...AudioCaptureIdlePolicy.SilentPCMRecoveryWatchdog.requiredSilentWindows { + XCTAssertFalse(legacyCaptureWatchdog.shouldRecover( + isBuiltInInput: true, isDirectCapture: false, rms: 0, peak: 0 + )) + } + + var ambientWatchdog = AudioCaptureIdlePolicy.SilentPCMRecoveryWatchdog() + XCTAssertFalse(ambientWatchdog.shouldRecover( + isBuiltInInput: true, isDirectCapture: true, rms: 0.02, peak: 0.08 + )) + for _ in 0...AudioCaptureIdlePolicy.SilentPCMRecoveryWatchdog.requiredSilentWindows { + XCTAssertFalse(ambientWatchdog.shouldRecover( + isBuiltInInput: true, isDirectCapture: true, rms: 0.000_1, peak: 0.001 + )) + } + } + @MainActor func testLegacySystemModeSeedsPriorityFromCurrentDefault() throws { try self.withRestoredDefaults(keys: [ From 77f357301fb7ea5956672fbd61e8c00558760371 Mon Sep 17 00:00:00 2001 From: altic-dev Date: Thu, 13 Aug 2026 15:20:52 -0700 Subject: [PATCH 4/9] fix(audio): preserve deferred mic reconciliation --- Sources/Fluid/Services/ASRService.swift | 20 ++++++++++++- .../Services/AudioCaptureIdlePolicy.swift | 28 +++++++++++++++++++ .../HotkeyShortcutTests.swift | 27 ++++++++++++++++++ 3 files changed, 74 insertions(+), 1 deletion(-) diff --git a/Sources/Fluid/Services/ASRService.swift b/Sources/Fluid/Services/ASRService.swift index 37e5a33b..47a39c5c 100644 --- a/Sources/Fluid/Services/ASRService.swift +++ b/Sources/Fluid/Services/ASRService.swift @@ -712,6 +712,8 @@ final class ASRService: ObservableObject { private var audioStartAttemptInputName: String? private var audioStartAttemptIsBluetooth = false private var audioStartAttemptIsBuiltIn = false + private var deferredBluetoothStartupRouteRecovery = + AudioCaptureIdlePolicy.DeferredBluetoothRouteRecovery() private var silentPCMRecoveryWatchdog = AudioCaptureIdlePolicy.SilentPCMRecoveryWatchdog() private var hasPreparedAudioCapture: Bool { @@ -2158,6 +2160,13 @@ final class ASRService: ObservableObject { private func finishAudioCaptureStart() { self.isStarting = false + if let deferredRecovery = self.deferredBluetoothStartupRouteRecovery.take() { + self.scheduleAudioRouteRecovery( + reason: "deferred after Bluetooth startup: \(deferredRecovery.reason)", + requiresIdlePrewarm: deferredRecovery.requiresIdlePrewarm, + reconcilesInputSelection: deferredRecovery.reconcilesInputSelection + ) + } self.audioCaptureStateSettledTick &+= 1 let waiters = self.audioCaptureStartWaiters self.audioCaptureStartWaiters.removeAll(keepingCapacity: false) @@ -3117,7 +3126,16 @@ final class ASRService: ObservableObject { // than once while entering microphone mode. The active start owns // its bounded retry loop; a second recovery owner would exclude the // same healthy device before the Bluetooth route settles. - self.benchmarkLog("bluetooth_start_route_change_deferred event=\(reason)") + self.deferredBluetoothStartupRouteRecovery.preserve( + reason: reason, + requiresIdlePrewarm: requiresIdlePrewarm, + reconcilesInputSelection: reconcilesInputSelection + ) + let preserved = requiresIdlePrewarm || reconcilesInputSelection + self.benchmarkLog( + "bluetooth_start_route_change_deferred event=\(reason) " + + "reconciliationPreserved=\(preserved)" + ) return } self.audioRouteRecoveryGeneration &+= 1 diff --git a/Sources/Fluid/Services/AudioCaptureIdlePolicy.swift b/Sources/Fluid/Services/AudioCaptureIdlePolicy.swift index c67ae148..0f3c7336 100644 --- a/Sources/Fluid/Services/AudioCaptureIdlePolicy.swift +++ b/Sources/Fluid/Services/AudioCaptureIdlePolicy.swift @@ -1,6 +1,34 @@ import CoreAudio enum AudioCaptureIdlePolicy { + struct DeferredBluetoothRouteRecovery { + struct Request { + let reason: String + let requiresIdlePrewarm: Bool + let reconcilesInputSelection: Bool + } + + private var request: Request? + + mutating func preserve( + reason: String, + requiresIdlePrewarm: Bool, + reconcilesInputSelection: Bool + ) { + guard requiresIdlePrewarm || reconcilesInputSelection else { return } + self.request = Request( + reason: self.request?.reason ?? reason, + requiresIdlePrewarm: requiresIdlePrewarm || self.request?.requiresIdlePrewarm == true, + reconcilesInputSelection: reconcilesInputSelection || self.request?.reconcilesInputSelection == true + ) + } + + mutating func take() -> Request? { + defer { self.request = nil } + return self.request + } + } + struct SilentPCMRecoveryWatchdog { static let requiredSilentWindows = 3 static let maximumSilentRMS: Float = 0.00_001 diff --git a/Tests/FluidDictationIntegrationTests/HotkeyShortcutTests.swift b/Tests/FluidDictationIntegrationTests/HotkeyShortcutTests.swift index 28ddde9f..a6946664 100644 --- a/Tests/FluidDictationIntegrationTests/HotkeyShortcutTests.swift +++ b/Tests/FluidDictationIntegrationTests/HotkeyShortcutTests.swift @@ -703,6 +703,33 @@ final class HotkeyShortcutTests: XCTestCase { )) } + func testBluetoothStartupPreservesOnlyExplicitReconciliationWork() { + var deferredRecovery = AudioCaptureIdlePolicy.DeferredBluetoothRouteRecovery() + + deferredRecovery.preserve( + reason: "ordinary route churn", + requiresIdlePrewarm: false, + reconcilesInputSelection: false + ) + XCTAssertNil(deferredRecovery.take()) + + deferredRecovery.preserve( + reason: "settings backup restored", + requiresIdlePrewarm: true, + reconcilesInputSelection: false + ) + deferredRecovery.preserve( + reason: "input topology changed", + requiresIdlePrewarm: false, + reconcilesInputSelection: true + ) + let request = deferredRecovery.take() + XCTAssertEqual(request?.reason, "settings backup restored") + XCTAssertEqual(request?.requiresIdlePrewarm, true) + XCTAssertEqual(request?.reconcilesInputSelection, true) + XCTAssertNil(deferredRecovery.take()) + } + func testSilentPCMWatchdogRecoversBuiltInDirectCaptureOnceAfterRealSignal() { var watchdog = AudioCaptureIdlePolicy.SilentPCMRecoveryWatchdog() From d3f94613cc9b5de3b8bbaefa3128cd2be5c244d6 Mon Sep 17 00:00:00 2001 From: altic-dev Date: Thu, 13 Aug 2026 15:29:20 -0700 Subject: [PATCH 5/9] fix(audio): avoid redundant bluetooth rebuild --- Sources/Fluid/Services/ASRService.swift | 81 +++++++++++++++++-- .../Services/AudioCaptureIdlePolicy.swift | 18 +++++ .../HotkeyShortcutTests.swift | 54 +++++++++++++ 3 files changed, 146 insertions(+), 7 deletions(-) diff --git a/Sources/Fluid/Services/ASRService.swift b/Sources/Fluid/Services/ASRService.swift index 47a39c5c..7e5142ac 100644 --- a/Sources/Fluid/Services/ASRService.swift +++ b/Sources/Fluid/Services/ASRService.swift @@ -2160,17 +2160,84 @@ final class ASRService: ObservableObject { private func finishAudioCaptureStart() { self.isStarting = false - if let deferredRecovery = self.deferredBluetoothStartupRouteRecovery.take() { - self.scheduleAudioRouteRecovery( - reason: "deferred after Bluetooth startup: \(deferredRecovery.reason)", - requiresIdlePrewarm: deferredRecovery.requiresIdlePrewarm, - reconcilesInputSelection: deferredRecovery.reconcilesInputSelection - ) - } + let deferredRecovery = self.deferredBluetoothStartupRouteRecovery.take() self.audioCaptureStateSettledTick &+= 1 let waiters = self.audioCaptureStartWaiters self.audioCaptureStartWaiters.removeAll(keepingCapacity: false) waiters.forEach { $0.resume() } + + if let deferredRecovery { + Task { @MainActor [weak self] in + await self?.processDeferredBluetoothStartupRouteRecovery(deferredRecovery) + } + } + } + + private func processDeferredBluetoothStartupRouteRecovery( + _ request: AudioCaptureIdlePolicy.DeferredBluetoothRouteRecovery.Request + ) async { + do { + try await Task.sleep(nanoseconds: self.audioRouteRecoveryDelayNanoseconds) + } catch { + return + } + guard self.isTerminating == false else { return } + + let snapshot = await withCheckedContinuation { continuation in + DispatchQueue.global(qos: .userInitiated).async { + let devices = AudioDevice.listInputDevicesRefreshingLiveness() + let defaultInputUID = AudioDevice.getDefaultInputDevice()?.uid + continuation.resume(returning: (devices, defaultInputUID)) + } + } + guard self.isTerminating == false else { return } + if self.isStarting { + self.deferredBluetoothStartupRouteRecovery.preserve( + reason: request.reason, + requiresIdlePrewarm: request.requiresIdlePrewarm, + reconcilesInputSelection: request.reconcilesInputSelection + ) + return + } + + let microphonePreferenceCoordinator = AppServices.shared.microphonePreferenceCoordinator + let resolvedInput: AudioDevice.Device? + if request.reconcilesInputSelection { + resolvedInput = microphonePreferenceCoordinator.reconcileMicrophoneSelection( + availableInputs: snapshot.0, + defaultInputUID: snapshot.1 + ) + } else { + resolvedInput = microphonePreferenceCoordinator.inputDeviceForCapture( + availableInputs: snapshot.0, + defaultInputUID: snapshot.1 + ) + } + self.cacheCurrentDeviceList(snapshot.0) + + let activeSnapshot = self.directAudioLifecycleController.snapshot + let shouldRecover = AudioCaptureIdlePolicy.shouldRecoverAfterDeferredBluetoothReconciliation( + isRunning: self.isRunning, + confirmedInputUID: microphonePreferenceCoordinator.confirmedActiveInputUID, + activeDeviceID: activeSnapshot.deviceID, + resolvedInputUID: resolvedInput?.uid, + resolvedDeviceID: resolvedInput?.id, + hasPreparedCapture: self.hasPreparedAudioCapture, + requiresIdlePrewarm: request.requiresIdlePrewarm + ) + guard shouldRecover else { + self.benchmarkLog( + "bluetooth_deferred_reconciliation_noop event=\(request.reason) " + + "resolvedUID=\(resolvedInput?.uid ?? "none")" + ) + return + } + + self.scheduleAudioRouteRecovery( + reason: "deferred after Bluetooth startup: \(request.reason)", + requiresIdlePrewarm: request.requiresIdlePrewarm, + reconcilesInputSelection: false + ) } /// Stops the recording session and returns the transcribed text. diff --git a/Sources/Fluid/Services/AudioCaptureIdlePolicy.swift b/Sources/Fluid/Services/AudioCaptureIdlePolicy.swift index 0f3c7336..f6b251cf 100644 --- a/Sources/Fluid/Services/AudioCaptureIdlePolicy.swift +++ b/Sources/Fluid/Services/AudioCaptureIdlePolicy.swift @@ -145,4 +145,22 @@ enum AudioCaptureIdlePolicy { ) -> Bool { directCaptureEnabled && isStarting && isRunning == false && attemptedInputIsBluetooth } + + static func shouldRecoverAfterDeferredBluetoothReconciliation( + isRunning: Bool, + confirmedInputUID: String?, + activeDeviceID: AudioObjectID?, + resolvedInputUID: String?, + resolvedDeviceID: AudioObjectID?, + hasPreparedCapture: Bool, + requiresIdlePrewarm: Bool + ) -> Bool { + if isRunning { + return resolvedInputUID != confirmedInputUID || resolvedDeviceID != activeDeviceID + } + if hasPreparedCapture { + return resolvedDeviceID != activeDeviceID + } + return requiresIdlePrewarm + } } diff --git a/Tests/FluidDictationIntegrationTests/HotkeyShortcutTests.swift b/Tests/FluidDictationIntegrationTests/HotkeyShortcutTests.swift index a6946664..59265781 100644 --- a/Tests/FluidDictationIntegrationTests/HotkeyShortcutTests.swift +++ b/Tests/FluidDictationIntegrationTests/HotkeyShortcutTests.swift @@ -730,6 +730,60 @@ final class HotkeyShortcutTests: XCTestCase { XCTAssertNil(deferredRecovery.take()) } + func testDeferredBluetoothReconciliationLeavesMatchingActiveInputUntouched() { + XCTAssertFalse(AudioCaptureIdlePolicy.shouldRecoverAfterDeferredBluetoothReconciliation( + isRunning: true, + confirmedInputUID: "airpods", + activeDeviceID: 42, + resolvedInputUID: "airpods", + resolvedDeviceID: 42, + hasPreparedCapture: true, + requiresIdlePrewarm: true + )) + } + + func testDeferredBluetoothReconciliationRecoversChangedSelectionOrIdentity() { + XCTAssertTrue(AudioCaptureIdlePolicy.shouldRecoverAfterDeferredBluetoothReconciliation( + isRunning: true, + confirmedInputUID: "airpods", + activeDeviceID: 42, + resolvedInputUID: "usb", + resolvedDeviceID: 88, + hasPreparedCapture: true, + requiresIdlePrewarm: true + )) + XCTAssertTrue(AudioCaptureIdlePolicy.shouldRecoverAfterDeferredBluetoothReconciliation( + isRunning: true, + confirmedInputUID: "airpods", + activeDeviceID: 42, + resolvedInputUID: "airpods", + resolvedDeviceID: 43, + hasPreparedCapture: true, + requiresIdlePrewarm: true + )) + } + + func testDeferredBluetoothReconciliationPreservesIdlePrewarmIntent() { + XCTAssertFalse(AudioCaptureIdlePolicy.shouldRecoverAfterDeferredBluetoothReconciliation( + isRunning: false, + confirmedInputUID: nil, + activeDeviceID: 42, + resolvedInputUID: "airpods", + resolvedDeviceID: 42, + hasPreparedCapture: true, + requiresIdlePrewarm: true + )) + XCTAssertTrue(AudioCaptureIdlePolicy.shouldRecoverAfterDeferredBluetoothReconciliation( + isRunning: false, + confirmedInputUID: nil, + activeDeviceID: nil, + resolvedInputUID: "airpods", + resolvedDeviceID: 42, + hasPreparedCapture: false, + requiresIdlePrewarm: true + )) + } + func testSilentPCMWatchdogRecoversBuiltInDirectCaptureOnceAfterRealSignal() { var watchdog = AudioCaptureIdlePolicy.SilentPCMRecoveryWatchdog() From 9d2795666d6ecd8e3d1358a9a0c3eb84ef39f3ca Mon Sep 17 00:00:00 2001 From: altic-dev Date: Thu, 13 Aug 2026 15:39:36 -0700 Subject: [PATCH 6/9] fix(audio): close bluetooth recovery races --- Sources/Fluid/Services/ASRService.swift | 46 ++++++++++---- .../Services/AudioCaptureIdlePolicy.swift | 31 ++++++++-- .../HotkeyShortcutTests.swift | 61 +++++++++++++++---- 3 files changed, 108 insertions(+), 30 deletions(-) diff --git a/Sources/Fluid/Services/ASRService.swift b/Sources/Fluid/Services/ASRService.swift index 7e5142ac..8f3539f4 100644 --- a/Sources/Fluid/Services/ASRService.swift +++ b/Sources/Fluid/Services/ASRService.swift @@ -711,7 +711,7 @@ final class ASRService: ObservableObject { private var audioStartAttemptInputUID: String? private var audioStartAttemptInputName: String? private var audioStartAttemptIsBluetooth = false - private var audioStartAttemptIsBuiltIn = false + private var audioStartAttemptIsInternalMicrophone = false private var deferredBluetoothStartupRouteRecovery = AudioCaptureIdlePolicy.DeferredBluetoothRouteRecovery() private var silentPCMRecoveryWatchdog = AudioCaptureIdlePolicy.SilentPCMRecoveryWatchdog() @@ -930,7 +930,7 @@ final class ASRService: ObservableObject { self.audioStartAttemptInputUID = nil self.audioStartAttemptInputName = nil self.audioStartAttemptIsBluetooth = false - self.audioStartAttemptIsBuiltIn = false + self.audioStartAttemptIsInternalMicrophone = false if SettingsStore.shared.experimentalDirectAudioCaptureEnabled { // A non-route path may have scheduled a fire-and-forget retirement. // Do not let direct capture startup overlap a queued AVAudioEngine @@ -956,7 +956,7 @@ final class ASRService: ObservableObject { self.audioStartAttemptInputUID = device.uid self.audioStartAttemptInputName = device.name self.audioStartAttemptIsBluetooth = device.isBluetooth - self.audioStartAttemptIsBuiltIn = device.isBuiltIn + self.audioStartAttemptIsInternalMicrophone = device.isUnavailableWhenClamshellClosed AppServices.shared.microphonePreferenceCoordinator.reportResolvedSelection( uid: device.uid, name: device.name @@ -1153,7 +1153,7 @@ final class ASRService: ObservableObject { "inputUID=\(self.audioStartAttemptInputUID ?? "unknown")" ) if self.silentPCMRecoveryWatchdog.shouldRecover( - isBuiltInInput: self.audioStartAttemptIsBuiltIn, + isInternalMicrophone: self.audioStartAttemptIsInternalMicrophone, isDirectCapture: self.activeAudioCaptureBackend == .directCoreAudio, rms: rms, peak: peak @@ -2123,12 +2123,13 @@ final class ASRService: ObservableObject { reason: String ) { let elapsedMilliseconds = Int((elapsed * 1000).rounded()) - let maximumMilliseconds = Int( - (AudioCaptureIdlePolicy.BluetoothInputStabilization.maximumDuration * 1000).rounded() + let admissionWindowMilliseconds = Int( + (AudioCaptureIdlePolicy.BluetoothInputStabilization.retryAdmissionWindow * 1000).rounded() ) self.benchmarkLog( "bluetooth_start_retry uid=\(uid) attempt=\(attempt) " + - "elapsedMs=\(elapsedMilliseconds) budgetMs=\(maximumMilliseconds) reason=\(reason)" + "elapsedMs=\(elapsedMilliseconds) " + + "admissionWindowMs=\(admissionWindowMilliseconds) reason=\(reason)" ) } @@ -3177,7 +3178,8 @@ final class ASRService: ObservableObject { private func scheduleAudioRouteRecovery( reason: String, requiresIdlePrewarm: Bool = false, - reconcilesInputSelection: Bool = false + reconcilesInputSelection: Bool = false, + invalidatesCurrentStart: Bool = false ) { guard self.isTerminating == false else { self.benchmarkLog("route_recovery_ignored reason=app_terminating event=\(reason)") @@ -3193,15 +3195,32 @@ final class ASRService: ObservableObject { // than once while entering microphone mode. The active start owns // its bounded retry loop; a second recovery owner would exclude the // same healthy device before the Bluetooth route settles. - self.deferredBluetoothStartupRouteRecovery.preserve( - reason: reason, + let disposition = AudioCaptureIdlePolicy.bluetoothStartupRouteChangeDisposition( + invalidatesCurrentStart: invalidatesCurrentStart, requiresIdlePrewarm: requiresIdlePrewarm, reconcilesInputSelection: reconcilesInputSelection ) - let preserved = requiresIdlePrewarm || reconcilesInputSelection + if disposition == .retryCurrentStart { + // Make routeStayedStable false even if first PCM won the + // readiness-gate race. The startup loop then retries the same + // Bluetooth input without handing it to active-route recovery. + self.audioRouteRecoveryGeneration &+= 1 + self.benchmarkLog( + "bluetooth_start_route_invalidation_retry event=\(reason) " + + "routeGeneration=\(self.audioRouteRecoveryGeneration)" + ) + return + } + if disposition == .preserveDeferredWork { + self.deferredBluetoothStartupRouteRecovery.preserve( + reason: reason, + requiresIdlePrewarm: requiresIdlePrewarm, + reconcilesInputSelection: reconcilesInputSelection + ) + } self.benchmarkLog( "bluetooth_start_route_change_deferred event=\(reason) " + - "reconciliationPreserved=\(preserved)" + "disposition=\(disposition)" ) return } @@ -3604,7 +3623,8 @@ final class ASRService: ObservableObject { ) self.scheduleAudioRouteRecovery( reason: "direct format changed: \(invalidation.reason)", - requiresIdlePrewarm: true + requiresIdlePrewarm: true, + invalidatesCurrentStart: true ) } diff --git a/Sources/Fluid/Services/AudioCaptureIdlePolicy.swift b/Sources/Fluid/Services/AudioCaptureIdlePolicy.swift index f6b251cf..9350d6fc 100644 --- a/Sources/Fluid/Services/AudioCaptureIdlePolicy.swift +++ b/Sources/Fluid/Services/AudioCaptureIdlePolicy.swift @@ -1,6 +1,12 @@ import CoreAudio enum AudioCaptureIdlePolicy { + enum BluetoothStartupRouteChangeDisposition: Equatable { + case retryCurrentStart + case preserveDeferredWork + case ignore + } + struct DeferredBluetoothRouteRecovery { struct Request { let reason: String @@ -39,12 +45,12 @@ enum AudioCaptureIdlePolicy { private var hasRequestedRecovery = false mutating func shouldRecover( - isBuiltInInput: Bool, + isInternalMicrophone: Bool, isDirectCapture: Bool, rms: Float, peak: Float ) -> Bool { - guard isBuiltInInput, isDirectCapture, self.hasRequestedRecovery == false else { return false } + guard isInternalMicrophone, isDirectCapture, self.hasRequestedRecovery == false else { return false } let isEffectivelySilent = rms <= Self.maximumSilentRMS && peak <= Self.maximumSilentPeak if isEffectivelySilent == false { self.hasSeenSignal = true @@ -60,7 +66,10 @@ enum AudioCaptureIdlePolicy { } struct BluetoothInputStabilization { - static let maximumDuration: TimeInterval = 5 + /// New same-device retries may begin within this window. An admitted + /// attempt still gets its normal readiness timeout so a nearly settled + /// Bluetooth route is not cancelled at the deadline. + static let retryAdmissionWindow: TimeInterval = 5 private(set) var inputUID: String? private(set) var startedAt: TimeInterval? @@ -75,7 +84,7 @@ enum AudioCaptureIdlePolicy { self.inputUID = inputUID self.startedAt = now } - return self.elapsed(at: now) < Self.maximumDuration + return self.elapsed(at: now) < Self.retryAdmissionWindow } func elapsed(at now: TimeInterval) -> TimeInterval { @@ -146,6 +155,20 @@ enum AudioCaptureIdlePolicy { directCaptureEnabled && isStarting && isRunning == false && attemptedInputIsBluetooth } + static func bluetoothStartupRouteChangeDisposition( + invalidatesCurrentStart: Bool, + requiresIdlePrewarm: Bool, + reconcilesInputSelection: Bool + ) -> BluetoothStartupRouteChangeDisposition { + if invalidatesCurrentStart { + return .retryCurrentStart + } + if requiresIdlePrewarm || reconcilesInputSelection { + return .preserveDeferredWork + } + return .ignore + } + static func shouldRecoverAfterDeferredBluetoothReconciliation( isRunning: Bool, confirmedInputUID: String?, diff --git a/Tests/FluidDictationIntegrationTests/HotkeyShortcutTests.swift b/Tests/FluidDictationIntegrationTests/HotkeyShortcutTests.swift index 59265781..c2614f1e 100644 --- a/Tests/FluidDictationIntegrationTests/HotkeyShortcutTests.swift +++ b/Tests/FluidDictationIntegrationTests/HotkeyShortcutTests.swift @@ -652,10 +652,20 @@ final class HotkeyShortcutTests: XCTestCase { ) XCTAssertTrue(builtInDevice.isBuiltIn) + XCTAssertTrue(builtInDevice.isUnavailableWhenClamshellClosed) XCTAssertFalse(builtInDevice.isBluetooth) + + let analogHeadset = Self.device( + uid: "analog-headset", + name: "External Microphone", + transportType: kAudioDeviceTransportTypeBuiltIn, + inputDataSourceID: AudioDevice.Device.externalMicrophoneDataSourceID + ) + XCTAssertTrue(analogHeadset.isBuiltIn) + XCTAssertFalse(analogHeadset.isUnavailableWhenClamshellClosed) } - func testBluetoothStartupRetriesSameInputWithinBoundedWindow() { + func testBluetoothStartupAdmitsSameInputRetriesWithinFiveSecondWindow() { var stabilization = AudioCaptureIdlePolicy.BluetoothInputStabilization() XCTAssertTrue(stabilization.shouldRetry( @@ -701,6 +711,31 @@ final class HotkeyShortcutTests: XCTestCase { isRunning: false, attemptedInputIsBluetooth: false )) + + XCTAssertEqual( + AudioCaptureIdlePolicy.bluetoothStartupRouteChangeDisposition( + invalidatesCurrentStart: true, + requiresIdlePrewarm: true, + reconcilesInputSelection: false + ), + .retryCurrentStart + ) + XCTAssertEqual( + AudioCaptureIdlePolicy.bluetoothStartupRouteChangeDisposition( + invalidatesCurrentStart: false, + requiresIdlePrewarm: true, + reconcilesInputSelection: true + ), + .preserveDeferredWork + ) + XCTAssertEqual( + AudioCaptureIdlePolicy.bluetoothStartupRouteChangeDisposition( + invalidatesCurrentStart: false, + requiresIdlePrewarm: false, + reconcilesInputSelection: false + ), + .ignore + ) } func testBluetoothStartupPreservesOnlyExplicitReconciliationWork() { @@ -784,56 +819,56 @@ final class HotkeyShortcutTests: XCTestCase { )) } - func testSilentPCMWatchdogRecoversBuiltInDirectCaptureOnceAfterRealSignal() { + func testSilentPCMWatchdogRecoversInternalDirectCaptureOnceAfterRealSignal() { var watchdog = AudioCaptureIdlePolicy.SilentPCMRecoveryWatchdog() XCTAssertFalse(watchdog.shouldRecover( - isBuiltInInput: true, isDirectCapture: true, rms: 0, peak: 0 + isInternalMicrophone: true, isDirectCapture: true, rms: 0, peak: 0 )) XCTAssertFalse(watchdog.shouldRecover( - isBuiltInInput: true, isDirectCapture: true, rms: 0.02, peak: 0.08 + isInternalMicrophone: true, isDirectCapture: true, rms: 0.02, peak: 0.08 )) for _ in 0..<(AudioCaptureIdlePolicy.SilentPCMRecoveryWatchdog.requiredSilentWindows - 1) { XCTAssertFalse(watchdog.shouldRecover( - isBuiltInInput: true, isDirectCapture: true, rms: 0, peak: 0 + isInternalMicrophone: true, isDirectCapture: true, rms: 0, peak: 0 )) } XCTAssertTrue(watchdog.shouldRecover( - isBuiltInInput: true, isDirectCapture: true, rms: 0, peak: 0 + isInternalMicrophone: true, isDirectCapture: true, rms: 0, peak: 0 )) XCTAssertFalse(watchdog.shouldRecover( - isBuiltInInput: true, isDirectCapture: true, rms: 0, peak: 0 + isInternalMicrophone: true, isDirectCapture: true, rms: 0, peak: 0 )) } func testSilentPCMWatchdogIgnoresExternalAndLowAmbientInputs() { var externalWatchdog = AudioCaptureIdlePolicy.SilentPCMRecoveryWatchdog() XCTAssertFalse(externalWatchdog.shouldRecover( - isBuiltInInput: false, isDirectCapture: true, rms: 0.02, peak: 0.08 + isInternalMicrophone: false, isDirectCapture: true, rms: 0.02, peak: 0.08 )) for _ in 0...AudioCaptureIdlePolicy.SilentPCMRecoveryWatchdog.requiredSilentWindows { XCTAssertFalse(externalWatchdog.shouldRecover( - isBuiltInInput: false, isDirectCapture: true, rms: 0, peak: 0 + isInternalMicrophone: false, isDirectCapture: true, rms: 0, peak: 0 )) } var legacyCaptureWatchdog = AudioCaptureIdlePolicy.SilentPCMRecoveryWatchdog() XCTAssertFalse(legacyCaptureWatchdog.shouldRecover( - isBuiltInInput: true, isDirectCapture: false, rms: 0.02, peak: 0.08 + isInternalMicrophone: true, isDirectCapture: false, rms: 0.02, peak: 0.08 )) for _ in 0...AudioCaptureIdlePolicy.SilentPCMRecoveryWatchdog.requiredSilentWindows { XCTAssertFalse(legacyCaptureWatchdog.shouldRecover( - isBuiltInInput: true, isDirectCapture: false, rms: 0, peak: 0 + isInternalMicrophone: true, isDirectCapture: false, rms: 0, peak: 0 )) } var ambientWatchdog = AudioCaptureIdlePolicy.SilentPCMRecoveryWatchdog() XCTAssertFalse(ambientWatchdog.shouldRecover( - isBuiltInInput: true, isDirectCapture: true, rms: 0.02, peak: 0.08 + isInternalMicrophone: true, isDirectCapture: true, rms: 0.02, peak: 0.08 )) for _ in 0...AudioCaptureIdlePolicy.SilentPCMRecoveryWatchdog.requiredSilentWindows { XCTAssertFalse(ambientWatchdog.shouldRecover( - isBuiltInInput: true, isDirectCapture: true, rms: 0.000_1, peak: 0.001 + isInternalMicrophone: true, isDirectCapture: true, rms: 0.000_1, peak: 0.001 )) } } From c9eece66d35b63df49a1ecc7e8e0c72a9e825de1 Mon Sep 17 00:00:00 2001 From: altic-dev Date: Fri, 14 Aug 2026 12:33:53 -0700 Subject: [PATCH 7/9] fix(audio): retain bluetooth retry identity --- Sources/Fluid/Services/ASRService.swift | 32 ++++++++++++--- .../Services/AudioCaptureIdlePolicy.swift | 33 ++++++++++++++++ .../HotkeyShortcutTests.swift | 39 +++++++++++++++++++ 3 files changed, 98 insertions(+), 6 deletions(-) diff --git a/Sources/Fluid/Services/ASRService.swift b/Sources/Fluid/Services/ASRService.swift index 8f3539f4..004a4193 100644 --- a/Sources/Fluid/Services/ASRService.swift +++ b/Sources/Fluid/Services/ASRService.swift @@ -927,6 +927,14 @@ final class ASRService: ObservableObject { excluding excludedInputUIDs: Set = [], forcingInputUID: String? = nil ) async throws { + let previousAttemptIdentity = self.audioStartAttemptInputUID.map { + AudioCaptureIdlePolicy.CaptureAttemptIdentity( + uid: $0, + name: self.audioStartAttemptInputName, + isBluetooth: self.audioStartAttemptIsBluetooth, + isInternalMicrophone: self.audioStartAttemptIsInternalMicrophone + ) + } self.audioStartAttemptInputUID = nil self.audioStartAttemptInputName = nil self.audioStartAttemptIsBluetooth = false @@ -937,18 +945,30 @@ final class ASRService: ObservableObject { // release. await self.audioEngineRetirementDrain.waitForScheduledReleases() do { - let selection = forcingInputUID.map(DirectCoreAudioDeviceSelection.preferredUID) ?? - self.directCoreAudioDeviceSelection(excluding: excludedInputUIDs) - guard let selection else { + let selectedInput: AudioDevice.Device? + if let forcingInputUID { + selectedInput = AudioDevice.listInputDevices().first { $0.uid == forcingInputUID } + } else { + selectedInput = self.resolvedInputDeviceForCapture(excluding: excludedInputUIDs) + } + guard let attemptIdentity = AudioCaptureIdlePolicy.CaptureAttemptIdentity.resolve( + selectedInput: selectedInput, + forcingInputUID: forcingInputUID, + previous: previousAttemptIdentity + ) else { throw NSError( domain: "ASRService", code: -4, userInfo: [NSLocalizedDescriptionKey: "No remaining microphone is available."] ) } - if case let .preferredUID(uid) = selection { - self.audioStartAttemptInputUID = uid - } + let selection = DirectCoreAudioDeviceSelection.preferredUID(attemptIdentity.uid) + // Preserve the selected endpoint's identity before the async UID + // resolution, where Bluetooth topology churn can make it vanish. + self.audioStartAttemptInputUID = attemptIdentity.uid + self.audioStartAttemptInputName = attemptIdentity.name + self.audioStartAttemptIsBluetooth = attemptIdentity.isBluetooth + self.audioStartAttemptIsInternalMicrophone = attemptIdentity.isInternalMicrophone let device = try await self.directAudioLifecycleController.resolveDevice( selection: selection, reason: "recording_start" diff --git a/Sources/Fluid/Services/AudioCaptureIdlePolicy.swift b/Sources/Fluid/Services/AudioCaptureIdlePolicy.swift index 9350d6fc..62b6d91b 100644 --- a/Sources/Fluid/Services/AudioCaptureIdlePolicy.swift +++ b/Sources/Fluid/Services/AudioCaptureIdlePolicy.swift @@ -1,6 +1,39 @@ import CoreAudio enum AudioCaptureIdlePolicy { + struct CaptureAttemptIdentity: Equatable { + let uid: String + let name: String? + let isBluetooth: Bool + let isInternalMicrophone: Bool + + static func resolve( + selectedInput: AudioDevice.Device?, + forcingInputUID: String?, + previous: Self? + ) -> Self? { + let uid = forcingInputUID ?? selectedInput?.uid + guard let uid else { return nil } + if let selectedInput, selectedInput.uid == uid { + return Self( + uid: uid, + name: selectedInput.name, + isBluetooth: selectedInput.isBluetooth, + isInternalMicrophone: selectedInput.isUnavailableWhenClamshellClosed + ) + } + if previous?.uid == uid { + return previous + } + return Self( + uid: uid, + name: nil, + isBluetooth: false, + isInternalMicrophone: false + ) + } + } + enum BluetoothStartupRouteChangeDisposition: Equatable { case retryCurrentStart case preserveDeferredWork diff --git a/Tests/FluidDictationIntegrationTests/HotkeyShortcutTests.swift b/Tests/FluidDictationIntegrationTests/HotkeyShortcutTests.swift index c2614f1e..63f5cfdc 100644 --- a/Tests/FluidDictationIntegrationTests/HotkeyShortcutTests.swift +++ b/Tests/FluidDictationIntegrationTests/HotkeyShortcutTests.swift @@ -685,6 +685,45 @@ final class HotkeyShortcutTests: XCTestCase { )) } + func testCaptureAttemptRetainsBluetoothIdentityWhenForcedDeviceDisappears() { + let airPods = Self.device( + uid: "airpods", + name: "AirPods Microphone", + transportType: kAudioDeviceTransportTypeBluetooth + ) + let selectedIdentity = AudioCaptureIdlePolicy.CaptureAttemptIdentity.resolve( + selectedInput: airPods, + forcingInputUID: nil, + previous: nil + ) + let retryIdentity = AudioCaptureIdlePolicy.CaptureAttemptIdentity.resolve( + selectedInput: nil, + forcingInputUID: "airpods", + previous: selectedIdentity + ) + + XCTAssertEqual(retryIdentity, selectedIdentity) + XCTAssertTrue(retryIdentity?.isBluetooth == true) + } + + func testCaptureAttemptDoesNotTransferBluetoothIdentityToDifferentDevice() { + let previous = AudioCaptureIdlePolicy.CaptureAttemptIdentity( + uid: "airpods", + name: "AirPods Microphone", + isBluetooth: true, + isInternalMicrophone: false + ) + + let replacement = AudioCaptureIdlePolicy.CaptureAttemptIdentity.resolve( + selectedInput: nil, + forcingInputUID: "usb-mic", + previous: previous + ) + + XCTAssertEqual(replacement?.uid, "usb-mic") + XCTAssertFalse(replacement?.isBluetooth == true) + } + func testBluetoothStartupPolicyDoesNotAffectOtherInputsOrActiveRecovery() { var stabilization = AudioCaptureIdlePolicy.BluetoothInputStabilization() From 7d780f400654afa24096aef7643e91d7059dd100 Mon Sep 17 00:00:00 2001 From: altic-dev Date: Fri, 14 Aug 2026 12:54:50 -0700 Subject: [PATCH 8/9] fix(audio): retry settling preferred input --- Sources/Fluid/Services/ASRService.swift | 29 ++++++++++-- .../Services/AudioCaptureIdlePolicy.swift | 19 ++++++++ .../Fluid/Services/AudioDeviceService.swift | 12 +++-- .../HotkeyShortcutTests.swift | 46 +++++++++++++++++++ 4 files changed, 98 insertions(+), 8 deletions(-) diff --git a/Sources/Fluid/Services/ASRService.swift b/Sources/Fluid/Services/ASRService.swift index 004a4193..8924e693 100644 --- a/Sources/Fluid/Services/ASRService.swift +++ b/Sources/Fluid/Services/ASRService.swift @@ -865,12 +865,13 @@ final class ASRService: ObservableObject { } private func resolvedInputDeviceForCapture( + availableInputs: [AudioDevice.Device] = AudioDevice.listInputDevices(), + defaultInputUID: String? = AudioDevice.getDefaultInputDevice()?.uid, excluding excludedUIDs: Set = [] ) -> AudioDevice.Device? { - let inputs = AudioDevice.listInputDevices() return AppServices.shared.microphonePreferenceCoordinator.inputDeviceForCapture( - availableInputs: inputs, - defaultInputUID: AudioDevice.getDefaultInputDevice()?.uid, + availableInputs: availableInputs, + defaultInputUID: defaultInputUID, excluding: excludedUIDs ) } @@ -945,11 +946,29 @@ final class ASRService: ObservableObject { // release. await self.audioEngineRetirementDrain.waitForScheduledReleases() do { + let deviceSnapshot = await Task.detached(priority: .userInitiated) { + let allDevices = AudioDevice.listAllDevices() + return ( + allDevices: allDevices, + defaultInputUID: AudioDevice.getDefaultInputDevice(from: allDevices)?.uid + ) + }.value + let allDevices = deviceSnapshot.allDevices + let availableInputs = allDevices.filter(\.hasInput) let selectedInput: AudioDevice.Device? if let forcingInputUID { - selectedInput = AudioDevice.listInputDevices().first { $0.uid == forcingInputUID } + selectedInput = availableInputs.first { $0.uid == forcingInputUID } } else { - selectedInput = self.resolvedInputDeviceForCapture(excluding: excludedInputUIDs) + selectedInput = AudioCaptureIdlePolicy.bluetoothInputAwaitingAvailability( + priorityInputUIDs: SettingsStore.shared.microphonePriority.map(\.uid), + preferredInputUID: SettingsStore.shared.preferredInputDeviceUID, + allDevices: allDevices, + excluding: excludedInputUIDs + ) ?? self.resolvedInputDeviceForCapture( + availableInputs: availableInputs, + defaultInputUID: deviceSnapshot.defaultInputUID, + excluding: excludedInputUIDs + ) } guard let attemptIdentity = AudioCaptureIdlePolicy.CaptureAttemptIdentity.resolve( selectedInput: selectedInput, diff --git a/Sources/Fluid/Services/AudioCaptureIdlePolicy.swift b/Sources/Fluid/Services/AudioCaptureIdlePolicy.swift index 62b6d91b..4f52dd6f 100644 --- a/Sources/Fluid/Services/AudioCaptureIdlePolicy.swift +++ b/Sources/Fluid/Services/AudioCaptureIdlePolicy.swift @@ -126,6 +126,25 @@ enum AudioCaptureIdlePolicy { } } + static func bluetoothInputAwaitingAvailability( + priorityInputUIDs: [String], + preferredInputUID: String?, + allDevices: [AudioDevice.Device], + excluding excludedUIDs: Set + ) -> AudioDevice.Device? { + let priorityUID = priorityInputUIDs.first { excludedUIDs.contains($0) == false } + let candidateUID = priorityUID ?? preferredInputUID.flatMap { uid in + excludedUIDs.contains(uid) ? nil : uid + } + guard let candidateUID, + let candidate = allDevices.first(where: { $0.uid == candidateUID }), + candidate.isBluetooth, + candidate.hasInput == false, + candidate.isAlive + else { return nil } + return candidate + } + static func shouldPrewarmCapture(experimentalDirectAudioCaptureEnabled: Bool) -> Bool { experimentalDirectAudioCaptureEnabled } diff --git a/Sources/Fluid/Services/AudioDeviceService.swift b/Sources/Fluid/Services/AudioDeviceService.swift index c9669fa1..206247b6 100644 --- a/Sources/Fluid/Services/AudioDeviceService.swift +++ b/Sources/Fluid/Services/AudioDeviceService.swift @@ -14,7 +14,7 @@ import IOKit.pwr_mgt // MARK: - Audio Device Manager nonisolated enum AudioDevice { - struct Device: Identifiable, Hashable { + struct Device: Identifiable, Hashable, Sendable { static let externalMicrophoneDataSourceID: UInt32 = 0x656d6963 // 'emic' let id: AudioObjectID @@ -213,8 +213,14 @@ nonisolated enum AudioDevice { } static func getDefaultInputDevice() -> Device? { - guard let devId: AudioObjectID = getDefaultDeviceId(selector: kAudioHardwarePropertyDefaultInputDevice) else { return nil } - return self.listAllDevices().first { $0.id == devId } + self.getDefaultInputDevice(from: self.listAllDevices()) + } + + static func getDefaultInputDevice(from devices: [Device]) -> Device? { + guard let deviceID: AudioObjectID = getDefaultDeviceId( + selector: kAudioHardwarePropertyDefaultInputDevice + ) else { return nil } + return devices.first { $0.id == deviceID } } static func getDefaultOutputDevice() -> Device? { diff --git a/Tests/FluidDictationIntegrationTests/HotkeyShortcutTests.swift b/Tests/FluidDictationIntegrationTests/HotkeyShortcutTests.swift index 63f5cfdc..af3756c6 100644 --- a/Tests/FluidDictationIntegrationTests/HotkeyShortcutTests.swift +++ b/Tests/FluidDictationIntegrationTests/HotkeyShortcutTests.swift @@ -724,6 +724,52 @@ final class HotkeyShortcutTests: XCTestCase { XCTAssertFalse(replacement?.isBluetooth == true) } + func testCaptureAttemptSeedsPreferredBluetoothIdentityBeforeInputAppears() { + let airPodsOutputProfile = AudioDevice.Device( + id: 42, + uid: "airpods", + name: "AirPods", + hasInput: false, + hasOutput: true, + transportType: kAudioDeviceTransportTypeBluetooth + ) + + let candidate = AudioCaptureIdlePolicy.bluetoothInputAwaitingAvailability( + priorityInputUIDs: ["airpods", "built-in"], + preferredInputUID: "airpods", + allDevices: [airPodsOutputProfile], + excluding: [] + ) + + XCTAssertEqual(candidate?.uid, "airpods") + XCTAssertTrue(candidate?.isBluetooth == true) + } + + func testCaptureAttemptDoesNotWaitForLowerPriorityBluetoothInput() { + let builtIn = Self.device( + uid: "built-in", + name: "MacBook Pro Microphone", + transportType: kAudioDeviceTransportTypeBuiltIn + ) + let airPodsOutputProfile = AudioDevice.Device( + id: 42, + uid: "airpods", + name: "AirPods", + hasInput: false, + hasOutput: true, + transportType: kAudioDeviceTransportTypeBluetooth + ) + + let candidate = AudioCaptureIdlePolicy.bluetoothInputAwaitingAvailability( + priorityInputUIDs: ["built-in", "airpods"], + preferredInputUID: "built-in", + allDevices: [builtIn, airPodsOutputProfile], + excluding: [] + ) + + XCTAssertNil(candidate) + } + func testBluetoothStartupPolicyDoesNotAffectOtherInputsOrActiveRecovery() { var stabilization = AudioCaptureIdlePolicy.BluetoothInputStabilization() From 3789b79ade186d81e8da1c36ace77f4159d2dce1 Mon Sep 17 00:00:00 2001 From: altic-dev Date: Fri, 14 Aug 2026 13:06:13 -0700 Subject: [PATCH 9/9] fix(audio): skip absent retry priorities --- Sources/Fluid/Services/ASRService.swift | 12 +++++---- .../Services/AudioCaptureIdlePolicy.swift | 26 ++++++++++++------- .../HotkeyShortcutTests.swift | 23 ++++++++++++++++ 3 files changed, 47 insertions(+), 14 deletions(-) diff --git a/Sources/Fluid/Services/ASRService.swift b/Sources/Fluid/Services/ASRService.swift index 8924e693..1bdb0cd4 100644 --- a/Sources/Fluid/Services/ASRService.swift +++ b/Sources/Fluid/Services/ASRService.swift @@ -959,16 +959,18 @@ final class ASRService: ObservableObject { if let forcingInputUID { selectedInput = availableInputs.first { $0.uid == forcingInputUID } } else { + let resolvedInput = self.resolvedInputDeviceForCapture( + availableInputs: availableInputs, + defaultInputUID: deviceSnapshot.defaultInputUID, + excluding: excludedInputUIDs + ) selectedInput = AudioCaptureIdlePolicy.bluetoothInputAwaitingAvailability( priorityInputUIDs: SettingsStore.shared.microphonePriority.map(\.uid), preferredInputUID: SettingsStore.shared.preferredInputDeviceUID, + resolvedInputUID: resolvedInput?.uid, allDevices: allDevices, excluding: excludedInputUIDs - ) ?? self.resolvedInputDeviceForCapture( - availableInputs: availableInputs, - defaultInputUID: deviceSnapshot.defaultInputUID, - excluding: excludedInputUIDs - ) + ) ?? resolvedInput } guard let attemptIdentity = AudioCaptureIdlePolicy.CaptureAttemptIdentity.resolve( selectedInput: selectedInput, diff --git a/Sources/Fluid/Services/AudioCaptureIdlePolicy.swift b/Sources/Fluid/Services/AudioCaptureIdlePolicy.swift index 4f52dd6f..7a967387 100644 --- a/Sources/Fluid/Services/AudioCaptureIdlePolicy.swift +++ b/Sources/Fluid/Services/AudioCaptureIdlePolicy.swift @@ -129,20 +129,28 @@ enum AudioCaptureIdlePolicy { static func bluetoothInputAwaitingAvailability( priorityInputUIDs: [String], preferredInputUID: String?, + resolvedInputUID: String?, allDevices: [AudioDevice.Device], excluding excludedUIDs: Set ) -> AudioDevice.Device? { - let priorityUID = priorityInputUIDs.first { excludedUIDs.contains($0) == false } - let candidateUID = priorityUID ?? preferredInputUID.flatMap { uid in - excludedUIDs.contains(uid) ? nil : uid + func settlingBluetoothDevice(uid: String) -> AudioDevice.Device? { + guard let candidate = allDevices.first(where: { $0.uid == uid }), + candidate.isBluetooth, + candidate.hasInput == false, + candidate.isAlive + else { return nil } + return candidate + } + + for uid in priorityInputUIDs where excludedUIDs.contains(uid) == false { + if uid == resolvedInputUID { return nil } + if let candidate = settlingBluetoothDevice(uid: uid) { return candidate } } - guard let candidateUID, - let candidate = allDevices.first(where: { $0.uid == candidateUID }), - candidate.isBluetooth, - candidate.hasInput == false, - candidate.isAlive + guard let preferredInputUID, + excludedUIDs.contains(preferredInputUID) == false, + preferredInputUID != resolvedInputUID else { return nil } - return candidate + return settlingBluetoothDevice(uid: preferredInputUID) } static func shouldPrewarmCapture(experimentalDirectAudioCaptureEnabled: Bool) -> Bool { diff --git a/Tests/FluidDictationIntegrationTests/HotkeyShortcutTests.swift b/Tests/FluidDictationIntegrationTests/HotkeyShortcutTests.swift index af3756c6..38b83136 100644 --- a/Tests/FluidDictationIntegrationTests/HotkeyShortcutTests.swift +++ b/Tests/FluidDictationIntegrationTests/HotkeyShortcutTests.swift @@ -737,6 +737,7 @@ final class HotkeyShortcutTests: XCTestCase { let candidate = AudioCaptureIdlePolicy.bluetoothInputAwaitingAvailability( priorityInputUIDs: ["airpods", "built-in"], preferredInputUID: "airpods", + resolvedInputUID: "built-in", allDevices: [airPodsOutputProfile], excluding: [] ) @@ -763,6 +764,7 @@ final class HotkeyShortcutTests: XCTestCase { let candidate = AudioCaptureIdlePolicy.bluetoothInputAwaitingAvailability( priorityInputUIDs: ["built-in", "airpods"], preferredInputUID: "built-in", + resolvedInputUID: "built-in", allDevices: [builtIn, airPodsOutputProfile], excluding: [] ) @@ -770,6 +772,27 @@ final class HotkeyShortcutTests: XCTestCase { XCTAssertNil(candidate) } + func testCaptureAttemptSkipsDisconnectedPriorityBeforeSettlingBluetoothInput() { + let airPodsOutputProfile = AudioDevice.Device( + id: 42, + uid: "airpods", + name: "AirPods", + hasInput: false, + hasOutput: true, + transportType: kAudioDeviceTransportTypeBluetooth + ) + + let candidate = AudioCaptureIdlePolicy.bluetoothInputAwaitingAvailability( + priorityInputUIDs: ["disconnected-usb", "airpods", "built-in"], + preferredInputUID: "disconnected-usb", + resolvedInputUID: "built-in", + allDevices: [airPodsOutputProfile], + excluding: [] + ) + + XCTAssertEqual(candidate?.uid, "airpods") + } + func testBluetoothStartupPolicyDoesNotAffectOtherInputsOrActiveRecovery() { var stabilization = AudioCaptureIdlePolicy.BluetoothInputStabilization()