Fix microphone reliability and device selection - #233
Merged
jatinkrmalik merged 6 commits intoAug 23, 2026
Conversation
jatinkrmalik
previously requested changes
Aug 23, 2026
jatinkrmalik
left a comment
Member
There was a problem hiding this comment.
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.
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
force-pushed
the
fix/microphone-reliability
branch
from
August 23, 2026 10:55
3c09a3d to
3f6c3e0
Compare
✅ Deploy Preview for voca-mac canceled.
|
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
approved these changes
Aug 23, 2026
6 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.
processAudioBufferruns on Core Audio's render thread and started withguard isCurrentlyRecording, which reads throughlifecycleQueue.sync.stopRecording()holdslifecycleQueuewhile callingengine.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 toucheslifecycleQueue.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 beforeengine.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),
configureInputRoutereturnedniland 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, andstartRecordingholds the lifecycle queue for the whole negotiation. Release the hotkey during it andstopRecordingqueued 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 ofstartRecordingcheck, 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.AppStatedefers 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
connectingphase (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
.idlewith no explanation — press the hotkey, nothing happens. And the pickers were only rebuilt ononAppearplus 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) watcheskAudioHardwarePropertyDevicesandkAudioHardwarePropertyDefaultInputDevice, 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.testAudioBufferNotEmptyAfterRecordingrecorded 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.inputRouteCandidatesordering/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..errorwith 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 letsAVAudioEnginefollow the default route naturally and only mutatesCurrentDevicefor a real non-default override.Microphone permission now uses the macOS 14+
AVAudioApplicationAPI. 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
preparingcapture phase: buffers are retained for the first word, but silence and max-duration callbacks cannot fire until the route is confirmed liveAdditional verification
swift test --scratch-path /Users/kanishkpachauri/Projects/vocamac/.build— 415 tests passed, 3 skipped, 0 failuresmake build— release app built and ad-hoc signed successfullygit diff --check upstream/main...HEAD