Skip to content

Fix microphone reliability and device selection - #233

Merged
jatinkrmalik merged 6 commits into
VocaHQ:mainfrom
Mr-Sunglasses:fix/microphone-reliability
Aug 23, 2026
Merged

Fix microphone reliability and device selection#233
jatinkrmalik merged 6 commits into
VocaHQ:mainfrom
Mr-Sunglasses:fix/microphone-reliability

Conversation

@Mr-Sunglasses

@Mr-Sunglasses Mr-Sunglasses commented Aug 21, 2026

Copy link
Copy Markdown
Member

Why

Several users (and I) hit the same cluster of symptoms: push-to-talk does nothing, recording gets stuck, the first word is missing, "No microphone audio detected" on Bluetooth, or the transcript clearly came from the built-in mic instead of the selected one. Digging in, these are five separate causes.

What was wrong

1. Deadlock between the input tap and stopping the engine. processAudioBuffer runs on Core Audio's render thread and started with guard isCurrentlyRecording, which reads through lifecycleQueue.sync. stopRecording() holds lifecycleQueue while calling engine.stop(), which waits for the render thread to finish. Render thread waits on the queue; queue waits on the render thread. This is the "mic just stops working / recording is stuck" report, and it gets likelier the more you record.

The tap now reads a realtime-safe mirror flag (OSAllocatedUnfairLock<Bool>) that never touches lifecycleQueue.

2. Clipped first words. The tap was installed before _isCurrentlyRecording = true, so every buffer arriving in between was discarded — on short push-to-talk bursts, that's the start of the sentence. Capture now arms right before engine.start(); failure paths still clear the buffer.

3. One route failure killed the whole recording. If Core Audio wouldn't apply the pinned device (Bluetooth mid A2DP→HFP switch, a device another app holds exclusively, a USB mic that just re-enumerated), configureInputRoute returned nil and the recording was abandoned. It now tries the pinned mic, then falls back to the system default (inputRouteCandidates), and surfaces which device it actually used. Related to the tail of #196.

4. Push-to-talk blocked on the Bluetooth settle. A headset takes up to bluetoothInputRouteConfigurationTimeout (3 s) to switch to its mic, and startRecording holds the lifecycle queue for the whole negotiation. Release the hotkey during it and stopRecording queued behind the settle, waited it out, and handed back an empty buffer — "No microphone audio detected", every time, for anyone who speaks in short bursts on AirPods.

cancelPendingStart() sets a lock-free flag that the settle loops and each stage of startRecording check, so an abandoned start unwinds within about one poll interval (~50 ms) instead of running to the timeout. The engine is deliberately kept warm on that path — the negotiated route is the expensive part, and someone who released too early is about to press again, so the retry is instant.

AppState defers a stop that arrives mid-start instead of blocking on it, then finishes once the start returns: transcribe if the engine reached capture, otherwise tell the user the microphone was still connecting and to hold until the start sound. Overlay cancel takes the same path and ends silently.

5. The overlay claimed to be listening before it was. It was shown in its recording phase the moment the hotkey was pressed, while the engine was still negotiating the route — so on a headset you got up to three seconds of an indicator saying "listening" while nothing was captured, and your first words vanished with no explanation.

The overlay now opens in a new connecting phase (muted accent, spinner, "Waiting for microphone…") and only flips to recording when the engine confirms the route is live. A start that fails or is abandoned never reaches the recording phase.

6. Silent failures and stale device lists. A failed start dropped back to .idle with no explanation — press the hotkey, nothing happens. And the pickers were only rebuilt on onAppear plus a manual Refresh button, so connecting or dropping a headset while the menu or Settings was open left you choosing from a list that no longer matched reality.

AudioDeviceMonitor (new) watches kAudioHardwarePropertyDevices and kAudioHardwarePropertyDefaultInputDevice, coalesced at 300 ms since Bluetooth profile switches fire several changes in a row. Both pickers refresh live, and a fallback shows as "X is unavailable — recording from Y."

Testing

  • swift test — 410 tests, 0 failures (3 skipped), confirmed across sixteen consecutive runs.
  • The intermittent failure I flagged earlier is identified and fixed: testAudioBufferNotEmptyAfterRecording recorded for 300 ms and asserted the buffer was non-empty. A cold Core Audio start does not guarantee a buffer that fast once other tests in the process are cycling the same device — it failed about one full-suite run in twelve while passing 10/10 in isolation. The window is now 1 s, which is still a tight assertion but no longer races the hardware.
  • New coverage: inputRouteCandidates ordering/dedupe; testStartStopCyclesDoNotDeadlock (five real start/stop cycles against the machine's microphone, skipped on CI); stop-and-cancel during a slow start, asserting the stop returns in under 200 ms and nothing is sent to transcription; that a stale cancel cannot poison the next start; and that the overlay never enters its recording phase unless the engine actually went live.
  • Manually exercised on hardware — dictation is working reliably again.
  • Two existing tests changed, both encoding behavior this PR deliberately replaces: a failed start now reports .error with a message rather than a silent .idle, and a stop during a slow start now abandons the start instead of waiting it out.

Deliberately not done

Keeping the negotiated Bluetooth route warm for longer would let back-to-back dictations skip the settle entirely, but it holds the headset in HFP and drops music to headset quality for that window. Not worth the trade.

M4 Pro / M4 Air follow-up

The reported M4 Pro and M4 Air failure exposed two more startup gaps. When the selected input was already the system default, VocaMac still wrote Core Audio's kAudioOutputUnitProperty_CurrentDevice; on the affected path that redundant mutation can return OSStatus 'nope' or leave AUHAL delivering zero-filled input. VocaMac now lets AVAudioEngine follow the default route naturally and only mutates CurrentDevice for a real non-default override.

Microphone permission now uses the macOS 14+ AVAudioApplication API. At dictation start, VocaMac clears any stale per-application input mute that would deliberately zero microphone samples. The required macOS mute callback is also implemented correctly: a later headset/application mute keeps timing intact while replacing captured frames with zeros.

Startup still requires a real first input buffer before the UI reports the mic as live and cold-retries a graph whose route applied without opening an input stream.

Review follow-up

  • pre-live input now uses an explicit preparing capture phase: buffers are retained for the first word, but silence and max-duration callbacks cannot fire until the route is confirmed live
  • a restarted graph that delivers no input now disables capture, removes the tap, and stops the engine before retirement; a successful restart restores the live recording phase

Additional verification

  • swift test --scratch-path /Users/kanishkpachauri/Projects/vocamac/.build — 415 tests passed, 3 skipped, 0 failures
  • make build — release app built and ad-hoc signed successfully
  • git diff --check upstream/main...HEAD
  • Physical verification on the specifically affected M4 Pro and M4 Air is still required; automated tests cannot reproduce their microphone hardware or macOS privacy state.

@github-actions github-actions Bot added app bug Something isn't working ci and removed ci labels Aug 21, 2026

@jatinkrmalik jatinkrmalik left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

The deadlock fix and the Bluetooth cancel look right. Two issues on the new capture-before-ready path I'd like fixed before merge: silence detection can fire on the first Bluetooth buffer (double-tap then auto-stops), and a failed waitForFirstInputBuffer() in restartRecordingGraph leaves a running engine on the input route.

Comment thread Sources/VocaMac/Services/AudioEngine.swift
Comment thread Sources/VocaMac/Services/AudioEngine.swift
Recording would often do nothing, get stuck, or come from the wrong
input. Four separate causes:

Deadlock between the input tap and stopping the engine. The tap runs on
Core Audio's render thread and began with `guard isCurrentlyRecording`,
which reads through `lifecycleQueue.sync`. `stopRecording` holds that
queue while `engine.stop()` waits for the render thread to finish, so
the render thread waited on the queue and the queue waited on the render
thread. The tap now reads a realtime-safe mirror flag instead.

Clipped first words. The tap was installed before the recording flag was
set, so every buffer arriving in between was dropped — the start of
short push-to-talk utterances. Capture now arms right before
`engine.start()`; failure paths still clear the buffer.

One route failure aborted the whole recording. When Core Audio would not
apply the pinned device (Bluetooth mid A2DP/HFP switch, a device another
app holds, a USB mic that re-enumerated), `configureInputRoute` returned
nil and nothing was recorded. It now tries the pinned mic, then the
system default, and reports which device it actually used.

Silent failures and stale device lists. A failed start dropped back to
idle with no explanation; the pickers were only rebuilt on appear, so a
headset connected or removed while the menu was open left the user
choosing from a list that no longer matched reality. AudioDeviceMonitor
watches Core Audio for device and default-input changes (coalesced), and
both pickers refresh live.
A Bluetooth headset takes up to three seconds to switch from A2DP to its
microphone, and startRecording holds the audio lifecycle queue for that
whole negotiation. Releasing push-to-talk during it meant stopRecording
queued behind the settle, waited it out, and then returned a buffer that
was empty because capture had not begun yet — surfaced to the user as
"No microphone audio detected".

The engine can now be told to abandon a start that is still negotiating.
cancelPendingStart() sets a lock-free flag that the settle loops and each
stage of startRecording check, so the start unwinds within about one poll
interval instead of running to the timeout. The engine is kept warm on
that path: the route it just negotiated is the expensive part, and
someone who released too early is about to press again.

AppState defers a stop that arrives mid-start rather than blocking on it,
then finishes the job once the start returns: transcribe if the engine
reached capture, otherwise report that the microphone was still
connecting and to hold until the start sound. Overlay cancel takes the
same path and ends silently.

An existing test covered this scenario with the old blocking behavior;
its expectations are updated to the new outcome.
The overlay appeared in its recording phase the moment the hotkey was
pressed, while the audio engine was still negotiating the input route.
On a Bluetooth headset that is up to three seconds of an indicator that
says it is listening while nothing is being captured, so the first words
vanish with no explanation.

The overlay now opens in a new connecting phase — muted accent, a
spinner, "Waiting for microphone…" — and AppState flips it to recording
only once the engine reports the route is live. A start that fails or is
abandoned never reaches the recording phase at all.

Also widens the recording window in testAudioBufferNotEmptyAfterRecording
from 300ms to 1s. A cold Core Audio start does not guarantee a buffer in
300ms once other tests in the process are cycling the same device, which
made the suite fail roughly one run in twelve.
A started AVAudioEngine on a successfully applied route was treated as
proof that capture had begun. It isn't. Apple's USB-C EarPods, in
clamshell with an external display, report a fully applied route and
engine.isRunning == true while Core Audio never opens an input stream —
CoreAudio's own trace shows input_running: false against a running
output. The tap fires zero times, the recording ends with an empty
buffer, and AppState's empty-audio path returns to idle without a word,
so the user sees the hotkey apparently do nothing. The logs show only
"Using input device: EarPods Microphone" and no error anywhere.

startRecording now waits up to 750ms for the tap's first real buffer
before reporting success. If none arrives, recoverFromStartFailure drops
the engine entirely so the retry cold-acquires a fresh AUHAL rather than
reusing the stuck one; that retry is the actual repair. A second failure
returns false, surfacing the existing "Could not start <device>" message
before the user speaks instead of after a lost utterance. The same check
guards restartRecordingGraph, where a mid-recording configuration change
can rebuild into the identical trap.

The flag is written from the render thread, so it uses
OSAllocatedUnfairLock alongside captureActive rather than touching
lifecycleQueue. The wait honours isStartCancelled, so releasing
push-to-talk during it still takes the abandonCancelledStart path.

That path needed fixing too: it previously ran only before
engine.start(), so it never stopped the engine, and releaseEngine only
drops the reference. Reachable after start, it would have leaked a
running engine holding the input route — pinning Bluetooth headsets to
HFP while idle, which is what the lazy-engine design exists to avoid.

Cost on the happy path is one buffer period (~85ms at 48kHz) before the
overlay stops saying "connecting". No audio is lost: setCaptureActive
still runs before the wait, so buffers arriving during it are kept.

Also logs the tap's actual input format and the captured sample count,
neither of which was recorded, and applies vocamac.logLevel at startup —
it was stored but never passed to VocaLogger.setLogLevel, pinning the
level at .info and discarding every input-route debug line.
@Mr-Sunglasses
Mr-Sunglasses force-pushed the fix/microphone-reliability branch from 3c09a3d to 3f6c3e0 Compare August 23, 2026 10:55
@netlify

netlify Bot commented Aug 23, 2026

Copy link
Copy Markdown

Deploy Preview for voca-mac canceled.

Name Link
🔨 Latest commit 91f6828
🔍 Latest deploy log https://app.netlify.com/projects/voca-mac/deploys/6a8ad558306dbe00085eaa43

@Mr-Sunglasses
Mr-Sunglasses dismissed jatinkrmalik’s stale review August 23, 2026 11:24

Requested changes were addressed in 91f6828, both review threads received implementation details, and exact-SHA Debug, tests, Release, and app-bundle CI passed. Dismissed to perform the explicitly authorized admin merge.

@jatinkrmalik
jatinkrmalik merged commit cc1f282 into VocaHQ:main Aug 23, 2026
9 checks passed
@jatinkrmalik jatinkrmalik mentioned this pull request Aug 24, 2026
6 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

app bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants