Skip to content

Fix Bluetooth headset mode churn while idle - #749

Open
AshwanthramKL wants to merge 6 commits into
altic-dev:mainfrom
AshwanthramKL:agent/fix-bluetooth-idle-audio-loop
Open

Fix Bluetooth headset mode churn while idle#749
AshwanthramKL wants to merge 6 commits into
altic-dev:mainfrom
AshwanthramKL:agent/fix-bluetooth-idle-audio-loop

Conversation

@AshwanthramKL

Copy link
Copy Markdown

Description

Prevents FluidVoice from repeatedly forcing Bluetooth headphones into low-quality HFP/call mode while dictation is idle.

On v1.6.6, a legacy AVAudioEngine idle prewarm can publish AVAudioEngineConfigurationChange immediately after it is created. FluidVoice treats that notification as another idle route change, retires the engine, prewarms a replacement, and repeats indefinitely. In a reproduced session this reached more than 3,000 recovery generations, with each generation creating input/output audio units again. Bluetooth playback oscillated or remained at 16 kHz mono until FluidVoice quit.

This PR:

  • only prewarms the idle capture backend when direct Core Audio is enabled; the legacy AVAudioEngine is created on demand instead
  • ignores AVAudioEngineConfigurationChange while capture is fully idle, preventing the self-generated recovery loop
  • fully retires the legacy engine after dictation rather than retaining a stopped engine that still owns AUHAL resources
  • reasserts the manually selected microphone at startup and after default-input/device-list changes even when no recording is active
  • keeps the safe direct Core Audio prepared path unchanged
  • adds regression coverage for the idle capture policy

Type of Change

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

Related Issue or Discussion

Closes #694.

Also relates to #678 and the user reports collected in #727, #663, and #695.

Testing

  • Tested on Intel Mac
  • Tested on Apple Silicon Mac
  • Tested on macOS version: 26.5.2 (25F84)
  • Ran linter locally: swiftlint --strict --config .swiftlint.yml Sources — blocked because this machine has Command Line Tools but not Xcode's sourcekitdInProc.framework
  • Ran formatter locally: targeted SwiftFormat lint passed for all three changed files (the repository currently has unrelated wrapIfStatementBodies baseline findings in ASRService.swift under SwiftFormat 0.62.1)
  • Ran tests locally: the extracted idle-policy smoke test passed and all changed Swift files passed compiler parsing; the added XCTest cases are included for CI/Xcode

Hardware regression sequence:

  1. Sony WH-CH720N connected as output; MacBook Pro Microphone selected manually as input.
  2. Confirmed the release build looped through engine configuration changed idle recoveries and dropped output to the Bluetooth headset route.
  3. Confirmed quitting FluidVoice restored 2 ch 32-bit Float 44.1 kHz output.
  4. Enabled direct Core Audio and verified a real record/stop cycle used the MacBook microphone at 48 kHz with zero dropped packets while headphone output stayed 2-channel/44.1 kHz.
  5. Quit and relaunched FluidVoice; the direct input preference persisted and headphone output remained stereo.

Screenshots / Video

  • No UI/visual changes; screenshots/video are not applicable.

Notes

The normal Bluetooth quality drop while actively recording from a Bluetooth microphone is inherent to HFP. This fix targets the avoidable case: FluidVoice touching or retaining the input audio unit while idle, or allowing macOS to replace an explicitly preferred built-in microphone after a Bluetooth reconnect.

The direct Core Audio prewarm remains safe because it prepares registration and fixed memory without starting hardware IO.

@AshwanthramKL

Copy link
Copy Markdown
Author

@grohith327 This fork PR’s required Build and Test and PR Archive workflows are currently blocked with action_required pending maintainer approval. Could you approve the workflow runs so the lint and Xcode test suite can execute? I’ll wait for green CI before requesting code review.

@greptile-apps

greptile-apps Bot commented Jul 31, 2026

Copy link
Copy Markdown

Greptile Summary

Fixes idle Bluetooth headset mode churn by:

  • Deferring legacy AVAudioEngine creation until capture begins while preserving direct Core Audio prewarming.
  • Retiring the legacy engine after recording and ignoring idle engine-configuration notifications.
  • Reasserting manually selected microphones after relevant hardware changes.
  • Moving startup audio initialization and preferred-input enforcement behind AudioStartupGate.
  • Adding regression tests for the new idle-capture policy.

Confidence Score: 5/5

The PR appears safe to merge.

The previously reported startup-gate bypass is fixed: the sole ASR initialization caller awaits the gate before initialization, and preferred-input enforcement and subsequent Core Audio access occur only after the gate opens; no blocking failure remains.

Reviews (6): Last reviewed commit: "Serialize direct prewarm after engine re..." | Re-trigger Greptile

Comment thread Sources/Fluid/Services/ASRService.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: 785b2bca0f

ℹ️ 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 1841 to 1843
} else {
self.scheduleAudioEngineStandbyRetirement()
await self.retireAudioEngineAndWait(reason: "recording_stop_release")
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Avoid blocking stop on engine retirement

When Faster Recording Start is off, this awaits retireAudioEngineAndWait on every normal stop after the tap/engine have already been stopped but before onCaptureStopped and before final transcription begins. The retirement drain performs AVAudioEngine deallocation, which this codebase treats as potentially waiting on AVAudioIOUnit internals, so legacy-mode users can see stop cues/UI release and text output stall on Core Audio teardown. Consider scheduling the retirement off-main (while still waiting before the next engine creation) or moving the callback/final transcription path ahead of the deallocation barrier.

Useful? React with 👍 / 👎.

@AshwanthramKL

Copy link
Copy Markdown
Author

@codex review

@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: 65cbced092

ℹ️ 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
Comment on lines +1269 to +1270
await AudioStartupGate.shared.scheduleOpenAfterInitialUISettled()
await AudioStartupGate.shared.waitUntilOpen()

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 Move the startup gate before CoreAudio calls

This wait happens too late to protect launch from CoreAudio initialization: initialize() has already registered CoreAudio listeners and called AudioDevice.listInputDevices() on lines 1258-1264 before the gated task starts. Since ContentView opens the gate 2 seconds after appear while initialize() runs after 1.5 seconds, those earlier calls can still hit the SwiftUI/AVFoundation launch race the gate is meant to avoid; the wait needs to happen before any listener registration or device query.

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.

Valid—fixed in 4912bdc. ContentView now waits for AudioStartupGate before starting AudioHardwareObserver or invoking ASR initialization, and ASRService.initialize() itself awaits the gate before permission, listener, or device-list calls.

Comment thread Sources/Fluid/Services/ASRService.swift Outdated

let microphonePreferenceCoordinator =
AppServices.shared.microphonePreferenceCoordinator
_ = microphonePreferenceCoordinator.enforcePreferredInput(reason: "startup")

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Avoid changing the system mic on direct startup

When manual microphone mode is saved and Faster Recording Start remains enabled, this startup path still calls enforcePreferredInput, which writes macOS's global default input via setDefaultInputDevice. The direct capture path does not need that global switch—start() only enforces the preferred input for the legacy AVAudioEngine path when experimentalDirectAudioCaptureEnabled == false—so simply launching FluidVoice can override the user's system default and, for Bluetooth microphones, move the headset back toward the mic/HFP route while idle.

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.

Valid—fixed in 4912bdc. The direct backend no longer writes the global system input at startup or during default/device-list changes; it resolves the saved preferred device through direct route recovery instead. Legacy AVAudioEngine retains global-input enforcement, with focused policy coverage for both modes.

@AshwanthramKL

Copy link
Copy Markdown
Author

@codex review

@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: 4912bdc0e4

ℹ️ 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 +3271 to +3274
self.scheduleAudioRouteRecovery(
reason: "direct input device list changed",
requiresIdlePrewarm: 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 Gate direct recovery to selected-device changes

In manual microphone mode with direct capture enabled, this branch now schedules route recovery for any input device-list delta, not just changes involving the selected microphone. During an active dictation, scheduleAudioRouteRecovery immediately disables the capture pipeline before rebuilding, while the direct backend already owns liveness/format listeners for the active device, so plugging in or removing an unrelated input device can create an avoidable audio gap. Consider filtering the UID delta to the preferred/current device, or relying on the direct invalidation path for active-device failures.

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.

Valid—fixed in bcc85ca. Direct route recovery now runs only when the saved preferred input UID changes availability; unrelated device-list deltas do nothing, while the active device remains covered by its existing liveness and format listeners. Added regression coverage for unrelated additions plus preferred-device removal and return.

@AshwanthramKL

Copy link
Copy Markdown
Author

@codex review

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

ℹ️ 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 +560 to +562
Task {
await AudioStartupGate.shared.scheduleOpenAfterInitialUISettled()
}

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 Move default-device lookup behind the startup gate

This still schedules the startup gate from onAppear, after SwiftUI has already initialized the view's stored @State values; selectedInputUID is still initialized with AudioDevice.getDefaultInputDevice() in ContentView.swift:254, and the repo comment in SettingsView.swift:54-55 identifies that HAL query as the launch-time AttributeGraph/CoreAudio race. Fresh evidence: that initializer remains outside the gate, so the first ContentView construction can initialize CoreAudio before this task runs; move the default-device lookup into the gated initialization instead.

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.

Valid—fixed in 65ab727. selectedInputUID now initializes without touching Core Audio and is populated by the existing gated audio-initialization block after AudioStartupGate opens. The later hardware-change and backup-restore lookups occur only after startup.

@AshwanthramKL

Copy link
Copy Markdown
Author

@codex review

@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: 65ab7270ac

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

do {
try self.configureSession()
self.benchmarkLog("audio_engine_prewarm reason=\(reason) elapsedMs=\(self.elapsedMilliseconds(since: startedAt))")
_ = try await self.prepareDirectAudioInput(reason: reason)

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 Add drain barrier before direct prewarm

When Faster Recording Start is enabled, or an idle route recovery requests prewarm immediately after a legacy recording stop, the stop path only schedules AVAudioEngine deallocation on the retirement drain, but this path prepares Direct Core Audio without the waitForScheduledReleases() barrier that startConfiguredAudioCapture() uses. In that handoff window, direct capture can create/register a new Core Audio backend while the old AVAudioEngine/AUHAL is still being released, reintroducing the teardown/creation overlap this drain is meant to serialize; please await the drain before direct prewarm as well.

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.

Valid—fixed in ada31c3. Direct idle prewarm now waits for every scheduled AVAudioEngine release before registering the direct backend, matching the recording-start barrier. It rechecks termination, capture transition, and prepared state after the await to avoid continuing stale prewarm work.

@AshwanthramKL

Copy link
Copy Markdown
Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Already looking forward to the next diff.

Reviewed commit: ada31c335a

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

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.

[BUG] Keeps switching between mic pickup and mic at rest even when not at rest, when default mac os input is whatever connected bluetooth device

1 participant