diff --git a/Fluid.xcodeproj/project.pbxproj b/Fluid.xcodeproj/project.pbxproj index 2b135c8c..272bcca8 100644 --- a/Fluid.xcodeproj/project.pbxproj +++ b/Fluid.xcodeproj/project.pbxproj @@ -18,6 +18,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 /* FluidVoiceOnlyMicrophoneModeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A62300000000000000000003 /* FluidVoiceOnlyMicrophoneModeTests.swift */; }; C0DE63600000000000000002 /* AudioEngineRetirementDrainTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE63600000000000000001 /* AudioEngineRetirementDrainTests.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 */; }; @@ -53,6 +54,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 /* FluidVoiceOnlyMicrophoneModeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FluidVoiceOnlyMicrophoneModeTests.swift; sourceTree = ""; }; C0DE63600000000000000001 /* AudioEngineRetirementDrainTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioEngineRetirementDrainTests.swift; sourceTree = ""; }; 7C078D8F2E3B339200FB7CAC /* FluidVoice Debug.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "FluidVoice Debug.app"; sourceTree = BUILT_PRODUCTS_DIR; }; 7C91B0022F42AA0100C0DEF0 /* HotkeyShortcutTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotkeyShortcutTests.swift; sourceTree = ""; }; @@ -131,6 +133,7 @@ 343B29013F4441D6A797D12D /* LLMClientRequestBodyTests.swift */, 980330F3CE464336ADCE3E23 /* TemperatureSupportTests.swift */, A62300000000000000000001 /* AudioBufferConverterTests.swift */, + A62300000000000000000003 /* FluidVoiceOnlyMicrophoneModeTests.swift */, C0DE63600000000000000001 /* AudioEngineRetirementDrainTests.swift */, ); path = FluidDictationIntegrationTests; @@ -290,6 +293,7 @@ 86CAA2D4EF18433096185602 /* LLMClientRequestBodyTests.swift in Sources */, 272BFB5CB271489892CAE50C /* TemperatureSupportTests.swift in Sources */, A62300000000000000000002 /* AudioBufferConverterTests.swift in Sources */, + A62300000000000000000004 /* FluidVoiceOnlyMicrophoneModeTests.swift in Sources */, C0DE63600000000000000002 /* AudioEngineRetirementDrainTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/Sources/Fluid/ContentView.swift b/Sources/Fluid/ContentView.swift index 2410fd45..d003165d 100644 --- a/Sources/Fluid/ContentView.swift +++ b/Sources/Fluid/ContentView.swift @@ -388,7 +388,7 @@ struct ContentView: View { if let sysIn = AudioDevice.getDefaultInputDevice()?.uid { self.selectedInputUID = sysIn } - case .manual: + case .manual, .fluidVoiceOnly: // The refreshed device list drives the displayed selection. break } @@ -608,7 +608,7 @@ struct ContentView: View { if let defaultUID = AudioDevice.getDefaultInputDevice()?.uid { self.selectedInputUID = defaultUID } - case .manual: + case .manual, .fluidVoiceOnly: if let preferredUID = SettingsStore.shared.preferredInputDeviceUID, !preferredUID.isEmpty { self.selectedInputUID = preferredUID } else if let defaultUID = AudioDevice.getDefaultInputDevice()?.uid { @@ -4319,7 +4319,7 @@ private extension ContentView { switch SettingsStore.shared.microphoneSelectionMode { case .system: self.selectedInputUID = AudioDevice.getDefaultInputDevice()?.uid ?? "" - case .manual: + case .manual, .fluidVoiceOnly: self.selectedInputUID = SettingsStore.shared.preferredInputDeviceUID ?? AudioDevice.getDefaultInputDevice()?.uid ?? "" } self.selectedOutputUID = SettingsStore.shared.preferredOutputDeviceUID ?? "" diff --git a/Sources/Fluid/Persistence/SettingsStore.swift b/Sources/Fluid/Persistence/SettingsStore.swift index e786893f..0ab21e8c 100644 --- a/Sources/Fluid/Persistence/SettingsStore.swift +++ b/Sources/Fluid/Persistence/SettingsStore.swift @@ -183,6 +183,12 @@ final class SettingsStore: ObservableObject { enum MicrophoneSelectionMode: String, Codable, CaseIterable, Identifiable { case system 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. 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 { self.rawValue @@ -194,8 +200,15 @@ final class SettingsStore: ObservableObject { return "Use macOS Default" case .manual: return "Use Preferred Microphone" + case .fluidVoiceOnly: + return "Use Preferred Microphone (FluidVoice Only)" } } + + /// Whether capture resolves `preferredInputDeviceUID` instead of the macOS default input. + var usesPreferredInputDevice: Bool { + self != .system + } } enum DictationPromptSelection: Equatable { @@ -1814,6 +1827,14 @@ final class SettingsStore: ObservableObject { if let raw = self.defaults.string(forKey: Keys.microphoneSelectionMode), let mode = MicrophoneSelectionMode(rawValue: raw) { + // FluidVoice-only capture exists solely on the direct Core Audio path. If that path is + // off, degrade to `.manual` — which still honours the preferred microphone, by + // moving the system default — rather than silently recording from whatever macOS + // happens to be pointed at. + if mode == .fluidVoiceOnly, self.experimentalDirectAudioCaptureEnabled == false { + return .manual + } + return mode } @@ -1827,7 +1848,7 @@ final class SettingsStore: ObservableObject { func recordInputDeviceSelection(_ uid: String) { guard uid.isEmpty == false else { return } - guard self.microphoneSelectionMode == .manual else { return } + guard self.microphoneSelectionMode.usesPreferredInputDevice else { return } self.preferredInputDeviceUID = uid } @@ -1844,8 +1865,13 @@ final class SettingsStore: ObservableObject { ) -> String? { let previousMode = self.microphoneSelectionMode - if previousMode == .system, - mode == .manual, + // `.manual` is the one mode that moves the macOS default, so capture the user's own default + // on the way *in* — from any other mode, since neither `.system` nor `.fluidVoiceOnly` + // disturbs it, which makes the current value genuinely theirs. Capturing only from + // `.system` would miss the `.fluidVoiceOnly` -> `.manual` -> `.fluidVoiceOnly` round trip + // and leave the system input stuck on FluidVoice's choice after the user opted back out. + if mode == .manual, + previousMode != .manual, let currentSystemInputUID, currentSystemInputUID.isEmpty == false { @@ -1854,7 +1880,11 @@ final class SettingsStore: ObservableObject { self.microphoneSelectionMode = mode - guard mode == .system else { return nil } + // `.manual` is the only mode that holds the macOS default on FluidVoice's choice, so only + // *leaving* it hands the default back to whatever the user had before — whether that is for + // `.system` or for `.fluidVoiceOnly`. Restoring on any other transition would clobber a + // default the user changed themselves while FluidVoice was keeping its hands off it. + guard previousMode == .manual, mode != .manual else { return nil } if let previousSystemInputUID = self.defaults.string(forKey: Keys.systemInputDeviceUIDBeforeManual), previousSystemInputUID.isEmpty == false, diff --git a/Sources/Fluid/Services/ASRService.swift b/Sources/Fluid/Services/ASRService.swift index 8c3855c1..daf28930 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,6 +813,12 @@ final class ASRService: ObservableObject { } private func startPreferredAudioCapture() async throws { + // 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 // already queued. @@ -830,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( @@ -846,9 +859,50 @@ final class ASRService: ObservableObject { self.directAudioInput = nil } + // 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. 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 + /// 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 { @@ -1950,8 +2004,11 @@ 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. + /// 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` 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 { @@ -1988,8 +2045,8 @@ final class ASRService: ObservableObject { 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. + /// Output always follows the macOS default: per-app selection is input-only, so there is no + /// preferred output device to bind here. /// Returns true if binding succeeded or if no binding was needed, false if binding failed completely. @discardableResult private func bindPreferredOutputDeviceIfNeeded() -> Bool { @@ -2546,7 +2603,8 @@ final class ASRService: ObservableObject { } private func handleDefaultInputChanged() { - if SettingsStore.shared.microphoneSelectionMode == .manual { + switch SettingsStore.shared.microphoneSelectionMode { + case .manual: if self.isRunning { AppServices.shared.microphonePreferenceCoordinator.stabilizePreferredInputAfterHardwareChange( reason: "default input changed" @@ -2554,6 +2612,16 @@ final class ASRService: ObservableObject { self.scheduleAudioRouteRecovery(reason: "manual preferred input reasserted") } return + case .fluidVoiceOnly: + // The macOS default moved, but FluidVoice-only capture neither follows it nor pushes it + // back: the device we are bound to is unaffected, so there is nothing to recover. + DebugLogger.shared.info( + "Default input changed; FluidVoice-only capture keeps its own device", + source: "ASRService" + ) + return + case .system: + break } self.scheduleAudioRouteRecovery(reason: "default input changed") @@ -2765,13 +2833,28 @@ final class ASRService: ObservableObject { DebugLogger.shared.debug("Current input devices: \(currentDevices.map { $0.name }.joined(separator: ", "))", source: "ASRService") - if self.isRunning, - SettingsStore.shared.microphoneSelectionMode == .manual, - Set(currentDevices.map(\.uid)) != cachedUIDs - { - AppServices.shared.microphonePreferenceCoordinator.stabilizePreferredInputAfterHardwareChange( - reason: "input device list changed" - ) + if self.isRunning, Set(currentDevices.map(\.uid)) != cachedUIDs { + switch SettingsStore.shared.microphoneSelectionMode { + case .manual: + AppServices.shared.microphonePreferenceCoordinator + .stabilizePreferredInputAfterHardwareChange(reason: "input device list changed") + case .fluidVoiceOnly: + // 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 + } } self.cacheCurrentDeviceList(currentDevices) diff --git a/Sources/Fluid/Services/MenuBarManager.swift b/Sources/Fluid/Services/MenuBarManager.swift index d331201a..9ebd8eda 100644 --- a/Sources/Fluid/Services/MenuBarManager.swift +++ b/Sources/Fluid/Services/MenuBarManager.swift @@ -612,6 +612,23 @@ final class MenuBarManager: NSObject, ObservableObject, NSMenuDelegate { followSystemItem.state = SettingsStore.shared.microphoneSelectionMode == .system ? .on : .off followSystemItem.isEnabled = !self.isRecording submenu.addItem(followSystemItem) + + // Capturing for FluidVoice alone is a property of the preferred-microphone modes, so it gets + // its own check-mark rather than another position in the follow-system toggle. Without it, a + // user could only leave this mode via `.manual`, which would move their system input + // — the exact side effect they opted out of. + if SettingsStore.shared.experimentalDirectAudioCaptureEnabled { + let fluidVoiceOnlyItem = NSMenuItem( + title: "Use for FluidVoice Only", + action: #selector(toggleFluidVoiceOnlyMode(_:)), + keyEquivalent: "" + ) + fluidVoiceOnlyItem.target = self + fluidVoiceOnlyItem.state = SettingsStore.shared.microphoneSelectionMode == .fluidVoiceOnly ? .on : .off + fluidVoiceOnlyItem.isEnabled = !self.isRecording && SettingsStore.shared.microphoneSelectionMode != .system + submenu.addItem(fluidVoiceOnlyItem) + } + submenu.addItem(.separator()) let currentUID = self.currentPreferredInputUID(defaultInputUID: defaultInputUID) @@ -639,7 +656,7 @@ final class MenuBarManager: NSObject, ObservableObject, NSMenuDelegate { switch SettingsStore.shared.microphoneSelectionMode { case .system: return defaultInputUID - case .manual: + case .manual, .fluidVoiceOnly: return SettingsStore.shared.preferredInputDeviceUID ?? defaultInputUID } } @@ -686,7 +703,7 @@ final class MenuBarManager: NSObject, ObservableObject, NSMenuDelegate { ) let preferredInputUID = SettingsStore.shared.preferredInputDeviceUID ?? "" - if nextMode == .manual, + if nextMode.usesPreferredInputDevice, preferredInputUID.isEmpty, let defaultUID = currentSystemInputUID { @@ -700,6 +717,19 @@ final class MenuBarManager: NSObject, ObservableObject, NSMenuDelegate { self.refreshMicrophoneMenu() } + @objc private func toggleFluidVoiceOnlyMode(_ sender: NSMenuItem) { + guard self.isRecording == false else { return } + // Shares one implementation with the Settings switch — see `setFluidVoiceOnly`. + let enable = SettingsStore.shared.microphoneSelectionMode != .fluidVoiceOnly + AppServices.shared.microphonePreferenceCoordinator.setFluidVoiceOnly( + enable, + currentSystemInputUID: AudioDevice.getDefaultInputDevice()?.uid, + availableInputUIDs: Set(AudioDevice.listInputDevices().map(\.uid)) + ) + + 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 index 61b67cf7..d71a0d6e 100644 --- a/Sources/Fluid/Services/MicrophonePreferenceCoordinator.swift +++ b/Sources/Fluid/Services/MicrophonePreferenceCoordinator.swift @@ -27,6 +27,7 @@ struct CoreAudioDeviceManager: AudioDeviceManaging { final class MicrophonePreferenceCoordinator: ObservableObject { enum EnforcementResult: Equatable { case skippedSystemMode + case skippedFluidVoiceOnlyMode case skippedNoPreferredInput case skippedPreferredUnavailable(String) case alreadyUsingPreferred(String) @@ -50,9 +51,17 @@ final class MicrophonePreferenceCoordinator: ObservableObject { @discardableResult func enforcePreferredInput(reason: String) -> EnforcementResult { - guard self.settings.microphoneSelectionMode == .manual else { + switch self.settings.microphoneSelectionMode { + case .system: self.lastResult = .skippedSystemMode return .skippedSystemMode + case .fluidVoiceOnly: + // The whole point of FluidVoice-only mode: the preferred microphone is bound by the + // direct capture path for this app only, so the macOS default is never touched. + self.lastResult = .skippedFluidVoiceOnlyMode + return .skippedFluidVoiceOnlyMode + case .manual: + break } guard let preferredUID = self.settings.preferredInputDeviceUID, @@ -98,8 +107,55 @@ final class MicrophonePreferenceCoordinator: ObservableObject { return result } + /// Switches between the two preferred-microphone modes and applies the system-default side + /// effect that switch implies: leaving `.manual` hands the user's own input device back, and + /// returning to `.manual` reasserts the preference onto the system default. + /// + /// Both entry points — the Settings switch and the menu-bar item — route through here so their + /// behaviour cannot drift apart, and so the transition can be tested without driving the UI. + /// Returns the mode actually applied, or nil if the current mode isn't a preferred-microphone + /// mode (independence is only meaningful between `.manual` and `.fluidVoiceOnly`). + @discardableResult + func setFluidVoiceOnly( + _ enabled: Bool, + currentSystemInputUID: String?, + availableInputUIDs: Set + ) -> SettingsStore.MicrophoneSelectionMode? { + guard self.settings.microphoneSelectionMode.usesPreferredInputDevice else { return nil } + + let nextMode: SettingsStore.MicrophoneSelectionMode = enabled ? .fluidVoiceOnly : .manual + let restoredSystemInputUID = self.settings.setMicrophoneSelectionMode( + nextMode, + currentSystemInputUID: currentSystemInputUID, + availableInputUIDs: availableInputUIDs + ) + + // There has to be a device to capture from before we stop following the system default — + // an empty preference would resolve back to the default and silently record the wrong mic. + if (self.settings.preferredInputDeviceUID ?? "").isEmpty, + let seed = currentSystemInputUID, + seed.isEmpty == false + { + self.settings.recordInputDeviceSelection(seed) + } + + if nextMode == .fluidVoiceOnly { + if let restoredSystemInputUID { + self.devices.setDefaultInputDevice(uid: restoredSystemInputUID) + } + } else { + self.enforcePreferredInput(reason: "FluidVoice-only microphone mode disabled") + } + + return nextMode + } + + /// 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 == .manual, + if self.settings.microphoneSelectionMode.usesPreferredInputDevice, let preferredUID = self.settings.preferredInputDeviceUID, preferredUID.isEmpty == false, let preferredDevice = self.devices.listInputDevices().first(where: { $0.uid == preferredUID }) 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/Sources/Fluid/UI/SettingsView.swift b/Sources/Fluid/UI/SettingsView.swift index 115ebc21..c49338cd 100644 --- a/Sources/Fluid/UI/SettingsView.swift +++ b/Sources/Fluid/UI/SettingsView.swift @@ -1146,7 +1146,7 @@ struct SettingsView: View { } else if !newDevices.contains(where: { $0.uid == self.selectedInputUID }) { self.selectedInputUID = newDevices.first?.uid ?? "" } - case .manual: + case .manual, .fluidVoiceOnly: if let preferredUID = SettingsStore.shared.preferredInputDeviceUID, newDevices.contains(where: { $0.uid == preferredUID }) { @@ -2307,11 +2307,7 @@ private extension SettingsView { 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." - ) + Text(self.microphoneModeExplanation) .font(self.theme.typography.bodySmall) .foregroundStyle(self.settingsSecondaryText) .fixedSize(horizontal: false, vertical: true) @@ -2319,21 +2315,56 @@ private extension SettingsView { .padding(.vertical, 4) } + var microphoneModeExplanation: String { + switch self.microphoneSelectionMode { + case .system: + return "FluidVoice follows the macOS default microphone." + case .manual: + return "FluidVoice keeps your preferred microphone selected while it is available, " + + "switching the macOS input to match." + case .fluidVoiceOnly: + return "FluidVoice records from your preferred microphone directly and leaves the " + + "macOS input device unchanged, so other apps keep using it." + } + } + var microphoneModeToggle: some View { - Toggle( - "Use macOS default microphone", - isOn: Binding( - get: { self.microphoneSelectionMode == .system }, - set: { self.updateMicrophoneSelectionMode(useSystemDefault: $0) } + VStack(alignment: .leading, spacing: 8) { + 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) + .toggleStyle(.switch) + .font(self.theme.typography.bodyStrong) + .foregroundStyle(self.settingsTitleText) + .disabled(self.asr.isRunning) + + // Per-app capture is only possible on the direct Core Audio path, so the choice is + // only offered when that is enabled. + if self.microphoneSelectionMode != .system, + self.settings.experimentalDirectAudioCaptureEnabled + { + Toggle( + "Use this microphone for FluidVoice only", + isOn: Binding( + get: { self.microphoneSelectionMode == .fluidVoiceOnly }, + set: { self.updateFluidVoiceOnlyMode(isFluidVoiceOnly: $0) } + ) + ) + .toggleStyle(.switch) + .font(self.theme.typography.bodyStrong) + .foregroundStyle(self.settingsTitleText) + .disabled(self.asr.isRunning) + } + } } func updateMicrophoneSelectionMode(useSystemDefault: Bool) { + // Turning the system default back off returns to `.manual`; FluidVoice-only is a separate + // switch so that the mode with the side effect is never the silent default. let nextMode: SettingsStore.MicrophoneSelectionMode = useSystemDefault ? .system : .manual let currentSystemInputUID = AudioDevice.getDefaultInputDevice()?.uid let availableInputUIDs = Set(self.inputDevices.map(\.uid)) @@ -2344,7 +2375,7 @@ private extension SettingsView { ) self.microphoneSelectionMode = nextMode - if nextMode == .manual { + if nextMode.usesPreferredInputDevice { if self.selectedInputUID.isEmpty, let defaultUID = currentSystemInputUID { @@ -2358,6 +2389,25 @@ private extension SettingsView { _ = AudioDevice.setDefaultInputDevice(uid: restoredSystemInputUID) } } + + func updateFluidVoiceOnlyMode(isFluidVoiceOnly: Bool) { + // The transition itself lives on the coordinator so this and the menu-bar item share one + // implementation; only the view's own state is updated here. + guard let nextMode = AppServices.shared.microphonePreferenceCoordinator.setFluidVoiceOnly( + isFluidVoiceOnly, + currentSystemInputUID: AudioDevice.getDefaultInputDevice()?.uid, + availableInputUIDs: Set(self.inputDevices.map(\.uid)) + ) else { + return + } + + self.microphoneSelectionMode = nextMode + if self.selectedInputUID.isEmpty, + let preferredUID = SettingsStore.shared.preferredInputDeviceUID + { + self.selectedInputUID = preferredUID + } + } } private final class SettingsPersistentScroller: NSScroller { diff --git a/Tests/FluidDictationIntegrationTests/FluidVoiceOnlyMicrophoneModeTests.swift b/Tests/FluidDictationIntegrationTests/FluidVoiceOnlyMicrophoneModeTests.swift new file mode 100644 index 00000000..e732c41f --- /dev/null +++ b/Tests/FluidDictationIntegrationTests/FluidVoiceOnlyMicrophoneModeTests.swift @@ -0,0 +1,350 @@ +import Foundation +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. 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 +/// records every system-default mutation. +@MainActor +final class FluidVoiceOnlyMicrophoneModeTests: XCTestCase { + private let directCaptureKey = "ExperimentalDirectAudioCaptureEnabled" + private let modeKey = "MicrophoneSelectionMode" + private let preferredInputKey = "PreferredInputDeviceUID" + + private let preferredUID = "test-preferred-mic" + private let systemUID = "test-system-mic" + + override func tearDown() { + UserDefaults.standard.removeObject(forKey: self.directCaptureKey) + UserDefaults.standard.removeObject(forKey: self.modeKey) + UserDefaults.standard.removeObject(forKey: self.preferredInputKey) + UserDefaults.standard.removeObject(forKey: "SystemInputDeviceUIDBeforeManual") + super.tearDown() + } + + /// Records what the coordinator does to the system default, and reports a device list in which + /// the preferred microphone is present but is *not* the current default. + private final class SpyDeviceManager: AudioDeviceManaging { + let preferred: AudioDevice.Device + let systemDefault: AudioDevice.Device + private(set) var setDefaultCalls: [String] = [] + /// Simulates the preferred microphone being unplugged. + var hidePreferred = false + + init(preferred: AudioDevice.Device, systemDefault: AudioDevice.Device) { + self.preferred = preferred + self.systemDefault = systemDefault + } + + func listInputDevices() -> [AudioDevice.Device] { + self.hidePreferred ? [self.systemDefault] : [self.systemDefault, self.preferred] + } + + func defaultInputDevice() -> AudioDevice.Device? { + self.systemDefault + } + + @discardableResult + func setDefaultInputDevice(uid: String) -> Bool { + self.setDefaultCalls.append(uid) + return true + } + } + + private func makeSpy() -> SpyDeviceManager { + SpyDeviceManager( + preferred: AudioDevice.Device( + id: 2, uid: self.preferredUID, name: "Preferred Mic", hasInput: true, hasOutput: false + ), + systemDefault: AudioDevice.Device( + id: 1, uid: self.systemUID, name: "System Mic", hasInput: true, hasOutput: false + ) + ) + } + + private func configure(mode: SettingsStore.MicrophoneSelectionMode, directCapture: Bool) { + UserDefaults.standard.set(directCapture, forKey: self.directCaptureKey) + UserDefaults.standard.set(mode.rawValue, forKey: self.modeKey) + UserDefaults.standard.set(self.preferredUID, forKey: self.preferredInputKey) + } + + // MARK: - The core contract + + func testFluidVoiceOnlyModeNeverTouchesSystemDefault() { + self.configure(mode: .fluidVoiceOnly, directCapture: true) + let spy = self.makeSpy() + let coordinator = MicrophonePreferenceCoordinator(settings: .shared, devices: spy) + + let result = coordinator.enforcePreferredInput(reason: "test") + + XCTAssertEqual(result, .skippedFluidVoiceOnlyMode) + XCTAssertTrue( + spy.setDefaultCalls.isEmpty, + "FluidVoice-only mode must never change the macOS default input, got: \(spy.setDefaultCalls)" + ) + } + + func testManualModeStillMovesSystemDefault() { + self.configure(mode: .manual, directCapture: true) + let spy = self.makeSpy() + let coordinator = MicrophonePreferenceCoordinator(settings: .shared, devices: spy) + + let result = coordinator.enforcePreferredInput(reason: "test") + + XCTAssertEqual(result, .applied(self.preferredUID)) + XCTAssertEqual(spy.setDefaultCalls, [self.preferredUID], "Manual mode is defined by moving the default") + } + + func testFluidVoiceOnlyModeCapturesPreferredDevice() { + self.configure(mode: .fluidVoiceOnly, directCapture: true) + let spy = self.makeSpy() + let coordinator = MicrophonePreferenceCoordinator(settings: .shared, devices: spy) + + XCTAssertEqual( + coordinator.inputDeviceForCapture()?.uid, + self.preferredUID, + "Capture must resolve the preferred device even though the system default is different" + ) + } + + func testSystemModeCapturesSystemDefault() { + self.configure(mode: .system, directCapture: true) + let spy = self.makeSpy() + let coordinator = MicrophonePreferenceCoordinator(settings: .shared, devices: spy) + + XCTAssertEqual(coordinator.inputDeviceForCapture()?.uid, self.systemUID) + XCTAssertEqual(coordinator.enforcePreferredInput(reason: "test"), .skippedSystemMode) + XCTAssertTrue(spy.setDefaultCalls.isEmpty) + } + + // MARK: - Gating on the direct capture path + + func testFluidVoiceOnlyDegradesToManualWhenDirectCaptureDisabled() { + self.configure(mode: .fluidVoiceOnly, directCapture: false) + + XCTAssertEqual( + SettingsStore.shared.microphoneSelectionMode, + .manual, + "Per-app capture only exists on the direct path; without it the preference must still be " + + "honoured via the system default rather than silently ignored" + ) + } + + func testFluidVoiceOnlyHonoredWhenDirectCaptureEnabled() { + self.configure(mode: .fluidVoiceOnly, directCapture: true) + + XCTAssertEqual(SettingsStore.shared.microphoneSelectionMode, .fluidVoiceOnly) + } + + func testDefaultsToSystemModeForBackwardCompatibility() { + UserDefaults.standard.set(true, forKey: self.directCaptureKey) + UserDefaults.standard.removeObject(forKey: self.modeKey) + + XCTAssertEqual(SettingsStore.shared.microphoneSelectionMode, .system) + } + + // 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 { + let spy = self.makeSpy() + spy.hidePreferred = true + return spy + } + + /// 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()) + + XCTAssertEqual( + coordinator.inputDeviceForCapture()?.uid, + self.systemUID, + "An unplugged preferred mic must fall back to the system default, not stop recording" + ) + } + + /// 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()) + + XCTAssertEqual(coordinator.inputDeviceForCapture()?.uid, self.preferredUID) + } + + /// `.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()) + + XCTAssertEqual(coordinator.inputDeviceForCapture()?.uid, self.systemUID) + } + + /// 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()) + + XCTAssertEqual(coordinator.inputDeviceForCapture()?.uid, self.systemUID) + } + + // MARK: - Handing the system default back + + /// Leaving `.manual` — the only mode that moves the macOS default — should offer the input the + /// user had before FluidVoice took it over. + func testLeavingManualRestoresPreviousSystemInput() { + self.configure(mode: .system, directCapture: true) + + // system -> manual captures the pre-takeover default… + SettingsStore.shared.setMicrophoneSelectionMode( + .manual, currentSystemInputUID: self.systemUID, availableInputUIDs: [self.systemUID, self.preferredUID] + ) + // …and manual -> FluidVoice-only hands it back. + let restored = SettingsStore.shared.setMicrophoneSelectionMode( + .fluidVoiceOnly, + currentSystemInputUID: self.preferredUID, + availableInputUIDs: [self.systemUID, self.preferredUID] + ) + + XCTAssertEqual(restored, self.systemUID) + } + + /// FluidVoice-only mode never moved the default, so leaving it must not "restore" a stale value + /// over an input device the user changed themselves in the meantime. + func testLeavingFluidVoiceOnlyDoesNotRestoreStaleSystemInput() { + self.configure(mode: .system, directCapture: true) + SettingsStore.shared.setMicrophoneSelectionMode( + .manual, currentSystemInputUID: self.systemUID, availableInputUIDs: [self.systemUID, self.preferredUID] + ) + SettingsStore.shared.setMicrophoneSelectionMode( + .fluidVoiceOnly, + currentSystemInputUID: self.preferredUID, + availableInputUIDs: [self.systemUID, self.preferredUID] + ) + + let restored = SettingsStore.shared.setMicrophoneSelectionMode( + .system, currentSystemInputUID: "user-picked-mic", availableInputUIDs: [self.systemUID, "user-picked-mic"] + ) + + XCTAssertNil(restored, "FluidVoice-only mode left the default alone; nothing to restore") + } + + /// Round-tripping the FluidVoice-only switch must not leave the system input stuck on + /// FluidVoice's choice. `.manual` is entered from `.fluidVoiceOnly` here — never from + /// `.system` — so the pre-manual device has to be captured on that transition too. + func testFluidVoiceOnlyRoundTripReleasesTheSystemInput() { + self.configure(mode: .fluidVoiceOnly, directCapture: true) + let userDefault = "user-own-mic" + let available: Set = [userDefault, self.preferredUID] + + // fluidVoiceOnly -> manual: the current default is still the user's own, so capture it. + SettingsStore.shared.setMicrophoneSelectionMode( + .manual, currentSystemInputUID: userDefault, availableInputUIDs: available + ) + // manual has since moved the system default onto the preferred mic… + // …and toggling back must hand the user's own device back. + let restored = SettingsStore.shared.setMicrophoneSelectionMode( + .fluidVoiceOnly, currentSystemInputUID: self.preferredUID, availableInputUIDs: available + ) + + XCTAssertEqual( + restored, + userDefault, + "Leaving .manual must return the input the user had before FluidVoice took it over" + ) + } + + func testEnteringManualRestoresNothing() { + self.configure(mode: .system, directCapture: true) + + let restored = SettingsStore.shared.setMicrophoneSelectionMode( + .manual, currentSystemInputUID: self.systemUID, availableInputUIDs: [self.systemUID] + ) + + XCTAssertNil(restored) + } + + // MARK: - The switch, end to end + + /// The regression found on-device: flipping the FluidVoice-only switch off and back on left the + /// macOS input stuck on FluidVoice's microphone. Drives the same transition both entry points + /// use, and asserts on the actual system-default mutations. + func testTogglingFluidVoiceOnlyOffAndOnReleasesTheSystemInput() { + self.configure(mode: .fluidVoiceOnly, directCapture: true) + let spy = self.makeSpy() // system default = systemUID, preference = preferredUID + let coordinator = MicrophonePreferenceCoordinator(settings: .shared, devices: spy) + let available: Set = [self.systemUID, self.preferredUID] + + // Off: .manual takes the system input over. + let toManual = coordinator.setFluidVoiceOnly( + false, currentSystemInputUID: self.systemUID, availableInputUIDs: available + ) + XCTAssertEqual(toManual, .manual) + XCTAssertEqual(spy.setDefaultCalls, [self.preferredUID], "manual moves the default onto the preference") + + // Back on: the user's own device must come back. + let toFluidOnly = coordinator.setFluidVoiceOnly( + true, currentSystemInputUID: self.preferredUID, availableInputUIDs: available + ) + XCTAssertEqual(toFluidOnly, .fluidVoiceOnly) + XCTAssertEqual( + spy.setDefaultCalls, + [self.preferredUID, self.systemUID], + "leaving .manual must hand back the input the user had before" + ) + } + + /// Independence is only meaningful between the two preferred modes; from `.system` the switch + /// isn't offered and must be inert. + func testTogglingFluidVoiceOnlyIsInertFromSystemMode() { + self.configure(mode: .system, directCapture: true) + let spy = self.makeSpy() + let coordinator = MicrophonePreferenceCoordinator(settings: .shared, devices: spy) + + let result = coordinator.setFluidVoiceOnly( + true, currentSystemInputUID: self.systemUID, availableInputUIDs: [self.systemUID] + ) + + XCTAssertNil(result) + XCTAssertEqual(SettingsStore.shared.microphoneSelectionMode, .system) + XCTAssertTrue(spy.setDefaultCalls.isEmpty) + } + + /// Entering FluidVoice-only with no stored preference would resolve straight back to the macOS + /// default and silently record the wrong microphone, so a preference gets seeded. + func testTogglingFluidVoiceOnlySeedsAPreferenceWhenNoneIsSet() { + UserDefaults.standard.set(true, forKey: self.directCaptureKey) + UserDefaults.standard.set(SettingsStore.MicrophoneSelectionMode.manual.rawValue, forKey: self.modeKey) + UserDefaults.standard.removeObject(forKey: self.preferredInputKey) + let coordinator = MicrophonePreferenceCoordinator(settings: .shared, devices: self.makeSpy()) + + coordinator.setFluidVoiceOnly( + true, currentSystemInputUID: self.systemUID, availableInputUIDs: [self.systemUID] + ) + + XCTAssertEqual( + SettingsStore.shared.preferredInputDeviceUID, + self.systemUID, + "FluidVoice-only with an empty preference would capture the system default it claims to avoid" + ) + } + + // MARK: - Mode properties + + func testBothPreferredModesUsePreferredDevice() { + XCTAssertFalse(SettingsStore.MicrophoneSelectionMode.system.usesPreferredInputDevice) + XCTAssertTrue(SettingsStore.MicrophoneSelectionMode.manual.usesPreferredInputDevice) + XCTAssertTrue(SettingsStore.MicrophoneSelectionMode.fluidVoiceOnly.usesPreferredInputDevice) + } +}