fix(audio): stabilize microphone capture - #856
Conversation
Greptile SummaryThe PR stabilizes direct Core Audio microphone capture by adding bounded same-device Bluetooth retries, deferred route reconciliation, and conservative recovery from sustained digital-zero PCM.
|
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 405e0332f8
ℹ️ 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".
| self.benchmarkLog("bluetooth_start_route_change_deferred event=\(reason)") | ||
| return |
There was a problem hiding this comment.
Preserve reconciliation requests during Bluetooth startup
When a settings restore or input-topology change occurs while a Bluetooth microphone is starting, this unconditional return discards the request, including reconcilesInputSelection and requiresIdlePrewarm. If the existing Bluetooth attempt subsequently delivers first PCM, startup completes without another reconciliation, so the recording can continue on the stale device despite restored priorities or a newly resolved higher-priority input. Defer the hardware rebuild if necessary, but retain and process reconciliation requests after startup settles.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in 77f3573. Ordinary Bluetooth profile churn remains suppressed, but explicit prewarm/reconciliation work is now coalesced and replayed after capture startup settles. Added regression coverage for preservation, merged flags, one-shot consumption, and the ordinary-churn non-effect.
There was a problem hiding this comment.
Addressed in 77f3573 and refined in d3f9461 to preserve the original AirPods behavior. Explicit priority/topology work is retained during Bluetooth startup, then evaluated after startup settles using an off-main device snapshot. If the resolved UID and Core Audio device identity still match the active input, it is a logged no-op with no pipeline stop or rebuild; only a genuinely changed selection/identity enters the existing recovery path. Regression coverage now asserts the unchanged-active non-effect, changed UID/identity recovery, and idle-prewarm intent.
FluidVoice PR build readyDownload FluidVoice-PR-856-9d2795666d6e The artifact contains the ad-hoc-signed app ZIP, Xcode archive, build manifest, and installation instructions. It expires 5 days after the build. Install the app
This build has its own app identity, so its permissions are separate from the release version of FluidVoice. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 77f357301f
ℹ️ 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".
| if let deferredRecovery = self.deferredBluetoothStartupRouteRecovery.take() { | ||
| self.scheduleAudioRouteRecovery( |
There was a problem hiding this comment.
Avoid replaying handled Bluetooth invalidations after startup
When a Bluetooth format invalidation occurs during startup, handleDirectCaptureFormatInvalidation both wakes the bounded retry loop and preserves a request via requiresIdlePrewarm; if that retry then delivers first PCM, this unconditional replay schedules an active-route recovery because isRunning is now true. scheduleAudioRouteRecovery immediately disables the capture pipeline, so a successfully stabilized microphone is stopped again just after onCaptureStarted, losing opening audio and handing the device to the older, non-Bluetooth-aware recovery loop. Replay explicit reconciliation work, but discard a hardware invalidation that the successful startup retry already handled.
Useful? React with 👍 / 👎.
| self.audioStartAttemptInputUID = device.uid | ||
| self.audioStartAttemptInputName = device.name | ||
| self.audioStartAttemptIsBluetooth = device.isBluetooth | ||
| self.audioStartAttemptIsBuiltIn = device.isBuiltIn |
There was a problem hiding this comment.
Restrict the watchdog to the internal microphone
When an analog headset is connected, Core Audio reports it with kAudioDeviceTransportTypeBuiltIn, so AudioDevice.isBuiltIn is also true for that external microphone; AudioDeviceService.swift:58-61 already distinguishes this case using the input data source. Assigning that broader property here makes the new silent-PCM watchdog rebuild analog-headset capture after three zero windows—for example, after using a headset mute—even though the recovery is intended only for the internal microphone.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in 9d27956. The watchdog now uses the existing internal-microphone classification, which excludes the built-in transport when Core Audio reports the external microphone data source. Added coverage proving an analog headset remains external and cannot enter this recovery path.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: d3f94613cc
ℹ️ 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".
| "bluetooth_start_route_change_deferred event=\(reason) " + | ||
| "reconciliationPreserved=\(preserved)" | ||
| ) | ||
| return |
There was a problem hiding this comment.
Preserve post-readiness Bluetooth invalidations
When a format invalidation arrives after signalFirstPCM has fixed readiness to .ready but before start() sets isRunning, this branch still classifies it as Bluetooth startup churn. handleDirectCaptureFormatInvalidation has already disabled audioCapturePipeline, but the ordinary request is not preserved and this return does not advance the route generation, so routeStayedStable remains true and startup reports success with sampling disabled. Fresh evidence beyond the earlier handled-retry case is that AudioCaptureReadinessGate.finish ignores later invalidations once its result is .ready, leaving this post-readiness window unhandled; retain/replay this invalidation or make the readiness check observe it.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in 9d27956 without replaying recovery after a healthy start. A direct-format invalidation during Bluetooth startup now advances the route generation, so the readiness result is treated as unstable even if first PCM won the race. The owning bounded startup loop retries the same Bluetooth input; ordinary profile churn still does not rebuild or delay healthy capture. Policy coverage includes retry, preserved reconciliation, and no-op cases.
| self.inputUID = inputUID | ||
| self.startedAt = now | ||
| } | ||
| return self.elapsed(at: now) < Self.maximumDuration |
There was a problem hiding this comment.
Enforce the full five-second Bluetooth retry budget
When a failure occurs shortly before the five-second deadline, this check permits another retry without limiting that attempt: startup can then spend 300 ms waiting for topology quiet and the full two-second first-PCM timeout. A retry approved at 4.9 seconds can therefore block until roughly 7.2 seconds after the first failure, and the initial attempt is excluded because startedAt is only set here. This defeats the advertised bounded five-second stabilization window and delays fallback or error feedback; cap subsequent waits by the remaining budget or avoid starting an attempt that cannot fit.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Clarified in 9d27956; intentionally not changed to a hard wall-clock cutoff because that would reduce the AirPods stabilization window this PR is fixing. Five seconds is the retry-admission window: no new same-device retry begins after it, but an already admitted attempt keeps its normal readiness timeout rather than being cancelled while the route may be settling. Renamed the policy and log field and documented this explicitly.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 9d2795666d
ℹ️ 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".
| ) | ||
| self.audioStartAttemptInputUID = device.uid | ||
| self.audioStartAttemptInputName = device.name | ||
| self.audioStartAttemptIsBluetooth = device.isBluetooth |
There was a problem hiding this comment.
Retain Bluetooth identity before resolving the UID
When a selected Bluetooth endpoint disappears between directCoreAudioDeviceSelection and resolveDevice during startup topology churn, resolution throws before this assignment records that the attempt was Bluetooth. Consequently, BluetoothInputStabilization.shouldRetry receives isBluetoothInput: false; the first failure only gets the generic retry, and a second failure after the 300 ms topology wait excludes the AirPods UID and falls back instead of honoring the five-second stabilization window. Preserve the selected device's Bluetooth classification before re-resolving its unstable UID so these resolution failures enter the bounded Bluetooth retry path.
Useful? React with 👍 / 👎.
Description
Stabilizes Direct Core Audio microphone capture in two failure modes:
Healthy capture keeps the existing hotkey-to-first-PCM path. This does not reorder microphone priorities or add a recording delay.
Type of Change
Related Issue or Discussion
Addresses #852. Bluetooth stabilization follows customer AirPods route-switching reports.
Testing
swiftlint --strict --config .swiftlint.yml Sourcesswiftformat --config .swiftformat SourcesRelease validation completed with the private FI Release build: universal app compilation, install and launch, strict FI provenance, code signing, app/DMG notarization and stapling, Gatekeeper assessment, ZIP/DMG validation, and the private FI release gate.
Screenshots / Video
Notes
The silent-PCM recovery is intentionally conservative: built-in Direct Core Audio only, after real signal was observed, after three near-zero one-second windows, and at most once per recording. The artifact is universal; real Intel runtime remains unvalidated without an Intel/Rosetta dictation smoke test.