Repository navigation
fix(dictation): force the built-in Mac microphone - #67
Merged
Merged
Conversation
`getUserMedia({ audio: true })` returns whatever the OS thinks the
default input is. On macOS, pairing AirPods (or any other Bluetooth
headset) makes them the default, and Chromium inside Electron opens
them in HFP/SCO mode. On a non-trivial number of setups that
profile produces a stream that delivers zero audio samples — the
recorder still emits a valid WebM/Opus container, the AnalyserNode
in audioLevels.ts reads all zeros, the level meter stays flat, the
recorded blob ships to Deepgram, and Deepgram transcribes silence
as the empty string.
That's the failure mode the user has been hitting with AirPods
connected. None of the recent fixes (`preserve recorder chunk
order`, `use batch upload fallback`, `emit cumulative interim
text`, `capture EndOfTurn instead of falling back to interim`)
address it because all four assume audio data exists. They were
treating downstream symptoms of an upstream silent stream.
Fix: enumerate audio inputs at start() time, look for a
`MediaDeviceInfo.label` matching the built-in Mac mic patterns
("MacBook Pro Microphone", "Built-in Microphone", "iMac
Microphone", "Mac mini Microphone", "Mac Studio Microphone"), and
pass `{ deviceId: { exact: id } }` to getUserMedia. AirPods and
other Bluetooth/USB inputs are bypassed at the source.
Fall-throughs that preserve the previous behaviour:
* non-Mac platforms (no built-in label match) → `{ audio: true }`,
same as before
* Mac with no built-in mic (Mac Studio / headless server with
only an external mic) → `{ audio: true }`, same as before
* `enumerateDevices()` throws (locked-down sandbox / older
WebView) → caught, `{ audio: true }`, same as before
* permission not yet granted → labels come back empty, no match,
falls through to `{ audio: true }` which prompts permission;
next call hits the rule. The pre-warm at line 53 already
primes permission before the first real dictation, so this
only matters if pre-warm itself was denied.
Out of scope (deliberately):
* device-picker UI for users who actually want their USB studio
mic
* runtime "detect silence and switch device" heuristics
* extending the rule to non-Apple "built-in" patterns
Per user direction, the rule is "always record from the MacBook
microphone." Future work is the picker UI, not undoing this rule.
Debug log at the start() call site now also logs `forcedDeviceId`
so a later trace can confirm the constraint actually landed.
`pickAudioConstraints` lives next to `prewarmMicrophone` because
both deal with the same `getUserMedia` quirks.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This was referenced Sep 25, 2026
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.
Summary
getUserMedia({ audio: true })was always taking the OS default input. With AirPods (or any Bluetooth headset) paired, Chromium opens them in HFP/SCO and on a non-trivial number of setups the resulting stream produces zero audio samples — recorder valid, level meter flat, transcript empty. None of the four recent dictation fixes (preserve recorder chunk order,use batch upload fallback,emit cumulative interim text,capture EndOfTurn) helped because all of them assume audio data exists in the first place; they were treating downstream symptoms of a silent upstream stream.MediaDeviceInfo.labelmatch. AirPods and other Bluetooth/USB mics are bypassed at the source. Non-Mac platforms, Mac Studios with no built-in mic, sandboxed WebViews whereenumerateDevices()throws, and pre-permission states all fall through to{ audio: true }— same behaviour as before, no regression.Why two rules aren't needed
The label match is anchored on
microphoneAND one ofbuilt-in/built in/macbook/imac/mac mini/mac studio. That covers every Apple-built-in audio input I've seen labels for on macOS 14/15. AirPods labels areAirPods Pro,AirPods Max, etc. — none containmicrophoneso the match is structurally clean against them. Same for USB mics (Yeti,Shure MV7, etc.).Repro
Confirmed in the code path before shipping the fix; not a fixture-driven repro because it requires real Bluetooth audio hardware in HFP/SCO mode, which can't be simulated in CI:
npm run dev, open renderer DevTools.Fn).start:get-user-media:donelog showstracks[0].label === "AirPods …", the sine-wave indicator stays flat while you speak, transcription comes back empty.forcedDeviceIdset andtracks[0].labelending inMicrophone, indicator moves with your voice, transcription works.Test plan
npm run buildpasses{ audio: true }on Linux/WindowsOut of scope
packages/agent-voice-dictation/src/recorder/browserRecorder.ts) — itsmediaStreamConstraintsoption already lets the consumer pass any constraints they want; the cc-shell consumer just needed to actually use it. No submodule change.🤖 Generated with Claude Code