Skip to content

fix(dictation): force the built-in Mac microphone - #67

Merged
Juliusolsson05 merged 1 commit into
mainfrom
fix/dictation-builtin-mic
May 8, 2026
Merged

Juliusolsson05 merged 1 commit into
mainfrom
fix/dictation-builtin-mic

Conversation

@Juliusolsson05

Copy link
Copy Markdown
Owner

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.
  • This PR enumerates input devices and forces the built-in Mac microphone by MediaDeviceInfo.label match. AirPods and other Bluetooth/USB mics are bypassed at the source. Non-Mac platforms, Mac Studios with no built-in mic, sandboxed WebViews where enumerateDevices() throws, and pre-permission states all fall through to { audio: true } — same behaviour as before, no regression.
  • Per user direction the rule is "always record from the MacBook microphone." A future device-picker UI for users who actually want their USB studio mic is out of scope here.

Why two rules aren't needed

The label match is anchored on microphone AND one of built-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 are AirPods Pro, AirPods Max, etc. — none contain microphone so 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:

  1. Pair AirPods, leave them connected.
  2. npm run dev, open renderer DevTools.
  3. Hold the dictation hotkey (Fn).
  4. Pre-fix: start:get-user-media:done log shows tracks[0].label === "AirPods …", the sine-wave indicator stays flat while you speak, transcription comes back empty.
  5. Post-fix: same log shows forcedDeviceId set and tracks[0].label ending in Microphone, indicator moves with your voice, transcription works.

Test plan

  • npm run build passes
  • Reviewer: with AirPods connected, dictation produces a non-empty transcript and the level meter moves
  • Reviewer: with AirPods disconnected, dictation still works (no regression for the normal path)
  • Reviewer (optional, non-Mac): verify the constraint falls through to { audio: true } on Linux/Windows

Out of scope

  • Device-picker UI in cc-shell settings
  • Runtime "detect silent stream and switch device" heuristics
  • Extending the built-in match to non-Apple platforms (we have no equivalent label convention to anchor on)
  • The package-level recorder (packages/agent-voice-dictation/src/recorder/browserRecorder.ts) — its mediaStreamConstraints option 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

`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>
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.

1 participant