From 5c9f8bd375b04dbaebfba61e05344e2259e36074 Mon Sep 17 00:00:00 2001 From: Nabzx Date: Fri, 18 Sep 2026 21:27:28 +0400 Subject: [PATCH 1/5] settings: a microphoneDeviceId field for #137 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 --- electron/settingsStore.js | 20 ++++++++++++++++++++ electron/settingsStore.test.js | 11 +++++++++++ 2 files changed, 31 insertions(+) diff --git a/electron/settingsStore.js b/electron/settingsStore.js index 068c23f..f94b248 100644 --- a/electron/settingsStore.js +++ b/electron/settingsStore.js @@ -39,6 +39,9 @@ const DEFAULT_SETTINGS = { // #252: transcription input language. "en" (unchanged behaviour), "auto" // to let the model detect it, or a specific code from languages.js. inputLanguage: "en", + // #137: a specific input device's id (from the renderer's + // enumerateDevices()), or null for the OS default - unchanged behaviour. + microphoneDeviceId: null, }; // #257: a whole number of minutes, 0 (off) to a day. A day is already well @@ -64,6 +67,16 @@ function validateInputLanguage(language) { } } +// #137: a device id is an opaque string from the browser's MediaDeviceInfo, +// not something this process can otherwise validate - there's no device +// list to check it against outside a renderer with mic permission. Accept +// null (system default) or any non-empty string. +function validateMicrophoneDeviceId(deviceId) { + if (deviceId !== null && (typeof deviceId !== "string" || deviceId.trim().length === 0)) { + throw new Error("microphoneDeviceId must be null or a non-empty string"); + } +} + const VALID_MODIFIERS = new Set(["cmd", "shift", "alt", "ctrl"]); function errorMessage(error) { @@ -288,6 +301,11 @@ function createSettingsStore({ filePath }) { return commit({ ...load(), inputLanguage: language }); } + function setMicrophoneDeviceId(deviceId) { + validateMicrophoneDeviceId(deviceId); + return commit({ ...load(), microphoneDeviceId: deviceId }); + } + function setWindowBounds(bounds) { validateWindowBounds(bounds); // Only the four geometry keys are kept - a caller passing a whole @@ -322,6 +340,7 @@ function createSettingsStore({ filePath }) { setSoundCues, setOverlayPosition, setInputLanguage, + setMicrophoneDeviceId, setWindowBounds, onChange, }; @@ -339,4 +358,5 @@ module.exports = { validateIdleUnloadMinutes, MAX_IDLE_UNLOAD_MINUTES, validateInputLanguage, + validateMicrophoneDeviceId, }; diff --git a/electron/settingsStore.test.js b/electron/settingsStore.test.js index bfe6668..b1a0a99 100644 --- a/electron/settingsStore.test.js +++ b/electron/settingsStore.test.js @@ -276,6 +276,17 @@ test("#252: inputLanguage defaults to en, accepts auto and codes, rejects junk", assert.throws(() => store.setInputLanguage(""), /inputLanguage/); }); +test("#137: microphoneDeviceId defaults to null, accepts a device id, rejects junk", () => { + const store = createSettingsStore({ filePath: tempFilePath() }); + assert.equal(store.get().microphoneDeviceId, null); + store.setMicrophoneDeviceId("abc123"); + assert.equal(store.get().microphoneDeviceId, "abc123"); + store.setMicrophoneDeviceId(null); + assert.equal(store.get().microphoneDeviceId, null); + assert.throws(() => store.setMicrophoneDeviceId(""), /microphoneDeviceId/); + assert.throws(() => store.setMicrophoneDeviceId(42), /microphoneDeviceId/); +}); + test("setVocabularyProjectPath persists a trimmed path and get() reflects it afterwards", () => { const filePath = tempFilePath(); const store = createSettingsStore({ filePath }); From 656157d3dd079abfab7de11189aa9c4e728d0043 Mon Sep 17 00:00:00 2001 From: Nabzx Date: Fri, 18 Sep 2026 21:27:47 +0400 Subject: [PATCH 2/5] capture: open the configured input device, switch it live (#137) 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 --- electron/capture/capture.js | 92 ++++++++++++++++++++++++++++-- electron/capture/capturePreload.js | 3 + 2 files changed, 89 insertions(+), 6 deletions(-) diff --git a/electron/capture/capture.js b/electron/capture/capture.js index e1d3105..1d06ebf 100644 --- a/electron/capture/capture.js +++ b/electron/capture/capture.js @@ -7,10 +7,22 @@ const CHANNELS = 1; let audioContext = null; let processorNode = null; +let activeStream = null; let capturePromise = null; let isRecording = false; let chunks = []; +// #137: null means the OS default input, unchanged from before this +// existed. hasStarted guards against main's initial device push racing the +// very first prepareCapture() call - see onSetDevice below. +let currentDeviceId = null; +let hasStarted = false; +// A device change requested mid-recording is queued rather than applied +// immediately - tearing down the audio graph while chunks are still being +// collected would corrupt the in-progress recording. undefined means +// nothing is queued (null is a valid target: the system default). +let pendingDeviceId = undefined; + function writeAscii(view, offset, value) { for (let index = 0; index < value.length; index++) { view.setUint8(offset + index, value.charCodeAt(index)); @@ -56,19 +68,38 @@ function soundLevel(samples) { return Math.sqrt(sumOfSquares / samples.length); } -async function prepareCapture() { +async function openStream(deviceId) { + const constraints = { channelCount: CHANNELS }; + if (deviceId) constraints.deviceId = { exact: deviceId }; + try { + const stream = await navigator.mediaDevices.getUserMedia({ audio: constraints }); + return { stream, deviceId }; + } catch (error) { + if (!deviceId) throw error; + // #137: the selected device may be gone - unplugged, disconnected - so + // fall back to the system default rather than leaving every future + // dictation broken until the user notices and reopens Settings. + console.error(`[capture] selected microphone unavailable (${error}), falling back to the system default`); + const stream = await navigator.mediaDevices.getUserMedia({ audio: { channelCount: CHANNELS } }); + return { stream, deviceId: null }; + } +} + +async function prepareCapture(deviceId) { if (capturePromise) return capturePromise; + hasStarted = true; capturePromise = (async () => { - const stream = await navigator.mediaDevices.getUserMedia({ - audio: { channelCount: CHANNELS }, - }); + const { stream, deviceId: resolvedDeviceId } = await openStream(deviceId); + currentDeviceId = resolvedDeviceId; + activeStream = stream; audioContext = new AudioContext({ sampleRate: SAMPLE_RATE }); if (audioContext.sampleRate !== SAMPLE_RATE) { const actualSampleRate = audioContext.sampleRate; for (const track of stream.getTracks()) track.stop(); await audioContext.close(); audioContext = null; + activeStream = null; throw new Error(`Microphone sample rate is ${actualSampleRate} Hz, expected ${SAMPLE_RATE} Hz`); } const source = audioContext.createMediaStreamSource(stream); @@ -99,6 +130,55 @@ async function prepareCapture() { } } +// #137: tear the graph down so switchDevice can rebuild it against a +// different input. Never called while isRecording - see onSetDevice. +async function teardownCapture() { + if (processorNode) { + processorNode.disconnect(); + processorNode = null; + } + if (activeStream) { + for (const track of activeStream.getTracks()) track.stop(); + activeStream = null; + } + if (audioContext) { + await audioContext.close(); + audioContext = null; + } + capturePromise = null; +} + +async function switchDevice(deviceId) { + await teardownCapture(); + await prepareCapture(deviceId); +} + +function applyPendingDeviceSwitch() { + if (pendingDeviceId === undefined) return; + const next = pendingDeviceId; + pendingDeviceId = undefined; + void switchDevice(next); +} + +// #137: main pushes the configured device once at startup and again on +// every settings change (not only a microphone change - the renderer just +// no-ops when the id is unchanged). +window.capture.onSetDevice((deviceId) => { + const normalized = deviceId || null; + if (!hasStarted) { + // The very first call: prepareCapture() below hasn't run yet, so there + // is no graph to tear down - just start it against this device. + void prepareCapture(normalized); + return; + } + if (normalized === currentDeviceId) return; + if (isRecording) { + pendingDeviceId = normalized; + return; + } + void switchDevice(normalized); +}); + window.capture.onStart(() => { if (!audioContext) { window.capture.sendError("Microphone capture is not ready"); @@ -115,6 +195,7 @@ window.capture.onStop((timing) => { chunks = []; window.capture.sendSoundLevel(0); window.capture.sendRecording(wav, timing); + applyPendingDeviceSwitch(); }); // #134: stop recording and throw the audio away - no WAV, no @@ -123,6 +204,5 @@ window.capture.onCancel(() => { isRecording = false; chunks = []; window.capture.sendSoundLevel(0); + applyPendingDeviceSwitch(); }); - -prepareCapture(); diff --git a/electron/capture/capturePreload.js b/electron/capture/capturePreload.js index 550d1e2..0419017 100644 --- a/electron/capture/capturePreload.js +++ b/electron/capture/capturePreload.js @@ -7,6 +7,9 @@ contextBridge.exposeInMainWorld("capture", { onStart: (callback) => ipcRenderer.on("start-recording", callback), onStop: (callback) => ipcRenderer.on("stop-recording", (_event, timing) => callback(timing)), onCancel: (callback) => ipcRenderer.on("cancel-recording", callback), + // #137: the configured input device id (or null for the system default) - + // pushed once at startup and again on every settings change. + onSetDevice: (callback) => ipcRenderer.on("set-device", (_event, deviceId) => callback(deviceId)), sendReady: () => ipcRenderer.send("capture-ready"), sendRecording: (wavBuffer, timing) => ipcRenderer.send("recording-complete", wavBuffer, timing), sendSoundLevel: (level) => ipcRenderer.send("sound-level", level), From ae6c4e080e77f8ea6fe0912aebf6446cb1f18109 Mon Sep 17 00:00:00 2001 From: Nabzx Date: Fri, 18 Sep 2026 21:28:00 +0400 Subject: [PATCH 3/5] wire the microphone setting into the capture window (#137) 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 --- electron/main.js | 18 ++++++++++++++++++ electron/preload.js | 1 + src/openstreamBridge.d.ts | 3 +++ 3 files changed, 22 insertions(+) diff --git a/electron/main.js b/electron/main.js index c76d2db..001271c 100644 --- a/electron/main.js +++ b/electron/main.js @@ -425,6 +425,15 @@ function createApplicationMenu() { Menu.setApplicationMenu(Menu.buildFromTemplate(template)); } +// #137: capture.js waits for this before it opens the microphone at all - +// see its onSetDevice comment. Pushed once right after load (the renderer's +// deterministic start signal), then again on every settings change so a +// device picked mid-session takes effect without an app restart. +function pushMicrophoneDevice() { + if (!captureWin || captureWin.isDestroyed()) return; + captureWin.webContents.send("set-device", settingsStore ? settingsStore.get().microphoneDeviceId : null); +} + function createCaptureWindow() { captureWin = new BrowserWindow({ show: false, @@ -435,7 +444,9 @@ function createCaptureWindow() { }, }); hardenWindow(captureWin.webContents); + captureWin.webContents.once("did-finish-load", pushMicrophoneDevice); captureWin.loadFile(path.join(__dirname, "capture", "captureWindow.html")); + if (settingsStore) settingsStore.onChange(pushMicrophoneDevice); } // The resting size for the recording/idle waveform - named so @@ -1101,6 +1112,13 @@ ipcMain.handle("settings:set-input-language", (event, language) => { return settingsStore.setInputLanguage(language); }); +ipcMain.handle("settings:set-microphone-device", (event, deviceId) => { + // #137: the store validates; pushMicrophoneDevice (subscribed to + // settingsStore.onChange in createCaptureWindow) picks the change up and + // tells the capture window. + return settingsStore.setMicrophoneDeviceId(deviceId); +}); + // #19: pick an app from disk instead of hunting down its bundle id by // hand. Returns { bundleId, name } for the renderer to add, or null if the // dialog was cancelled; a bundle with no readable identifier rejects. diff --git a/electron/preload.js b/electron/preload.js index 3e72ad7..5022ac5 100644 --- a/electron/preload.js +++ b/electron/preload.js @@ -77,6 +77,7 @@ contextBridge.exposeInMainWorld("openstream", { setSoundCues: (enabled) => ipcRenderer.invoke("settings:set-sound-cues", enabled), setOverlayPosition: (position) => ipcRenderer.invoke("settings:set-overlay-position", position), setInputLanguage: (language) => ipcRenderer.invoke("settings:set-input-language", language), + setMicrophoneDeviceId: (deviceId) => ipcRenderer.invoke("settings:set-microphone-device", deviceId), }, vocabulary: { rescan: () => ipcRenderer.invoke("vocabulary:rescan"), diff --git a/src/openstreamBridge.d.ts b/src/openstreamBridge.d.ts index c478e54..f9bcf92 100644 --- a/src/openstreamBridge.d.ts +++ b/src/openstreamBridge.d.ts @@ -22,6 +22,8 @@ export type StoredSettings = { overlayPosition: OverlayPosition; /** #252: "auto", "en", or another supported language code. */ inputLanguage: string; + /** #137: a MediaDeviceInfo.deviceId, or null for the system default. */ + microphoneDeviceId: string | null; }; export type SetShortcutResult = @@ -114,6 +116,7 @@ declare global { setSoundCues(enabled: boolean): Promise; setOverlayPosition(position: OverlayPosition): Promise; setInputLanguage(language: string): Promise; + setMicrophoneDeviceId(deviceId: string | null): Promise; }; vocabulary: { rescan(): Promise; From 464b01bb45169ef26c968703d3da0591a0364400 Mon Sep 17 00:00:00 2001 From: Nabzx Date: Fri, 18 Sep 2026 21:28:08 +0400 Subject: [PATCH 4/5] settings UI: a Microphone picker (#137) 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 --- src/pages/Settings.tsx | 74 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 74 insertions(+) diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index cb9ec33..068192e 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -162,6 +162,78 @@ function InputLanguageSection() { ); } +type MicrophoneOption = { deviceId: string; label: string }; + +function MicrophoneSection() { + const [selected, setSelected] = useState(null); + const [devices, setDevices] = useState(null); + + useEffect(() => { + window.openstream.settings.get().then((settings) => setSelected(settings.microphoneDeviceId)); + }, []); + + useEffect(() => { + // #137: a device's label is blank until this window has an active or + // previously-granted mic permission - request just enough to unlock + // labels, then let the stream go immediately. The capture window keeps + // its own separate, resident stream; this one only exists for a moment. + navigator.mediaDevices + .getUserMedia({ audio: true }) + .then((stream) => { + stream.getTracks().forEach((track) => track.stop()); + return navigator.mediaDevices.enumerateDevices(); + }) + .then((infos) => + setDevices( + infos + .filter((info) => info.kind === "audioinput") + .map((info, index) => ({ deviceId: info.deviceId, label: info.label || `Microphone ${index + 1}` })), + ), + ) + .catch(() => setDevices([])); + }, []); + + const knownSelection = selected && devices?.some((device) => device.deviceId === selected); + + return ( +
+

Microphone

+

+ Which input device to record from. Leave this on System default to always follow whatever macOS is set to. +

+
+ +
+ {devices !== null && devices.length === 0 && ( +

No other input devices found, or the app doesn’t have microphone access yet.

+ )} +
+ ); +} + function PauseMediaSection() { const [enabled, setEnabled] = useState(null); @@ -311,6 +383,8 @@ export default function Settings() { + +

Names & terms

From a2193d66e077c3e21f5d4354c12433f3d72381fe Mon Sep 17 00:00:00 2001 From: Nabzx Date: Fri, 18 Sep 2026 21:28:16 +0400 Subject: [PATCH 5/5] docs: manual-check checklist for the microphone selector (#137) Claude-Session: https://claude.ai/code/session_0144sxo5CJiyk99vaR7sgjuj --- docs/testing/microphone-selector-137.md | 45 +++++++++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 docs/testing/microphone-selector-137.md diff --git a/docs/testing/microphone-selector-137.md b/docs/testing/microphone-selector-137.md new file mode 100644 index 0000000..9eaac26 --- /dev/null +++ b/docs/testing/microphone-selector-137.md @@ -0,0 +1,45 @@ +# Manual check: microphone device selector (#137) + +CI covers `settingsStore`'s `microphoneDeviceId` validation and persistence +against fakes. What it can't drive: real `getUserMedia`, a real second +input device, or the live capture renderer's audio graph. + +**`capture.js`'s device-switching path is unexercised** - this session's +sandbox has no real microphone hardware to test against, and this repo's +capture pipeline was never run live here (same constraint as the file +-transcription work in #253). Everything below needs a real Mac with at +least one alternate input device (an external mic, a webcam mic, a +Bluetooth headset - USB or built-in both count) to actually check. + +## Steps + +1. **The list populates.** Open Settings → Microphone. It should show + "System default" plus a real, named entry per connected input device + (not "Microphone 1" placeholders - if you see those, the app doesn't + have mic permission yet in that window; dictate once first, or check + System Settings → Privacy & Security → Microphone). +2. **Picking a device sticks.** Select a specific device, then reopen + Settings (or restart the app) - it should still show that device + selected, not have reverted to System default. +3. **Dictation still works.** With a specific device selected, dictate + normally. Confirm the words are actually coming from that device (mute + or unplug your system-default mic first, if you have two, to be sure). +4. **Switching while idle.** With the app idle (not recording), change + the selected device in Settings. The very next dictation should use the + new device, with no restart needed. +5. **Switching mid-recording.** Hold push-to-talk, and while still holding + it, switch the device in Settings. The recording in progress should + finish normally (against whichever device it started with) rather than + glitching or dropping audio - the switch should only take effect + starting with the *next* dictation. Console logs around + `[capture]`/`switchDevice` confirm the queued-switch path ran. +6. **A device that disappears.** Select a specific external device, then + physically disconnect it (unplug it, or turn off a Bluetooth headset), + then dictate. Confirm the app falls back to the system default rather + than the dictation silently failing - check the console for + `[capture] selected microphone unavailable ... falling back to the + system default`. Reselecting a connected device in Settings should + recover normal operation. +7. **System default, unchanged.** With System default selected (the + out-of-the-box setting), dictation should behave exactly as it did + before this change - this is the regression check.