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. 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), diff --git a/electron/main.js b/electron/main.js index fa0634e..7087837 100644 --- a/electron/main.js +++ b/electron/main.js @@ -447,6 +447,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, @@ -457,7 +466,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 @@ -1123,6 +1134,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); +}); + ipcMain.handle("settings:set-history-retention-days", (event, days) => { // #264: the store validates; recordingHistoryStore reads this fresh on // its next write or read, no extra wiring needed here. diff --git a/electron/preload.js b/electron/preload.js index 74d7c09..8868a7c 100644 --- a/electron/preload.js +++ b/electron/preload.js @@ -86,6 +86,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), setHistoryRetentionDays: (days) => ipcRenderer.invoke("settings:set-history-retention-days", days), setHistoryMaxEntries: (count) => ipcRenderer.invoke("settings:set-history-max-entries", count), setPostProcessScript: (scriptPath) => ipcRenderer.invoke("settings:set-post-process-script", scriptPath), diff --git a/electron/settingsStore.js b/electron/settingsStore.js index 0a32069..b3260ec 100644 --- a/electron/settingsStore.js +++ b/electron/settingsStore.js @@ -40,6 +40,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, // #264: recording-history retention. 0 days means "never expire by time" - // still bounded by historyMaxEntries. historyRetentionDays: DEFAULT_RETENTION_DAYS, @@ -73,6 +76,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"); + } +} + // #264: a year is already well past "why does this app still remember what // I said months ago" territory - the cap is a guard against a nonsense // hand-edited value, same reasoning as MAX_IDLE_UNLOAD_MINUTES. @@ -335,6 +348,11 @@ function createSettingsStore({ filePath }) { return commit({ ...load(), inputLanguage: language }); } + function setMicrophoneDeviceId(deviceId) { + validateMicrophoneDeviceId(deviceId); + return commit({ ...load(), microphoneDeviceId: deviceId }); + } + function setHistoryRetentionDays(days) { validateHistoryRetentionDays(days); return commit({ ...load(), historyRetentionDays: days }); @@ -380,6 +398,7 @@ function createSettingsStore({ filePath }) { setSoundCues, setOverlayPosition, setInputLanguage, + setMicrophoneDeviceId, setHistoryRetentionDays, setHistoryMaxEntries, setWindowBounds, @@ -399,6 +418,7 @@ module.exports = { validateIdleUnloadMinutes, MAX_IDLE_UNLOAD_MINUTES, validateInputLanguage, + validateMicrophoneDeviceId, validateHistoryRetentionDays, validateHistoryMaxEntries, MAX_HISTORY_RETENTION_DAYS, diff --git a/electron/settingsStore.test.js b/electron/settingsStore.test.js index 5442749..b88d503 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("#264: historyRetentionDays defaults to 30, accepts a whole number of days, rejects junk", () => { const store = createSettingsStore({ filePath: tempFilePath() }); assert.equal(store.get().historyRetentionDays, 30); diff --git a/src/openstreamBridge.d.ts b/src/openstreamBridge.d.ts index bbdf52a..0256f5e 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; /** #264: 0 means never expire by time - still bounded by historyMaxEntries. */ historyRetentionDays: number; historyMaxEntries: number; @@ -136,6 +138,7 @@ declare global { setSoundCues(enabled: boolean): Promise; setOverlayPosition(position: OverlayPosition): Promise; setInputLanguage(language: string): Promise; + setMicrophoneDeviceId(deviceId: string | null): Promise; setHistoryRetentionDays(days: number): Promise; setHistoryMaxEntries(count: number): Promise; setPostProcessScript(scriptPath: string | null): Promise; diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index c0041cd..7711e6d 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); @@ -460,6 +532,8 @@ export default function Settings() { + +

Names & terms