From ba52b9ddebb0f57ee339e9372163a80d08d4e7ce Mon Sep 17 00:00:00 2001 From: Ryan Duryea Date: Thu, 16 Jul 2026 17:19:46 -0600 Subject: [PATCH 1/5] feat(audio): restore independent input-device mode via direct Core Audio capture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per-app input selection was removed because binding a non-default device through AVAudioEngine's inputNode fails on macOS with kAudioUnitErr_InvalidPropertyValue (-10851), notably for aggregate/Bluetooth devices (see the syncAudioDevicesWithSystem comment and the note at the bindPreferred* call site). But the direct Core Audio capture path added since then (DirectCoreAudioInput -> CoreAudioCaptureSupport.c, using AudioDeviceCreateIOProcID/AudioDeviceStart on a specific device) binds an arbitrary input device per-app WITHOUT touching the system default and WITHOUT the AVAudioEngine limitation. resolvedInputDeviceForCapture() already returns the preferred device when sync is disabled — the feature was only gated off by syncAudioDevicesWithSystem being hardcoded to true. This change: - Restores syncAudioDevicesWithSystem as a real, persisted setting (default true for backward compatibility), gated on Direct Audio Capture being enabled so independent selection only ever rides the safe IOProc path. - Keeps the AVAudioEngine compatibility path pinned to the system default input/output whenever Direct Audio Capture is on, so the -10851 bind is never attempted (only in the legacy direct-capture-off configuration). - Fixes the Settings input-device picker to reflect the saved preferred device in independent mode (it previously always showed the system default at startup/reload, while the output picker already restored its preference) - capture was already correct; this fixes the display. - Adds a "Sync devices with macOS" toggle to Audio Devices settings (shown when Direct Audio Capture is on). With sync disabled, choosing an input device stores it as the preference and captures from it directly; when unplugged, capture falls back to the system default and auto-switches back on reconnect (existing behavior). Compiles clean via xcodebuild (Debug, macOS). Validated end-to-end on hardware: USB handset, generic Bluetooth (Shokz, HFP), and AirPods (AAC-ELD) each captured per-app with the system default left on the built-in mic and zero -10851 / fallback. Co-Authored-By: Claude Opus 4.8 (1M context) --- Fluid.xcodeproj/project.pbxproj | 4 + Sources/Fluid/ContentView.swift | 21 +++- Sources/Fluid/Persistence/SettingsStore.swift | 22 ++-- Sources/Fluid/Services/ASRService.swift | 18 ++- Sources/Fluid/Services/MenuBarManager.swift | 6 + Sources/Fluid/UI/SettingsView.swift | 110 +++++++++++------- .../AudioDeviceSyncSettingTests.swift | 54 +++++++++ 7 files changed, 180 insertions(+), 55 deletions(-) create mode 100644 Tests/FluidDictationIntegrationTests/AudioDeviceSyncSettingTests.swift diff --git a/Fluid.xcodeproj/project.pbxproj b/Fluid.xcodeproj/project.pbxproj index 14b46cb9..610a74cd 100644 --- a/Fluid.xcodeproj/project.pbxproj +++ b/Fluid.xcodeproj/project.pbxproj @@ -17,6 +17,7 @@ 86CAA2D4EF18433096185602 /* LLMClientRequestBodyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 343B29013F4441D6A797D12D /* LLMClientRequestBodyTests.swift */; }; 272BFB5CB271489892CAE50C /* TemperatureSupportTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 980330F3CE464336ADCE3E23 /* TemperatureSupportTests.swift */; }; A62300000000000000000002 /* AudioBufferConverterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A62300000000000000000001 /* AudioBufferConverterTests.swift */; }; + A62300000000000000000004 /* AudioDeviceSyncSettingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A62300000000000000000003 /* AudioDeviceSyncSettingTests.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 */; }; 7CE006BD2E80EBE600DDCCD6 /* AppUpdater in Frameworks */ = {isa = PBXBuildFile; productRef = 7CE006BC2E80EBE600DDCCD6 /* AppUpdater */; }; @@ -40,6 +41,7 @@ 343B29013F4441D6A797D12D /* LLMClientRequestBodyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LLMClientRequestBodyTests.swift; sourceTree = ""; }; 980330F3CE464336ADCE3E23 /* TemperatureSupportTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TemperatureSupportTests.swift; sourceTree = ""; }; A62300000000000000000001 /* AudioBufferConverterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioBufferConverterTests.swift; sourceTree = ""; }; + A62300000000000000000003 /* AudioDeviceSyncSettingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioDeviceSyncSettingTests.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; }; @@ -113,6 +115,7 @@ 343B29013F4441D6A797D12D /* LLMClientRequestBodyTests.swift */, 980330F3CE464336ADCE3E23 /* TemperatureSupportTests.swift */, A62300000000000000000001 /* AudioBufferConverterTests.swift */, + A62300000000000000000003 /* AudioDeviceSyncSettingTests.swift */, ); path = FluidDictationIntegrationTests; sourceTree = ""; @@ -270,6 +273,7 @@ 86CAA2D4EF18433096185602 /* LLMClientRequestBodyTests.swift in Sources */, 272BFB5CB271489892CAE50C /* TemperatureSupportTests.swift in Sources */, A62300000000000000000002 /* AudioBufferConverterTests.swift in Sources */, + A62300000000000000000004 /* AudioDeviceSyncSettingTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Sources/Fluid/ContentView.swift b/Sources/Fluid/ContentView.swift index 051c4f19..6324fbae 100644 --- a/Sources/Fluid/ContentView.swift +++ b/Sources/Fluid/ContentView.swift @@ -399,9 +399,10 @@ struct ContentView: View { { self.selectedInputUID = prefIn } else if let sysIn = AudioDevice.getDefaultInputDevice()?.uid { - // Fallback to system default if preferred device disconnected + // Preferred device disconnected: show the system default in the picker + // (capture falls back to it too), but KEEP preferredInputDeviceUID so it + // auto-restores when the device reconnects. self.selectedInputUID = sysIn - SettingsStore.shared.preferredInputDeviceUID = sysIn } if let prefOut = SettingsStore.shared.preferredOutputDeviceUID, @@ -628,8 +629,16 @@ struct ContentView: View { self.selectedOutputUID = defOut } - if let systemInputUID = AudioDevice.getDefaultInputDevice()?.uid, - self.inputDevices.contains(where: { $0.uid == systemInputUID }) + // Independent mode: reflect the saved preferred input device in the picker + // (mirrors the preferred-output handling below). In sync mode, follow the + // system default. + if SettingsStore.shared.syncAudioDevicesWithSystem == false, + let prefIn = SettingsStore.shared.preferredInputDeviceUID, prefIn.isEmpty == false, + self.inputDevices.contains(where: { $0.uid == prefIn }) + { + self.selectedInputUID = prefIn + } else if let systemInputUID = AudioDevice.getDefaultInputDevice()?.uid, + self.inputDevices.contains(where: { $0.uid == systemInputUID }) { self.selectedInputUID = systemInputUID } @@ -4327,7 +4336,9 @@ private extension ContentView { self.isRewriteModeShortcutEnabled = SettingsStore.shared.rewriteModeShortcutEnabled self.playgroundUsed = SettingsStore.shared.playgroundUsed self.visualizerNoiseThreshold = SettingsStore.shared.visualizerNoiseThreshold - self.selectedInputUID = AudioDevice.getDefaultInputDevice()?.uid ?? "" + self.selectedInputUID = SettingsStore.shared.syncAudioDevicesWithSystem + ? (AudioDevice.getDefaultInputDevice()?.uid ?? "") + : (SettingsStore.shared.preferredInputDeviceUID ?? AudioDevice.getDefaultInputDevice()?.uid ?? "") self.selectedOutputUID = SettingsStore.shared.preferredOutputDeviceUID ?? "" self.enableDebugLogs = SettingsStore.shared.enableDebugLogs self.hotkeyMode = SettingsStore.shared.hotkeyMode diff --git a/Sources/Fluid/Persistence/SettingsStore.swift b/Sources/Fluid/Persistence/SettingsStore.swift index 4cb7c599..09f04345 100644 --- a/Sources/Fluid/Persistence/SettingsStore.swift +++ b/Sources/Fluid/Persistence/SettingsStore.swift @@ -1785,17 +1785,25 @@ final class SettingsStore: ObservableObject { set { self.defaults.set(newValue, forKey: Keys.preferredOutputDeviceUID) } } - /// When enabled, changing audio devices in FluidVoice will also update macOS system audio settings. - /// ALWAYS TRUE: Independent mode removed due to CoreAudio aggregate device limitations (OSStatus -10851) + /// When enabled (the default), changing audio devices in FluidVoice also updates the macOS + /// system default input/output. When disabled, FluidVoice captures its preferred *input* + /// device independently via the direct Core Audio IOProc path (see `DirectCoreAudioInput` / + /// `CoreAudioCaptureSupport.c`, which uses `AudioDeviceCreateIOProcID`). That path binds a + /// specific device per-app without changing the system default and without the AVAudioEngine + /// limitation (`kAudioUnitErr_InvalidPropertyValue` / -10851) that originally forced this mode + /// off for aggregate/Bluetooth devices. + /// + /// Independent mode therefore requires Direct Audio Capture: if that is disabled we return + /// `true` so the AVAudioEngine compatibility path keeps following the system default. var syncAudioDevicesWithSystem: Bool { get { - // Always return true - independent mode doesn't work for Bluetooth/aggregate devices - return true + // Per-app input selection is only safe on the direct-capture path. + guard self.experimentalDirectAudioCaptureEnabled else { return true } + return (self.defaults.object(forKey: Keys.syncAudioDevicesWithSystem) as? Bool) ?? true } set { - // No-op: sync mode is always enabled - // Kept for backward compatibility but value is ignored - _ = newValue + objectWillChange.send() + self.defaults.set(newValue, forKey: Keys.syncAudioDevicesWithSystem) } } diff --git a/Sources/Fluid/Services/ASRService.swift b/Sources/Fluid/Services/ASRService.swift index 5366c12b..336c342c 100644 --- a/Sources/Fluid/Services/ASRService.swift +++ b/Sources/Fluid/Services/ASRService.swift @@ -1900,8 +1900,14 @@ final class ASRService: ObservableObject { private func bindPreferredInputDeviceIfNeeded() -> Bool { DebugLogger.shared.debug("bindPreferredInputDeviceIfNeeded() - Starting input device binding", source: "ASRService") - guard SettingsStore.shared.syncAudioDevicesWithSystem == false else { - DebugLogger.shared.info("Sync mode enabled - using system default input device", source: "ASRService") + // Per-app device selection rides the direct Core Audio IOProc path when Direct Audio + // Capture is enabled. The AVAudioEngine path must never bind a non-default device + // (kAudioUnitErr_InvalidPropertyValue / -10851), so it only attempts a preferred-device + // bind in the legacy configuration where direct capture is off. Otherwise it follows the + // macOS system default (identical to sync mode). + guard SettingsStore.shared.syncAudioDevicesWithSystem == false, + SettingsStore.shared.experimentalDirectAudioCaptureEnabled == false else { + DebugLogger.shared.info("Using system default input device (AVAudioEngine path)", source: "ASRService") return true } @@ -1944,8 +1950,12 @@ final class ASRService: ObservableObject { private func bindPreferredOutputDeviceIfNeeded() -> Bool { DebugLogger.shared.debug("bindPreferredOutputDeviceIfNeeded() - Starting output device binding", source: "ASRService") - guard SettingsStore.shared.syncAudioDevicesWithSystem == false else { - DebugLogger.shared.info("Sync mode enabled - using system default output device", source: "ASRService") + // Output has no direct-capture equivalent (direct capture is input-only), so the + // AVAudioEngine output path always follows the macOS system default when Direct Audio + // Capture is enabled — avoiding the -10851 non-default-device binding limitation. + guard SettingsStore.shared.syncAudioDevicesWithSystem == false, + SettingsStore.shared.experimentalDirectAudioCaptureEnabled == false else { + DebugLogger.shared.info("Using system default output device (AVAudioEngine path)", source: "ASRService") return true } diff --git a/Sources/Fluid/Services/MenuBarManager.swift b/Sources/Fluid/Services/MenuBarManager.swift index 6621b54e..6ec0dd9a 100644 --- a/Sources/Fluid/Services/MenuBarManager.swift +++ b/Sources/Fluid/Services/MenuBarManager.swift @@ -625,6 +625,12 @@ final class MenuBarManager: NSObject, ObservableObject, NSMenuDelegate { } private func currentPreferredInputUID(defaultInputUID: String?) -> String? { + // In independent mode the menu bar should check-mark the device FluidVoice will + // actually capture (the preferred device), not the macOS system default. + if SettingsStore.shared.syncAudioDevicesWithSystem == false, + let preferred = SettingsStore.shared.preferredInputDeviceUID, preferred.isEmpty == false { + return preferred + } return defaultInputUID } diff --git a/Sources/Fluid/UI/SettingsView.swift b/Sources/Fluid/UI/SettingsView.swift index 356b5a7e..3b097d04 100644 --- a/Sources/Fluid/UI/SettingsView.swift +++ b/Sources/Fluid/UI/SettingsView.swift @@ -1086,20 +1086,79 @@ struct SettingsView: View { Image(systemName: "info.circle") .foregroundStyle(self.settingsSecondaryText) .font(self.theme.typography.bodyStrong) - Text("Audio devices are synced with macOS System Settings.") + Text(self.settings.syncAudioDevicesWithSystem + ? (self.settings.experimentalDirectAudioCaptureEnabled + ? "Audio devices are synced with macOS System Settings. Turn off syncing to use a dedicated input device for FluidVoice only." + : "Audio devices are synced with macOS System Settings.") + : "FluidVoice captures its selected input device directly, without changing your macOS system input.") .font(self.theme.typography.bodySmall) .foregroundStyle(self.settingsSecondaryText) .fixedSize(horizontal: false, vertical: true) } .padding(.vertical, 4) + // Independent input device: capture the selected input per-app via the + // direct Core Audio path, leaving the macOS system default untouched. + // Only offered when Direct Audio Capture is enabled (that path is what + // makes per-app, non-default capture possible without the -10851 limit). + if self.settings.experimentalDirectAudioCaptureEnabled { + HStack { + Text("Sync devices with macOS") + .font(self.theme.typography.bodyStrong) + .foregroundStyle(self.settingsTitleText) + Spacer() + Toggle("", isOn: Binding( + get: { SettingsStore.shared.syncAudioDevicesWithSystem }, + set: { SettingsStore.shared.syncAudioDevicesWithSystem = $0 } + )) + .labelsHidden() + .disabled(self.asr.isRunning) + } + } + VStack(alignment: .leading, spacing: 12) { HStack { Text("Input Device") .font(self.theme.typography.bodyStrong) .foregroundStyle(self.settingsTitleText) Spacer() - Picker("", selection: self.$selectedInputUID) { + Picker("", selection: Binding( + get: { + // Display the device that will actually be captured: the + // preferred device when present in independent mode, else + // the current system default. Derived on every render so it + // can't go stale (mode toggle, connect/disconnect, startup); + // capture resolves the same preference. + if SettingsStore.shared.syncAudioDevicesWithSystem == false, + let pref = SettingsStore.shared.preferredInputDeviceUID, + pref.isEmpty == false, + self.inputDevices.contains(where: { $0.uid == pref }) { + return pref + } + // Fall back to the system default, matched by UID (device + // names are not unique) so it can't check-mark the wrong + // one of two identically-named devices. + if let defaultUID = AudioDevice.getDefaultInputDevice()?.uid, + self.inputDevices.contains(where: { $0.uid == defaultUID }) { + return defaultUID + } + return self.selectedInputUID + }, + set: { newUID in + // Called only on an explicit user selection. Programmatic + // display updates (mode toggle, device connect/disconnect, + // startup) flow through `get` and never persist a preference + // — this keeps preferredInputDeviceUID intact across a + // temporary disconnect so it auto-restores on reconnect. + guard !newUID.isEmpty, !self.asr.isRunning else { return } + self.selectedInputUID = newUID + SettingsStore.shared.preferredInputDeviceUID = newUID + // Only change the macOS system default when syncing is on. + if SettingsStore.shared.syncAudioDevicesWithSystem { + _ = AudioDevice.setDefaultInputDevice(uid: newUID) + } + } + )) { // Handle empty state gracefully if self.inputDevices.isEmpty { Text("Loading...").tag("") @@ -1114,41 +1173,14 @@ struct SettingsView: View { .pickerStyle(.menu) .frame(width: 240) .disabled(self.asr.isRunning) // Disable device changes during recording - .onChange(of: self.selectedInputUID) { oldUID, newUID in - guard !newUID.isEmpty else { return } - - // Prevent device changes during active recording - if self.asr.isRunning { - DebugLogger.shared.warning("Cannot change input device during recording", source: "SettingsView") - // Revert to previous value - self.selectedInputUID = oldUID - return - } - - SettingsStore.shared.preferredInputDeviceUID = newUID - // Only change system default if sync is enabled - if SettingsStore.shared.syncAudioDevicesWithSystem { - _ = AudioDevice.setDefaultInputDevice(uid: newUID) - } - } // Sync selection when devices load or change - .onChange(of: self.inputDevices) { _, newDevices in - // Update cached default device name when device list changes + .onChange(of: self.inputDevices) { _, _ in + // Keep the cached default-device name fresh (used for the + // "(System Default)" label). The picker's displayed value is + // derived in its binding `get`, so no manual re-selection — + // which could clobber the saved preference on a temporary + // disconnect — is needed here. self.cachedDefaultInputName = AudioDevice.getDefaultInputDevice()?.name ?? "" - - // If selection is empty or not found in new list, select first available - if !newDevices.isEmpty { - let currentValid = newDevices.contains { $0.uid == self.selectedInputUID } - if !currentValid { - if let defaultUID = AudioDevice.getDefaultInputDevice()?.uid, - newDevices.contains(where: { $0.uid == defaultUID }) - { - self.selectedInputUID = defaultUID - } else { - self.selectedInputUID = newDevices.first?.uid ?? "" - } - } - } } } @@ -1184,10 +1216,10 @@ struct SettingsView: View { } SettingsStore.shared.preferredOutputDeviceUID = newUID - // Only change system default if sync is enabled - if SettingsStore.shared.syncAudioDevicesWithSystem { - _ = AudioDevice.setDefaultOutputDevice(uid: newUID) - } + // Independent mode is input-only (direct capture has no output + // path), so selecting an output always updates the macOS system + // default — otherwise the picker would have no effect. + _ = AudioDevice.setDefaultOutputDevice(uid: newUID) } // Sync selection when devices load or change .onChange(of: self.outputDevices) { _, newDevices in diff --git a/Tests/FluidDictationIntegrationTests/AudioDeviceSyncSettingTests.swift b/Tests/FluidDictationIntegrationTests/AudioDeviceSyncSettingTests.swift new file mode 100644 index 00000000..83cc55b0 --- /dev/null +++ b/Tests/FluidDictationIntegrationTests/AudioDeviceSyncSettingTests.swift @@ -0,0 +1,54 @@ +import Foundation +import XCTest + +@testable import FluidVoice_Debug + +/// Covers the gating rule at the heart of independent input-device mode: +/// `syncAudioDevicesWithSystem` is only user-controllable when Direct Audio +/// Capture is enabled, because per-app selection rides that path. When Direct +/// Audio Capture is off, sync is force-enabled so the AVAudioEngine fallback +/// keeps following the macOS system default (and never attempts the -10851 +/// non-default bind). +final class AudioDeviceSyncSettingTests: XCTestCase { + private let directCaptureKey = "ExperimentalDirectAudioCaptureEnabled" + private let syncKey = "SyncAudioDevicesWithSystem" + + override func tearDown() { + UserDefaults.standard.removeObject(forKey: directCaptureKey) + UserDefaults.standard.removeObject(forKey: syncKey) + super.tearDown() + } + + func testSyncForcedOnWhenDirectCaptureDisabled() { + UserDefaults.standard.set(false, forKey: directCaptureKey) + UserDefaults.standard.set(false, forKey: syncKey) + + XCTAssertTrue( + SettingsStore.shared.syncAudioDevicesWithSystem, + "Sync must stay enabled when Direct Audio Capture is off, even if the stored value is false" + ) + } + + func testSyncHonoredWhenDirectCaptureEnabled() { + UserDefaults.standard.set(true, forKey: directCaptureKey) + + UserDefaults.standard.set(false, forKey: syncKey) + XCTAssertFalse( + SettingsStore.shared.syncAudioDevicesWithSystem, + "With Direct Audio Capture on, the user's stored sync preference should be honored" + ) + + UserDefaults.standard.set(true, forKey: syncKey) + XCTAssertTrue(SettingsStore.shared.syncAudioDevicesWithSystem) + } + + func testDefaultsToSyncOnForBackwardCompatibility() { + UserDefaults.standard.set(true, forKey: directCaptureKey) + UserDefaults.standard.removeObject(forKey: syncKey) + + XCTAssertTrue( + SettingsStore.shared.syncAudioDevicesWithSystem, + "An unset sync preference should default to on (backward compatible with existing behavior)" + ) + } +} From dc53e92f983e8551e081d391910cc68751522f23 Mon Sep 17 00:00:00 2001 From: Ryan Duryea Date: Sun, 19 Jul 2026 12:03:52 -0600 Subject: [PATCH 2/5] refactor(audio): remove unreachable AVAudioEngine device-binding code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per-app input selection routes through the direct Core Audio IOProc path, and syncAudioDevicesWithSystem is forced true whenever Direct Audio Capture is off. That makes the preferred-device guard in bindPreferred{Input,Output}DeviceIfNeeded unreachable: the AVAudioEngine path always follows the macOS system default, so the code below each guard never ran. Collapse both functions to explicit, always-succeeding no-ops with accurate comments, and remove the helpers only the dead paths reached (tryBindToSystemDefault{Input,Output}, setEngineOutputDevice). Keep setEngineInputDevice — still used by the reconnect path. No behavior change (the removed code was unreachable); the full suite (132 tests) passes. Addresses Greptile's dead-code / misleading-comment finding on #642. Co-Authored-By: Claude Opus 4.8 (1M context) --- Sources/Fluid/Services/ASRService.swift | 223 +++--------------------- 1 file changed, 20 insertions(+), 203 deletions(-) diff --git a/Sources/Fluid/Services/ASRService.swift b/Sources/Fluid/Services/ASRService.swift index 336c342c..72fc0bed 100644 --- a/Sources/Fluid/Services/ASRService.swift +++ b/Sources/Fluid/Services/ASRService.swift @@ -1893,170 +1893,36 @@ final class ASRService: ObservableObject { DebugLogger.shared.debug("✅ configureSession() - COMPLETED", source: "ASRService") } - /// In independent mode, attempt to bind AVAudioEngine's input to the user's preferred input device. - /// In sync-with-system mode, we intentionally do nothing so the engine follows macOS defaults. - /// Returns true if binding succeeded or if no binding was needed, false if binding failed completely. + /// The AVAudioEngine capture path always follows the macOS system default input. + /// + /// Per-app input selection instead rides the direct Core Audio IOProc path (see + /// `resolvedInputDeviceForCapture()` / `DirectCoreAudioInput`), which binds an arbitrary device + /// without touching the system default. Binding a non-default device through AVAudioEngine's + /// input node fails with `kAudioUnitErr_InvalidPropertyValue` (-10851) for aggregate/Bluetooth + /// devices, so it is never attempted on this path. `syncAudioDevicesWithSystem` is forced `true` + /// whenever Direct Audio Capture is off, so there is never a preferred device to bind here. Kept + /// as an explicit, always-succeeding seam so `startEngine()` reads clearly and the pre-`prepare()` + /// binding call order is preserved. @discardableResult private func bindPreferredInputDeviceIfNeeded() -> Bool { - DebugLogger.shared.debug("bindPreferredInputDeviceIfNeeded() - Starting input device binding", source: "ASRService") - - // Per-app device selection rides the direct Core Audio IOProc path when Direct Audio - // Capture is enabled. The AVAudioEngine path must never bind a non-default device - // (kAudioUnitErr_InvalidPropertyValue / -10851), so it only attempts a preferred-device - // bind in the legacy configuration where direct capture is off. Otherwise it follows the - // macOS system default (identical to sync mode). - guard SettingsStore.shared.syncAudioDevicesWithSystem == false, - SettingsStore.shared.experimentalDirectAudioCaptureEnabled == false else { - DebugLogger.shared.info("Using system default input device (AVAudioEngine path)", source: "ASRService") - return true - } - - guard let preferredUID = SettingsStore.shared.preferredInputDeviceUID, preferredUID.isEmpty == false else { - DebugLogger.shared.info("No preferred input device set - using system default", source: "ASRService") - return true - } - - DebugLogger.shared.debug("Attempting to bind to preferred input device (uid: \(preferredUID))", source: "ASRService") - - guard let device = AudioDevice.getInputDevice(byUID: preferredUID) else { - DebugLogger.shared.warning( - "Preferred input device not found (uid: \(preferredUID)). Falling back to system default input.", - source: "ASRService" - ) - // Try to use system default as fallback - return self.tryBindToSystemDefaultInput() - } - - DebugLogger.shared.debug("Found preferred input device: '\(device.name)' (id: \(device.id))", source: "ASRService") - - let ok = self.setEngineInputDevice(deviceID: device.id, deviceUID: device.uid, deviceName: device.name) - if ok == false { - DebugLogger.shared.warning( - "Failed to bind engine input to preferred device '\(device.name)' (uid: \(device.uid)). Trying system default input.", - source: "ASRService" - ) - // Try to use system default as fallback - return self.tryBindToSystemDefaultInput() - } - - DebugLogger.shared.info("✅ Successfully bound input to '\(device.name)'", source: "ASRService") + DebugLogger.shared.info("AVAudioEngine input follows system default (per-app selection uses direct capture)", source: "ASRService") return true } - /// In independent mode, attempt to bind AVAudioEngine's output to the user's preferred output device. - /// In sync-with-system mode, we intentionally do nothing so the engine follows macOS defaults. - /// Returns true if binding succeeded or if no binding was needed, false if binding failed completely. + /// The AVAudioEngine path always follows the macOS system default output. + /// + /// Output has no direct-capture equivalent (direct capture is input-only) and per-app output + /// selection is out of scope, so the output node always tracks the system default; binding a + /// non-default device here would hit the same `kAudioUnitErr_InvalidPropertyValue` (-10851) + /// limitation. `syncAudioDevicesWithSystem` is forced `true` whenever Direct Audio Capture is + /// off, so there is never a preferred output device to bind. Kept as an explicit, + /// always-succeeding seam alongside its input counterpart. @discardableResult private func bindPreferredOutputDeviceIfNeeded() -> Bool { - DebugLogger.shared.debug("bindPreferredOutputDeviceIfNeeded() - Starting output device binding", source: "ASRService") - - // Output has no direct-capture equivalent (direct capture is input-only), so the - // AVAudioEngine output path always follows the macOS system default when Direct Audio - // Capture is enabled — avoiding the -10851 non-default-device binding limitation. - guard SettingsStore.shared.syncAudioDevicesWithSystem == false, - SettingsStore.shared.experimentalDirectAudioCaptureEnabled == false else { - DebugLogger.shared.info("Using system default output device (AVAudioEngine path)", source: "ASRService") - return true - } - - guard let preferredUID = SettingsStore.shared.preferredOutputDeviceUID, preferredUID.isEmpty == false else { - DebugLogger.shared.info("No preferred output device set - using system default", source: "ASRService") - return true - } - - DebugLogger.shared.debug("Attempting to bind to preferred output device (uid: \(preferredUID))", source: "ASRService") - - guard let device = AudioDevice.getOutputDevice(byUID: preferredUID) else { - DebugLogger.shared.warning( - "Preferred output device not found (uid: \(preferredUID)). Falling back to system default output.", - source: "ASRService" - ) - // Try to use system default as fallback - return self.tryBindToSystemDefaultOutput() - } - - DebugLogger.shared.debug("Found preferred output device: '\(device.name)' (id: \(device.id))", source: "ASRService") - - let ok = self.setEngineOutputDevice(deviceID: device.id, deviceUID: device.uid, deviceName: device.name) - if ok == false { - DebugLogger.shared.warning( - "Failed to bind engine output to preferred device '\(device.name)' (uid: \(device.uid)). Trying system default output.", - source: "ASRService" - ) - // Try to use system default as fallback - return self.tryBindToSystemDefaultOutput() - } - - DebugLogger.shared.info("✅ Successfully bound output to '\(device.name)'", source: "ASRService") + DebugLogger.shared.info("AVAudioEngine output follows system default", source: "ASRService") return true } - /// Attempts to bind to the system default input device as a fallback. - /// Returns true if binding succeeded, false otherwise. - private func tryBindToSystemDefaultInput() -> Bool { - guard let defaultDevice = AudioDevice.getDefaultInputDevice() else { - DebugLogger.shared.error( - "No system default input device available. Cannot start audio capture.", - source: "ASRService" - ) - return false - } - - DebugLogger.shared.info( - "Attempting to bind to system default input: '\(defaultDevice.name)' (uid: \(defaultDevice.uid))", - source: "ASRService" - ) - - let ok = self.setEngineInputDevice( - deviceID: defaultDevice.id, - deviceUID: defaultDevice.uid, - deviceName: defaultDevice.name - ) - - if !ok { - DebugLogger.shared.error( - "Failed to bind to system default input device '\(defaultDevice.name)'. Audio capture cannot proceed.", - source: "ASRService" - ) - } - - return ok - } - - /// Attempts to bind to the system default output device as a fallback. - /// Returns true if binding succeeded, false otherwise. - private func tryBindToSystemDefaultOutput() -> Bool { - DebugLogger.shared.debug("tryBindToSystemDefaultOutput() - Starting", source: "ASRService") - - guard let defaultDevice = AudioDevice.getDefaultOutputDevice() else { - DebugLogger.shared.error( - "No system default output device available. Cannot bind output.", - source: "ASRService" - ) - return false - } - - DebugLogger.shared.info( - "Attempting to bind to system default output: '\(defaultDevice.name)' (uid: \(defaultDevice.uid))", - source: "ASRService" - ) - - let ok = self.setEngineOutputDevice( - deviceID: defaultDevice.id, - deviceUID: defaultDevice.uid, - deviceName: defaultDevice.name - ) - - if !ok { - DebugLogger.shared.error( - "Failed to bind to system default output device '\(defaultDevice.name)'. Audio playback may not work correctly.", - source: "ASRService" - ) - } - - return ok - } - /// Selects a specific CoreAudio device for AVAudioEngine's input node without changing system defaults. /// This uses the AUHAL AudioUnit backing `engine.inputNode` on macOS. @discardableResult @@ -2106,55 +1972,6 @@ final class ASRService: ObservableObject { return true } - /// Selects a specific CoreAudio device for AVAudioEngine's output node without changing system defaults. - /// This uses the AUHAL AudioUnit backing `engine.outputNode` on macOS. - @discardableResult - private func setEngineOutputDevice(deviceID: AudioObjectID, deviceUID: String, deviceName: String) -> Bool { - DebugLogger.shared.debug("setEngineOutputDevice() - Binding output to device ID: \(deviceID)", source: "ASRService") - - let outputNode = self.engine.outputNode - - // `AVAudioOutputNode` is backed by an AudioUnit on macOS. Setting this property selects - // which physical device the node outputs to. - guard let audioUnit = outputNode.audioUnit else { - DebugLogger.shared.error( - "Unable to access AudioUnit for AVAudioEngine.outputNode; cannot bind to '\(deviceName)' (uid: \(deviceUID))", - source: "ASRService" - ) - return false - } - - var mutableDeviceID = deviceID - let status = AudioUnitSetProperty( - audioUnit, - kAudioOutputUnitProperty_CurrentDevice, - kAudioUnitScope_Global, - 0, - &mutableDeviceID, - UInt32(MemoryLayout.size) - ) - - if status != noErr { - // OSStatus -10851 (kAudioUnitErr_InvalidPropertyValue) occurs for aggregate devices (Bluetooth, etc.) - // This is expected for certain device types - not a fatal error - if status == -10_851 { - DebugLogger.shared.warning( - "Cannot bind OUTPUT to '\(deviceName)' - likely an aggregate device (OSStatus: \(status)). Will use system default.", - source: "ASRService" - ) - } else { - DebugLogger.shared.error( - "AudioUnitSetProperty(CurrentDevice) failed for OUTPUT '\(deviceName)' (uid: \(deviceUID), id: \(deviceID)) with OSStatus: \(status)", - source: "ASRService" - ) - } - return false - } - - DebugLogger.shared.info("✅ Bound ASR output to '\(deviceName)' (uid: \(deviceUID), id: \(deviceID))", source: "ASRService") - return true - } - /// Explicitly unbinds the input device from AVAudioEngine's AudioUnit /// This is CRITICAL for releasing Bluetooth devices so macOS can switch back to high-quality A2DP mode private func unbindInputDevice() { From e50f29cf30ac0cdc4f2cb3251195da365ad0d069 Mon Sep 17 00:00:00 2001 From: Ryan Duryea Date: Wed, 22 Jul 2026 07:32:59 -0600 Subject: [PATCH 3/5] fix(audio): fall back to the default mic in FluidVoice-only mode instead of refusing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FluidVoice-only mode pinned capture to one microphone and *refused to record* when that device could not be used — hitting the hotkey with the preferred headset unplugged produced an error sound and no recording. Dogfooding made clear that is the wrong trade: a preference should degrade to the default, not become a hard requirement that blocks dictation. The capture machinery already resolved preferred-else-default (MicrophonePreferenceCoordinator.inputDeviceForCapture()) and the direct Core Audio path binds whatever that returns by device ID — so it already captured the default through the private path, without moving the system input. Two guards were the only thing converting that working fallback into a refusal, and both are removed: - the "preferred is missing" throw at the top of startPreferredAudioCapture() - the .fluidVoiceOnly throw before the AVAudioEngine fallback When the preferred device is unavailable, capture now records the macOS default (the system default is still never changed): via the direct path when it can bind the default, and via AVAudioEngine — which records the default natively — when it cannot. The reviewers' concern was a *silent* swap: UI shows the headset while audio comes from another mic. That is answered by making the fallback visible, not by refusing. announcePreferredMicrophoneFallbackIfNeeded(recording:) posts a notification ("Preferred microphone unavailable — recording through …") whenever the device actually being recorded differs from the pinned one. It fires in both fallback situations — the preferred mic unplugged, and a present mic the direct path cannot bind on this hardware (aggregate/Bluetooth, -10851) — deduplicated per fallback device so repeated recordings don't spam, and reset when the preferred device is used again. MicrophonePreferenceCoordinator.preferredInputIsMissing() is removed: the announce path now compares the recorded device against the preference (which also covers the present-but-unbindable case the predicate could not), leaving the predicate with no production caller. Tests: reframed the "detects missing → refuse" cases to assert the resolution behaviour production relies on — inputDeviceForCapture() returns the pinned device when present and the default when it is absent, in both preferred modes. The negative contract (never call setDefaultInputDevice in this mode) is unchanged and still covered. Full suite green (163/163). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01SG5paEAboAJeNcbazuzqrQ --- Sources/Fluid/Persistence/SettingsStore.swift | 4 +- Sources/Fluid/Services/ASRService.swift | 85 ++++++++++++------- .../MicrophonePreferenceCoordinator.swift | 22 +---- .../Fluid/Services/NotificationService.swift | 58 +++++++++++++ .../FluidVoiceOnlyMicrophoneModeTests.swift | 41 +++++---- 5 files changed, 137 insertions(+), 73 deletions(-) diff --git a/Sources/Fluid/Persistence/SettingsStore.swift b/Sources/Fluid/Persistence/SettingsStore.swift index 7f22eabf..0ab21e8c 100644 --- a/Sources/Fluid/Persistence/SettingsStore.swift +++ b/Sources/Fluid/Persistence/SettingsStore.swift @@ -185,7 +185,9 @@ final class SettingsStore: ObservableObject { case manual /// Like `.manual`, but the preferred microphone is captured for FluidVoice alone and the /// macOS default input is never changed. Only available on the direct Core Audio capture - /// path (`DirectCoreAudioInput`), which can bind an arbitrary device per-app. + /// path (`DirectCoreAudioInput`), which can bind an arbitrary device per-app. When the + /// preferred device is unavailable, capture falls back to the macOS default (still without + /// moving it) and the substitution is surfaced to the user rather than recorded silently. case fluidVoiceOnly var id: String { diff --git a/Sources/Fluid/Services/ASRService.swift b/Sources/Fluid/Services/ASRService.swift index 8b477ef8..ee541e0a 100644 --- a/Sources/Fluid/Services/ASRService.swift +++ b/Sources/Fluid/Services/ASRService.swift @@ -621,6 +621,10 @@ final class ASRService: ObservableObject { private var directAudioInput: DirectCoreAudioInput? private var activeAudioCaptureBackend: AudioCaptureBackend = .none private var isFallingBackFromDirectCapture = false + /// UID of the device the FluidVoice-only "preferred mic unavailable" notification last named, so + /// a single fallback announces once rather than on every recording. Reset (to nil) when the + /// preferred device is used again. See `announcePreferredMicrophoneFallbackIfNeeded(recording:)`. + private var lastAnnouncedFallbackDeviceUID: String? private var hasPreparedAudioCapture: Bool { self.directAudioInput != nil || self.hasWarmAudioEngine @@ -809,19 +813,11 @@ final class ASRService: ObservableObject { } private func startPreferredAudioCapture() async throws { - // FluidVoice-only mode is pinned to one device. If that device is gone — unplugged, powered - // off — device resolution falls back to the macOS default, and both capture backends would - // happily record it while Settings still shows the missing microphone. Refuse before either - // backend starts, for the same reason the AVAudioEngine guard below exists. - if AppServices.shared.microphonePreferenceCoordinator.preferredInputIsMissing() { - throw NSError( - domain: "FluidVoice.MicrophoneSelection", - code: 2, - userInfo: [NSLocalizedDescriptionKey: - "The microphone selected for FluidVoice is not available. Reconnect it, or pick " - + "another microphone in Settings."] - ) - } + // FluidVoice-only mode prefers one microphone but does not require it. If the preferred + // device is unavailable — unplugged, or unbindable on this hardware — capture records the + // macOS default instead, through this same private path and without ever moving the system + // input. That substitution is announced (never silent) at whichever backend actually starts, + // so this mode records rather than refuses. // A non-route path may have scheduled a fire-and-forget retirement. Do // not let capture startup overlap any final AVAudioEngine release that is @@ -844,6 +840,9 @@ final class ASRService: ObservableObject { "audio_backend kind=direct_core_audio device=\(directAudioInput.deviceID) " + "frames=\(directAudioInput.hardwareBufferFrameSize) callbackMs=\(callbackMs)" ) + // The direct path bound whatever resolution returned — the preferred device, or the + // macOS default when it was unavailable. Announce the latter. + self.announcePreferredMicrophoneFallbackIfNeeded(recording: self.resolvedInputDeviceForCapture()) return } catch { DebugLogger.shared.warning( @@ -860,28 +859,49 @@ final class ASRService: ObservableObject { self.directAudioInput = nil } - // The AVAudioEngine path always records whatever macOS is pointed at: it cannot bind an - // arbitrary input device for this app alone (`kAudioUnitErr_InvalidPropertyValue`, -10851, - // on aggregate and Bluetooth devices). Falling back to it in FluidVoice-only mode would - // silently capture the system default while Settings and the menu bar still show the - // dedicated microphone, so fail loudly instead. - if SettingsStore.shared.microphoneSelectionMode == .fluidVoiceOnly { - let deviceName = self.resolvedInputDeviceForCapture()?.name ?? "the selected microphone" - throw NSError( - domain: "FluidVoice.MicrophoneSelection", - code: 1, - userInfo: [NSLocalizedDescriptionKey: - "Could not record from \(deviceName) without changing your macOS input device. " + - "Switch the microphone mode to \"\(SettingsStore.MicrophoneSelectionMode.manual.displayName)\" " + - "in Settings, or choose a different microphone."] - ) - } - + // The AVAudioEngine path can only record the current macOS default input — it cannot bind an + // arbitrary device for this app alone (`kAudioUnitErr_InvalidPropertyValue`, -10851, on + // aggregate and Bluetooth devices). In FluidVoice-only mode the default is the correct + // target here: either the preferred device was unavailable and resolved to the default, or + // the direct path could not bind it on this hardware and the default is the best capture we + // have. Announce the substitution (this backend always records the default, so pass it + // explicitly rather than the resolved preference), then record rather than refuse. + self.announcePreferredMicrophoneFallbackIfNeeded(recording: AudioDevice.getDefaultInputDevice()) try await self.startCompatibilityAudioCapture( reason: directCaptureEnabled ? "direct_unavailable" : "experimental_disabled" ) } + /// FluidVoice-only mode prefers one microphone but does not require it. When capture lands on a + /// device other than the pinned one — the preferred mic is unplugged, or the direct path cannot + /// bind it on this hardware — it records the macOS default through the private path, without ever + /// moving the system input. That substitution is invisible to the audio pipeline, so announce it + /// once per distinct fallback device; the record resets whenever the preferred device is used + /// again (`device.uid == preferredUID`), so a later fallback announces afresh. + /// + /// `device` is the input actually being recorded, passed by the caller because it differs by + /// backend: the direct path binds the resolved device, while AVAudioEngine always records the + /// macOS default regardless of the preference. + private func announcePreferredMicrophoneFallbackIfNeeded(recording device: AudioDevice.Device?) { + guard SettingsStore.shared.microphoneSelectionMode == .fluidVoiceOnly, + let preferredUID = SettingsStore.shared.preferredInputDeviceUID, + preferredUID.isEmpty == false, + let device, + device.uid != preferredUID + else { + self.lastAnnouncedFallbackDeviceUID = nil + return + } + guard self.lastAnnouncedFallbackDeviceUID != device.uid else { return } + self.lastAnnouncedFallbackDeviceUID = device.uid + + DebugLogger.shared.info( + "Preferred microphone unavailable; recording through fallback '\(device.name)'", + source: "ASRService" + ) + NotificationService.showPreferredMicrophoneFallback(fallbackDeviceName: device.name) + } + private func startCompatibilityAudioCapture(reason: String) async throws { await self.audioEngineRetirementDrain.waitForScheduledReleases() self.benchmarkLog("audio_backend kind=av_audio_engine_fallback reason=\(reason)") @@ -1983,8 +2003,9 @@ final class ASRService: ObservableObject { /// In `.manual` mode, attempt to bind AVAudioEngine's input to the user's preferred input device. /// In `.system` mode we intentionally do nothing so the engine follows macOS defaults. - /// `.fluidVoiceOnly` never reaches this path — `startPreferredAudioCapture()` refuses the - /// AVAudioEngine fallback rather than let it record the system default. + /// `.fluidVoiceOnly` also does nothing here: if it has reached the AVAudioEngine path at all, the + /// preferred device could not be captured directly, so recording the macOS default is the + /// intended fallback (announced by `announcePreferredMicrophoneFallbackIfNeeded()`). /// Returns true if binding succeeded or if no binding was needed, false if binding failed completely. @discardableResult private func bindPreferredInputDeviceIfNeeded() -> Bool { diff --git a/Sources/Fluid/Services/MicrophonePreferenceCoordinator.swift b/Sources/Fluid/Services/MicrophonePreferenceCoordinator.swift index 0a99aec2..d71a0d6e 100644 --- a/Sources/Fluid/Services/MicrophonePreferenceCoordinator.swift +++ b/Sources/Fluid/Services/MicrophonePreferenceCoordinator.swift @@ -150,24 +150,10 @@ final class MicrophonePreferenceCoordinator: ObservableObject { return nextMode } - /// True when the user pinned FluidVoice to one specific microphone and that microphone is not - /// currently connected. - /// - /// `inputDeviceForCapture()` deliberately falls back to the macOS default so `.manual` mode - /// keeps working through a disconnect, but in `.fluidVoiceOnly` mode that same fallback would - /// record the system microphone while Settings still shows the missing one. The recording path - /// uses this to refuse rather than capture the wrong device. - func preferredInputIsMissing() -> Bool { - guard self.settings.microphoneSelectionMode == .fluidVoiceOnly, - let preferredUID = self.settings.preferredInputDeviceUID, - preferredUID.isEmpty == false - else { - return false - } - - return self.devices.listInputDevices().contains(where: { $0.uid == preferredUID }) == false - } - + /// Resolves the device capture should use: the user's pinned microphone when it is connected, + /// otherwise the macOS default. The fallback deliberately keeps both preferred modes working + /// through a disconnect; in `.fluidVoiceOnly` the recording path notices the resolved device + /// differs from the pinned one and announces the substitution rather than recording it silently. func inputDeviceForCapture() -> AudioDevice.Device? { if self.settings.microphoneSelectionMode.usesPreferredInputDevice, let preferredUID = self.settings.preferredInputDeviceUID, diff --git a/Sources/Fluid/Services/NotificationService.swift b/Sources/Fluid/Services/NotificationService.swift index e0922581..a07db292 100644 --- a/Sources/Fluid/Services/NotificationService.swift +++ b/Sources/Fluid/Services/NotificationService.swift @@ -10,6 +10,38 @@ enum NotificationService { static let aiProcessingFallback = "aiProcessingFallback" static let audioCaptureFallback = "audioCaptureFallback" static let commandModeFailure = "commandModeFailure" + static let preferredMicrophoneFallback = "preferredMicrophoneFallback" + } + + /// Announces that FluidVoice-only mode could not use the pinned microphone and is recording + /// through the macOS default instead. The fallback itself is intended behaviour — this only + /// makes it visible so the substitution is never silent. + static func showPreferredMicrophoneFallback(fallbackDeviceName: String) { + let center = UNUserNotificationCenter.current() + center.getNotificationSettings { settings in + switch settings.authorizationStatus { + case .authorized, .provisional, .ephemeral: + self.deliverPreferredMicrophoneFallback(fallbackDeviceName: fallbackDeviceName, using: center) + case .notDetermined: + center.requestAuthorization(options: [.alert]) { granted, requestError in + if let requestError { + DebugLogger.shared.warning( + "Notification permission request failed: \(requestError.localizedDescription)", + source: "NotificationService" + ) + } + guard granted else { return } + self.deliverPreferredMicrophoneFallback(fallbackDeviceName: fallbackDeviceName, using: center) + } + case .denied: + DebugLogger.shared.debug( + "Skipping preferred microphone fallback notification because notification permission is denied", + source: "NotificationService" + ) + @unknown default: + break + } + } } static func showAudioCaptureFallback( @@ -167,6 +199,32 @@ enum NotificationService { } } + private static func deliverPreferredMicrophoneFallback( + fallbackDeviceName: String, + using center: UNUserNotificationCenter + ) { + let content = UNMutableNotificationContent() + content.title = "Preferred microphone unavailable" + content.body = "Recording through \(fallbackDeviceName) until your selected microphone is reconnected." + content.sound = nil + content.userInfo = [UserInfoKey.kind: Kind.preferredMicrophoneFallback] + + let request = UNNotificationRequest( + identifier: "preferred-microphone-fallback-\(UUID().uuidString)", + content: content, + trigger: nil + ) + + center.add(request) { addError in + if let addError { + DebugLogger.shared.warning( + "Failed to show preferred microphone fallback notification: \(addError.localizedDescription)", + source: "NotificationService" + ) + } + } + } + private static func deliverCommandModeFailure(error: String, using center: UNUserNotificationCenter) { let content = UNMutableNotificationContent() content.title = "Command Mode needs setup" diff --git a/Tests/FluidDictationIntegrationTests/FluidVoiceOnlyMicrophoneModeTests.swift b/Tests/FluidDictationIntegrationTests/FluidVoiceOnlyMicrophoneModeTests.swift index 460f9f96..e732c41f 100644 --- a/Tests/FluidDictationIntegrationTests/FluidVoiceOnlyMicrophoneModeTests.swift +++ b/Tests/FluidDictationIntegrationTests/FluidVoiceOnlyMicrophoneModeTests.swift @@ -4,7 +4,8 @@ import XCTest @testable import FluidVoice_Debug /// Covers `.fluidVoiceOnly` microphone mode: FluidVoice captures the preferred microphone through -/// the direct Core Audio path while leaving the macOS default input alone. +/// the direct Core Audio path while leaving the macOS default input alone. When that microphone is +/// unavailable it falls back to the system default (still without moving it) rather than refusing. /// /// The contract that matters is negative — this mode must never call /// `setDefaultInputDevice` — so the coordinator is driven with a fake `AudioDeviceManaging` that @@ -147,7 +148,7 @@ final class FluidVoiceOnlyMicrophoneModeTests: XCTestCase { XCTAssertEqual(SettingsStore.shared.microphoneSelectionMode, .system) } - // MARK: - Preferred microphone unplugged + // MARK: - Preferred microphone unplugged: fall back to the default /// A spy whose device list does *not* contain the preferred microphone — i.e. it is unplugged. private func makeSpyWithoutPreferred() -> SpyDeviceManager { @@ -156,50 +157,46 @@ final class FluidVoiceOnlyMicrophoneModeTests: XCTestCase { return spy } - /// The regression this guards: capture resolution falls back to the macOS default when the - /// preferred device is gone, which in this mode would silently record the wrong microphone. - func testDetectsMissingPreferredMicrophone() { + /// The behaviour this locks in: when the pinned microphone is unplugged, capture resolves to the + /// macOS default and records *that* — dictation keeps working rather than refusing. The recording + /// path compares the resolved device against the preference and announces the substitution. + func testFallsBackToDefaultWhenPreferredMicrophoneUnplugged() { self.configure(mode: .fluidVoiceOnly, directCapture: true) let coordinator = MicrophonePreferenceCoordinator(settings: .shared, devices: self.makeSpyWithoutPreferred()) - XCTAssertTrue(coordinator.preferredInputIsMissing()) XCTAssertEqual( coordinator.inputDeviceForCapture()?.uid, self.systemUID, - "Resolution still falls back — which is exactly why the recording path must check first" + "An unplugged preferred mic must fall back to the system default, not stop recording" ) } - func testPresentPreferredMicrophoneIsNotMissing() { + /// When the pinned microphone is present, capture resolves to it (not the default), so there is + /// no substitution to announce. + func testUsesPreferredMicrophoneWhenPresent() { self.configure(mode: .fluidVoiceOnly, directCapture: true) let coordinator = MicrophonePreferenceCoordinator(settings: .shared, devices: self.makeSpy()) - XCTAssertFalse(coordinator.preferredInputIsMissing()) + XCTAssertEqual(coordinator.inputDeviceForCapture()?.uid, self.preferredUID) } - /// `.manual` moves the system default rather than binding a device, so it tolerates a - /// disconnect and must not be blocked by this check. - func testManualModeToleratesMissingPreferredMicrophone() { + /// `.manual` moves the system default rather than binding a device, so a disconnect leaves it on + /// whatever the default now is — it too resolves to the default rather than refusing. + func testManualModeFallsBackToDefaultWhenPreferredMicrophoneUnplugged() { self.configure(mode: .manual, directCapture: true) let coordinator = MicrophonePreferenceCoordinator(settings: .shared, devices: self.makeSpyWithoutPreferred()) - XCTAssertFalse(coordinator.preferredInputIsMissing()) + XCTAssertEqual(coordinator.inputDeviceForCapture()?.uid, self.systemUID) } - func testNoPreferenceIsNotMissing() { + /// With no preference recorded, capture resolves to the system default regardless of mode. + func testResolvesToDefaultWhenNoPreferenceRecorded() { UserDefaults.standard.set(true, forKey: self.directCaptureKey) UserDefaults.standard.set(SettingsStore.MicrophoneSelectionMode.fluidVoiceOnly.rawValue, forKey: self.modeKey) UserDefaults.standard.removeObject(forKey: self.preferredInputKey) let coordinator = MicrophonePreferenceCoordinator(settings: .shared, devices: self.makeSpyWithoutPreferred()) - XCTAssertFalse(coordinator.preferredInputIsMissing()) - } - - func testSystemModeIsNeverMissing() { - self.configure(mode: .system, directCapture: true) - let coordinator = MicrophonePreferenceCoordinator(settings: .shared, devices: self.makeSpyWithoutPreferred()) - - XCTAssertFalse(coordinator.preferredInputIsMissing()) + XCTAssertEqual(coordinator.inputDeviceForCapture()?.uid, self.systemUID) } // MARK: - Handing the system default back From e6251e11d7aa50e68f003ad7a4b26e9b735073da Mon Sep 17 00:00:00 2001 From: Ryan Duryea Date: Thu, 23 Jul 2026 21:02:41 -0600 Subject: [PATCH 4/5] fix(audio): post the mic-fallback notification only after capture starts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The FluidVoice-only fallback notification fired just before startCompatibilityAudioCapture(), so a failed AVAudioEngine start would have left a misleading "recording through …" notification behind for a recording that never began. Move the announce to after the capture starts, matching the direct-capture site (which already announces after its start()). Low-probability, no data-loss; addresses Greptile review feedback on #642. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01SG5paEAboAJeNcbazuzqrQ --- Sources/Fluid/Services/ASRService.swift | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/Sources/Fluid/Services/ASRService.swift b/Sources/Fluid/Services/ASRService.swift index ee541e0a..b14feccd 100644 --- a/Sources/Fluid/Services/ASRService.swift +++ b/Sources/Fluid/Services/ASRService.swift @@ -864,12 +864,15 @@ final class ASRService: ObservableObject { // aggregate and Bluetooth devices). In FluidVoice-only mode the default is the correct // target here: either the preferred device was unavailable and resolved to the default, or // the direct path could not bind it on this hardware and the default is the best capture we - // have. Announce the substitution (this backend always records the default, so pass it - // explicitly rather than the resolved preference), then record rather than refuse. - self.announcePreferredMicrophoneFallbackIfNeeded(recording: AudioDevice.getDefaultInputDevice()) + // have. Record it rather than refuse. try await self.startCompatibilityAudioCapture( reason: directCaptureEnabled ? "direct_unavailable" : "experimental_disabled" ) + // Announce the substitution only after the capture has actually started, so a failed start + // never leaves a misleading "recording through …" notification behind. This backend always + // records the macOS default, so pass it explicitly rather than the resolved preference. (The + // direct-capture site above announces after its `start()` for the same reason.) + self.announcePreferredMicrophoneFallbackIfNeeded(recording: AudioDevice.getDefaultInputDevice()) } /// FluidVoice-only mode prefers one microphone but does not require it. When capture lands on a From 18c10d7532099ee28b83e9eb2d7f978ba2ee1893 Mon Sep 17 00:00:00 2001 From: Ryan Duryea Date: Thu, 23 Jul 2026 23:17:16 -0600 Subject: [PATCH 5/5] fix(audio): don't interrupt a FluidVoice-only recording on unrelated device changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit handleDeviceListChanged called scheduleAudioRouteRecovery — which immediately disables the capture pipeline — for every input-device-list change while recording in .fluidVoiceOnly. Because DirectCoreAudioInput binds a specific device by ID and is unaffected by unrelated hardware, any unrelated USB or Bluetooth device connecting or disconnecting mid-recording caused a needless interruption (missed audio, gap in transcription). Leave the running capture undisturbed on device-list changes. The two cases that genuinely matter are handled elsewhere: if the *bound* device itself disconnects, the per-device availability monitor (handleDeviceAvailabilityChanged, armed via startMonitoringDevice at recording start) recovers it; and a changed device preference is applied on the next recording, which re-resolves. This also matches the mode's premise — capture stays independent of unrelated audio churn. (handleDefaultInputChanged already treated .fluidVoiceOnly this way.) Addresses Greptile review on #642. Live-validated: two device-list changes (aggregate create + destroy) during a FluidVoice-only recording bound to BlackHole left the capture undisturbed with zero route recovery; the recording ran continuously until stopped. No unit test — handleDeviceListChanged needs real audio hardware, like the rest of ASRService; validated live instead. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01SG5paEAboAJeNcbazuzqrQ --- Sources/Fluid/Services/ASRService.swift | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/Sources/Fluid/Services/ASRService.swift b/Sources/Fluid/Services/ASRService.swift index b14feccd..daf28930 100644 --- a/Sources/Fluid/Services/ASRService.swift +++ b/Sources/Fluid/Services/ASRService.swift @@ -2839,9 +2839,19 @@ final class ASRService: ObservableObject { AppServices.shared.microphonePreferenceCoordinator .stabilizePreferredInputAfterHardwareChange(reason: "input device list changed") case .fluidVoiceOnly: - // Re-resolve the capture device so a reconnected preferred microphone is - // picked up again, without reasserting anything onto the system default. - self.scheduleAudioRouteRecovery(reason: "FluidVoice-only preferred input list changed") + // A device-list change never interrupts an in-progress FluidVoice-only + // recording. Direct capture is bound to one device by ID and is unaffected by + // unrelated hardware coming or going, so reacting here would needlessly halt + // the pipeline (missed audio, gap in transcription) on any unrelated USB or + // Bluetooth event. If the *bound* device itself disconnects, the per-device + // availability monitor (handleDeviceAvailabilityChanged) recovers it + // separately; a changed device preference is applied on the next recording, + // which re-resolves. So leave the running capture alone. + DebugLogger.shared.debug( + "Input device list changed during FluidVoice-only recording; " + + "leaving direct capture undisturbed (re-resolves on next recording)", + source: "ASRService" + ) case .system: break }