Skip to content

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

Open
aguynamedryan wants to merge 7 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 7 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 it doesn't cover: leaving the system default input untouched 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.

Behaviour when the preferred device is unavailable — prefer, then fall back visibly

An earlier revision of this PR refused to record when the pinned device was gone — hitting the hotkey with the mic unplugged produced an error sound and no recording. Dogfooding showed that was the wrong trade: a preference should degrade to the system default, not become a hard requirement that blocks dictation. So .fluidVoiceOnly now:

  • records the preferred device when it is available (direct path);
  • falls back to the macOS default — still through the private path, still without moving the system default — when the preferred device is unplugged, or when the direct path cannot bind it on this hardware (e.g. a multi-stream aggregate device, which the capture helper rejects with kAudioHardwareUnsupportedOperationError; AVAudioEngine then records the default);
  • surfaces the fallback with a notification ("Preferred microphone unavailable — recording through …"), deduplicated per fallback device and reset when the preferred device returns.

This directly answers the earlier review feedback about silently recording the wrong microphone: the substitution is now visible, not refused. The two fail-loud guards and MicrophonePreferenceCoordinator.preferredInputIsMissing() are removed; the recording path instead compares the device it is about to record against the preference and announces any mismatch.

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 via the existing path rather than silently ignored.
  • ASRService.announcePreferredMicrophoneFallbackIfNeeded(recording:) posts the fallback notification whenever the device actually being recorded differs from the preference — at both capture backends (direct-capture success, and the AVAudioEngine fallback).
  • setMicrophoneSelectionMode() captures the user's own input device when entering .manual from any other mode and hands it back when leaving, fixing a fluidVoiceOnly → manual → fluidVoiceOnly round-trip that left the system input stuck on FluidVoice's choice.
  • 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).

Type of Change

  • ✨ New feature

Related Issue or Discussion

Testing

  • xcodebuild test163 tests, 0 failures (incl. FluidVoiceOnlyMicrophoneModeTests).
  • swiftlint --strict --config .swiftlint.yml — 0 violations.
  • Tested on Apple Silicon Mac, macOS 26.5.2.

Hardware-validated by reading the device ASRService actually bound out of its own logging, sampling the macOS default input before/during/after each recording, and driving live device transitions (Bluetooth connect/disconnect via blueutil):

Scenario Device recorded macOS default input Fallback notification
Preferred present (AirPods HFP / BlackHole / USB handset) the preferred device (direct path) unchanged none
Preferred unplugged (USB handset absent; Bluetooth disconnected live) macOS default (direct path) unchanged ✅ fired
Preferred present but unbindable (2-stream aggregate → direct path rejects) macOS default (AVAudioEngine) unchanged ✅ fired
Bluetooth reconnected mid-session back to the preferred device unchanged none (dedup reset)

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.
  • When the preferred device is unavailable, capture falls back to the macOS default and posts a notification — it never moves the system default input. Multi-stream aggregate devices (rejected by the direct-capture helper CoreAudioCaptureSupport.c by design) take this same path: AVAudioEngine records the default and the substitution is surfaced, rather than being refused or swapped silently.
  • 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 adds a .fluidVoiceOnly microphone mode that captures a chosen input device for FluidVoice alone, using the existing DirectCoreAudioInput path to bind an arbitrary device per-app without touching the macOS system default input.

  • New mode and coordination: MicrophoneSelectionMode.fluidVoiceOnly is added with a usesPreferredInputDevice helper; MicrophonePreferenceCoordinator.setFluidVoiceOnly centralises all .manual ↔ .fluidVoiceOnly transitions (system-default save/restore, preference seeding, and enforcePreferredInput) so the Settings toggle and menu-bar item cannot diverge.
  • Graceful fallback with visible notification: When the pinned device is absent or unbindable, capture falls back to the macOS default (still without moving it); announcePreferredMicrophoneFallbackIfNeeded posts a deduplicated UNUserNotification surfacing the substitution, and resets dedup state when the preferred device is used again.
  • Hardware event handling: handleDefaultInputChanged and handleDeviceListChanged both no-op in .fluidVoiceOnly mode (the bound device ID is unaffected by system-default changes), and a .fluidVoiceOnly → .manual → .fluidVoiceOnly round-trip is fixed by capturing the user's pre-manual system input on any transition into .manual, not just from .system.

Confidence Score: 5/5

Safe to merge. The new .fluidVoiceOnly mode is cleanly isolated — it never calls setDefaultInputDevice, degrades gracefully to .manual when the direct capture path is disabled, and all mode transitions share one implementation path through the coordinator so Settings and the menu bar cannot diverge.

The mode-switch logic (save/restore of the pre-manual system UID, enforcement on entering .manual, skip on entering .fluidVoiceOnly) is exercised end-to-end by dedicated tests including the round-trip regression fix. Hardware-change handlers are correctly no-opped for the new mode. The fallback notification is deduplicated at the app level and follows the established notification pattern. No breaking changes to the existing .system and .manual flows.

No files require special attention. The most complex paths are in ASRService.swift (announcement timing) and SettingsStore.swift (mode degradation in the computed getter), both of which are covered by the new test suite.

Reviews (8): Last reviewed commit: "fix(audio): don't interrupt a FluidVoice..." | 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 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

This is now the intended, announced behaviour. When the direct path can't bind the preferred device (e.g. a multi-stream aggregate, or a device that fails to start), FluidVoice-only records the macOS default via AVAudioEngine — "prefer, else default" — and announcePreferredMicrophoneFallbackIfNeeded(recording:) posts a notification naming the device it fell back to, so the swap is visible rather than silent.

Verified live by forcing the reject with a purpose-built 2-stream aggregate as the preferred device: the direct path returns kAudioHardwareUnsupportedOperationError, AVAudioEngine records the system default, the fallback notification fires, and the macOS system default input is left unchanged.

…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".

Comment thread Sources/Fluid/Services/ASRService.swift Outdated
// 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 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Thanks — this was a real gap in the guard-based design, but the latest commit removes the premise rather than patching it. FluidVoice-only no longer fails loudly when it can't use the pinned device: it falls back to the macOS system default (via the direct path, or AVAudioEngine when the direct path can't bind the device) and surfaces the substitution with a notification. There's no longer a guard to bypass.

The runtime duration-mismatch recovery recording the default is now intended behaviour, and that path already carries its own user notification (showAudioCaptureFallback). Rationale: dogfooding showed refusing-to-record was worse for users than a visible fallback. Both fail-loud guards and preferredInputIsMissing() are gone in the latest commit.

@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

aguynamedryan and others added 2 commits July 21, 2026 12:43
…v#655

altic-dev#655 ("Drain audio engine off main queue") landed while this branch was open
and reworked the same capture-startup path this branch guards, so the two
collided in `ASRService.startPreferredAudioCapture()`.

Both sides are additive and orthogonal — altic-dev#655 serialises AVAudioEngine
teardown against startup, this branch refuses to start when the pinned
microphone cannot be honoured — so the resolution keeps both:

- `startPreferredAudioCapture()` becomes `async throws` and awaits
  `audioEngineRetirementDrain.waitForScheduledReleases()`, per altic-dev#655.
- The FluidVoice-only guards (missing preferred device, AVAudioEngine
  fallback) are retained unchanged.
- The missing-device check runs *before* the drain await: it is a cheap
  precondition that throws, and there is no reason to wait on a teardown
  for a capture that is about to be refused.
- The compatibility-capture call becomes `try await`.

Both callers of `startPreferredAudioCapture()` already awaited it on main's
side, so no further propagation was needed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SG5paEAboAJeNcbazuzqrQ
…ead of refusing

FluidVoice-only mode pinned capture to one microphone and *refused to record*
when that device could not be used — hitting the hotkey with the preferred
headset unplugged produced an error sound and no recording. Dogfooding made
clear that is the wrong trade: a preference should degrade to the default,
not become a hard requirement that blocks dictation.

The capture machinery already resolved preferred-else-default
(MicrophonePreferenceCoordinator.inputDeviceForCapture()) and the direct Core
Audio path binds whatever that returns by device ID — so it already captured
the default through the private path, without moving the system input. Two
guards were the only thing converting that working fallback into a refusal,
and both are removed:

  - the "preferred is missing" throw at the top of startPreferredAudioCapture()
  - the .fluidVoiceOnly throw before the AVAudioEngine fallback

When the preferred device is unavailable, capture now records the macOS
default (the system default is still never changed): via the direct path when
it can bind the default, and via AVAudioEngine — which records the default
natively — when it cannot.

The reviewers' concern was a *silent* swap: UI shows the headset while audio
comes from another mic. That is answered by making the fallback visible, not
by refusing. announcePreferredMicrophoneFallbackIfNeeded(recording:) posts a
notification ("Preferred microphone unavailable — recording through <device>…")
whenever the device actually being recorded differs from the pinned one. It
fires in both fallback situations — the preferred mic unplugged, and a present
mic the direct path cannot bind on this hardware (aggregate/Bluetooth, -10851)
— deduplicated per fallback device so repeated recordings don't spam, and
reset when the preferred device is used again.

MicrophonePreferenceCoordinator.preferredInputIsMissing() is removed: the
announce path now compares the recorded device against the preference (which
also covers the present-but-unbindable case the predicate could not), leaving
the predicate with no production caller.

Tests: reframed the "detects missing → refuse" cases to assert the resolution
behaviour production relies on — inputDeviceForCapture() returns the pinned
device when present and the default when it is absent, in both preferred
modes. The negative contract (never call setDefaultInputDevice in this mode)
is unchanged and still covered. Full suite green (163/163).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SG5paEAboAJeNcbazuzqrQ
@aguynamedryan

Copy link
Copy Markdown
Author

Heads-up: this PR pivoted its unavailable-device behaviour, and the description above is rewritten to match.

An earlier revision refused to record (fail-loud) when the pinned microphone was unavailable. Based on dogfooding, .fluidVoiceOnly now prefers the chosen device, then falls back to the macOS default — still without ever moving the system default input — and surfaces the substitution with a notification. This directly addresses the earlier "silently records the wrong mic" feedback: the swap is now visible, not refused. The two fail-loud guards and preferredInputIsMissing() are removed; the recording path instead compares the device it is about to record against the preference and announces any mismatch, at both capture backends.

Latest verification:

  • xcodebuild test — 163 tests, 0 failures
  • swiftlint --strict --config .swiftlint.yml — 0 violations
  • Hardware-validated live: preferred present → records it (direct path); preferred unplugged (incl. a live Bluetooth disconnect) → falls back to the default + notification; preferred present-but-unbindable (a forced multi-stream aggregate → kAudioHardwareUnsupportedOperationError) → AVAudioEngine on the default + notification; Bluetooth reconnected → resumes the preferred device. The macOS system default input was sampled before/during/after and stayed unchanged in every case.

I've replied inline on the two open Codex threads (runtime-fallback / honor-preferred-on-fallback), which the pivot resolves by design.

The FluidVoice-only fallback notification fired just before
startCompatibilityAudioCapture(), so a failed AVAudioEngine start would have
left a misleading "recording through <device>…" notification behind for a
recording that never began. Move the announce to after the capture starts,
matching the direct-capture site (which already announces after its start()).

Low-probability, no data-loss; addresses Greptile review feedback on altic-dev#642.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SG5paEAboAJeNcbazuzqrQ
@aguynamedryan

Copy link
Copy Markdown
Author

Addressed the one observation from the Greptile summary (the fallback notification firing just before the AVAudioEngine capture is confirmed) in e6251e1 — the announce now runs after startCompatibilityAudioCapture returns, so a failed start cannot leave a misleading "recording through …" notification behind. This matches the direct-capture site, which already announces after its start(). Local: 163/163 tests, swiftlint --strict clean.

…device changes

handleDeviceListChanged called scheduleAudioRouteRecovery — which immediately
disables the capture pipeline — for every input-device-list change while
recording in .fluidVoiceOnly. Because DirectCoreAudioInput binds a specific
device by ID and is unaffected by unrelated hardware, any unrelated USB or
Bluetooth device connecting or disconnecting mid-recording caused a needless
interruption (missed audio, gap in transcription).

Leave the running capture undisturbed on device-list changes. The two cases
that genuinely matter are handled elsewhere: if the *bound* device itself
disconnects, the per-device availability monitor (handleDeviceAvailabilityChanged,
armed via startMonitoringDevice at recording start) recovers it; and a changed
device preference is applied on the next recording, which re-resolves. This also
matches the mode's premise — capture stays independent of unrelated audio churn.
(handleDefaultInputChanged already treated .fluidVoiceOnly this way.)

Addresses Greptile review on altic-dev#642. Live-validated: two device-list changes
(aggregate create + destroy) during a FluidVoice-only recording bound to
BlackHole left the capture undisturbed with zero route recovery; the recording
ran continuously until stopped. No unit test — handleDeviceListChanged needs
real audio hardware, like the rest of ASRService; validated live instead.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SG5paEAboAJeNcbazuzqrQ
@aguynamedryan

Copy link
Copy Markdown
Author

Addressed the device-list-change concern from the Greptile summary (the 3/5 note) in 18c10d7.

In .fluidVoiceOnly, handleDeviceListChanged no longer calls scheduleAudioRouteRecovery — a device-list change never interrupts an in-progress recording. The two cases that actually matter are handled elsewhere:

  • the bound device disconnecting is caught by the per-device availability monitor (handleDeviceAvailabilityChanged, armed via startMonitoringDevice at recording start), which recovers/falls back;
  • a changed device preference is applied on the next recording, which re-resolves.

This fixes the interruption more completely than a "filter on relevance" approach would — it also avoids the mid-recording switch to a reconnected preferred device, which is itself churn this mode is meant to avoid — and it matches the feature's premise that capture stays independent of unrelated audio changes. (handleDefaultInputChanged already treated .fluidVoiceOnly this way.)

Live-validated: two device-list changes (a CoreAudio aggregate device created then destroyed) during a FluidVoice-only recording bound to BlackHole each left the capture undisturbed with zero route recovery — the recording ran continuously until stopped. No unit test, since handleDeviceListChanged needs real audio hardware like the rest of ASRService.

@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: 18c10d7532

ℹ️ 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 +871 to +875
// Announce the substitution only after the capture has actually started, so a failed start
// never leaves a misleading "recording through …" notification behind. This backend always
// records the macOS default, so pass it explicitly rather than the resolved preference. (The
// direct-capture site above announces after its `start()` for the same reason.)
self.announcePreferredMicrophoneFallbackIfNeeded(recording: AudioDevice.getDefaultInputDevice())

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 Announce runtime fallback to the default mic

Fresh evidence in the current diff is that the new preferred-mic notification is only invoked after startPreferredAudioCapture() startup fallbacks; handleDirectCaptureDurationMismatch() still switches an active direct session with startCompatibilityAudioCapture(reason: "duration_mismatch") and never calls this helper. When .fluidVoiceOnly is recording the preferred mic and the direct path detects a duration mismatch, it falls back to AVAudioEngine/default input silently aside from the generic audio-capture-path notification, so users are not told their selected mic is no longer being used.

Useful? React with 👍 / 👎.

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