Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions Sources/Fluid/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
10 changes: 10 additions & 0 deletions Sources/Fluid/Persistence/SettingsStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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"
Expand Down
19 changes: 16 additions & 3 deletions Sources/Fluid/Services/ASRService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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")
}

Expand Down Expand Up @@ -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")
{
Expand Down
11 changes: 10 additions & 1 deletion Sources/Fluid/Services/MenuBarManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand All @@ -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)
}

Expand Down
83 changes: 73 additions & 10 deletions Sources/Fluid/UI/SettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
}
Expand All @@ -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 })
Comment on lines +1105 to +1107

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve the pinned UID when falling back

This locked-mode guard only returns while the preferred device is present; when the locked mic disappears with Settings open, the handler falls through to the existing fallback that assigns selectedInputUID to the system default. That programmatic selection change still runs the picker onChange above, which writes the fallback UID to SettingsStore.shared.preferredInputDeviceUID, so the original pinned microphone is forgotten and reconnecting it will not restore the lock target.

Useful? React with 👍 / 👎.

{
self.selectedInputUID = prefUID
return
}

let currentValid = newDevices.contains { $0.uid == self.selectedInputUID }
if !currentValid {
if let defaultUID = AudioDevice.getDefaultInputDevice()?.uid,
Expand All @@ -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)
Expand Down Expand Up @@ -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 ?? ""
}
}
}
}
Expand Down Expand Up @@ -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 ?? ""
Comment on lines +1662 to +1664

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Gate the default-device refresh on startup

$changeTick is a @Published publisher, so this subscription receives the current value as soon as SettingsView appears, not only after later hardware changes. For onboarded users Settings is the initial detail view, so this can call AudioDevice.getDefaultInputDevice() before the .onAppear task has awaited AudioStartupGate, bypassing the startup ordering that the adjacent comments say prevents the CoreAudio/AttributeGraph launch crash.

Useful? React with 👍 / 👎.

}
}

private func refreshRollbackState() {
Expand Down