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
7 changes: 4 additions & 3 deletions Fluid.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand All @@ -32,12 +33,12 @@
/* End PBXContainerItemProxy section */

/* Begin PBXFileReference section */
343B29013F4441D6A797D12D /* LLMClientRequestBodyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LLMClientRequestBodyTests.swift; sourceTree = "<group>"; };
980330F3CE464336ADCE3E23 /* TemperatureSupportTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TemperatureSupportTests.swift; sourceTree = "<group>"; };
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 = "<group>"; };
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 = "<group>"; };
343B29013F4441D6A797D12D /* LLMClientRequestBodyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LLMClientRequestBodyTests.swift; sourceTree = "<group>"; };
980330F3CE464336ADCE3E23 /* TemperatureSupportTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TemperatureSupportTests.swift; sourceTree = "<group>"; };
7CDB0A2A2F3C4D5600FB7CAD /* AudioFixtureLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioFixtureLoader.swift; sourceTree = "<group>"; };
7CDB0A2B2F3C4D5600FB7CAD /* dictation_fixture.wav */ = {isa = PBXFileReference; lastKnownFileType = audio.wav; path = dictation_fixture.wav; sourceTree = "<group>"; };
7CDB0A2C2F3C4D5600FB7CAD /* XCTest.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = XCTest.framework; path = Platforms/MacOSX.platform/Developer/Library/Frameworks/XCTest.framework; sourceTree = DEVELOPER_DIR; };
Expand Down
63 changes: 29 additions & 34 deletions Sources/Fluid/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 = ""
Expand Down Expand Up @@ -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
}
Comment on lines +365 to 368

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Preferred-device unavailability not handled after hardware change

In .manual mode the hardware-change handler now sets selectedInputUID to the stored preferredInputDeviceUID unconditionally, even when the device was just disconnected. Because refreshDevices() runs first, inputDevices is already up-to-date, so the picker immediately shows a UID that is not in the list — the selection renders blank/missing. The old implementation guarded against this with an availability check and fell back to the system default, which needs to be restored here. Note: MicrophonePreferenceCoordinator.inputDeviceForCapture() does fall back correctly for the actual recording path, but the UI state is left stale.

Suggested change
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
}
case .manual:
if let prefIn = SettingsStore.shared.preferredInputDeviceUID,
self.inputDevices.contains(where: { $0.uid == prefIn })
{
self.selectedInputUID = prefIn
} else if let sysIn = AudioDevice.getDefaultInputDevice()?.uid {
self.selectedInputUID = sysIn
}
Prompt To Fix With AI
This is a comment left during a code review.
Path: Sources/Fluid/ContentView.swift
Line: 365-368

Comment:
**Preferred-device unavailability not handled after hardware change**

In `.manual` mode the hardware-change handler now sets `selectedInputUID` to the stored `preferredInputDeviceUID` unconditionally, even when the device was just disconnected. Because `refreshDevices()` runs first, `inputDevices` is already up-to-date, so the picker immediately shows a UID that is not in the list — the selection renders blank/missing. The old implementation guarded against this with an availability check and fell back to the system default, which needs to be restored here. Note: `MicrophonePreferenceCoordinator.inputDeviceForCapture()` does fall back correctly for the actual recording path, but the UI state is left stale.

```suggestion
                case .manual:
                    if let prefIn = SettingsStore.shared.preferredInputDeviceUID,
                       self.inputDevices.contains(where: { $0.uid == prefIn })
                    {
                        self.selectedInputUID = prefIn
                    } else if let sysIn = AudioDevice.getDefaultInputDevice()?.uid {
                        self.selectedInputUID = sysIn
                    }
```

How can I resolve this? If you propose a fix, please make it concise.

Fix in Codex

}

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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions Sources/Fluid/Persistence/BackupService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
84 changes: 75 additions & 9 deletions Sources/Fluid/Persistence/SettingsStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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>
) -> 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)
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
}
Comment on lines +3024 to +3026

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 Reset missing microphone mode to system on restore

When restoring a backup from before this field existed while the current install is in manual mode, the if let leaves microphoneSelectionMode unchanged. Those older backups came from builds where audio selection always followed macOS defaults, so after restore future recordings can keep forcing the restored PreferredInputDeviceUID as a manual mic instead of following the legacy system-mode behavior. Treat nil as .system (or remove the key) during restore.

Useful? React with 👍 / 👎.

self.visualizerNoiseThreshold = payload.visualizerNoiseThreshold
self.overlayPosition = payload.overlayPosition
self.overlayBottomOffset = payload.overlayBottomOffset
Expand Down Expand Up @@ -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"
Expand Down
Loading
Loading