Skip to content

Fix degraded AirPods/Bluetooth dictation accuracy#593

Open
rohithgoud30 wants to merge 8 commits into
altic-dev:mainfrom
rohithgoud30:fix/airpods-bluetooth-capture-format
Open

Fix degraded AirPods/Bluetooth dictation accuracy#593
rohithgoud30 wants to merge 8 commits into
altic-dev:mainfrom
rohithgoud30:fix/airpods-bluetooth-capture-format

Conversation

@rohithgoud30

@rohithgoud30 rohithgoud30 commented Jul 12, 2026

Copy link
Copy Markdown

Description

With AirPods as the input device, dictation was noticeably worse than with the built-in microphone: garbled or time-stretched transcriptions, missing first words, and lower accuracy overall. Three capture-path defects caused this, all tied to the Bluetooth A2DP to HFP route transition that happens when the AirPods microphone activates.

  • The direct Core Audio backend read the input stream's virtual format once at creation and never revalidated it, so after the HFP switch every packet was decoded and resampled with a stale sample rate and byte layout. Capture start now re-reads and validates the format before AudioDeviceStart. Block-based listeners (on a private serial queue, drained before teardown) watch kAudioStreamPropertyVirtualFormat and kAudioDevicePropertyNominalSampleRate, the IOProc stops publishing misdecoded packets the moment a change is flagged, and the existing audio-route recovery rebuilds capture with a fresh format.
  • onCaptureStarted fired right after AudioDeviceStart returned, 1 to 2 seconds before a Bluetooth mic actually delivers audio, and pre-start samples are timestamp-trimmed, so the UI invited users to speak into a dead mic. It now fires on the first accepted audio packet, with a 2.5 s timeout fallback.
  • The 16 kHz conversion used linear interpolation with no anti-alias filter. A new StreamingResampler (AVAudioConverter, max SRC quality, reused buffers, stateful across hardware callbacks) replaces it, flushes the converter's FIR tail when recording stops so the end of the final word is preserved, and appends to the audio buffer under the pipeline lock so ordering is deterministic during route recovery.

Built-in microphone behavior is unchanged (its format never changes and its first packet arrives within tens of milliseconds).

Type of Change

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

Related Issue or Discussion

Closes #592

Testing

  • Tested on Intel Mac
  • Tested on Apple Silicon Mac (manual dictation with AirPods and built-in mic; garbling and missing first words gone with AirPods)
  • Tested on macOS version: 26.5.2
  • Ran linter locally: swiftlint --strict --config .swiftlint.yml Sources (0 violations)
  • Ran formatter locally: swiftformat --config .swiftformat on the files this PR adds/changes (pre-existing violations elsewhere in ASRService.swift left untouched to avoid drive-by churn)
  • Ran tests locally: xcodebuild test -project Fluid.xcodeproj -scheme Fluid passes 29 tests, including 6 new StreamingResampler tests (alias suppression, in-band power preservation, chunked continuity vs one-shot, flush tail)

Screenshots / Video

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

Notes

This does not eliminate the brief playback dip reported in #452 (the A2DP to HFP profile switch itself is macOS behavior), but it fixes the capture-side fallout of that transition. Bluetooth HFP microphone quality also remains physically below the built-in mic; the fix removes the app-side corruption (wrong-rate decode, lost first words, aliasing) so AirPods accuracy lands close to the route's actual capability. The one behavior change reviewers may notice: with Bluetooth input, the recording overlay appears when the mic is genuinely live (up to 1 to 2 seconds later) rather than immediately.

@rohithgoud30 rohithgoud30 force-pushed the fix/airpods-bluetooth-capture-format branch from ecfe0d6 to e309b8f Compare July 12, 2026 07:31

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

ℹ️ About Codex in GitHub

Your team has set up Codex to 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 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread Sources/Fluid/Services/ASRService.swift Outdated
@greptile-apps

greptile-apps Bot commented Jul 12, 2026

Copy link
Copy Markdown

Greptile Summary

This PR fixes three distinct capture-path defects that degraded dictation accuracy when AirPods or other Bluetooth headsets were the input device. The changes are tightly scoped and correctly implement the described fixes with no unrelated UI or behavioral changes.

  • Format revalidation: The C backend now re-reads and validates the stream format on every start() call, rebinds format-change listeners when the stream ID changes (queueing a drain between remove and install), and has the IOProc suppress misdecoded packets with an acquire-load of the formatChanged flag — all consistent with the PR description.
  • Deferred onCaptureStarted: The recording-overlay callback is now triggered by the first audio packet arriving in the pipeline (with a 2.5 s timeout fallback), so the UI no longer invites dictation into a dead Bluetooth mic. Pre-start work (captureRecordingContext, applyDictationPromptConfiguration, prewarmPrivateAIDictationIfNeeded) is correctly moved before asr.start() and is idempotent on failure.
  • StreamingResampler: Replaces the hand-rolled linear interpolation with an AVAudioConverter-backed implementation at AVAudioQuality.max. flush() drains the FIR tail on recording stop, and the resampler is properly reset on route recovery. Tests cover aliasing suppression, in-band power preservation, chunked continuity, and flush behavior.

Confidence Score: 5/5

Safe to merge — all three capture-path defects described in the PR are correctly implemented, previous review findings are addressed, and built-in microphone behavior is unchanged.

The format-change listener lifecycle, acquire/release memory ordering, gate mechanism, and AVAudioConverter resampler are all implemented correctly. No regressions introduced to the non-Bluetooth path. Tests cover the new resampler's critical properties.

No files require special attention.

Reviews (6): Last reviewed commit: "Apply dictation prompt configuration bef..." | Re-trigger Greptile

Comment thread Sources/CoreAudioCaptureSupport/CoreAudioCaptureSupport.c Outdated
Comment thread Sources/CoreAudioCaptureSupport/CoreAudioCaptureSupport.c Outdated
Comment thread Sources/Fluid/Services/StreamingResampler.swift
@rohithgoud30

Copy link
Copy Markdown
Author

Addressed in a8fdd52: the gate is now installed as the first statement in the do block, before startPreferredAudioCapture(), so the first-audio event can never be reported before the gate exists. The start-failure catch path already cancels the pending gate, and the 2.5 s timeout task cannot fire mid-start since the start sequence is synchronous on the main actor.

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

ℹ️ About Codex in GitHub

Your team has set up Codex to 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 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread Sources/Fluid/Services/StreamingResampler.swift

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

ℹ️ About Codex in GitHub

Your team has set up Codex to 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 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread Sources/CoreAudioCaptureSupport/CoreAudioCaptureSupport.c Outdated
@altic-dev

Copy link
Copy Markdown
Owner

Thanks for the PR! Seems like a good one but since this could overall break the build if I ship it without testing it for a while, how do you suggest we go with this? since it touches the bottleneck part of the code, little worried haha. I do not use headphones much, so I am not a reliable candidate to test this unfortunately.

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

ℹ️ About Codex in GitHub

Your team has set up Codex to 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 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread Sources/Fluid/Services/ASRService.swift
@rohithgoud30

rohithgoud30 commented Jul 12, 2026

Copy link
Copy Markdown
Author

@altic-dev Totally fair! I suggest merging it into a beta or pre-release first and asking the folks from #452 to try it. I use AirPods Pro and Max daily, so I can test it heavily during the first week.

@altic-dev

Copy link
Copy Markdown
Owner

down for that! if you want to tag em and see if you can bring the bros together on this, that would give me more confidence to send this in :) Thanks for understanding!!

@rohithgoud30

Copy link
Copy Markdown
Author

Hi @NACC96 and @kyrichen55, you both reported AirPods issues in #452. This PR changes how FluidVoice handles Bluetooth microphone format changes. Would either of you be willing to test a beta build once it is available?

The direct capture backend cached the input stream's virtual format once
at creation. AirPods switch A2DP->HFP when input IO starts, which can
change the stream format after creation, so packets were decoded and
labeled with a stale sample rate and byte layout.

Capture start now re-reads and validates the format (and rebinds the
stream listener if the stream object changed) before AudioDeviceStart.
Block-based listeners on a private serial queue watch the stream's
virtual format and the device's nominal sample rate; on a change the
IOProc stops publishing misdecoded packets and the consumer surfaces an
onFormatChange callback so the owner can rebuild capture. Teardown
drains the listener queue before freeing to avoid a use-after-free.
The capture pipeline downsampled to 16 kHz with linear interpolation and
no anti-alias filter, folding content above 8 kHz into the speech band.
StreamingResampler wraps AVAudioConverter (max SRC quality, no priming)
behind a stateful streaming API with reused buffers, a flush() that
drains the converter's FIR tail at end of recording, and a self-reset
when the source rate changes across passthrough interludes.

Tests cover 16 kHz passthrough, 48->16 kHz length, alias suppression
(10 kHz at 24 kHz source must not fold to 6 kHz), in-band power
preservation, chunked-vs-one-shot continuity, and flush behavior.
The capture pipeline now converts to 16 kHz through StreamingResampler,
flushes the converter tail when recording stops, and appends to the
audio buffer under the pipeline lock so a flushed tail cannot land
before in-flight packet samples during route recovery.

A direct-capture format change (AirPods A2DP->HFP) schedules the
existing audio route recovery, which rebuilds capture with a fresh
format; a prepared instance whose format changed is never reused.

onCaptureStarted now fires on the first accepted audio packet (2.5 s
timeout fallback) instead of right after AudioDeviceStart, so the UI
stops inviting the user to speak into a Bluetooth mic that is not yet
delivering audio.
Arming the gate first guarantees the pipeline's first-audio event can
never be reported before the gate exists, regardless of how the start
sequence evolves. The start-failure catch already cancels the gate.
Use an acquire load for formatChanged in the IOProc, drain the listener
queue in start() before clearing the flag so a stale queued invocation
cannot re-trigger recovery, reset the output buffer frameLength in
process() to match flush(), and reset a stale converter when the stream
returns to native 16 kHz so flush() cannot drain a previous rate's tail.
A notification landing between the format read and the flag clear was
drained and then wiped, starting capture with a stale format and no
recovery trigger. The drain and clear now happen first, so any change
after the clear leaves the flag set, and the stream-rebind path
re-reads the format after the new stream's listener is armed.
captureRecordingContext, private AI prewarm, and the dictation prompt
configuration now run before capture starts, so a quick stop that
cancels the capture-started gate can no longer skip target-app context
or process the transcript with a stale provider configuration. The
gated callback now only shows the recording overlay.
@rohithgoud30 rohithgoud30 force-pushed the fix/airpods-bluetooth-capture-format branch from e0c88cd to c01bcbb Compare July 12, 2026 22:31

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

ℹ️ About Codex in GitHub

Your team has set up Codex to 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 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread Sources/Fluid/ContentView.swift Outdated
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] AirPods/Bluetooth dictation degraded: capture uses stale stream format and reports ready before the Bluetooth mic is live

2 participants