Skip to content

Add support for choosing custom audio input#531

Open
grohith327 wants to merge 3 commits into
mainfrom
allow-default-audio-input
Open

Add support for choosing custom audio input#531
grohith327 wants to merge 3 commits into
mainfrom
allow-default-audio-input

Conversation

@grohith327

@grohith327 grohith327 commented Jul 5, 2026

Copy link
Copy Markdown
Collaborator

Description

Add support for choosing custom audio input separate from MacOS default. The following is the behavior

  • Toggle on: input dropdown stays enabled.
  • Toggle on + user changes input: FluidVoice immediately syncs that selection to the Mac default input.
  • Toggle off + user changes input: FluidVoice only saves the manual preference and does not immediately change the Mac input.
  • Toggle on transition still restores/syncs back to the captured system input when available.

Also, add a menu bar setting to toggle micrphone

Type of Change

  • 🐞 Bug fix
  • ✨ New feature
  • 💥 Breaking change
  • 🧹 Chore
  • 📝 Documentation update

Related Issue or Discussion

Closes #136

Testing

  • Tested on Intel Mac
  • Tested on Apple Silicon Mac
  • Tested on macOS version: 27
  • Ran linter locally: swiftlint --strict --config .swiftlint.yml Sources
  • Ran tests locally

Screenshots / Video

fv_audio_input_compressed.mp4

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 838862350d

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +2359 to +2367
if nextMode == .manual {
if self.selectedInputUID.isEmpty,
let defaultUID = currentSystemInputUID
{
self.selectedInputUID = defaultUID
SettingsStore.shared.recordInputDeviceSelection(defaultUID)
} else {
SettingsStore.shared.recordInputDeviceSelection(self.selectedInputUID)
}

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 saved manual microphone when toggling back

When the Settings view is in system mode, selectedInputUID tracks the current macOS default, so turning off “Use macOS default microphone” here overwrites any previously saved manual microphone with that system default. This differs from the menu path, which only initializes preferredInputDeviceUID when it is empty, and it means a user who temporarily switches back to macOS default loses their custom mic preference as soon as they re-enable manual mode unless they reselect it.

Useful? React with 👍 / 👎.

@grohith327 grohith327 force-pushed the allow-default-audio-input branch from 8388623 to 457af51 Compare July 5, 2026 18:37

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 457af51ff5

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

SettingsStore.shared.preferredInputDeviceUID = newUID
// Only change system default if sync is enabled
if SettingsStore.shared.syncAudioDevicesWithSystem {
SettingsStore.shared.recordInputDeviceSelection(newUID)

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 Avoid recording fallback selections as manual preferences

In manual mode this onChange fires for programmatic selection changes too, not just user picker choices. For example, SettingsView.onAppear still replaces an invalid/unavailable selectedInputUID with the current default; this line then persists that fallback as PreferredInputDeviceUID, so simply opening Settings while the saved mic is disconnected loses the custom mic that manual mode is meant to preserve. Gate this write to user-initiated picker changes or make the fallback path mode-aware.

Useful? React with 👍 / 👎.

Comment on lines +3016 to +3018
if let microphoneSelectionMode = payload.microphoneSelectionMode {
self.microphoneSelectionMode = microphoneSelectionMode
}

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 👍 / 👎.

@grohith327

Copy link
Copy Markdown
Collaborator Author

@altic-dev Good to review, tested with the new audio optimized pipeline

@altic-dev

Copy link
Copy Markdown
Owner

@greptileai review

@greptile-apps

greptile-apps Bot commented Jul 10, 2026

Copy link
Copy Markdown

Greptile Summary

This PR replaces the previously no-op syncAudioDevicesWithSystem flag with a real MicrophoneSelectionMode enum (.system / .manual), introduces a new MicrophonePreferenceCoordinator service that reasserts the preferred microphone after hardware topology changes, and exposes the toggle in both the Settings UI and the menu-bar microphone submenu.

  • SettingsStore gains MicrophoneSelectionMode, recordInputDeviceSelection, and setMicrophoneSelectionMode helpers; backup/restore is backward-compatible via an optional field.
  • MicrophonePreferenceCoordinator handles enforcement and debounced re-assertion; it is well-covered by the new unit tests using a FakeAudioDeviceManager.
  • A fallback to a valid device is missing in manual mode when the preferred microphone disconnects \u2014 the picker in both ContentView\u2019s hardware-change handler and SettingsView\u2019s onChange(of: inputDevices) can end up pointing to a UID that is no longer in the device list, rendering the picker blank.

Confidence Score: 3/5

The new coordinator and settings model are sound, but the manual-mode device-disconnection path leaves the microphone picker blank with no fallback.

In manual mode, when the user's preferred microphone is unplugged, selectedInputUID is set to the disconnected device's UID (which is no longer in inputDevices) and neither the ContentView hardware-change handler nor SettingsView's onChange(of: inputDevices) provides a fallback to a valid device. The result is a blank input picker that the user cannot interact with until the device is reconnected or they restart the app.

The hardware-change branch in ContentView.swift (.manual case) and the onChange(of: self.inputDevices) block in SettingsView.swift (.manual case) both need an availability check with a fallback to the system default when the preferred device is absent.

Comments Outside Diff (1)

  1. Sources/Fluid/ContentView.swift, line 62-70 (link)

    P1 Picker goes blank when preferred mic disconnects in manual mode

    When a hardware change fires, refreshDevices() is called first (so the disconnected device is already removed from inputDevices), then the .manual branch unconditionally sets selectedInputUID = prefIn — a UID that is no longer present in the device list. SwiftUI's Picker has no matching option and renders blank. The old code fell back to the system default and updated preferredInputDeviceUID; the new code drops that fallback entirely.

    The companion onChange(of: self.inputDevices) handler in SettingsView (around line 1148) has the same gap: in .manual mode it only updates selectedInputUID when the preferred device is still available, but does nothing when it has just been removed — so the picker stays blank there as well.

    A guard against availability (e.g. inputDevices.contains(where: { $0.uid == prefIn })) before assigning, with a fallback to AudioDevice.getDefaultInputDevice()?.uid when the preferred device is gone, would restore the previous safe behaviour without changing the manual-mode preference stored in SettingsStore.

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: Sources/Fluid/ContentView.swift
    Line: 62-70
    
    Comment:
    **Picker goes blank when preferred mic disconnects in manual mode**
    
    When a hardware change fires, `refreshDevices()` is called first (so the disconnected device is already removed from `inputDevices`), then the `.manual` branch unconditionally sets `selectedInputUID = prefIn` — a UID that is no longer present in the device list. SwiftUI's `Picker` has no matching option and renders blank. The old code fell back to the system default and updated `preferredInputDeviceUID`; the new code drops that fallback entirely.
    
    The companion `onChange(of: self.inputDevices)` handler in `SettingsView` (around line 1148) has the same gap: in `.manual` mode it only updates `selectedInputUID` when the preferred device *is* still available, but does nothing when it has just been removed — so the picker stays blank there as well.
    
    A guard against availability (e.g. `inputDevices.contains(where: { $0.uid == prefIn })`) before assigning, with a fallback to `AudioDevice.getDefaultInputDevice()?.uid` when the preferred device is gone, would restore the previous safe behaviour without changing the manual-mode preference stored in `SettingsStore`.
    
    How can I resolve this? If you propose a fix, please make it concise.

    Fix in Codex

Fix All in Codex

Prompt To Fix All With AI
Fix the following 1 code review issue. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 1
Sources/Fluid/ContentView.swift:62-70
**Picker goes blank when preferred mic disconnects in manual mode**

When a hardware change fires, `refreshDevices()` is called first (so the disconnected device is already removed from `inputDevices`), then the `.manual` branch unconditionally sets `selectedInputUID = prefIn` — a UID that is no longer present in the device list. SwiftUI's `Picker` has no matching option and renders blank. The old code fell back to the system default and updated `preferredInputDeviceUID`; the new code drops that fallback entirely.

The companion `onChange(of: self.inputDevices)` handler in `SettingsView` (around line 1148) has the same gap: in `.manual` mode it only updates `selectedInputUID` when the preferred device *is* still available, but does nothing when it has just been removed — so the picker stays blank there as well.

A guard against availability (e.g. `inputDevices.contains(where: { $0.uid == prefIn })`) before assigning, with a fallback to `AudioDevice.getDefaultInputDevice()?.uid` when the preferred device is gone, would restore the previous safe behaviour without changing the manual-mode preference stored in `SettingsStore`.

Reviews (1): Last reviewed commit: "centralize microhpone input irrespective..." | Re-trigger Greptile

@greptile-apps

greptile-apps Bot commented Jul 10, 2026

Copy link
Copy Markdown

Greptile Summary

This PR introduces a MicrophoneSelectionMode enum (.system / .manual) to replace the always-true syncAudioDevicesWithSystem flag, giving users explicit control over whether FluidVoice follows the macOS default input or locks to a preferred microphone. A new MicrophonePreferenceCoordinator service centralizes enforcement of the preferred device — re-asserting it after hardware topology changes — and is injected via a lazy property on AppServices.

  • New coordinator: MicrophonePreferenceCoordinator provides enforcePreferredInput, inputDeviceForCapture, and a debounced stabilizePreferredInputAfterHardwareChange with a progressive retry schedule; a AudioDeviceManaging protocol makes it fully unit-testable via FakeAudioDeviceManager.
  • Settings & menu bar: a toggle is added to both the Settings panel and the menu bar microphone submenu; SettingsStore.setMicrophoneSelectionMode snapshots the system UID before entering manual mode so it can be restored on toggle-off.
  • One defect identified: the hardware-change handler in ContentView sets selectedInputUID to the stored preferred UID without verifying the device is still in the refreshed device list, leaving the picker blank when the preferred device disconnects.

Confidence Score: 3/5

The core recording path correctly delegates device resolution to MicrophonePreferenceCoordinator, which handles device unavailability safely. However, the ContentView hardware-change handler leaves the picker UI in a broken blank state when a preferred device disconnects in manual mode.

The device-availability check dropped from the ContentView hardware-change .manual branch is a real UI regression: after the preferred device is unplugged, the Picker renders no matching item while the stored UID persists, and neither the ContentView handler nor the SettingsView .onChange(of: inputDevices) recovers it. The actual recording path falls back correctly, but the UI state is wrong and will mislead users.

Sources/Fluid/ContentView.swift — hardware-change handler for .manual mode needs an availability check with a system-default fallback.

Fix All in Codex

Prompt To Fix All With AI
Fix the following 3 code review issues. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 3
Sources/Fluid/ContentView.swift:365-368
**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
                    }
```

### Issue 2 of 3
Sources/Fluid/Services/AppServices.swift:71-79
**Manual lazy-init pattern is not thread-safe; prefer `lazy var`**

The same manual double-check pattern is used for `_asr`, so it's consistent with the existing style, but `MicrophonePreferenceCoordinator` is `@MainActor`-isolated and is accessed from multiple call sites. Swift's `lazy var` is automatically initialized on first access and is thread-safe for value-type initialization, making intent clearer and removing the possibility of a race if the surrounding class ever stops being exclusively main-actor-accessed.

```suggestion
    lazy var microphonePreferenceCoordinator: MicrophonePreferenceCoordinator = MicrophonePreferenceCoordinator()
```

### Issue 3 of 3
Sources/Fluid/Services/MenuBarManager.swift:621-647
**Duplicated mode-toggle logic diverges from SettingsView**

`MenuBarManager.toggleMicrophoneSelectionMode` and `SettingsView.updateMicrophoneSelectionMode` implement the same "switch mode and seed the manual preference" flow independently. The key difference: the menu-bar path checks `SettingsStore.shared.preferredInputDeviceUID` to decide whether to seed a default, while the settings-view path checks the local UI binding `selectedInputUID`. This means flipping the toggle from the menu bar when `preferredInputDeviceUID` already holds a value will skip seeding (correct), but if the state somehow diverges the two call-sites will behave differently. Extracting the shared transition logic into `SettingsStore.setMicrophoneSelectionMode` (or a dedicated coordinator method) would remove this maintenance risk.

Reviews (2): Last reviewed commit: "centralize microhpone input irrespective..." | Re-trigger Greptile

Comment on lines +365 to 368
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
}

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

Comment on lines +71 to +79
private var _microphonePreferenceCoordinator: MicrophonePreferenceCoordinator?
var microphonePreferenceCoordinator: MicrophonePreferenceCoordinator {
if let existing = self._microphonePreferenceCoordinator {
return existing
}
let coordinator = MicrophonePreferenceCoordinator()
self._microphonePreferenceCoordinator = coordinator
return coordinator
}

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 Manual lazy-init pattern is not thread-safe; prefer lazy var

The same manual double-check pattern is used for _asr, so it's consistent with the existing style, but MicrophonePreferenceCoordinator is @MainActor-isolated and is accessed from multiple call sites. Swift's lazy var is automatically initialized on first access and is thread-safe for value-type initialization, making intent clearer and removing the possibility of a race if the surrounding class ever stops being exclusively main-actor-accessed.

Suggested change
private var _microphonePreferenceCoordinator: MicrophonePreferenceCoordinator?
var microphonePreferenceCoordinator: MicrophonePreferenceCoordinator {
if let existing = self._microphonePreferenceCoordinator {
return existing
}
let coordinator = MicrophonePreferenceCoordinator()
self._microphonePreferenceCoordinator = coordinator
return coordinator
}
lazy var microphonePreferenceCoordinator: MicrophonePreferenceCoordinator = MicrophonePreferenceCoordinator()
Prompt To Fix With AI
This is a comment left during a code review.
Path: Sources/Fluid/Services/AppServices.swift
Line: 71-79

Comment:
**Manual lazy-init pattern is not thread-safe; prefer `lazy var`**

The same manual double-check pattern is used for `_asr`, so it's consistent with the existing style, but `MicrophonePreferenceCoordinator` is `@MainActor`-isolated and is accessed from multiple call sites. Swift's `lazy var` is automatically initialized on first access and is thread-safe for value-type initialization, making intent clearer and removing the possibility of a race if the surrounding class ever stops being exclusively main-actor-accessed.

```suggestion
    lazy var microphonePreferenceCoordinator: MicrophonePreferenceCoordinator = MicrophonePreferenceCoordinator()
```

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

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Fix in Codex

Comment on lines +621 to +647
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

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 Duplicated mode-toggle logic diverges from SettingsView

MenuBarManager.toggleMicrophoneSelectionMode and SettingsView.updateMicrophoneSelectionMode implement the same "switch mode and seed the manual preference" flow independently. The key difference: the menu-bar path checks SettingsStore.shared.preferredInputDeviceUID to decide whether to seed a default, while the settings-view path checks the local UI binding selectedInputUID. This means flipping the toggle from the menu bar when preferredInputDeviceUID already holds a value will skip seeding (correct), but if the state somehow diverges the two call-sites will behave differently. Extracting the shared transition logic into SettingsStore.setMicrophoneSelectionMode (or a dedicated coordinator method) would remove this maintenance risk.

Prompt To Fix With AI
This is a comment left during a code review.
Path: Sources/Fluid/Services/MenuBarManager.swift
Line: 621-647

Comment:
**Duplicated mode-toggle logic diverges from SettingsView**

`MenuBarManager.toggleMicrophoneSelectionMode` and `SettingsView.updateMicrophoneSelectionMode` implement the same "switch mode and seed the manual preference" flow independently. The key difference: the menu-bar path checks `SettingsStore.shared.preferredInputDeviceUID` to decide whether to seed a default, while the settings-view path checks the local UI binding `selectedInputUID`. This means flipping the toggle from the menu bar when `preferredInputDeviceUID` already holds a value will skip seeding (correct), but if the state somehow diverges the two call-sites will behave differently. Extracting the shared transition logic into `SettingsStore.setMicrophoneSelectionMode` (or a dedicated coordinator method) would remove this maintenance risk.

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

Fix in Codex

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[✨ FEATURE] Set default microphone not matter what

2 participants