Skip to content

Add opt-in 'Lock input device' setting#489

Open
funkju wants to merge 1 commit into
altic-dev:mainfrom
funkju:feat/lock-input-device
Open

Add opt-in 'Lock input device' setting#489
funkju wants to merge 1 commit into
altic-dev:mainfrom
funkju:feat/lock-input-device

Conversation

@funkju

@funkju funkju commented Jun 30, 2026

Copy link
Copy Markdown

Summary

FluidVoice always followed the macOS system input device — Settings even states "Audio devices are synced with macOS System Settings." There was no way to pin dictation to a specific microphone, so any system input change (e.g. a Bluetooth headset connecting) would silently switch the mic used for capture.

The root cause is that SettingsStore.syncAudioDevicesWithSystem is hard-coded to return true, permanently disabling the existing preferred-input binding path in ASRService.

This PR adds an opt-in "Always use this input device when available" toggle (default off, so existing behavior is unchanged). When enabled, FluidVoice binds the chosen input device on the audio engine and ignores macOS input changes. If the locked device is unavailable, it transparently falls back to the system default and re-binds to the locked device when it reconnects.

Changes

  • SettingsStore — new opt-in lockInputDevice setting (LockInputDevice key).
  • ASRServicebindPreferredInputDeviceIfNeeded() now also binds when lockInputDevice is on; handleDefaultInputChanged() and handleDeviceListChanged() skip the auto-switch/route-recovery logic while locked.
  • SettingsView — new toggle (far-right, switch style) with concise help text; input picker no longer overrides the system default while locked; restores the locked device on appear. Refreshes the (System Default) marker via the audio observer's changeTick so it stays accurate while locked.
  • ContentView / MenuBarManager — keep the preferred input selected (instead of the system input) while locked.

Design notes

  • Input-only by intent. This deliberately does not touch output-device locking, which hits OSStatus -10851 (kAudioUnitErr_InvalidPropertyValue) for aggregate/Bluetooth devices.
  • Default off. No behavior change unless the user opts in, matching the existing syncAudioDevicesWithSystem contract; nothing new is added to backup/restore for the same reason.

Testing

Built locally (ad-hoc signed) and verified: locking pins capture to the selected mic, system input changes are ignored while locked, and disconnect/reconnect of the locked device falls back and recovers correctly.

🤖 Generated with Claude Code

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 <noreply@anthropic.com>

@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: 55d6911eed

ℹ️ 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 +1105 to +1107
if self.lockInputDevice,
let prefUID = SettingsStore.shared.preferredInputDeviceUID,
newDevices.contains(where: { $0.uid == prefUID })

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

Comment on lines +1662 to +1664
.onReceive(self.appServices.audioObserver.$changeTick) { _ in
self.cachedDefaultInputName = AudioDevice.getDefaultInputDevice()?.name ?? ""
self.cachedDefaultOutputName = AudioDevice.getDefaultOutputDevice()?.name ?? ""

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

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

Labels

None yet

1 participant