Skip to content

Microphone device selector (#137) - #415

Merged
Nabzx merged 6 commits into
mainfrom
microphone-selector-137
Sep 21, 2026
Merged

Nabzx merged 6 commits into
mainfrom
microphone-selector-137

Conversation

@Nabzx

@Nabzx Nabzx commented Sep 18, 2026

Copy link
Copy Markdown
Owner

Closes #137. Audio capture always used the OS default input device; this adds a Settings picker for a specific one.

What's built

  • settingsStore.js: microphoneDeviceId - null (default, unchanged behaviour) or a device id from the renderer's enumerateDevices().
  • capture.js: the mic and audio graph are resident for the app's lifetime (per the file's own header comment), so picking a different device means tearing the graph down and rebuilding it, not just changing a parameter. A switch requested mid-recording is queued and applied right after the recording finishes, rather than corrupting the chunks already collected. If the selected device is gone (unplugged, a Bluetooth headset turned off), it falls back to the system default and logs it - a broken selection would otherwise silently break every future dictation until the user reopens Settings.
  • Wiring: main pushes the configured device to the capture window once at startup and again on every settings change (pushMicrophoneDevice, same pattern as updateTrayMenu).
  • A Microphone picker in Settings: same select-based pattern as the language and overlay-position sections. Requests just enough mic permission to unlock device labels, then lets that stream go immediately - the capture window's own resident stream is untouched.

Judgement calls to veto

  1. Startup now waits for main's push instead of self-starting. Before, capture.js called prepareCapture() unconditionally at load. I changed it to wait for main's first set-device message instead - a race between an unconditional self-start and main's push arriving mid-flight could let an early device switch silently no-op against an already-in-flight, wrong-device capturePromise. This does mean capture now has a hard dependency on that first IPC message arriving; if it somehow didn't, capture would never start. I judged the deterministic single trigger safer than the alternative race, but flagging it since it changes a startup invariant.
  2. Fallback to system default on a failed specific-device open, rather than surfacing an error and stopping. Matches this app's general recall-over-block instinct (see Recording history / replay last transcription #136's reasoning) more than a strict fail-loud approach - a wrong-but-recoverable mic selection is worse UX left broken than auto-recovered-and-logged.

What I could not verify

No real second microphone in this session's sandbox, and no way to run the live capture pipeline here (same class of gap as #253's Swift piece). Everything is compile/typecheck/unit-verified; the actual device-switching behaviour - a real device list, a live mid-recording switch, a real unplug-and-fallback - is unexercised. docs/testing/microphone-selector-137.md has the manual checklist. Please run it (ideally with a second input device on hand) before trusting this - I'd rather you catch a live-audio-graph bug than merge on faith.

Tests

settingsStore.test.js (+1 for microphoneDeviceId). Full suite (369 electron + existing vitest) + typecheck + build green. capture.js has no unit tests, same as before this PR - it's browser-API-driven (getUserMedia, AudioContext) and was never covered.

🤖 Generated with Claude Code

null (default) means the OS default input, unchanged behaviour. Any
other value is an opaque device id from the renderer's
enumerateDevices() - nothing to validate it against outside a renderer
with mic permission, so the store only checks it's null or a non-empty
string.

Claude-Session: https://claude.ai/code/session_0144sxo5CJiyk99vaR7sgjuj
The mic and audio graph are resident for the app's lifetime (see the
file's own header comment), so picking a different device mid-session
means tearing the graph down and rebuilding it against the new one -
there's no "just change a parameter" path in the Web Audio API for this.

A device switch is queued rather than applied immediately when a
recording is in progress - rebuilding the graph mid-flight would corrupt
the chunks already collected. It applies right after the recording
finishes or is cancelled.

Startup now waits for main to push the configured device once, rather
than opening the system default unconditionally at load - the two
triggers racing (an unconditional self-start plus main's push arriving
mid-flight) would have let a device switch requested during the first
second of app launch silently no-op against an in-flight, already
-wrong-device capturePromise.

If the selected device is gone (unplugged, a Bluetooth headset turned
off) the open falls back to the system default rather than leaving
every dictation broken until the user notices and reopens Settings -
logged, not silent.

Claude-Session: https://claude.ai/code/session_0144sxo5CJiyk99vaR7sgjuj
pushMicrophoneDevice sends the configured device to the capture window
right after it loads (the deterministic start signal capture.js now
waits for - see the previous commit) and again on every settingsStore
change, same subscribe-on-create pattern as updateTrayMenu. The
capture window just no-ops when the pushed id matches what it's
already using.

IPC + bridge: settings:set-microphone-device, validated by the store.

Claude-Session: https://claude.ai/code/session_0144sxo5CJiyk99vaR7sgjuj
Same select-based pattern as the dictation-language and overlay
-position sections. Devices come from enumerateDevices() - labels need
an active or previously-granted mic permission on this window first,
so it opens a throwaway getUserMedia stream just long enough to unlock
them, then stops it immediately; the capture window's own resident
stream is untouched.

A saved device that's no longer in the list (unplugged since it was
picked) shows as "Not connected" rather than a blank selection -
capture.js's own fallback to the system default runs regardless of
what this dropdown shows.

Claude-Session: https://claude.ai/code/session_0144sxo5CJiyk99vaR7sgjuj
# Conflicts:
#	electron/main.js
#	electron/preload.js
#	electron/settingsStore.js
#	electron/settingsStore.test.js
#	src/openstreamBridge.d.ts
@Nabzx
Nabzx merged commit 74531f8 into main Sep 21, 2026
1 check passed
@Nabzx
Nabzx deleted the microphone-selector-137 branch September 21, 2026 09:32
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.

Microphone device selector

1 participant