Skip to content

feat(audio): per-app input device selection via the direct Core Audio path#642

Open
aguynamedryan wants to merge 3 commits into
altic-dev:mainfrom
aguynamedryan:feat/independent-input-device-via-direct-capture
Open

feat(audio): per-app input device selection via the direct Core Audio path#642
aguynamedryan wants to merge 3 commits into
altic-dev:mainfrom
aguynamedryan:feat/independent-input-device-via-direct-capture

Conversation

@aguynamedryan

@aguynamedryan aguynamedryan commented Jul 17, 2026

Copy link
Copy Markdown

Description

Adds a microphone mode that captures a chosen input device for FluidVoice alone, without changing the macOS system default input — dictate from a USB handset or Bluetooth headset while every other app keeps using the built-in mic.

Rebased onto #531. That PR landed while this was open and reworked the same code paths, so this branch was rebuilt as a small delta on top of it rather than a competing implementation. #531's .manual mode honours the preferred microphone by moving the macOS default input to it (enforcePreferredInput()setDefaultInputDevice, reasserted at recording start and on hardware changes). This PR adds the case that doesn't cover: leaving the system default alone entirely.

It works because of the direct Core Audio path already in the tree (DirectCoreAudioInputAudioDeviceCreateIOProcID), which binds an arbitrary input device per-app. That sidesteps the AVAudioEngine -10851 limitation that got the original independent mode removed in c8df54a.

Key changes:

  • MicrophoneSelectionMode gains case fluidVoiceOnly, plus usesPreferredInputDevice / changesSystemDefaultInput so call sites read as intent rather than mode-equality checks.
  • enforcePreferredInput() returns a new .skippedFluidVoiceOnlyMode — never calling setDefaultInputDevice is the feature.
  • Gated on experimentalDirectAudioCaptureEnabled. With direct capture off, a stored fluidVoiceOnly reads back as .manual, so the preference is still honoured by the existing path rather than silently ignored.
  • Two guards against silently recording the wrong microphone. Device resolution falls back to the macOS default by design — correct for .manual, which survives a disconnect by moving the default — but in this mode that fallback means capturing the system mic while Settings shows the dedicated one. So startPreferredAudioCapture() refuses the AVAudioEngine fallback, and preferredInputIsMissing() refuses to start when the pinned device is unplugged. This is a deliberate behaviour change from the earlier revision of this PR, which fell back silently in both cases.
  • Hardware-change handlers skip stabilizePreferredInputAfterHardwareChange in this mode (it reasserts the system default) and re-resolve the capture device instead.
  • setMicrophoneSelectionMode() now captures the user's own input device when entering .manual from any other mode, and hands it back when leaving. Capturing only on .system.manual (the previous rule) missed the fluidVoiceOnlymanualfluidVoiceOnly round trip — i.e. flipping the new switch off and back on — and left the system input stuck on FluidVoice's choice after the user had opted back out. Found on-device by walking that sequence; behaviour for .system.manual is unchanged.
  • One extra switch in Settings and in the menu-bar submenu. The menu-bar one isn't cosmetic: without it the only way out of this mode is via .manual, which moves the system input — the exact side effect the user opted out of.

Type of Change

  • ✨ New feature

Related Issue or Discussion

Testing

  • Tested on Apple Silicon Mac
  • Tested on macOS version: 26.5.2
  • Ran linter locally: swiftlint --strict --config .swiftlint.yml Sources Tests (0 violations)
  • Ran tests locally: xcodebuild test — 160 tests, 0 failures (incl. new FluidVoiceOnlyMicrophoneModeTests)

Hardware-validated by reading the device ASRService actually bound out of its own logging, and sampling the macOS default input before / during / after:

Configuration Device bound macOS default input
fluidVoiceOnly, USB handset USB handset unchanged through a live recording
fluidVoiceOnly, Bluetooth (AirPods, HFP @ 24 kHz) the Bluetooth mic unchanged; device released cleanly on stop
fluidVoiceOnly, USB handset while Bluetooth is the system default USB handset Bluetooth default undisturbed
fluidVoiceOnly, virtual device (BlackHole) BlackHole unchanged
system / manual, with Bluetooth connected as before as before
manual, USB handset USB handset moves to the handset at recording start, as designed
fluidVoiceOnly, preferred device unplugged refuses to record unchanged
fluidVoiceOnly, multi-stream aggregate device refuses to record unchanged
Direct capture disabled with fluidVoiceOnly stored degrades to .manual behaviour moves, as .manual should
No stored mode (fresh install / upgrade) macOS default unchanged; device preference ignored
Toggling FluidVoice-only off then on default moves to the preferred mic, then is handed back to the user's own device

Also exercised: disconnecting the Bluetooth device mid-recording while capturing from the handset — capture stayed on the handset (Default input changed; FluidVoice-only capture keeps its own device → route recovery → same device), and the system default was left to macOS rather than reasserted.

Screenshots / Video

FluidVoice-only mode — Input Device set to the USB handset while the macOS system input stays on the built-in mic:

Audio Devices settings beside System Settings Sound

Notes

  • Input-only: direct capture has no output equivalent, so output continues to follow the macOS default.
  • Multi-stream aggregate devices are rejected by CoreAudioCaptureSupport.c by design; in this mode that now surfaces as a clear refusal rather than a silent fallback to the system mic.
  • Using a Bluetooth mic forces HFP, so Bluetooth output quality drops while recording. That is inherent to capturing from a BT mic, not something this change introduces.
  • A settings backup containing fluidVoiceOnly won't decode on an older build — inherent to adding an enum case, flagging it in case that matters for rollback.
  • Naming, the gating, and the two-switch UI are all easy to change if you'd prefer something else.

🤖 Generated with Claude Code

@github-actions github-actions Bot added needs PR template Pull request is missing required template content. needs screenshots Pull request needs screenshot or video evidence. labels Jul 17, 2026
@github-actions

Copy link
Copy Markdown

The PR Policy check is blocking this PR because required template information is missing.

Please update the PR description with:

  • Description
  • Type of Change
  • Related Issue or Discussion
  • Screenshots / Video

Visual files detected:

  • Sources/Fluid/ContentView.swift
  • Sources/Fluid/Persistence/SettingsStore.swift
  • Sources/Fluid/UI/SettingsView.swift

Screenshots or video are required for UI, UX, settings, onboarding, overlay, menu bar, or visual behavior changes. If this PR has no visual changes, check the no-visual-change box in the template.

If this remains incomplete for 48 hours after opening, the PR may be closed.

@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: a9ddf78e92

ℹ️ 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 +1801 to +1802
guard self.experimentalDirectAudioCaptureEnabled else { return true }
return (self.defaults.object(forKey: Keys.syncAudioDevicesWithSystem) as? Bool) ?? true

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 Badge Preserve the preferred input across temporary disconnects

When independent mode is enabled and the selected microphone is unplugged, the existing device-refresh paths replace the saved preference with the system default: ContentView.swift:401-404 writes the fallback UID directly, and SettingsView.swift:1156-1169 changes the picker to the default, whose handler at line 1149 persists it. The original UID is therefore lost, so the advertised auto-switch on reconnect cannot occur; fallback should leave preferredInputDeviceUID intact.

Useful? React with 👍 / 👎.

Comment thread Sources/Fluid/Services/ASRService.swift Outdated
Comment on lines +1956 to +1957
guard SettingsStore.shared.syncAudioDevicesWithSystem == false,
SettingsStore.shared.experimentalDirectAudioCaptureEnabled == false else {

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 Keep output selection effective in independent-input mode

With Direct Audio Capture enabled and syncing disabled, choosing an output device only saves preferredOutputDeviceUID because the picker skips changing the system default, while this guard also skips binding that preferred output. The picker consequently displays a selection that has no effect and audio continues through the macOS default; either output should remain synchronized independently of the input-only mode, or its picker should follow and clearly expose the actual default.

Useful? React with 👍 / 👎.

Comment thread Sources/Fluid/UI/SettingsView.swift Outdated
Comment on lines +1108 to +1110
Toggle("", isOn: Binding(
get: { SettingsStore.shared.syncAudioDevicesWithSystem },
set: { SettingsStore.shared.syncAudioDevicesWithSystem = $0 }

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 Resynchronize device selections when syncing is re-enabled

If a user selects a dedicated microphone while syncing is off and then turns this toggle back on, the setter only changes the persisted Boolean. resolvedInputDeviceForCapture() immediately switches to the macOS default, but selectedInputUID remains on the former preferred device until an unrelated hardware notification, so Settings shows one microphone while the next recording uses another; the toggle-on transition should update the picker state to the current system defaults.

Useful? React with 👍 / 👎.

@greptile-apps

greptile-apps Bot commented Jul 17, 2026

Copy link
Copy Markdown

Greptile Summary

This PR re-introduces per-app input device selection by routing exclusively through DirectCoreAudioInput (AudioDeviceCreateIOProcID), sidestepping the -10851 / aggregate-device crash that caused the original removal. A new MicrophoneSelectionMode.fluidVoiceOnly case is added: it resolves the preferred device for capture without ever moving the macOS system default, degrades to .manual if direct capture is disabled, and is exposed via a new Settings toggle and a menu-bar item, both sharing a single coordinator transition path.

  • setMicrophoneSelectionMode is restructured so only entering or leaving .manual touches the saved "before-manual" system UID; .fluidVoiceOnly.system and back correctly skip the restore.
  • startPreferredAudioCapture adds two guards: one refuses to start when the preferred device is disconnected, the other refuses the AVAudioEngine fallback when the mode explicitly requires per-app binding.
  • A new test file (FluidVoiceOnlyMicrophoneModeTests) covers the core contract, degradation, disconnect detection, and mode-transition round-trips using a SpyDeviceManager.

Confidence Score: 4/5

Safe to merge with one quality fix worth addressing: the device-list change handler restarts audio for any topology change rather than only when the preferred device appears or disappears.

The core invariant — FluidVoice-only mode never calls setDefaultInputDevice — is well-guarded and well-tested. Mode transitions, degradation, and the AVAudioEngine rejection are all correct. The one issue that merits attention is in handleDeviceListChanged: scheduleAudioRouteRecovery fires in fluidVoiceOnly mode whenever any audio device is added or removed, not just when the preferred device's availability changes.

Sources/Fluid/Services/ASRService.swift — the .fluidVoiceOnly branch in handleDeviceListChanged should filter the recovery to preferred-device presence changes only.

Reviews (5): Last reviewed commit: "Merge origin/main: rebuild FluidVoice-on..." | Re-trigger Greptile

Comment thread Sources/Fluid/ContentView.swift
Comment thread Sources/Fluid/UI/SettingsView.swift Outdated
@aguynamedryan
aguynamedryan force-pushed the feat/independent-input-device-via-direct-capture branch from a9ddf78 to 490868f Compare July 17, 2026 17:03
aguynamedryan added a commit to aguynamedryan/FluidVoice that referenced this pull request Jul 17, 2026
@github-actions github-actions Bot removed needs PR template Pull request is missing required template content. needs screenshots Pull request needs screenshot or video evidence. labels Jul 17, 2026

@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: 490868f4ba

ℹ️ 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 1222 to +1226
SettingsStore.shared.preferredOutputDeviceUID = newUID
// Only change system default if sync is enabled
if SettingsStore.shared.syncAudioDevicesWithSystem {
_ = AudioDevice.setDefaultOutputDevice(uid: newUID)
}
// Independent mode is input-only (direct capture has no output
// path), so selecting an output always updates the macOS system
// default — otherwise the picker would have no effect.
_ = AudioDevice.setDefaultOutputDevice(uid: 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 Keep the output picker synchronized with the system default

When syncing is disabled, output remains system-controlled—this handler always changes the macOS default—but ContentView's independent-mode refresh at lines 408-415 and startup initialization at line 4342 continue restoring preferredOutputDeviceUID. Consequently, after the user changes output in Control Center while independent input mode is active, FluidVoice keeps displaying the previous output even though audio uses the new system default. The output selection should follow external default-output changes regardless of this input-only mode.

Useful? React with 👍 / 👎.

Comment on lines +1801 to +1802
guard self.experimentalDirectAudioCaptureEnabled else { return true }
return (self.defaults.object(forKey: Keys.syncAudioDevicesWithSystem) as? Bool) ?? true

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 Show the preferred microphone in the menu-bar picker

Once this getter permits independent mode, selecting a microphone from the menu bar persists the dedicated UID without changing the system default (MenuBarManager.selectMicrophone), but currentPreferredInputUID in that same file still unconditionally returns the default UID. Reopening the menu therefore checkmarks the system microphone rather than the microphone FluidVoice will capture, making the primary menu-bar UI report the wrong active device whenever syncing is off.

Useful? React with 👍 / 👎.

Comment thread Sources/Fluid/UI/SettingsView.swift Outdated
Comment on lines +1138 to +1139
return self.inputDevices.first { $0.name == self.cachedDefaultInputName }?.uid
?? 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 Resolve the default input by UID rather than device name

When two attached input devices have the same display name—for example, two microphones of the same model—this lookup always returns the first matching UID, which need not be the actual system default. In synchronized mode the picker can therefore checkmark one device while capture uses the other; cache or otherwise retain the default device UID instead of deriving it from the non-unique name.

Useful? React with 👍 / 👎.

…dio capture

Per-app input selection was removed because binding a non-default device through
AVAudioEngine's inputNode fails on macOS with kAudioUnitErr_InvalidPropertyValue
(-10851), notably for aggregate/Bluetooth devices (see the syncAudioDevicesWithSystem
comment and the note at the bindPreferred* call site).

But the direct Core Audio capture path added since then (DirectCoreAudioInput ->
CoreAudioCaptureSupport.c, using AudioDeviceCreateIOProcID/AudioDeviceStart on a
specific device) binds an arbitrary input device per-app WITHOUT touching the system
default and WITHOUT the AVAudioEngine limitation. resolvedInputDeviceForCapture()
already returns the preferred device when sync is disabled — the feature was only
gated off by syncAudioDevicesWithSystem being hardcoded to true.

This change:
- Restores syncAudioDevicesWithSystem as a real, persisted setting (default true for
  backward compatibility), gated on Direct Audio Capture being enabled so independent
  selection only ever rides the safe IOProc path.
- Keeps the AVAudioEngine compatibility path pinned to the system default input/output
  whenever Direct Audio Capture is on, so the -10851 bind is never attempted (only in
  the legacy direct-capture-off configuration).
- Fixes the Settings input-device picker to reflect the saved preferred device in
  independent mode (it previously always showed the system default at startup/reload,
  while the output picker already restored its preference) - capture was already
  correct; this fixes the display.
- Adds a "Sync devices with macOS" toggle to Audio Devices settings (shown when Direct
  Audio Capture is on).

With sync disabled, choosing an input device stores it as the preference and captures
from it directly; when unplugged, capture falls back to the system default and
auto-switches back on reconnect (existing behavior).

Compiles clean via xcodebuild (Debug, macOS). Validated end-to-end on hardware: USB
handset, generic Bluetooth (Shokz, HFP), and AirPods (AAC-ELD) each captured per-app
with the system default left on the built-in mic and zero -10851 / fallback.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@aguynamedryan
aguynamedryan force-pushed the feat/independent-input-device-via-direct-capture branch from 490868f to ba52b9d Compare July 17, 2026 17:41
@aguynamedryan

Copy link
Copy Markdown
Author

Thanks for the thorough reviews. Addressed in ba52b9d:

Fixed

  • Preserve preferred input across disconnects (P1): the input Picker persists a preference only on explicit user selection (its binding's set), and the device-list onChange no longer re-selects — so a temporary disconnect can't overwrite preferredInputDeviceUID, and auto-reconnect works.
  • Menu-bar picker check-marks the wrong mic: currentPreferredInputUID now returns the preferred device in independent mode.
  • Default resolved by device name: the picker's fallback resolves the system default by UID, not display name (no ambiguity between two identically-named devices).
  • CTA shown when the toggle is hidden: the "turn off syncing" hint only renders when Direct Audio Capture (and thus the toggle) is enabled.

Already resolved by the picker refactor

  • Picker stale when the sync toggle flips / on resync (P1/P2): the input Picker's displayed value is now derived on every render from syncAudioDevicesWithSystem + preferredInputDeviceUID instead of a separately-tracked selectedInputUID, so it can't go stale without an observer. Verified on-device: it shows the dedicated input while sync is off and the macOS default is unchanged.

Deferred (out of scope here)

  • Output picker following external default-output changes: independence is input-only (direct capture is input-only), so output continues to follow the macOS default. Reactively tracking external Control-Center output changes is a separate enhancement I'd rather not fold into this PR.

Local: swiftlint --strict clean; xcodebuild test green (131 tests, incl. the new gating test).

@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: ba52b9ddeb

ℹ️ 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 thread Sources/Fluid/UI/SettingsView.swift Outdated
Comment on lines +1141 to +1142
if let defaultUID = AudioDevice.getDefaultInputDevice()?.uid,
self.inputDevices.contains(where: { $0.uid == defaultUID }) {

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 Badge Avoid querying Core Audio from the picker binding getter

Whenever SwiftUI evaluates this picker, the binding getter now calls AudioDevice.getDefaultInputDevice(), which enumerates Core Audio devices via listAllDevices(). This same view explicitly caches device information because Core Audio calls during view-body evaluation can race with AttributeGraph and cause EXC_BAD_ACCESS (see the warning at SettingsView.swift:52-54); derive the selection from a cached default UID populated outside rendering instead.

Useful? React with 👍 / 👎.

Comment on lines +1804 to +1806
set {
// No-op: sync mode is always enabled
// Kept for backward compatibility but value is ignored
_ = newValue
objectWillChange.send()
self.defaults.set(newValue, forKey: Keys.syncAudioDevicesWithSystem)

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 Include the restored sync preference in settings backups

When a user backs up an independent-input configuration and restores it into a fresh profile, this newly persisted value is neither represented in SettingsBackupPayload nor saved/restored by makeBackupPayload() and restore(from:), although the preferred device UIDs are. The restored profile therefore defaults syncing back to true, so it follows and modifies the macOS default instead of preserving the backed-up per-app input behavior; add an optional backup field for backward-compatible decoding.

Useful? React with 👍 / 👎.

Per-app input selection routes through the direct Core Audio IOProc path, and
syncAudioDevicesWithSystem is forced true whenever Direct Audio Capture is off.
That makes the preferred-device guard in bindPreferred{Input,Output}DeviceIfNeeded
unreachable: the AVAudioEngine path always follows the macOS system default, so
the code below each guard never ran.

Collapse both functions to explicit, always-succeeding no-ops with accurate
comments, and remove the helpers only the dead paths reached
(tryBindToSystemDefault{Input,Output}, setEngineOutputDevice). Keep
setEngineInputDevice — still used by the reconnect path.

No behavior change (the removed code was unreachable); the full suite (132 tests)
passes. Addresses Greptile's dead-code / misleading-comment finding on altic-dev#642.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Comment thread Sources/Fluid/UI/SettingsView.swift Outdated

@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: dc53e92f98

ℹ️ 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 thread Sources/Fluid/UI/SettingsView.swift Outdated
Comment on lines +1111 to +1112
get: { SettingsStore.shared.syncAudioDevicesWithSystem },
set: { SettingsStore.shared.syncAudioDevicesWithSystem = $0 }

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 Seed the preferred input when disabling sync

When a fresh user turns this toggle off without first changing the input picker, this setter only stores false and can leave preferredInputDeviceUID nil. resolvedInputDeviceForCapture() only stays independent when that UID is present; otherwise it falls through to AudioDevice.getDefaultInputDevice(), so subsequent macOS input changes are still captured while the UI says sync is off. Seed the preference from the currently displayed/default input when disabling sync.

Useful? React with 👍 / 👎.

Comment on lines 1907 to 1909
private func bindPreferredInputDeviceIfNeeded() -> Bool {
DebugLogger.shared.debug("bindPreferredInputDeviceIfNeeded() - Starting input device binding", source: "ASRService")

guard SettingsStore.shared.syncAudioDevicesWithSystem == false else {
DebugLogger.shared.info("Sync mode enabled - using system default input device", source: "ASRService")
return true
}

guard let preferredUID = SettingsStore.shared.preferredInputDeviceUID, preferredUID.isEmpty == false else {
DebugLogger.shared.info("No preferred input device set - using system default", source: "ASRService")
return true
}

DebugLogger.shared.debug("Attempting to bind to preferred input device (uid: \(preferredUID))", source: "ASRService")

guard let device = AudioDevice.getInputDevice(byUID: preferredUID) else {
DebugLogger.shared.warning(
"Preferred input device not found (uid: \(preferredUID)). Falling back to system default input.",
source: "ASRService"
)
// Try to use system default as fallback
return self.tryBindToSystemDefaultInput()
}

DebugLogger.shared.debug("Found preferred input device: '\(device.name)' (id: \(device.id))", source: "ASRService")

let ok = self.setEngineInputDevice(deviceID: device.id, deviceUID: device.uid, deviceName: device.name)
if ok == false {
DebugLogger.shared.warning(
"Failed to bind engine input to preferred device '\(device.name)' (uid: \(device.uid)). Trying system default input.",
source: "ASRService"
)
// Try to use system default as fallback
return self.tryBindToSystemDefaultInput()
}

DebugLogger.shared.info("✅ Successfully bound input to '\(device.name)'", source: "ASRService")
DebugLogger.shared.info("AVAudioEngine input follows system default (per-app selection uses direct capture)", source: "ASRService")
return true

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 Honor preferred input on direct-capture fallback

When sync is disabled but DirectCoreAudioInput cannot be used for the selected mic (for example the helper rejects devices with multiple input streams in CoreAudioCaptureSupport.c:54-57, or AudioDeviceStart fails), startPreferredAudioCapture() falls back to the AVAudioEngine path. This method now always succeeds without binding preferredInputDeviceUID, so the recording silently uses the macOS default while Settings/menu still show the dedicated mic; either fail/notify or bind/re-sync before using the fallback.

Useful? React with 👍 / 👎.

…eferenceCoordinator

Upstream altic-dev#531 ("Add support for choosing custom audio input") landed while this
branch was open and reworked the same code paths, so the conflicting files are
resolved wholly in favour of upstream and this branch's contribution is
re-expressed as a small delta on their model.

altic-dev#531's `.manual` mode honours the preferred microphone by moving the macOS
default input to it (`enforcePreferredInput` -> `setDefaultInputDevice`,
reasserted at recording start and on hardware changes). That leaves the original
goal of this branch uncovered: capturing a dedicated microphone for FluidVoice
alone, without taking over the system input for every other app.

So `MicrophoneSelectionMode` gains a third case, `.fluidVoiceOnly`:

- `inputDeviceForCapture()` resolves the preference in both preferred modes.
- `enforcePreferredInput()` returns the new `.skippedFluidVoiceOnlyMode` — never
  touching the system default is the entire feature.
- Gated on `experimentalDirectAudioCaptureEnabled`; a stored `.fluidVoiceOnly`
  reads back as `.manual` when direct capture is off, so the preference is still
  honoured rather than silently ignored.
- Two guards against silently recording the wrong microphone, since device
  resolution falls back to the macOS default by design:
  `preferredInputIsMissing()` refuses to start when the pinned device is
  unplugged, and `startPreferredAudioCapture()` refuses the AVAudioEngine
  fallback, which can only ever record the system default.
- Hardware-change handlers skip `stabilizePreferredInputAfterHardwareChange`
  (it reasserts the default) and re-resolve the capture device instead.
- `setMicrophoneSelectionMode()` captures the user's own input device when
  *entering* `.manual` from any other mode, and hands it back when leaving.
  Capturing only from `.system` (as before) missed the
  `.fluidVoiceOnly` -> `.manual` -> `.fluidVoiceOnly` round trip and left the
  system input stuck on FluidVoice's choice after the user opted back out —
  found on-device by walking that exact sequence.
- Settings and the menu bar each gain one "use for FluidVoice only" switch.

Superseded by upstream and dropped: the preferred-UID-clobbered-on-disconnect
fix, the derived Picker binding, and the `syncAudioDevicesWithSystem` boolean.

Local: swiftlint --strict clean; 161 tests pass.
aguynamedryan added a commit to aguynamedryan/FluidVoice that referenced this pull request Jul 20, 2026
@aguynamedryan

Copy link
Copy Markdown
Author

Congrats on landing #531 — and apologies for the collision, we were clearly working the same problem in parallel. I've rebased this PR onto it, and in doing so it got a lot smaller: #531 already has the right structure, so this is now a mode that plugs into MicrophonePreferenceCoordinator rather than a competing implementation.

What the two do differently. #531's .manual mode honours the preferred microphone by moving the macOS default input to it — enforcePreferredInput()AudioDevice.setDefaultInputDevice, reasserted at recording start and on hardware changes. That's the right behaviour for "FluidVoice should always use this mic," and it works on both capture backends.

This PR adds a third mode, .fluidVoiceOnly, for the case #531 doesn't cover: capture the preferred microphone for FluidVoice alone, leaving the macOS default untouched. The use case is a dedicated dictation mic (a headset, a handset) that you don't want becoming the input for Zoom, Discord, and everything else — today, selecting it in FluidVoice takes over the whole system.

Why it's possible without the -10851 problem. It rides the DirectCoreAudioInput / AudioDeviceCreateIOProcID path that's already in the tree, which binds an arbitrary device per-app. That sidesteps the AVAudioEngine limitation (kAudioUnitErr_InvalidPropertyValue) that made this unworkable before and got the old setting hard-disabled.

How it composes with your code:

  • MicrophoneSelectionMode gains case fluidVoiceOnly, plus usesPreferredInputDevice / changesSystemDefaultInput so the call sites read as intent rather than mode-equality checks.
  • inputDeviceForCapture() already returned the preferred device without side effects — it now applies to both preferred modes, unchanged otherwise.
  • enforcePreferredInput() returns a new .skippedFluidVoiceOnlyMode. This is the whole feature: the mode is defined by not calling setDefaultInputDevice.
  • Gated on experimentalDirectAudioCaptureEnabled. If direct capture is off, a stored .fluidVoiceOnly reads back as .manual, so the preference is still honoured by your existing path rather than silently ignored.
  • Two guards against silently recording the wrong microphone. Device resolution falls back to the macOS default by design — correct for .manual, which survives a disconnect by moving the default — but in this mode that fallback means capturing the system mic while Settings still shows the dedicated one. So startPreferredAudioCapture() refuses the AVAudioEngine fallback (it can only ever record the system default), and preferredInputIsMissing() refuses to start at all when the pinned device is unplugged. Both fail with a message rather than recording the wrong input.
  • The hardware-change handlers skip stabilizePreferredInputAfterHardwareChange in this mode (it reasserts the system default) and re-resolve the capture device instead, so a reconnected mic is picked up without touching the default.
  • setMicrophoneSelectionMode() now hands the system default back only when leaving .manual — the one mode that moved it. Restoring on any other transition would clobber a default the user changed themselves while FluidVoice was keeping its hands off it.
  • UI: one extra switch, "Use this microphone for FluidVoice only," shown under the existing toggle only in a preferred-microphone mode with direct capture on. Same affordance in the menu-bar submenu — without it, the only way out would be through .manual, which moves the system input, i.e. the exact side effect the user opted out of.

Tests are in FluidVoiceOnlyMicrophoneModeTests, driving the coordinator through a fake AudioDeviceManaging that records every setDefaultInputDevice call — the assertions are mostly negative, since "never touches the system default" is the contract.

A couple of things I deliberately left alone, happy to change either:

  • Output devices. Untouched — direct capture is input-only, so output still follows the macOS default.
  • The .system ↔ preferred toggle keeps your exact behaviour; leaving .system still lands in .manual, never in the new mode.

Also worth noting: your fix for the disconnect clobbering preferredInputDeviceUID covers what I'd fixed separately here, so that part of the original PR is gone.

Local: swiftlint --strict clean, xcodebuild test green (160 tests).

Verified on-device (Apple Silicon), reading the device actually bound out of ASRService's own logging:

Configuration Device bound macOS default input
.fluidVoiceOnly, preferred = BlackHole 2ch BlackHole 2ch unchanged, before / during / after recording
.fluidVoiceOnly, preferred = an aggregate device that aggregate device unchanged
.manual, preferred = BlackHole 2ch BlackHole 2ch moved to BlackHole at recording start, as designed
.system, preferred = BlackHole 2ch macOS default unchanged
.fluidVoiceOnly, preferred device unplugged refuses to record unchanged
.fluidVoiceOnly, preferred = Bluetooth (AirPods Max) that Bluetooth mic, 24 kHz unchanged
.fluidVoiceOnly, preferred = USB mic while Bluetooth is the system default the USB mic Bluetooth default undisturbed
.system / .manual with Bluetooth connected as before as before
.fluidVoiceOnly, preferred = a multi-stream device refuses to record unchanged

The Bluetooth rows matter because that is the device class this feature originally broke on. Direct capture binds an AirPods mic (negotiating 24 kHz rather than the 48 kHz the other devices use), transcribes from it, and releases the device cleanly on stop — and pinning FluidVoice to a wired mic while Bluetooth is the system default leaves the Bluetooth default alone.

The last row is the AVAudioEngine-fallback guard firing for real: CoreAudioCaptureSupport.c rejects devices with more than one input stream, so an aggregate device with two sub-devices takes that path and gets a refusal rather than a silent recording from the system default.

Understood if you'd rather ship #531 alone for now and revisit — in that case I'd still suggest keeping the direct-capture seam in mind, since it's what makes per-app selection possible at all.

@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: c55f060094

ℹ️ 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".

// on aggregate and Bluetooth devices). Falling back to it in FluidVoice-only mode would
// silently capture the system default while Settings and the menu bar still show the
// dedicated microphone, so fail loudly instead.
if SettingsStore.shared.microphoneSelectionMode == .fluidVoiceOnly {

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 Badge Block runtime fallback in FluidVoice-only mode

Fresh evidence: this new guard only covers the startup fallback path; when an already-running direct capture hits the duration-mismatch recovery, handleDirectCaptureDurationMismatch() calls startCompatibilityAudioCapture(reason: "duration_mismatch") directly at ASRService.swift:942, bypassing this check. In a FluidVoice-only session whose direct stream drifts, the app can switch to AVAudioEngine and capture/bind the macOS/default path while the UI still promises not to, so the runtime fallback needs the same fail-loudly behavior or should route through startPreferredAudioCapture().

Useful? React with 👍 / 👎.

@aguynamedryan

Copy link
Copy Markdown
Author

A few cross-references, since this lands in code several open reports are about. Flagging, not claiming to fix any of them.

#655 (Drain audio engine off main queue) — that PR and this one overlap in ASRService: it makes the AVAudioEngine teardown/recreate sequential on device switch, and this one rewrites handleDefaultInputChanged / handleDeviceListChanged right next to it. Whichever lands second will want a rebase, and I'd rather it be this one — happy to redo it on top of #655 whenever that merges.

#644 (crash on audio source switch) — explicitly not claiming a fix; the build in that thread already has confirmations from two reporters. But since this PR rewrites those two handlers, it seemed worth recording what the branch does under that scenario. Recording from a USB mic, then disconnecting the Bluetooth device — which moves the macOS default input out from under the session — recovered cleanly, no crash or hang:

Default input changed; FluidVoice-only capture keeps its own device
FluidVoice-only preferred input list changed → recovery → device=134 → Audio route recovery succeeded

#592 (AirPods/Bluetooth stale stream format) — related, and observable on this path: capture from AirPods prepares at 24 kHz / 480 frames where wired devices come up at 48 kHz / 512, so the HFP format is being read correctly rather than reused from the A2DP route.

#663 and #617 (audio muffled after dictating over Bluetooth) — not fixed here, and worth being plain about why: capturing from a Bluetooth microphone forces the headset into HFP, which degrades playback for the duration. That is inherent to using a BT mic and this PR doesn't change it.

It does make it avoidable, though, which may be the more useful outcome for those reporters: with a dedicated input selected, FluidVoice captures that device and never opens the AirPods' input stream, so the headset stays in A2DP and music keeps playing at full quality while you dictate. Under .system or .manual the AirPods are the input, so they get pulled into HFP either way. That "listening on AirPods, dictating from another mic" case is the one I'd expect most of those users actually want, and it's only reachable if FluidVoice can capture a device without taking over the system default.

As before — happy to rebase, split the coordinator refactor back out, or rename any of it.

@altic-dev

Copy link
Copy Markdown
Owner

Thanks for the PR!

@grohith327 check this out - could be related and we can take it in for next-next update or so if relevant

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.

2 participants