From 55d6911eed56cf03b4051d6bc53a907c8b7980dd Mon Sep 17 00:00:00 2001 From: Justin Funk Date: Tue, 30 Jun 2026 11:29:02 -0500 Subject: [PATCH] Add opt-in "lock input device" setting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FluidVoice always follows the macOS default input because `syncAudioDevicesWithSystem` is hard-coded to `true`, leaving the existing preferred-device binding path as dead code. As a result, capture switches to whatever input macOS selects, and AirPods/Bluetooth connections silently overwrite the preferred mic. This adds an opt-in `lockInputDevice` setting (default off, so existing behaviour is unchanged) that pins capture to a chosen microphone: - ASRService binds the engine input to the preferred device when locked, and ignores macOS default-input changes and Bluetooth auto-switching. Falls back to the system default only when the locked device is unavailable. - SettingsView adds an "Always use this input device when available" toggle and keeps the picker pinned to the locked device without rewriting the macOS system default. - ContentView and MenuBarManager keep their UI in sync with the locked device. Scope is intentionally input-only to avoid the aggregate/Bluetooth output issues (OSStatus -10851) that motivated removing the original independent mode. Also refresh the cached default-device names on audio hardware ticks so the "(System Default)" markers stay accurate when only the system default changes (e.g. while the input is locked) — this also fixes a latent staleness bug for the output marker. Co-Authored-By: Claude Opus 4.8 --- Sources/Fluid/ContentView.swift | 11 ++- Sources/Fluid/Persistence/SettingsStore.swift | 10 +++ Sources/Fluid/Services/ASRService.swift | 19 ++++- Sources/Fluid/Services/MenuBarManager.swift | 11 ++- Sources/Fluid/UI/SettingsView.swift | 83 ++++++++++++++++--- 5 files changed, 118 insertions(+), 16 deletions(-) diff --git a/Sources/Fluid/ContentView.swift b/Sources/Fluid/ContentView.swift index 1a5d3ec3..0855e18f 100644 --- a/Sources/Fluid/ContentView.swift +++ b/Sources/Fluid/ContentView.swift @@ -347,8 +347,15 @@ struct ContentView: View { // 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 - if let sysIn = AudioDevice.getDefaultInputDevice()?.uid { + // Sync mode: Update UI to match current system defaults. + // Exception: when the input device is locked, keep the pinned input selection + // (falling back to the system default only if the pinned device disappeared). + if SettingsStore.shared.lockInputDevice, + let prefIn = SettingsStore.shared.preferredInputDeviceUID, + inputDevices.contains(where: { $0.uid == prefIn }) + { + self.selectedInputUID = prefIn + } else if let sysIn = AudioDevice.getDefaultInputDevice()?.uid { self.selectedInputUID = sysIn } if let sysOut = AudioDevice.getDefaultOutputDevice()?.uid { diff --git a/Sources/Fluid/Persistence/SettingsStore.swift b/Sources/Fluid/Persistence/SettingsStore.swift index 82d0fbec..21663dbf 100644 --- a/Sources/Fluid/Persistence/SettingsStore.swift +++ b/Sources/Fluid/Persistence/SettingsStore.swift @@ -1615,6 +1615,15 @@ final class SettingsStore: ObservableObject { } } + /// When enabled, FluidVoice always captures from `preferredInputDeviceUID` (when available), + /// ignoring the macOS system default input and any automatic device switching. + /// Falls back to the system default only if the preferred device is unavailable. + /// Opt-in (default false) so existing users keep following the macOS default. + var lockInputDevice: Bool { + get { self.defaults.bool(forKey: Keys.lockInputDevice) } + set { self.defaults.set(newValue, forKey: Keys.lockInputDevice) } + } + var visualizerNoiseThreshold: Double { get { let value = self.defaults.double(forKey: Keys.visualizerNoiseThreshold) @@ -4331,6 +4340,7 @@ private extension SettingsStore { static let preferredInputDeviceUID = "PreferredInputDeviceUID" static let preferredOutputDeviceUID = "PreferredOutputDeviceUID" static let syncAudioDevicesWithSystem = "SyncAudioDevicesWithSystem" + static let lockInputDevice = "LockInputDevice" 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 29d4d7b2..2e676029 100644 --- a/Sources/Fluid/Services/ASRService.swift +++ b/Sources/Fluid/Services/ASRService.swift @@ -1402,7 +1402,11 @@ final class ASRService: ObservableObject { private func bindPreferredInputDeviceIfNeeded() -> Bool { DebugLogger.shared.debug("bindPreferredInputDeviceIfNeeded() - Starting input device binding", source: "ASRService") - guard SettingsStore.shared.syncAudioDevicesWithSystem == false else { + // Bind to the preferred input when either independent (non-sync) mode is active, or the + // user has explicitly locked the input device. Otherwise follow the macOS system default. + let shouldBindPreferredInput = SettingsStore.shared.syncAudioDevicesWithSystem == false + || SettingsStore.shared.lockInputDevice + guard shouldBindPreferredInput else { DebugLogger.shared.info("Sync mode enabled - using system default input device", source: "ASRService") return true } @@ -1934,6 +1938,13 @@ final class ASRService: ObservableObject { return } + // When the input device is locked, FluidVoice intentionally stays on the preferred device + // regardless of what macOS switches its default input to. + guard SettingsStore.shared.lockInputDevice == false else { + DebugLogger.shared.debug("Ignoring system default input change (input device locked)", source: "ASRService") + return + } + self.scheduleAudioRouteRecovery(reason: "default input changed") } @@ -2168,8 +2179,10 @@ final class ASRService: ObservableObject { } } - // Check for newly connected Bluetooth devices (auto-switch) - for device in currentDevices { + // Check for newly connected Bluetooth devices (auto-switch). + // Skip entirely when the input device is locked: the user has pinned a specific + // microphone and we must not silently repoint it at newly connected AirPods/BT mics. + for device in currentDevices where SettingsStore.shared.lockInputDevice == false { if device.name.localizedCaseInsensitiveContains("airpods") || device.name.localizedCaseInsensitiveContains("bluetooth") { diff --git a/Sources/Fluid/Services/MenuBarManager.swift b/Sources/Fluid/Services/MenuBarManager.swift index 35c207ef..a3bd0f7d 100644 --- a/Sources/Fluid/Services/MenuBarManager.swift +++ b/Sources/Fluid/Services/MenuBarManager.swift @@ -566,6 +566,13 @@ final class MenuBarManager: NSObject, ObservableObject, NSMenuDelegate { } private func currentPreferredInputUID(defaultInputUID: String?) -> String? { + // When the input device is locked, reflect the pinned preferred device rather than the + // macOS system default so the menu check mark matches what FluidVoice will actually use. + if SettingsStore.shared.lockInputDevice, + let uid = SettingsStore.shared.preferredInputDeviceUID, !uid.isEmpty + { + return uid + } return defaultInputUID } @@ -575,7 +582,9 @@ final class MenuBarManager: NSObject, ObservableObject, NSMenuDelegate { SettingsStore.shared.preferredInputDeviceUID = uid - if SettingsStore.shared.syncAudioDevicesWithSystem { + // When locked, keep the macOS system default untouched so FluidVoice uses this device + // independently of the rest of the system. + if SettingsStore.shared.syncAudioDevicesWithSystem, SettingsStore.shared.lockInputDevice == false { _ = AudioDevice.setDefaultInputDevice(uid: uid) } diff --git a/Sources/Fluid/UI/SettingsView.swift b/Sources/Fluid/UI/SettingsView.swift index d9b280e1..4b5af2ff 100644 --- a/Sources/Fluid/UI/SettingsView.swift +++ b/Sources/Fluid/UI/SettingsView.swift @@ -53,6 +53,10 @@ struct SettingsView: View { @State private var cachedDefaultInputName: String = "" @State private var cachedDefaultOutputName: String = "" + // When enabled, FluidVoice always captures from the selected input device and ignores + // macOS system-default input changes / Bluetooth auto-switching. + @State private var lockInputDevice: Bool = SettingsStore.shared.lockInputDevice + // Analytics consent UI state (default ON; user can opt-out) @State private var shareAnonymousAnalytics: Bool = SettingsStore.shared.shareAnonymousAnalytics @State private var showAnalyticsPrivacy: Bool = false @@ -1083,8 +1087,10 @@ struct SettingsView: View { } SettingsStore.shared.preferredInputDeviceUID = newUID - // Only change system default if sync is enabled - if SettingsStore.shared.syncAudioDevicesWithSystem { + // Only change the macOS system default if syncing is enabled and the + // input device is not locked. When locked we keep the system default + // untouched so FluidVoice can use this device independently. + if SettingsStore.shared.syncAudioDevicesWithSystem, !self.lockInputDevice { _ = AudioDevice.setDefaultInputDevice(uid: newUID) } } @@ -1095,6 +1101,15 @@ struct SettingsView: View { // If selection is empty or not found in new list, select first available if !newDevices.isEmpty { + // When locked, keep the preferred device selected as long as it's available. + if self.lockInputDevice, + let prefUID = SettingsStore.shared.preferredInputDeviceUID, + newDevices.contains(where: { $0.uid == prefUID }) + { + self.selectedInputUID = prefUID + return + } + let currentValid = newDevices.contains { $0.uid == self.selectedInputUID } if !currentValid { if let defaultUID = AudioDevice.getDefaultInputDevice()?.uid, @@ -1109,6 +1124,36 @@ struct SettingsView: View { } } + // Lock input device: always capture from the selected mic regardless of + // the macOS system default or Bluetooth auto-switching. + HStack(alignment: .center, spacing: 12) { + VStack(alignment: .leading, spacing: 2) { + Text("Always use this input device when available") + .font(self.theme.typography.bodyStrong) + .foregroundStyle(self.settingsTitleText) + Text("Ignores macOS input changes. Falls back to the system default if unavailable.") + .font(self.theme.typography.bodySmall) + .foregroundStyle(self.settingsSecondaryText) + .fixedSize(horizontal: false, vertical: true) + } + + Spacer(minLength: 0) + + Toggle("", isOn: self.$lockInputDevice) + .labelsHidden() + .toggleStyle(.switch) + .tint(self.theme.palette.accent) + } + .disabled(self.asr.isRunning) + .padding(.bottom, 6) + .onChange(of: self.lockInputDevice) { _, locked in + SettingsStore.shared.lockInputDevice = locked + if locked, !self.selectedInputUID.isEmpty { + // Pin the currently selected device as the preferred input. + SettingsStore.shared.preferredInputDeviceUID = self.selectedInputUID + } + } + HStack { Text("Output Device") .font(self.theme.typography.bodyStrong) @@ -1556,16 +1601,27 @@ struct SettingsView: View { self.refreshDevices() + // Keep the lock toggle in sync with persisted state. + self.lockInputDevice = SettingsStore.shared.lockInputDevice + // Sync input device selection after refresh if !self.inputDevices.isEmpty { - let inputValid = self.inputDevices.contains { $0.uid == self.selectedInputUID } - if !inputValid || self.selectedInputUID.isEmpty { - if let defaultUID = AudioDevice.getDefaultInputDevice()?.uid, - self.inputDevices.contains(where: { $0.uid == defaultUID }) - { - self.selectedInputUID = defaultUID - } else { - self.selectedInputUID = self.inputDevices.first?.uid ?? "" + // When locked, restore the pinned preferred device if it's currently available. + if self.lockInputDevice, + let prefUID = SettingsStore.shared.preferredInputDeviceUID, + self.inputDevices.contains(where: { $0.uid == prefUID }) + { + self.selectedInputUID = prefUID + } else { + let inputValid = self.inputDevices.contains { $0.uid == self.selectedInputUID } + if !inputValid || self.selectedInputUID.isEmpty { + if let defaultUID = AudioDevice.getDefaultInputDevice()?.uid, + self.inputDevices.contains(where: { $0.uid == defaultUID }) + { + self.selectedInputUID = defaultUID + } else { + self.selectedInputUID = self.inputDevices.first?.uid ?? "" + } } } } @@ -1600,6 +1656,13 @@ struct SettingsView: View { .onChange(of: self.visualizerNoiseThreshold) { _, newValue in SettingsStore.shared.visualizerNoiseThreshold = newValue } + // Keep the "(System Default)" markers accurate even when only the system default changes + // (not the device list) — e.g. while the input device is locked and the picker selection + // no longer tracks the macOS default. Safe outside view-body layout, like the other handlers. + .onReceive(self.appServices.audioObserver.$changeTick) { _ in + self.cachedDefaultInputName = AudioDevice.getDefaultInputDevice()?.name ?? "" + self.cachedDefaultOutputName = AudioDevice.getDefaultOutputDevice()?.name ?? "" + } } private func refreshRollbackState() {