Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions docs/testing/microphone-selector-137.md
Original file line number Diff line number Diff line change
@@ -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.
92 changes: 86 additions & 6 deletions electron/capture/capture.js
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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");
Expand All @@ -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
Expand All @@ -123,6 +204,5 @@ window.capture.onCancel(() => {
isRecording = false;
chunks = [];
window.capture.sendSoundLevel(0);
applyPendingDeviceSwitch();
});

prepareCapture();
3 changes: 3 additions & 0 deletions electron/capture/capturePreload.js
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
18 changes: 18 additions & 0 deletions electron/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions electron/preload.js
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
20 changes: 20 additions & 0 deletions electron/settingsStore.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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 });
Expand Down Expand Up @@ -380,6 +398,7 @@ function createSettingsStore({ filePath }) {
setSoundCues,
setOverlayPosition,
setInputLanguage,
setMicrophoneDeviceId,
setHistoryRetentionDays,
setHistoryMaxEntries,
setWindowBounds,
Expand All @@ -399,6 +418,7 @@ module.exports = {
validateIdleUnloadMinutes,
MAX_IDLE_UNLOAD_MINUTES,
validateInputLanguage,
validateMicrophoneDeviceId,
validateHistoryRetentionDays,
validateHistoryMaxEntries,
MAX_HISTORY_RETENTION_DAYS,
Expand Down
11 changes: 11 additions & 0 deletions electron/settingsStore.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
3 changes: 3 additions & 0 deletions src/openstreamBridge.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -136,6 +138,7 @@ declare global {
setSoundCues(enabled: boolean): Promise<StoredSettings>;
setOverlayPosition(position: OverlayPosition): Promise<StoredSettings>;
setInputLanguage(language: string): Promise<StoredSettings>;
setMicrophoneDeviceId(deviceId: string | null): Promise<StoredSettings>;
setHistoryRetentionDays(days: number): Promise<StoredSettings>;
setHistoryMaxEntries(count: number): Promise<StoredSettings>;
setPostProcessScript(scriptPath: string | null): Promise<StoredSettings>;
Expand Down
Loading
Loading