From dde1528e837f4d29c87c677d57e4bccf1e5ff440 Mon Sep 17 00:00:00 2001 From: grohith327 Date: Sun, 5 Jul 2026 10:17:02 -0700 Subject: [PATCH 1/3] add support for choosing custom audio input --- Fluid.xcodeproj/project.pbxproj | 7 +- Sources/Fluid/ContentView.swift | 63 ++--- Sources/Fluid/Persistence/BackupService.swift | 1 + Sources/Fluid/Persistence/SettingsStore.swift | 84 +++++- Sources/Fluid/Services/ASRService.swift | 164 +++-------- Sources/Fluid/Services/AppServices.swift | 10 + Sources/Fluid/Services/MenuBarManager.swift | 51 +++- .../MicrophonePreferenceCoordinator.swift | 118 ++++++++ Sources/Fluid/UI/SettingsView.swift | 117 +++++--- .../HotkeyShortcutTests.swift | 257 ++++++++++++++++++ 10 files changed, 657 insertions(+), 215 deletions(-) create mode 100644 Sources/Fluid/Services/MicrophonePreferenceCoordinator.swift diff --git a/Fluid.xcodeproj/project.pbxproj b/Fluid.xcodeproj/project.pbxproj index 3c98aacd..9d8ffa54 100644 --- a/Fluid.xcodeproj/project.pbxproj +++ b/Fluid.xcodeproj/project.pbxproj @@ -19,6 +19,7 @@ 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 */; }; + 86CAA2D4EF18433096185602 /* LLMClientRequestBodyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 343B29013F4441D6A797D12D /* LLMClientRequestBodyTests.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -32,12 +33,12 @@ /* End PBXContainerItemProxy section */ /* Begin PBXFileReference section */ + 343B29013F4441D6A797D12D /* LLMClientRequestBodyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LLMClientRequestBodyTests.swift; sourceTree = ""; }; + 980330F3CE464336ADCE3E23 /* TemperatureSupportTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TemperatureSupportTests.swift; sourceTree = ""; }; 7C078D8F2E3B339200FB7CAC /* FluidVoice Debug.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "FluidVoice Debug.app"; sourceTree = BUILT_PRODUCTS_DIR; }; - 7CDB0A202F3C4D5600FB7CAD /* FluidDictationIntegrationTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = FluidDictationIntegrationTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 7C91B0022F42AA0100C0DEF0 /* HotkeyShortcutTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotkeyShortcutTests.swift; sourceTree = ""; }; + 7CDB0A202F3C4D5600FB7CAD /* FluidDictationIntegrationTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = FluidDictationIntegrationTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 7CDB0A292F3C4D5600FB7CAD /* DictationE2ETests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DictationE2ETests.swift; sourceTree = ""; }; - 343B29013F4441D6A797D12D /* LLMClientRequestBodyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LLMClientRequestBodyTests.swift; sourceTree = ""; }; - 980330F3CE464336ADCE3E23 /* TemperatureSupportTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TemperatureSupportTests.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; }; diff --git a/Sources/Fluid/ContentView.swift b/Sources/Fluid/ContentView.swift index 4aeec06f..f171df48 100644 --- a/Sources/Fluid/ContentView.swift +++ b/Sources/Fluid/ContentView.swift @@ -227,6 +227,7 @@ struct ContentView: View { @State private var outputDevices: [AudioDevice.Device] = [] @State private var selectedInputUID: String = AudioDevice.getDefaultInputDevice()?.uid ?? "" @State private var selectedOutputUID: String = SettingsStore.shared.preferredOutputDeviceUID ?? "" + @State private var microphoneSelectionMode: SettingsStore.MicrophoneSelectionMode = SettingsStore.shared.microphoneSelectionMode // AI Prompts Tab State @State private var aiInputText: String = "" @@ -356,37 +357,19 @@ struct ContentView: View { // Hardware change detected → refresh device lists self.refreshDevices() - // Only sync UI with system defaults when sync is enabled - // When sync is disabled, keep the user's preferred device selection - if SettingsStore.shared.syncAudioDevicesWithSystem { - // Sync mode: Update UI to match current system defaults + switch SettingsStore.shared.microphoneSelectionMode { + case .system: if let sysIn = AudioDevice.getDefaultInputDevice()?.uid { self.selectedInputUID = sysIn } - if let sysOut = AudioDevice.getDefaultOutputDevice()?.uid { - self.selectedOutputUID = sysOut - } - } else { - // Independent mode: Only update if preferred device is no longer available - if let prefIn = SettingsStore.shared.preferredInputDeviceUID, - inputDevices.contains(where: { $0.uid == prefIn }) - { + case .manual: + if let prefIn = SettingsStore.shared.preferredInputDeviceUID { self.selectedInputUID = prefIn - } else if let sysIn = AudioDevice.getDefaultInputDevice()?.uid { - // Fallback to system default if preferred device disconnected - self.selectedInputUID = sysIn - SettingsStore.shared.preferredInputDeviceUID = sysIn } + } - if let prefOut = SettingsStore.shared.preferredOutputDeviceUID, - outputDevices.contains(where: { $0.uid == prefOut }) - { - self.selectedOutputUID = prefOut - } else if let sysOut = AudioDevice.getDefaultOutputDevice()?.uid { - // Fallback to system default if preferred device disconnected - self.selectedOutputUID = sysOut - SettingsStore.shared.preferredOutputDeviceUID = sysOut - } + if let sysOut = AudioDevice.getDefaultOutputDevice()?.uid { + self.selectedOutputUID = sysOut } } .onDisappear { @@ -595,19 +578,24 @@ struct ContentView: View { self.menuBarManager.configure(asrService: self.appServices.asr) self.refreshDevices() - if self.selectedInputUID.isEmpty, let defIn = AudioDevice.getDefaultInputDevice()?.uid { - self.selectedInputUID = defIn + switch SettingsStore.shared.microphoneSelectionMode { + case .system: + if let defaultUID = AudioDevice.getDefaultInputDevice()?.uid { + self.selectedInputUID = defaultUID + } + case .manual: + if let preferredUID = SettingsStore.shared.preferredInputDeviceUID, !preferredUID.isEmpty { + self.selectedInputUID = preferredUID + } else if let defaultUID = AudioDevice.getDefaultInputDevice()?.uid { + self.selectedInputUID = defaultUID + SettingsStore.shared.preferredInputDeviceUID = defaultUID + } } + if self.selectedOutputUID.isEmpty, let defOut = AudioDevice.getDefaultOutputDevice()?.uid { self.selectedOutputUID = defOut } - if let systemInputUID = AudioDevice.getDefaultInputDevice()?.uid, - self.inputDevices.contains(where: { $0.uid == systemInputUID }) - { - self.selectedInputUID = systemInputUID - } - if let prefOut = SettingsStore.shared.preferredOutputDeviceUID, !prefOut.isEmpty, outputDevices.first(where: { $0.uid == prefOut }) != nil @@ -1465,6 +1453,7 @@ struct ContentView: View { visualizerNoiseThreshold: self.$visualizerNoiseThreshold, selectedInputUID: self.$selectedInputUID, selectedOutputUID: self.$selectedOutputUID, + microphoneSelectionMode: self.$microphoneSelectionMode, inputDevices: self.$inputDevices, outputDevices: self.$outputDevices, accessibilityEnabled: self.$accessibilityEnabled, @@ -4359,7 +4348,13 @@ private extension ContentView { self.isRewriteModeShortcutEnabled = SettingsStore.shared.rewriteModeShortcutEnabled self.playgroundUsed = SettingsStore.shared.playgroundUsed self.visualizerNoiseThreshold = SettingsStore.shared.visualizerNoiseThreshold - self.selectedInputUID = AudioDevice.getDefaultInputDevice()?.uid ?? "" + self.microphoneSelectionMode = SettingsStore.shared.microphoneSelectionMode + switch SettingsStore.shared.microphoneSelectionMode { + case .system: + self.selectedInputUID = AudioDevice.getDefaultInputDevice()?.uid ?? "" + case .manual: + self.selectedInputUID = 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/BackupService.swift b/Sources/Fluid/Persistence/BackupService.swift index f69a7966..9458077e 100644 --- a/Sources/Fluid/Persistence/BackupService.swift +++ b/Sources/Fluid/Persistence/BackupService.swift @@ -61,6 +61,7 @@ struct SettingsBackupPayload: Codable, Equatable { let textInsertionMode: SettingsStore.TextInsertionMode let preferredInputDeviceUID: String? let preferredOutputDeviceUID: String? + let microphoneSelectionMode: SettingsStore.MicrophoneSelectionMode? let visualizerNoiseThreshold: Double let overlayPosition: SettingsStore.OverlayPosition let overlayBottomOffset: Double diff --git a/Sources/Fluid/Persistence/SettingsStore.swift b/Sources/Fluid/Persistence/SettingsStore.swift index 715b2525..1174455e 100644 --- a/Sources/Fluid/Persistence/SettingsStore.swift +++ b/Sources/Fluid/Persistence/SettingsStore.swift @@ -143,6 +143,24 @@ final class SettingsStore: ObservableObject { } } + enum MicrophoneSelectionMode: String, Codable, CaseIterable, Identifiable { + case system + case manual + + var id: String { + self.rawValue + } + + var displayName: String { + switch self { + case .system: + return "Use macOS Default" + case .manual: + return "Use Preferred Microphone" + } + } + } + enum DictationPromptSelection: Equatable { case off, `default`, privateAI case profile(String) @@ -1664,20 +1682,63 @@ 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) - var syncAudioDevicesWithSystem: Bool { + var microphoneSelectionMode: MicrophoneSelectionMode { get { - // Always return true - independent mode doesn't work for Bluetooth/aggregate devices - return true + if let raw = self.defaults.string(forKey: Keys.microphoneSelectionMode), + let mode = MicrophoneSelectionMode(rawValue: raw) + { + return mode + } + + return .system } set { - // No-op: sync mode is always enabled - // Kept for backward compatibility but value is ignored - _ = newValue + objectWillChange.send() + self.defaults.set(newValue.rawValue, forKey: Keys.microphoneSelectionMode) } } + func recordInputDeviceSelection(_ uid: String) { + guard uid.isEmpty == false else { return } + guard self.microphoneSelectionMode == .manual else { return } + + self.preferredInputDeviceUID = uid + } + + func shouldSyncInputSelectionToSystemDefault() -> Bool { + self.microphoneSelectionMode == .system + } + + @discardableResult + func setMicrophoneSelectionMode( + _ mode: MicrophoneSelectionMode, + currentSystemInputUID: String?, + availableInputUIDs: Set + ) -> String? { + let previousMode = self.microphoneSelectionMode + + if previousMode == .system, + mode == .manual, + let currentSystemInputUID, + currentSystemInputUID.isEmpty == false + { + self.defaults.set(currentSystemInputUID, forKey: Keys.systemInputDeviceUIDBeforeManual) + } + + self.microphoneSelectionMode = mode + + guard mode == .system else { return nil } + + if let previousSystemInputUID = self.defaults.string(forKey: Keys.systemInputDeviceUIDBeforeManual), + previousSystemInputUID.isEmpty == false, + availableInputUIDs.contains(previousSystemInputUID) + { + return previousSystemInputUID + } + + return currentSystemInputUID + } + var visualizerNoiseThreshold: Double { get { let value = self.defaults.double(forKey: Keys.visualizerNoiseThreshold) @@ -2854,6 +2915,7 @@ final class SettingsStore: ObservableObject { textInsertionMode: self.textInsertionMode, preferredInputDeviceUID: self.preferredInputDeviceUID, preferredOutputDeviceUID: self.preferredOutputDeviceUID, + microphoneSelectionMode: self.microphoneSelectionMode, visualizerNoiseThreshold: self.visualizerNoiseThreshold, overlayPosition: self.overlayPosition, overlayBottomOffset: self.overlayBottomOffset, @@ -2959,6 +3021,9 @@ final class SettingsStore: ObservableObject { self.textInsertionMode = payload.textInsertionMode self.preferredInputDeviceUID = payload.preferredInputDeviceUID self.preferredOutputDeviceUID = payload.preferredOutputDeviceUID + if let microphoneSelectionMode = payload.microphoneSelectionMode { + self.microphoneSelectionMode = microphoneSelectionMode + } self.visualizerNoiseThreshold = payload.visualizerNoiseThreshold self.overlayPosition = payload.overlayPosition self.overlayBottomOffset = payload.overlayBottomOffset @@ -4460,7 +4525,8 @@ private extension SettingsStore { static let primaryDictationShortcutsKey = "PrimaryDictationShortcuts" static let preferredInputDeviceUID = "PreferredInputDeviceUID" static let preferredOutputDeviceUID = "PreferredOutputDeviceUID" - static let syncAudioDevicesWithSystem = "SyncAudioDevicesWithSystem" + static let microphoneSelectionMode = "MicrophoneSelectionMode" + static let systemInputDeviceUIDBeforeManual = "SystemInputDeviceUIDBeforeManual" static let visualizerNoiseThreshold = "VisualizerNoiseThreshold" static let launchAtStartup = "LaunchAtStartup" static let showInDock = "ShowInDock" diff --git a/Sources/Fluid/Services/ASRService.swift b/Sources/Fluid/Services/ASRService.swift index fbef8c16..1c850c39 100644 --- a/Sources/Fluid/Services/ASRService.swift +++ b/Sources/Fluid/Services/ASRService.swift @@ -843,7 +843,9 @@ final class ASRService: ObservableObject { (self.transcriptionProvider as? FluidAudioProvider)?.underlyingManager } #else - var asrManager: Any? { nil } + var asrManager: Any? { + nil + } #endif // Thread-safe buffer to prevent "Array mutation while enumerating" and memory corruption crashes @@ -880,7 +882,10 @@ final class ASRService: ObservableObject { private var didPauseMediaForThisSession: Bool = false private var audioLevelSubject = PassthroughSubject() - var audioLevelPublisher: AnyPublisher { self.audioLevelSubject.eraseToAnyPublisher() } + var audioLevelPublisher: AnyPublisher { + self.audioLevelSubject.eraseToAnyPublisher() + } + private var lastAudioLevelSentAt: TimeInterval = 0 func consumeLastCompletedAudioSnapshot() -> DictationAudioSnapshot? { @@ -1204,6 +1209,13 @@ final class ASRService: ObservableObject { self.isDictionaryTrainingCaptureActive = forDictionaryTraining DebugLogger.shared.info("✅ Audio capture running", source: "ASRService") onCaptureStarted?() + if SettingsStore.shared.microphoneSelectionMode == .manual { + AppServices.shared.microphonePreferenceCoordinator.enforcePreferredInput(reason: "recording start") + } + + DebugLogger.shared.debug("⚙️ Calling configureSession()...", source: "ASRService") + try self.configureSession() + DebugLogger.shared.debug("✅ configureSession() completed", source: "ASRService") // Pause only after capture is live so media control cannot delay the // first PCM packet. A quick stop while this await is in flight is @@ -1743,40 +1755,10 @@ 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") - 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( + "Using current macOS default input device. Manual microphone mode reasserts the preferred device before engine start.", + source: "ASRService" + ) return true } @@ -1787,40 +1769,7 @@ 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") - 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("Using current macOS default output device", source: "ASRService") return true } @@ -2279,10 +2228,13 @@ final class ASRService: ObservableObject { } private func handleDefaultInputChanged() { - // If we're not syncing with macOS system settings, ignore system-default changes. - // In independent mode, we explicitly bind to `preferredInputDeviceUID` on start/restart. - guard SettingsStore.shared.syncAudioDevicesWithSystem else { - DebugLogger.shared.debug("Ignoring system default input change (sync disabled)", source: "ASRService") + if SettingsStore.shared.microphoneSelectionMode == .manual { + if self.isRunning { + AppServices.shared.microphonePreferenceCoordinator.stabilizePreferredInputAfterHardwareChange( + reason: "default input changed" + ) + self.scheduleAudioRouteRecovery(reason: "manual preferred input reasserted") + } return } @@ -2484,10 +2436,8 @@ final class ASRService: ObservableObject { // Perform CoreAudio queries off the main thread — during a device topology change // the HAL may still be settling, and synchronous queries on main can deadlock. - let preferredUID = SettingsStore.shared.preferredInputDeviceUID DispatchQueue.global(qos: .userInitiated).async { [weak self] in let currentDevices = AudioDevice.listInputDevices() - let systemDefault = AudioDevice.getDefaultInputDevice() DispatchQueue.main.async { [weak self] in guard let self else { return } @@ -2495,61 +2445,13 @@ final class ASRService: ObservableObject { DebugLogger.shared.debug("Current input devices: \(currentDevices.map { $0.name }.joined(separator: ", "))", source: "ASRService") - // Check if preferred device is now available (for auto-switch) - if let preferredUID, - let preferredDevice = currentDevices.first(where: { $0.uid == preferredUID }) + if self.isRunning, + SettingsStore.shared.microphoneSelectionMode == .manual, + Set(currentDevices.map(\.uid)) != cachedUIDs { - if let currentDevice = self.getCurrentlyBoundInputDevice(), - currentDevice.uid != preferredUID, - currentDevice.uid == systemDefault?.uid - { - DebugLogger.shared.info( - "🔌 Preferred device '\(preferredDevice.name)' reconnected. Auto-switching...", - source: "ASRService" - ) - - if self.isRunning { - DebugLogger.shared.info( - "Recording in progress - deferring preferred device switch until audio route recovery", - source: "ASRService" - ) - self.scheduleAudioRouteRecovery(reason: "preferred input reconnected") - } else { - DebugLogger.shared.info("Not recording - updating binding for next session", source: "ASRService") - _ = self.setEngineInputDevice( - deviceID: preferredDevice.id, - deviceUID: preferredDevice.uid, - deviceName: preferredDevice.name - ) - } - } - } - - // Check for newly connected Bluetooth devices (auto-switch) - for device in currentDevices { - if device.name.localizedCaseInsensitiveContains("airpods") || - device.name.localizedCaseInsensitiveContains("bluetooth") - { - if !cachedUIDs.contains(device.uid) { - DebugLogger.shared.info( - "🎧 New Bluetooth device detected: '\(device.name)'. Auto-switching...", - source: "ASRService" - ) - - SettingsStore.shared.preferredInputDeviceUID = device.uid - DebugLogger.shared.debug("Updated preferred input device to: \(device.uid)", source: "ASRService") - - if self.isRunning { - DebugLogger.shared.info( - "Recording in progress - deferring Bluetooth switch until audio route recovery", - source: "ASRService" - ) - self.scheduleAudioRouteRecovery(reason: "bluetooth input connected") - } else { - DebugLogger.shared.info("Not recording - Bluetooth device will be used on next recording", source: "ASRService") - } - } - } + AppServices.shared.microphonePreferenceCoordinator.stabilizePreferredInputAfterHardwareChange( + reason: "input device list changed" + ) } self.cacheCurrentDeviceList(currentDevices) @@ -2621,7 +2523,7 @@ final class ASRService: ObservableObject { return nil } - // Device caching for change detection + /// Device caching for change detection private var cachedDeviceUIDs: Set = [] private func cacheCurrentDeviceList(_ devices: [AudioDevice.Device]) { diff --git a/Sources/Fluid/Services/AppServices.swift b/Sources/Fluid/Services/AppServices.swift index 0c73e026..d1bb7a94 100644 --- a/Sources/Fluid/Services/AppServices.swift +++ b/Sources/Fluid/Services/AppServices.swift @@ -68,6 +68,16 @@ final class AppServices: ObservableObject { return service } + private var _microphonePreferenceCoordinator: MicrophonePreferenceCoordinator? + var microphonePreferenceCoordinator: MicrophonePreferenceCoordinator { + if let existing = self._microphonePreferenceCoordinator { + return existing + } + let coordinator = MicrophonePreferenceCoordinator() + self._microphonePreferenceCoordinator = coordinator + return coordinator + } + private var cancellables = Set() private init() { diff --git a/Sources/Fluid/Services/MenuBarManager.swift b/Sources/Fluid/Services/MenuBarManager.swift index 78b3fe40..21c82985 100644 --- a/Sources/Fluid/Services/MenuBarManager.swift +++ b/Sources/Fluid/Services/MenuBarManager.swift @@ -581,6 +581,17 @@ final class MenuBarManager: NSObject, ObservableObject, NSMenuDelegate { return } + let followSystemItem = NSMenuItem( + title: "Use macOS Default Microphone", + action: #selector(toggleMicrophoneSelectionMode(_:)), + keyEquivalent: "" + ) + followSystemItem.target = self + followSystemItem.state = SettingsStore.shared.microphoneSelectionMode == .system ? .on : .off + followSystemItem.isEnabled = !self.isRecording + submenu.addItem(followSystemItem) + submenu.addItem(.separator()) + let currentUID = self.currentPreferredInputUID(defaultInputUID: defaultInputUID) for device in inputDevices { @@ -603,22 +614,54 @@ final class MenuBarManager: NSObject, ObservableObject, NSMenuDelegate { } private func currentPreferredInputUID(defaultInputUID: String?) -> String? { - return defaultInputUID + switch SettingsStore.shared.microphoneSelectionMode { + case .system: + return defaultInputUID + case .manual: + return SettingsStore.shared.preferredInputDeviceUID ?? defaultInputUID + } } @objc private func selectMicrophone(_ sender: NSMenuItem) { guard self.isRecording == false else { return } guard let uid = sender.representedObject as? String, !uid.isEmpty else { return } - SettingsStore.shared.preferredInputDeviceUID = uid - - if SettingsStore.shared.syncAudioDevicesWithSystem { + SettingsStore.shared.recordInputDeviceSelection(uid) + if SettingsStore.shared.shouldSyncInputSelectionToSystemDefault() { _ = AudioDevice.setDefaultInputDevice(uid: uid) } self.refreshMicrophoneMenu() } + @objc private func toggleMicrophoneSelectionMode(_ sender: NSMenuItem) { + guard self.isRecording == false else { return } + + let nextMode: SettingsStore.MicrophoneSelectionMode = + SettingsStore.shared.microphoneSelectionMode == .system ? .manual : .system + let currentSystemInputUID = AudioDevice.getDefaultInputDevice()?.uid + let availableInputUIDs = Set(AudioDevice.listInputDevices().map(\.uid)) + let restoredSystemInputUID = SettingsStore.shared.setMicrophoneSelectionMode( + nextMode, + currentSystemInputUID: currentSystemInputUID, + availableInputUIDs: availableInputUIDs + ) + + let preferredInputUID = SettingsStore.shared.preferredInputDeviceUID ?? "" + if nextMode == .manual, + preferredInputUID.isEmpty, + let defaultUID = currentSystemInputUID + { + SettingsStore.shared.preferredInputDeviceUID = defaultUID + } + + if nextMode == .system, let restoredSystemInputUID { + _ = AudioDevice.setDefaultInputDevice(uid: restoredSystemInputUID) + } + + self.refreshMicrophoneMenu() + } + @objc private func checkForUpdates(_ sender: Any?) { DebugLogger.shared.info("🔎 Menu action: Check for Updates…", source: "MenuBarManager") diff --git a/Sources/Fluid/Services/MicrophonePreferenceCoordinator.swift b/Sources/Fluid/Services/MicrophonePreferenceCoordinator.swift new file mode 100644 index 00000000..9ae85a7b --- /dev/null +++ b/Sources/Fluid/Services/MicrophonePreferenceCoordinator.swift @@ -0,0 +1,118 @@ +import Combine +import Foundation + +@MainActor +protocol AudioDeviceManaging { + func listInputDevices() -> [AudioDevice.Device] + func defaultInputDevice() -> AudioDevice.Device? + @discardableResult func setDefaultInputDevice(uid: String) -> Bool +} + +struct CoreAudioDeviceManager: AudioDeviceManaging { + func listInputDevices() -> [AudioDevice.Device] { + AudioDevice.listInputDevices() + } + + func defaultInputDevice() -> AudioDevice.Device? { + AudioDevice.getDefaultInputDevice() + } + + @discardableResult + func setDefaultInputDevice(uid: String) -> Bool { + AudioDevice.setDefaultInputDevice(uid: uid) + } +} + +@MainActor +final class MicrophonePreferenceCoordinator: ObservableObject { + enum EnforcementResult: Equatable { + case skippedSystemMode + case skippedNoPreferredInput + case skippedPreferredUnavailable(String) + case alreadyUsingPreferred(String) + case applied(String) + case failed(String) + } + + @Published private(set) var lastResult: EnforcementResult? + + private let settings: SettingsStore + private let devices: any AudioDeviceManaging + private var stabilizationTask: Task? + + init( + settings: SettingsStore? = nil, + devices: (any AudioDeviceManaging)? = nil + ) { + self.settings = settings ?? .shared + self.devices = devices ?? CoreAudioDeviceManager() + } + + @discardableResult + func enforcePreferredInput(reason: String) -> EnforcementResult { + guard self.settings.microphoneSelectionMode == .manual else { + self.lastResult = .skippedSystemMode + return .skippedSystemMode + } + + guard let preferredUID = self.settings.preferredInputDeviceUID, + preferredUID.isEmpty == false + else { + self.lastResult = .skippedNoPreferredInput + return .skippedNoPreferredInput + } + + let inputs = self.devices.listInputDevices() + guard inputs.contains(where: { $0.uid == preferredUID }) else { + let result = EnforcementResult.skippedPreferredUnavailable(preferredUID) + self.lastResult = result + DebugLogger.shared.warning( + "Preferred microphone unavailable during \(reason): \(preferredUID)", + source: "MicrophonePreferenceCoordinator" + ) + return result + } + + if self.devices.defaultInputDevice()?.uid == preferredUID { + let result = EnforcementResult.alreadyUsingPreferred(preferredUID) + self.lastResult = result + return result + } + + let didApply = self.devices.setDefaultInputDevice(uid: preferredUID) + let result: EnforcementResult = didApply ? .applied(preferredUID) : .failed(preferredUID) + self.lastResult = result + + if didApply { + DebugLogger.shared.info( + "Reasserted preferred microphone during \(reason): \(preferredUID)", + source: "MicrophonePreferenceCoordinator" + ) + } else { + DebugLogger.shared.error( + "Failed to reassert preferred microphone during \(reason): \(preferredUID)", + source: "MicrophonePreferenceCoordinator" + ) + } + + return result + } + + func stabilizePreferredInputAfterHardwareChange(reason: String) { + self.stabilizationTask?.cancel() + self.stabilizationTask = Task { [weak self] in + let delaysNanoseconds: [UInt64] = [ + 250_000_000, + 750_000_000, + 1_500_000_000, + 2_500_000_000, + ] + + for delay in delaysNanoseconds { + try? await Task.sleep(nanoseconds: delay) + guard Task.isCancelled == false else { return } + _ = self?.enforcePreferredInput(reason: reason) + } + } + } +} diff --git a/Sources/Fluid/UI/SettingsView.swift b/Sources/Fluid/UI/SettingsView.swift index 5b551d40..6d0237f0 100644 --- a/Sources/Fluid/UI/SettingsView.swift +++ b/Sources/Fluid/UI/SettingsView.swift @@ -31,6 +31,7 @@ struct SettingsView: View { @Binding var visualizerNoiseThreshold: Double @Binding var selectedInputUID: String @Binding var selectedOutputUID: String + @Binding var microphoneSelectionMode: SettingsStore.MicrophoneSelectionMode @Binding var inputDevices: [AudioDevice.Device] @Binding var outputDevices: [AudioDevice.Device] @Binding var accessibilityEnabled: Bool @@ -1080,18 +1081,11 @@ struct SettingsView: View { } // Info note about device syncing - HStack(alignment: .top, spacing: 8) { - Image(systemName: "info.circle") - .foregroundStyle(self.settingsSecondaryText) - .font(self.theme.typography.bodyStrong) - Text("Audio devices are synced with macOS System Settings.") - .font(self.theme.typography.bodySmall) - .foregroundStyle(self.settingsSecondaryText) - .fixedSize(horizontal: false, vertical: true) - } - .padding(.vertical, 4) + self.microphoneModeInfo VStack(alignment: .leading, spacing: 12) { + self.microphoneModeToggle + HStack { Text("Input Device") .font(self.theme.typography.bodyStrong) @@ -1111,7 +1105,7 @@ struct SettingsView: View { } .pickerStyle(.menu) .frame(width: 240) - .disabled(self.asr.isRunning) // Disable device changes during recording + .disabled(self.asr.isRunning) .onChange(of: self.selectedInputUID) { oldUID, newUID in guard !newUID.isEmpty else { return } @@ -1123,9 +1117,8 @@ struct SettingsView: View { return } - SettingsStore.shared.preferredInputDeviceUID = newUID - // Only change system default if sync is enabled - if SettingsStore.shared.syncAudioDevicesWithSystem { + SettingsStore.shared.recordInputDeviceSelection(newUID) + if SettingsStore.shared.shouldSyncInputSelectionToSystemDefault() { _ = AudioDevice.setDefaultInputDevice(uid: newUID) } } @@ -1134,17 +1127,22 @@ struct SettingsView: View { // Update cached default device name when device list changes 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 ?? "" - } + guard !newDevices.isEmpty else { return } + + switch self.microphoneSelectionMode { + case .system: + if let defaultUID = AudioDevice.getDefaultInputDevice()?.uid, + newDevices.contains(where: { $0.uid == defaultUID }) + { + self.selectedInputUID = defaultUID + } else if !newDevices.contains(where: { $0.uid == self.selectedInputUID }) { + self.selectedInputUID = newDevices.first?.uid ?? "" + } + case .manual: + if let preferredUID = SettingsStore.shared.preferredInputDeviceUID, + newDevices.contains(where: { $0.uid == preferredUID }) + { + self.selectedInputUID = preferredUID } } } @@ -1182,10 +1180,7 @@ struct SettingsView: View { } SettingsStore.shared.preferredOutputDeviceUID = newUID - // Only change system default if sync is enabled - if SettingsStore.shared.syncAudioDevicesWithSystem { - _ = AudioDevice.setDefaultOutputDevice(uid: newUID) - } + _ = AudioDevice.setDefaultOutputDevice(uid: newUID) } // Sync selection when devices load or change .onChange(of: self.outputDevices) { _, newDevices in @@ -1222,11 +1217,6 @@ struct SettingsView: View { .lineLimit(1) } } - - // REMOVED: Sync mode toggle - // Independent mode doesn't work for aggregate devices (Bluetooth, etc.) - // due to CoreAudio limitation (OSStatus -10851) - // Always use sync mode for reliability across all device types } } .padding(16) @@ -2301,6 +2291,65 @@ struct SettingsView: View { } } +private extension SettingsView { + var microphoneModeInfo: some View { + HStack(alignment: .top, spacing: 8) { + Image(systemName: "info.circle") + .foregroundStyle(self.settingsSecondaryText) + .font(self.theme.typography.bodyStrong) + Text( + self.microphoneSelectionMode == .system + ? "FluidVoice follows the macOS default microphone." + : "FluidVoice keeps your preferred microphone selected while it is available." + ) + .font(self.theme.typography.bodySmall) + .foregroundStyle(self.settingsSecondaryText) + .fixedSize(horizontal: false, vertical: true) + } + .padding(.vertical, 4) + } + + var microphoneModeToggle: some View { + Toggle( + "Use macOS default microphone", + isOn: Binding( + get: { self.microphoneSelectionMode == .system }, + set: { self.updateMicrophoneSelectionMode(useSystemDefault: $0) } + ) + ) + .toggleStyle(.switch) + .font(self.theme.typography.bodyStrong) + .foregroundStyle(self.settingsTitleText) + .disabled(self.asr.isRunning) + } + + func updateMicrophoneSelectionMode(useSystemDefault: Bool) { + let nextMode: SettingsStore.MicrophoneSelectionMode = useSystemDefault ? .system : .manual + let currentSystemInputUID = AudioDevice.getDefaultInputDevice()?.uid + let availableInputUIDs = Set(self.inputDevices.map(\.uid)) + let restoredSystemInputUID = SettingsStore.shared.setMicrophoneSelectionMode( + nextMode, + currentSystemInputUID: currentSystemInputUID, + availableInputUIDs: availableInputUIDs + ) + self.microphoneSelectionMode = nextMode + + if nextMode == .manual { + if self.selectedInputUID.isEmpty, + let defaultUID = currentSystemInputUID + { + self.selectedInputUID = defaultUID + SettingsStore.shared.recordInputDeviceSelection(defaultUID) + } else { + SettingsStore.shared.recordInputDeviceSelection(self.selectedInputUID) + } + } else if let restoredSystemInputUID { + self.selectedInputUID = restoredSystemInputUID + _ = AudioDevice.setDefaultInputDevice(uid: restoredSystemInputUID) + } + } +} + private final class SettingsPersistentScroller: NSScroller { override static var isCompatibleWithOverlayScrollers: Bool { false diff --git a/Tests/FluidDictationIntegrationTests/HotkeyShortcutTests.swift b/Tests/FluidDictationIntegrationTests/HotkeyShortcutTests.swift index b7e0f8ee..bfffd688 100644 --- a/Tests/FluidDictationIntegrationTests/HotkeyShortcutTests.swift +++ b/Tests/FluidDictationIntegrationTests/HotkeyShortcutTests.swift @@ -1,4 +1,5 @@ import AppKit +import CoreAudio @testable import FluidVoice_Debug import Foundation import XCTest @@ -8,6 +9,9 @@ final class HotkeyShortcutTests: XCTestCase { private let primaryDictationShortcutsKey = "PrimaryDictationShortcuts" private let pasteLastTranscriptionShortcutKey = "PasteLastTranscriptionHotkeyShortcut" private let pasteLastTranscriptionEnabledKey = "PasteLastTranscriptionShortcutEnabled" + private let microphoneSelectionModeKey = "MicrophoneSelectionMode" + private let preferredInputDeviceUIDKey = "PreferredInputDeviceUID" + private let systemInputDeviceUIDBeforeManualKey = "SystemInputDeviceUIDBeforeManual" func testLegacyKeyboardShortcutPayloadDefaultsToKeyboardKind() throws { let json = #"{"keyCode":61,"modifierFlagsRawValue":0}"# @@ -155,6 +159,232 @@ final class HotkeyShortcutTests: XCTestCase { } } + func testMicrophoneSelectionModeDefaultsToSystem() throws { + try self.withRestoredDefaults(keys: [self.microphoneSelectionModeKey]) { + UserDefaults.standard.removeObject(forKey: self.microphoneSelectionModeKey) + + XCTAssertEqual(SettingsStore.shared.microphoneSelectionMode, .system) + } + } + + func testMicrophoneSelectionModePersistsManual() throws { + try self.withRestoredDefaults(keys: [self.microphoneSelectionModeKey]) { + SettingsStore.shared.microphoneSelectionMode = .manual + + XCTAssertEqual(SettingsStore.shared.microphoneSelectionMode, .manual) + XCTAssertEqual( + UserDefaults.standard.string(forKey: self.microphoneSelectionModeKey), + SettingsStore.MicrophoneSelectionMode.manual.rawValue + ) + } + } + + func testLegacySyncFalseDoesNotEnableManualMode() throws { + try self.withRestoredDefaults(keys: [ + self.microphoneSelectionModeKey, + "SyncAudioDevicesWithSystem", + ]) { + UserDefaults.standard.removeObject(forKey: self.microphoneSelectionModeKey) + UserDefaults.standard.set(false, forKey: "SyncAudioDevicesWithSystem") + + XCTAssertEqual(SettingsStore.shared.microphoneSelectionMode, .system) + } + } + + func testSystemModeInputSelectionDoesNotOverwriteManualPreference() throws { + try self.withRestoredDefaults(keys: [ + self.microphoneSelectionModeKey, + self.preferredInputDeviceUIDKey, + ]) { + SettingsStore.shared.microphoneSelectionMode = .manual + SettingsStore.shared.preferredInputDeviceUID = "internal" + SettingsStore.shared.microphoneSelectionMode = .system + + SettingsStore.shared.recordInputDeviceSelection("airpods") + + XCTAssertEqual(SettingsStore.shared.preferredInputDeviceUID, "internal") + } + } + + func testManualModeInputSelectionPersistsManualPreference() throws { + try self.withRestoredDefaults(keys: [ + self.microphoneSelectionModeKey, + self.preferredInputDeviceUIDKey, + ]) { + SettingsStore.shared.microphoneSelectionMode = .manual + + SettingsStore.shared.recordInputDeviceSelection("studio-mic") + + XCTAssertEqual(SettingsStore.shared.preferredInputDeviceUID, "studio-mic") + } + } + + func testSystemModeInputSelectionSyncsToSystemDefault() throws { + try self.withRestoredDefaults(keys: [self.microphoneSelectionModeKey]) { + SettingsStore.shared.microphoneSelectionMode = .system + + XCTAssertTrue(SettingsStore.shared.shouldSyncInputSelectionToSystemDefault()) + } + } + + func testManualModeInputSelectionDoesNotImmediatelySyncToSystemDefault() throws { + try self.withRestoredDefaults(keys: [self.microphoneSelectionModeKey]) { + SettingsStore.shared.microphoneSelectionMode = .manual + + XCTAssertFalse(SettingsStore.shared.shouldSyncInputSelectionToSystemDefault()) + } + } + + func testSwitchingBackToSystemModeRestoresPreviousSystemInput() throws { + try self.withRestoredDefaults(keys: [ + self.microphoneSelectionModeKey, + self.preferredInputDeviceUIDKey, + self.systemInputDeviceUIDBeforeManualKey, + ]) { + SettingsStore.shared.microphoneSelectionMode = .system + _ = SettingsStore.shared.setMicrophoneSelectionMode( + .manual, + currentSystemInputUID: "internal", + availableInputUIDs: ["internal", "airpods"] + ) + SettingsStore.shared.recordInputDeviceSelection("airpods") + + let restoreUID = SettingsStore.shared.setMicrophoneSelectionMode( + .system, + currentSystemInputUID: "airpods", + availableInputUIDs: ["internal", "airpods"] + ) + + XCTAssertEqual(restoreUID, "internal") + XCTAssertEqual(SettingsStore.shared.microphoneSelectionMode, .system) + XCTAssertEqual(SettingsStore.shared.preferredInputDeviceUID, "airpods") + } + } + + func testSwitchingBackToSystemModeFallsBackWhenPreviousSystemInputUnavailable() throws { + try self.withRestoredDefaults(keys: [ + self.microphoneSelectionModeKey, + self.preferredInputDeviceUIDKey, + self.systemInputDeviceUIDBeforeManualKey, + ]) { + SettingsStore.shared.microphoneSelectionMode = .system + _ = SettingsStore.shared.setMicrophoneSelectionMode( + .manual, + currentSystemInputUID: "internal", + availableInputUIDs: ["internal", "airpods"] + ) + SettingsStore.shared.recordInputDeviceSelection("airpods") + + let restoreUID = SettingsStore.shared.setMicrophoneSelectionMode( + .system, + currentSystemInputUID: "airpods", + availableInputUIDs: ["airpods"] + ) + + XCTAssertEqual(restoreUID, "airpods") + XCTAssertEqual(SettingsStore.shared.preferredInputDeviceUID, "airpods") + } + } + + @MainActor + func testMicrophoneCoordinatorSkipsSystemMode() throws { + try self.withRestoredDefaults(keys: [ + self.microphoneSelectionModeKey, + self.preferredInputDeviceUIDKey, + ]) { + SettingsStore.shared.microphoneSelectionMode = .system + SettingsStore.shared.preferredInputDeviceUID = "internal" + let devices = FakeAudioDeviceManager( + inputs: [Self.device(uid: "internal", name: "MacBook Pro Microphone")], + defaultInputUID: "airpods" + ) + let coordinator = MicrophonePreferenceCoordinator(settings: .shared, devices: devices) + + let result = coordinator.enforcePreferredInput(reason: "unit test") + + XCTAssertEqual(result, .skippedSystemMode) + XCTAssertEqual(devices.setInputCalls, []) + } + } + + @MainActor + func testMicrophoneCoordinatorAppliesManualPreferredInput() throws { + try self.withRestoredDefaults(keys: [ + self.microphoneSelectionModeKey, + self.preferredInputDeviceUIDKey, + ]) { + SettingsStore.shared.microphoneSelectionMode = .manual + SettingsStore.shared.preferredInputDeviceUID = "internal" + let devices = FakeAudioDeviceManager( + inputs: [ + Self.device(uid: "internal", name: "MacBook Pro Microphone"), + Self.device(uid: "airpods", name: "AirPods"), + ], + defaultInputUID: "airpods" + ) + let coordinator = MicrophonePreferenceCoordinator(settings: .shared, devices: devices) + + let result = coordinator.enforcePreferredInput(reason: "unit test") + + XCTAssertEqual(result, .applied("internal")) + XCTAssertEqual(devices.setInputCalls, ["internal"]) + XCTAssertEqual(devices.defaultInputUID, "internal") + } + } + + @MainActor + func testMicrophoneCoordinatorKeepsUnavailableManualPreference() throws { + try self.withRestoredDefaults(keys: [ + self.microphoneSelectionModeKey, + self.preferredInputDeviceUIDKey, + ]) { + SettingsStore.shared.microphoneSelectionMode = .manual + SettingsStore.shared.preferredInputDeviceUID = "external" + let devices = FakeAudioDeviceManager( + inputs: [Self.device(uid: "internal", name: "MacBook Pro Microphone")], + defaultInputUID: "internal" + ) + let coordinator = MicrophonePreferenceCoordinator(settings: .shared, devices: devices) + + let result = coordinator.enforcePreferredInput(reason: "unit test") + + XCTAssertEqual(result, .skippedPreferredUnavailable("external")) + XCTAssertEqual(SettingsStore.shared.preferredInputDeviceUID, "external") + XCTAssertEqual(devices.setInputCalls, []) + } + } + + @MainActor + func testMicrophoneCoordinatorNoOpsWhenPreferredAlreadyDefault() throws { + try self.withRestoredDefaults(keys: [ + self.microphoneSelectionModeKey, + self.preferredInputDeviceUIDKey, + ]) { + SettingsStore.shared.microphoneSelectionMode = .manual + SettingsStore.shared.preferredInputDeviceUID = "internal" + let devices = FakeAudioDeviceManager( + inputs: [Self.device(uid: "internal", name: "MacBook Pro Microphone")], + defaultInputUID: "internal" + ) + let coordinator = MicrophonePreferenceCoordinator(settings: .shared, devices: devices) + + let result = coordinator.enforcePreferredInput(reason: "unit test") + + XCTAssertEqual(result, .alreadyUsingPreferred("internal")) + XCTAssertEqual(devices.setInputCalls, []) + } + } + + private static func device(uid: String, name: String) -> AudioDevice.Device { + AudioDevice.Device( + id: AudioObjectID(abs(uid.hashValue % 100_000) + 1), + uid: uid, + name: name, + hasInput: true, + hasOutput: false + ) + } + private func withRestoredDefaults(keys: [String], run: () throws -> Void) rethrows { let defaults = UserDefaults.standard var snapshot: [String: Any] = [:] @@ -177,3 +407,30 @@ final class HotkeyShortcutTests: XCTestCase { try run() } } + +@MainActor +private final class FakeAudioDeviceManager: AudioDeviceManaging { + let inputs: [AudioDevice.Device] + var defaultInputUID: String? + private(set) var setInputCalls: [String] = [] + + init(inputs: [AudioDevice.Device], defaultInputUID: String?) { + self.inputs = inputs + self.defaultInputUID = defaultInputUID + } + + func listInputDevices() -> [AudioDevice.Device] { + self.inputs + } + + func defaultInputDevice() -> AudioDevice.Device? { + guard let defaultInputUID else { return nil } + return self.inputs.first { $0.uid == defaultInputUID } + } + + func setDefaultInputDevice(uid: String) -> Bool { + self.setInputCalls.append(uid) + self.defaultInputUID = uid + return true + } +} From 8c35e96c554f02aabc8163caa324e5d4a4de06d3 Mon Sep 17 00:00:00 2001 From: grohith327 Date: Sun, 5 Jul 2026 11:37:22 -0700 Subject: [PATCH 2/3] fix merge conflict --- Sources/Fluid/Services/ASRService.swift | 18 +++++------------- 1 file changed, 5 insertions(+), 13 deletions(-) diff --git a/Sources/Fluid/Services/ASRService.swift b/Sources/Fluid/Services/ASRService.swift index 1c850c39..8babc3a7 100644 --- a/Sources/Fluid/Services/ASRService.swift +++ b/Sources/Fluid/Services/ASRService.swift @@ -685,7 +685,7 @@ final class ASRService: ObservableObject { } private func resolvedInputDeviceForCapture() -> AudioDevice.Device? { - if SettingsStore.shared.syncAudioDevicesWithSystem == false, + if SettingsStore.shared.microphoneSelectionMode == .manual, let preferredUID = SettingsStore.shared.preferredInputDeviceUID, preferredUID.isEmpty == false, let preferredDevice = AudioDevice.getInputDevice(byUID: preferredUID) @@ -1204,18 +1204,15 @@ final class ASRService: ObservableObject { self.isDictionaryTrainingCaptureActive = false do { + if SettingsStore.shared.microphoneSelectionMode == .manual { + AppServices.shared.microphonePreferenceCoordinator.enforcePreferredInput(reason: "recording start") + } + try self.startPreferredAudioCapture() self.isRunning = true self.isDictionaryTrainingCaptureActive = forDictionaryTraining DebugLogger.shared.info("✅ Audio capture running", source: "ASRService") onCaptureStarted?() - if SettingsStore.shared.microphoneSelectionMode == .manual { - AppServices.shared.microphonePreferenceCoordinator.enforcePreferredInput(reason: "recording start") - } - - DebugLogger.shared.debug("⚙️ Calling configureSession()...", source: "ASRService") - try self.configureSession() - DebugLogger.shared.debug("✅ configureSession() completed", source: "ASRService") // Pause only after capture is live so media control cannot delay the // first PCM packet. A quick stop while this await is in flight is @@ -2242,11 +2239,6 @@ final class ASRService: ObservableObject { } private func handleDefaultOutputChanged() { - guard SettingsStore.shared.syncAudioDevicesWithSystem else { - DebugLogger.shared.debug("Ignoring system default output change (sync disabled)", source: "ASRService") - return - } - // Input-only direct capture has no output device dependency. if self.directAudioInput != nil { return From 6dff573ca1a4f429833cb5f8b69feecb35c5758e Mon Sep 17 00:00:00 2001 From: grohith327 Date: Sun, 5 Jul 2026 12:21:13 -0700 Subject: [PATCH 3/3] centralize microhpone input irrespective of audio pipeline --- Sources/Fluid/Services/ASRService.swift | 37 ++++++++++++++----- .../MicrophonePreferenceCoordinator.swift | 12 ++++++ .../HotkeyShortcutTests.swift | 25 +++++++++++++ 3 files changed, 64 insertions(+), 10 deletions(-) diff --git a/Sources/Fluid/Services/ASRService.swift b/Sources/Fluid/Services/ASRService.swift index 8babc3a7..70d00671 100644 --- a/Sources/Fluid/Services/ASRService.swift +++ b/Sources/Fluid/Services/ASRService.swift @@ -685,14 +685,7 @@ final class ASRService: ObservableObject { } private func resolvedInputDeviceForCapture() -> AudioDevice.Device? { - if SettingsStore.shared.microphoneSelectionMode == .manual, - let preferredUID = SettingsStore.shared.preferredInputDeviceUID, - preferredUID.isEmpty == false, - let preferredDevice = AudioDevice.getInputDevice(byUID: preferredUID) - { - return preferredDevice - } - return AudioDevice.getDefaultInputDevice() + AppServices.shared.microphonePreferenceCoordinator.inputDeviceForCapture() } /// Prepares the direct device callback without starting hardware IO. This @@ -1752,10 +1745,34 @@ final class ASRService: ObservableObject { private func bindPreferredInputDeviceIfNeeded() -> Bool { DebugLogger.shared.debug("bindPreferredInputDeviceIfNeeded() - Starting input device binding", source: "ASRService") - DebugLogger.shared.info( - "Using current macOS default input device. Manual microphone mode reasserts the preferred device before engine start.", + guard SettingsStore.shared.microphoneSelectionMode == .manual else { + DebugLogger.shared.info("Using current macOS default input device", source: "ASRService") + return true + } + + guard let device = self.resolvedInputDeviceForCapture() else { + DebugLogger.shared.error( + "No input device available for manual microphone capture.", + source: "ASRService" + ) + return false + } + + DebugLogger.shared.debug( + "Attempting to bind AVAudioEngine input to capture device '\(device.name)' (uid: \(device.uid))", 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 '\(device.name)' (uid: \(device.uid)). Trying system default input.", + source: "ASRService" + ) + return self.tryBindToSystemDefaultInput() + } + + DebugLogger.shared.info("✅ Bound AVAudioEngine input to '\(device.name)'", source: "ASRService") return true } diff --git a/Sources/Fluid/Services/MicrophonePreferenceCoordinator.swift b/Sources/Fluid/Services/MicrophonePreferenceCoordinator.swift index 9ae85a7b..61b67cf7 100644 --- a/Sources/Fluid/Services/MicrophonePreferenceCoordinator.swift +++ b/Sources/Fluid/Services/MicrophonePreferenceCoordinator.swift @@ -98,6 +98,18 @@ final class MicrophonePreferenceCoordinator: ObservableObject { return result } + func inputDeviceForCapture() -> AudioDevice.Device? { + if self.settings.microphoneSelectionMode == .manual, + let preferredUID = self.settings.preferredInputDeviceUID, + preferredUID.isEmpty == false, + let preferredDevice = self.devices.listInputDevices().first(where: { $0.uid == preferredUID }) + { + return preferredDevice + } + + return self.devices.defaultInputDevice() + } + func stabilizePreferredInputAfterHardwareChange(reason: String) { self.stabilizationTask?.cancel() self.stabilizationTask = Task { [weak self] in diff --git a/Tests/FluidDictationIntegrationTests/HotkeyShortcutTests.swift b/Tests/FluidDictationIntegrationTests/HotkeyShortcutTests.swift index bfffd688..0b8518b3 100644 --- a/Tests/FluidDictationIntegrationTests/HotkeyShortcutTests.swift +++ b/Tests/FluidDictationIntegrationTests/HotkeyShortcutTests.swift @@ -375,6 +375,31 @@ final class HotkeyShortcutTests: XCTestCase { } } + @MainActor + func testMicrophoneCoordinatorResolvesManualPreferredInputForCapture() throws { + try self.withRestoredDefaults(keys: [ + self.microphoneSelectionModeKey, + self.preferredInputDeviceUIDKey, + ]) { + SettingsStore.shared.microphoneSelectionMode = .manual + SettingsStore.shared.preferredInputDeviceUID = "studio-mic" + let studioMic = Self.device(uid: "studio-mic", name: "Studio Mic") + let devices = FakeAudioDeviceManager( + inputs: [ + Self.device(uid: "internal", name: "MacBook Pro Microphone"), + studioMic, + ], + defaultInputUID: "internal" + ) + let coordinator = MicrophonePreferenceCoordinator(settings: .shared, devices: devices) + + let resolved = coordinator.inputDeviceForCapture() + + XCTAssertEqual(resolved, studioMic) + XCTAssertEqual(devices.setInputCalls, []) + } + } + private static func device(uid: String, name: String) -> AudioDevice.Device { AudioDevice.Device( id: AudioObjectID(abs(uid.hashValue % 100_000) + 1),