diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index 1fe1c8c70..ef5086f51 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -75,6 +75,7 @@ import { toHelperRect } from "../native-bridge/helperCoordinates"; import { isSalvageableFragmentedCapture, NATIVE_WINDOWS_SALVAGEABLE_OUTPUT_BYTES, + readMicrophoneDefaulted, readWebcamFormat, readWebcamUnavailable, terminateNativeWindowsCapture, @@ -474,6 +475,18 @@ let currentRecordingSession: RecordingSession | null = null; export interface RecordingPrefs { micEnabled: boolean; micDeviceId: string | null; + /** + * The microphone's LABEL, carried beside its id because the native Windows + * helper selects by name and Chromium selects by id. + * + * Without it, a HUD rebuilt for a new recording restored the id and had to + * re-derive the name from its own `enumerateDevices()` — which needs a full + * getUserMedia permission round-trip first, and an auto-started recording + * beat it. The request then went out with no name at all, and the helper + * answers that by recording the Windows default endpoint instead of the + * microphone the user picked (getopenscreen/openscreen#404). + */ + micDeviceName: string | null; camEnabled: boolean; camDeviceId: string | null; systemAudioEnabled: boolean; @@ -482,6 +495,7 @@ export interface RecordingPrefs { let recordingPrefs: RecordingPrefs = { micEnabled: false, micDeviceId: null, + micDeviceName: null, camEnabled: false, camDeviceId: null, systemAudioEnabled: false, @@ -2459,6 +2473,17 @@ export function registerIpcHandlers( // whose camera is working perfectly. const webcamUnavailable = request.webcam.enabled && readWebcamUnavailable(nativeWindowsCaptureOutput); + // Same shape as the camera notice: the helper records the Windows + // default input rather than failing, so this take is usable but is + // almost certainly the wrong microphone. + const microphoneDefaulted = + request.audio.microphone.enabled && readMicrophoneDefaulted(nativeWindowsCaptureOutput); + if (microphoneDefaulted) { + console.warn("[native-wgc] recording the default input; the microphone was not named", { + deviceId: request.audio.microphone.deviceId, + deviceName: request.audio.microphone.deviceName, + }); + } if (webcamUnavailable) { console.warn("[native-wgc] recording without a camera; the helper could not open it", { deviceId: request.webcam.deviceId, @@ -2473,6 +2498,7 @@ export function registerIpcHandlers( helperPath, videoEncoderSelection: encoderSelection?.video ?? null, webcamUnavailable, + microphoneDefaulted, }; } catch (error) { console.error("Failed to start native Windows recording:", error); diff --git a/electron/native/wgc-capture/src/wasapi_loopback_capture.cpp b/electron/native/wgc-capture/src/wasapi_loopback_capture.cpp index 0256b0425..49239ad01 100644 --- a/electron/native/wgc-capture/src/wasapi_loopback_capture.cpp +++ b/electron/native/wgc-capture/src/wasapi_loopback_capture.cpp @@ -65,6 +65,50 @@ std::wstring normalizeDeviceName(const std::wstring& value) { return result; } +/** + * Does `needle` appear in `haystack` as whole words? + * + * Plain containment is what let a requested "Micro" answer for + * "Microphone (Logitech StreamCam)" -- the request is inside the name, just not + * as a word -- and a requested "Logi" for "Logitech". Either resolved an + * endpoint nobody asked for, and silenced the fallback warning by doing so. + * + * Both sides are normalized, so a boundary is the start of the string, the end, + * or a space. + */ +bool containsAsWords(const std::wstring& haystack, const std::wstring& needle) { + if (haystack.empty() || needle.empty()) { + return false; + } + size_t pos = haystack.find(needle); + while (pos != std::wstring::npos) { + const bool startsOnBoundary = pos == 0 || haystack[pos - 1] == L' '; + const size_t after = pos + needle.size(); + const bool endsOnBoundary = after == haystack.size() || haystack[after] == L' '; + if (startsOnBoundary && endsOnBoundary) { + return true; + } + pos = haystack.find(needle, pos + 1); + } + return false; +} + +/** + * How well a candidate endpoint answers a requested name, or 0 for "not this + * one" -- which the caller must treat as a real answer. + * + * Only decisive matches count: equal once normalized, or one containing the + * other, which is the ordinary case since Chromium appends USB ids to what the + * driver reports. + * + * A further tier used to score shared WORDS, to bridge names differing more than + * that. It bridged endpoints that were not the same device -- a requested + * "micro" matched the "microphone" that opens nearly every Windows endpoint + * name, so asking for a microphone that does not exist quietly recorded + * whichever one sorted first, and the fallback warning could not fire because a + * device HAD been resolved (getopenscreen/openscreen#404). Returning 0 is what + * makes that warning reachable. + */ int scoreDeviceName(const std::wstring& candidateName, const std::wstring& candidateId, const std::wstring& requestedName) { const std::wstring candidate = normalizeDeviceName(candidateName); const std::wstring id = normalizeDeviceName(candidateId); @@ -75,31 +119,14 @@ int scoreDeviceName(const std::wstring& candidateName, const std::wstring& candi if (candidate == requested) { return 1000; } - if (!candidate.empty() && (candidate.find(requested) != std::wstring::npos || requested.find(candidate) != std::wstring::npos)) { + if (containsAsWords(candidate, requested) || containsAsWords(requested, candidate)) { return 900; } - if (!id.empty() && (id.find(requested) != std::wstring::npos || requested.find(id) != std::wstring::npos)) { + if (containsAsWords(id, requested) || containsAsWords(requested, id)) { return 800; } - int score = 0; - size_t pos = 0; - while (pos < requested.size()) { - const size_t end = requested.find(L' ', pos); - const std::wstring word = requested.substr(pos, end == std::wstring::npos ? std::wstring::npos : end - pos); - if (word.size() > 1 && word != L"microphone" && word != L"mic" && word != L"audio" && word != L"input") { - if (candidate.find(word) != std::wstring::npos) { - score += 100; - } else if (id.find(word) != std::wstring::npos) { - score += 50; - } - } - if (end == std::wstring::npos) { - break; - } - pos = end + 1; - } - return score; + return 0; } std::wstring getDeviceFriendlyName(IMMDevice* device) { @@ -169,6 +196,23 @@ bool WasapiLoopbackCapture::initialize(WasapiCaptureEndpoint endpoint, const std } if (!device_) { + // A particular microphone was asked for and nothing here could find it, + // so the recording is about to capture whatever Windows calls the default + // input. Worth saying out loud, and keyed on the OUTCOME rather than on + // which lookup failed: the caller sends an empty name when it could not + // resolve one in time, but a name that simply matches no endpoint lands + // in exactly the same place. Either way the take sounds like the wrong + // microphone with nothing explaining why (getopenscreen/openscreen#404). + const bool wantedAParticularMicrophone = + endpoint == WasapiCaptureEndpoint::Microphone && + ((!deviceId.empty() && deviceId != L"default") || !deviceName.empty()); + if (wantedAParticularMicrophone) { + std::cerr << "{\"event\":\"warning\",\"code\":\"microphone-defaulted\"," + "\"message\":\"The requested microphone could not be resolved; " + "capturing the default input\"}" + << std::endl; + } + const EDataFlow flow = endpoint == WasapiCaptureEndpoint::SystemLoopback ? eRender : eCapture; hr = deviceEnumerator_->GetDefaultAudioEndpoint(flow, eConsole, &device_); diff --git a/electron/recording/nativeWindowsCaptureStop.test.ts b/electron/recording/nativeWindowsCaptureStop.test.ts index acbee862e..8c1d48caf 100644 --- a/electron/recording/nativeWindowsCaptureStop.test.ts +++ b/electron/recording/nativeWindowsCaptureStop.test.ts @@ -5,6 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { isSalvageableFragmentedCapture, NATIVE_WINDOWS_SALVAGEABLE_OUTPUT_BYTES, + readMicrophoneDefaulted, readStoppedPath, readWebcamFormat, readWebcamUnavailable, @@ -99,6 +100,30 @@ describe("readWebcamUnavailable", () => { }); }); +describe("readMicrophoneDefaulted", () => { + // The helper keys the event on the OUTCOME — it ended up on the default input + // — not on which lookup failed, so both routes to that fallback land here. + it("sees the fallback when no microphone name was supplied", () => { + const output = + '{"event":"warning","code":"microphone-defaulted","message":"The requested microphone could not be resolved; capturing the default input"}\n' + + "Recording started\n"; + expect(readMicrophoneDefaulted(output)).toBe(true); + }); + + it("sees it when a supplied name matched no endpoint", () => { + const output = + "WARNING: Could not resolve microphone by name; using default capture endpoint\n" + + '{"event":"warning","code":"microphone-defaulted","message":"The requested microphone could not be resolved; capturing the default input"}\n'; + expect(readMicrophoneDefaulted(output)).toBe(true); + }); + + it("is false when the requested microphone was found", () => { + const output = + '{"event":"audio-format","schemaVersion":2,"microphone":true,"microphoneDeviceName":"Microphone (Logitech PRO X)"}\n'; + expect(readMicrophoneDefaulted(output)).toBe(false); + }); +}); + describe("readWebcamFormat", () => { it("reads the negotiated camera format", () => { const output = diff --git a/electron/recording/nativeWindowsCaptureStop.ts b/electron/recording/nativeWindowsCaptureStop.ts index 24f047f3b..f472cad71 100644 --- a/electron/recording/nativeWindowsCaptureStop.ts +++ b/electron/recording/nativeWindowsCaptureStop.ts @@ -109,6 +109,19 @@ export function readWebcamUnavailable(output: string) { return output.includes('"code":"webcam-unavailable"'); } +/** + * Did the helper record the Windows default input instead of the microphone + * that was asked for? + * + * It falls back rather than failing, which is right — a take with the wrong + * microphone still holds the screen and the moment. But it used to fall back in + * silence, and the only symptom was a recording that sounded wrong + * (getopenscreen/openscreen#404). + */ +export function readMicrophoneDefaulted(output: string) { + return output.includes('"code":"microphone-defaulted"'); +} + /** * Index of the `}` that closes the object starting at `start`, or -1. * diff --git a/package.json b/package.json index 67cd2585b..f98c541ba 100644 --- a/package.json +++ b/package.json @@ -67,6 +67,7 @@ "test:wgc-window:win": "node scripts/test-windows-wgc-helper.mjs --window", "test:wgc-audio:win": "node scripts/test-windows-wgc-helper.mjs --system-audio", "test:wgc-mic:win": "node scripts/test-windows-wgc-helper.mjs --microphone", + "test:wgc-mic-selection:win": "node scripts/test-windows-microphone-selection.mjs", "test:wgc-mixed-audio:win": "node scripts/test-windows-wgc-helper.mjs --system-audio --microphone", "test:wgc-webcam:win": "node scripts/test-windows-wgc-helper.mjs --webcam", "test:wgc-full:win": "node scripts/test-windows-wgc-helper.mjs --webcam --system-audio --microphone", diff --git a/scripts/test-windows-microphone-selection.mjs b/scripts/test-windows-microphone-selection.mjs new file mode 100644 index 000000000..7d5b8444a --- /dev/null +++ b/scripts/test-windows-microphone-selection.mjs @@ -0,0 +1,213 @@ +/** + * Which microphone does the helper actually open? + * + * WASAPI cannot run on Linux CI and there is no C++ test harness here, so the + * selection rules are checked the only way that proves anything: by driving the + * real helper on a real Windows machine and reading what it says it chose. + * + * What is being pinned is the pair of promises the recording flow rests on — + * the microphone the user asked for is the one recorded, and when it cannot be + * found the helper SAYS SO instead of quietly capturing something else + * (getopenscreen/openscreen#404). The second half needs the first: a fuzzy name + * match that resolved "some microphone" made the warning unreachable, because a + * device had after all been resolved. + * + * npm run test:wgc-mic-selection:win + * + * Case 4 needs a real microphone name; pass one that exists on this machine + * through OPENSCREEN_WGC_TEST_MICROPHONE_DEVICE_NAME, or it is skipped. + */ +import { spawn } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const ROOT = path.join(__dirname, ".."); +const HELPER = + process.env.OPENSCREEN_WGC_CAPTURE_EXE ?? + path.join(ROOT, "electron", "native", "bin", "win32-x64", "wgc-capture.exe"); +const REAL_MIC_NAME = process.env.OPENSCREEN_WGC_TEST_MICROPHONE_DEVICE_NAME ?? ""; +const RECORD_MS = Number(process.env.OPENSCREEN_WGC_TEST_DURATION_MS ?? 2500); + +if (process.platform !== "win32") { + console.log("Windows only — skipping."); + process.exit(0); +} +if (!fs.existsSync(HELPER)) { + console.error(`Helper not found at ${HELPER}. Run: npm run build:native:win`); + process.exit(1); +} + +function runHelper(label, microphoneDeviceId, microphoneDeviceName) { + return new Promise((resolve) => { + const outputPath = path.join(os.tmpdir(), `wgc-mic-${label}.mp4`); + const config = { + schemaVersion: 2, + recordingId: Date.now(), + outputPath, + sourceType: "display", + sourceId: "screen:0:0", + displayId: 0, + fps: 30, + videoWidth: 1280, + videoHeight: 720, + hasDisplayBounds: false, + captureSystemAudio: false, + captureMic: true, + microphoneDeviceId, + microphoneDeviceName, + microphoneGain: 1, + webcamEnabled: false, + cursorCaptureMode: "editable-overlay", + }; + + const proc = spawn(HELPER, [JSON.stringify(config)], { windowsHide: true }); + let output = ""; + let spawnError = null; + proc.on("error", (error) => { + spawnError = error.message; + }); + proc.stdout.on("data", (chunk) => { + output += chunk.toString(); + }); + proc.stderr.on("data", (chunk) => { + output += chunk.toString(); + }); + const stopTimer = setTimeout(() => { + try { + proc.stdin.write("stop\n"); + } catch { + // The helper may already be gone; the kill below is the backstop. + } + }, RECORD_MS); + const killTimer = setTimeout(() => proc.kill(), RECORD_MS + 6000); + + proc.on("close", (code, signal) => { + clearTimeout(stopTimer); + clearTimeout(killTimer); + fs.rmSync(outputPath, { force: true }); + resolve({ + // A helper that dies can still have printed everything expected, so + // how it ended is part of the result rather than something to skip. + spawnError, + code, + signal, + defaulted: output.includes('"code":"microphone-defaulted"'), + selected: output.match(/"microphoneDeviceName":"([^"]*)"/)?.[1] ?? null, + // Which endpoints the helper actually saw. A negative case proves + // nothing if the device it was meant to be tempted by was absent. + candidates: [...output.matchAll(/Native microphone candidate: (.+?) score=(\d+)/g)].map( + (match) => ({ name: match[1].trim(), score: Number(match[2]) }), + ), + }); + }); + }); +} + +const cases = [ + { + label: "unresolvable-id-no-name", + why: "the reported bug: a browser device id the helper cannot resolve, and no name to fall back on", + deviceId: "0f6a4c1e9b2d47a3ba55d8e01c7f9a24", + deviceName: "", + expectDefaulted: true, + }, + { + label: "name-matches-nothing", + why: "a name was supplied and matches no endpoint — the match must not invent one", + deviceId: "", + deviceName: "A Microphone That Is Not Here", + expectDefaulted: true, + }, + { + label: "short-word-inside-a-name", + why: '"Micro" sits inside the "Microphone" that opens nearly every Windows endpoint name — containment must be whole words', + deviceId: "", + deviceName: "Micro", + expectDefaulted: true, + }, + { + label: "short-word-inside-a-brand", + why: '"Logi" sits inside "Logitech" — the same mistake one word along', + deviceId: "", + deviceName: "Logi", + expectDefaulted: true, + }, + { + label: "shares-a-brand-only", + why: "another device from the same maker is still another device — a shared brand must not answer for it", + deviceId: "", + deviceName: "Logitech Blue Yeti", + expectDefaulted: true, + }, + { + label: "plain-default-request", + why: "no particular device was asked for, so the default endpoint is the right answer and no warning is due", + deviceId: "default", + deviceName: "", + expectDefaulted: false, + }, +]; + +if (REAL_MIC_NAME) { + cases.push({ + label: "real-device-name", + why: "the happy path: a name that exists resolves, and stays silent", + deviceId: "", + deviceName: REAL_MIC_NAME, + expectDefaulted: false, + expectSelectedToMatch: true, + }); +} else { + console.log( + "NOTE: set OPENSCREEN_WGC_TEST_MICROPHONE_DEVICE_NAME to a real microphone to cover the happy path.\n", + ); +} + +let failures = 0; +for (const testCase of cases) { + const result = await runHelper(testCase.label, testCase.deviceId, testCase.deviceName); + + const problems = []; + if (result.spawnError) problems.push(`could not start the helper: ${result.spawnError}`); + if (result.signal) problems.push(`helper killed by ${result.signal}`); + if (result.code !== 0 && result.code !== null) problems.push(`helper exited ${result.code}`); + if (result.defaulted !== testCase.expectDefaulted) { + problems.push(`defaulted=${result.defaulted}, expected ${testCase.expectDefaulted}`); + } + // A case that supplies a name is only meaningful if the helper had endpoints + // to be tempted by; with none enumerated it would pass whatever the rules + // say. A case supplying no name never reaches enumeration at all, and that + // short circuit IS the behaviour under test there. + const scoringWasExercised = testCase.deviceName !== ""; + if (testCase.expectDefaulted && scoringWasExercised && result.candidates.length === 0) { + problems.push("no endpoint was enumerated, so this case proves nothing"); + } + if (testCase.expectDefaulted && result.candidates.some((c) => c.score > 0)) { + const scored = result.candidates + .filter((c) => c.score > 0) + .map((c) => `${c.name}=${c.score}`) + .join(", "); + problems.push(`something scored above zero: ${scored}`); + } + if (testCase.expectSelectedToMatch) { + const matches = Boolean(result.selected) && testCase.deviceName.includes(result.selected); + if (!matches) problems.push(`opened "${result.selected}", which is not what was asked for`); + } + + if (problems.length) failures += 1; + console.log( + `${problems.length ? "FAIL" : "PASS"} ${testCase.label.padEnd(24)} defaulted=${String(result.defaulted).padEnd(5)} opened="${result.selected}"`, + ); + console.log(` ${testCase.why}`); + for (const problem of problems) console.log(` -> ${problem}`); +} + +console.log( + failures === 0 + ? `\nAll ${cases.length} microphone selection cases behaved.` + : `\n${failures} of ${cases.length} cases did not.`, +); +process.exit(failures === 0 ? 0 : 1); diff --git a/src/components/ai-edition/v4/RecStage.tsx b/src/components/ai-edition/v4/RecStage.tsx index af8405711..8564d4895 100644 --- a/src/components/ai-edition/v4/RecStage.tsx +++ b/src/components/ai-edition/v4/RecStage.tsx @@ -23,6 +23,7 @@ import styles from "./EditorShellV4.module.css"; interface RecordingPrefsState { micEnabled: boolean; micDeviceId: string | null; + micDeviceName: string | null; camEnabled: boolean; camDeviceId: string | null; systemAudioEnabled: boolean; @@ -32,6 +33,7 @@ interface RecordingPrefsState { const DEFAULT_PREFS: RecordingPrefsState = { micEnabled: false, micDeviceId: null, + micDeviceName: null, camEnabled: false, camDeviceId: null, systemAudioEnabled: false, @@ -272,8 +274,16 @@ export function RecStage({ className={styles.recSelect} value={prefs.micDeviceId ?? micDevices.selectedDeviceId} onChange={(e) => { - micDevices.setSelectedDeviceId(e.target.value); - updatePrefs({ micDeviceId: e.target.value }); + const deviceId = e.target.value; + micDevices.setSelectedDeviceId(deviceId); + // The label travels with the id: the native Windows + // helper selects a microphone by NAME, and records the + // Windows default endpoint when it is missing. + updatePrefs({ + micDeviceId: deviceId, + micDeviceName: + micDevices.devices.find((d) => d.deviceId === deviceId)?.label ?? null, + }); }} > {micDevices.devices.map((d) => ( diff --git a/src/components/launch/LaunchWindow.tsx b/src/components/launch/LaunchWindow.tsx index 14046bc39..9c184438e 100644 --- a/src/components/launch/LaunchWindow.tsx +++ b/src/components/launch/LaunchWindow.tsx @@ -91,6 +91,7 @@ export function LaunchWindow() { setMicrophoneEnabled, microphoneDeviceId, setMicrophoneDeviceId, + microphoneDeviceName, setMicrophoneDeviceName, systemAudioEnabled, setSystemAudioEnabled, @@ -160,7 +161,11 @@ export function LaunchWindow() { devices: micDevices, selectedDeviceId: selectedMicId, setSelectedDeviceId: setSelectedMicId, - } = useMicrophoneDevices(microphoneEnabled || isDeviceSettingsOpen); + } = useMicrophoneDevices( + microphoneEnabled || isDeviceSettingsOpen, + microphoneDeviceId, + microphoneDeviceName, + ); useEffect(() => { if (selectedMicId && selectedMicId !== "default") { @@ -649,10 +654,15 @@ export function LaunchWindow() { * previous device. Best-effort on purpose: failing to persist a preference * must not stop a recording. */ - const persistCameraPrefs = useCallback( - (patch: { camEnabled?: boolean; camDeviceId?: string }) => { + const persistRecordingPrefs = useCallback( + (patch: { + camEnabled?: boolean; + camDeviceId?: string; + micDeviceId?: string; + micDeviceName?: string; + }) => { void window.electronAPI?.setRecordingPrefs?.(patch).catch((error) => { - console.warn("Failed to persist the camera preference:", error); + console.warn("Failed to persist the device preference:", error); }); }, [], @@ -662,9 +672,9 @@ export function LaunchWindow() { if (controlsLocked) return; const next = !webcamEnabled; void setWebcamEnabled(next).then((ok) => { - if (ok) persistCameraPrefs({ camEnabled: next }); + if (ok) persistRecordingPrefs({ camEnabled: next }); }); - }, [controlsLocked, persistCameraPrefs, setWebcamEnabled, webcamEnabled]); + }, [controlsLocked, persistRecordingPrefs, setWebcamEnabled, webcamEnabled]); // Selecting a device never switches it on. If the device is already live the // recorder re-acquires on the id change; if it isn't, this just records which @@ -674,8 +684,9 @@ export function LaunchWindow() { setSelectedMicId(device.deviceId); setMicrophoneDeviceId(device.deviceId); setMicrophoneDeviceName(device.label); + persistRecordingPrefs({ micDeviceId: device.deviceId, micDeviceName: device.label }); }, - [setMicrophoneDeviceId, setMicrophoneDeviceName, setSelectedMicId], + [persistRecordingPrefs, setMicrophoneDeviceId, setMicrophoneDeviceName, setSelectedMicId], ); const handleSelectCameraDevice = useCallback( @@ -683,9 +694,9 @@ export function LaunchWindow() { setSelectedCameraId(device.deviceId); setWebcamDeviceId(device.deviceId); setWebcamDeviceName(device.label); - persistCameraPrefs({ camDeviceId: device.deviceId }); + persistRecordingPrefs({ camDeviceId: device.deviceId }); }, - [persistCameraPrefs, setSelectedCameraId, setWebcamDeviceId, setWebcamDeviceName], + [persistRecordingPrefs, setSelectedCameraId, setWebcamDeviceId, setWebcamDeviceName], ); const toggleDeviceSettings = useCallback(() => { diff --git a/src/hooks/useMicrophoneDevices.test.ts b/src/hooks/useMicrophoneDevices.test.ts new file mode 100644 index 000000000..17c8eefb9 --- /dev/null +++ b/src/hooks/useMicrophoneDevices.test.ts @@ -0,0 +1,59 @@ +// @vitest-environment jsdom +import { renderHook, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { useMicrophoneDevices } from "./useMicrophoneDevices"; + +const DEVICES = [ + { kind: "audioinput", deviceId: "mic-a", label: "Realtek Array Microphone", groupId: "g1" }, + { kind: "audioinput", deviceId: "mic-b", label: "Microphone (Logitech PRO X)", groupId: "g2" }, + { kind: "videoinput", deviceId: "cam", label: "Webcam", groupId: "g3" }, +]; + +const enumerateDevices = vi.fn(async () => DEVICES); + +Object.defineProperty(global.navigator, "mediaDevices", { + value: { + enumerateDevices, + getUserMedia: vi.fn(async () => ({ getTracks: () => [{ stop: vi.fn() }] })), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + }, + configurable: true, +}); + +describe("useMicrophoneDevices", () => { + beforeEach(() => { + vi.clearAllMocks(); + enumerateDevices.mockResolvedValue(DEVICES); + }); + + it("falls back to the first input when nothing is remembered", async () => { + const { result } = renderHook(() => useMicrophoneDevices(true)); + await waitFor(() => expect(result.current.selectedDeviceId).toBe("mic-a")); + }); + + it("prefers the remembered microphone over the first input", async () => { + const { result } = renderHook(() => useMicrophoneDevices(true, "mic-b")); + await waitFor(() => expect(result.current.selectedDeviceId).toBe("mic-b")); + }); + + /** + * Chromium's device ids are per-origin salted, so the id one window persisted + * can name nothing in the next while the microphone itself is right there in + * the list. Falling through to the first input would silently swap the user's + * microphone — which is the bug, one layer down. + */ + it("finds the remembered microphone by label when its id no longer matches", async () => { + const { result } = renderHook(() => + useMicrophoneDevices(true, "stale-id", "Microphone (Logitech PRO X)"), + ); + await waitFor(() => expect(result.current.selectedDeviceId).toBe("mic-b")); + }); + + it("still falls back to the first input when neither id nor label matches", async () => { + const { result } = renderHook(() => + useMicrophoneDevices(true, "stale-id", "A microphone that left"), + ); + await waitFor(() => expect(result.current.selectedDeviceId).toBe("mic-a")); + }); +}); diff --git a/src/hooks/useMicrophoneDevices.ts b/src/hooks/useMicrophoneDevices.ts index 37591e669..55f059351 100644 --- a/src/hooks/useMicrophoneDevices.ts +++ b/src/hooks/useMicrophoneDevices.ts @@ -6,7 +6,23 @@ export interface MicrophoneDevice { groupId: string; } -export function useMicrophoneDevices(enabled: boolean = true) { +/** + * @param preferredDeviceId The microphone the session already settled on — + * normally the one restored from the recording prefs. It outranks "first in the + * list", which is the OS enumeration order and has nothing to do with what the + * user chose. The HUD window is destroyed and rebuilt for every recording, so + * without this its pick reverted on each take. + * @param preferredDeviceName The same choice by label, tried when the id finds + * nothing. Chromium's device ids are per-origin salted, so the id a previous + * window persisted can name nothing in this one while the microphone is sitting + * right there in the list — and falling through to the first input would then + * discard a choice that was perfectly resolvable. + */ +export function useMicrophoneDevices( + enabled: boolean = true, + preferredDeviceId?: string, + preferredDeviceName?: string, +) { const [devices, setDevices] = useState([]); const [selectedDeviceId, setSelectedDeviceId] = useState("default"); const [isLoading, setIsLoading] = useState(false); @@ -15,7 +31,17 @@ export function useMicrophoneDevices(enabled: boolean = true) { // this very effect, so depending on it re-ran the whole load — a second // getUserMedia() permission stream acquired and torn down on every open. const selectedDeviceIdRef = useRef(selectedDeviceId); - selectedDeviceIdRef.current = selectedDeviceId; + const preferredDeviceIdRef = useRef(preferredDeviceId); + const preferredDeviceNameRef = useRef(preferredDeviceName); + // Synchronised in an effect rather than during render: React may discard a + // render without committing it, and a ref written there keeps the value + // anyway, which would resolve the selection against a device the committed + // tree never agreed on. + useEffect(() => { + selectedDeviceIdRef.current = selectedDeviceId; + preferredDeviceIdRef.current = preferredDeviceId; + preferredDeviceNameRef.current = preferredDeviceName; + }, [selectedDeviceId, preferredDeviceId, preferredDeviceName]); useEffect(() => { if (!enabled) { @@ -49,7 +75,16 @@ export function useMicrophoneDevices(enabled: boolean = true) { const currentId = selectedDeviceIdRef.current; const stillAvailable = audioInputs.some((d) => d.deviceId === currentId); if ((currentId === "default" || !stillAvailable) && audioInputs.length > 0) { - setSelectedDeviceId(audioInputs[0].deviceId); + const preferredId = preferredDeviceIdRef.current; + const preferredName = preferredDeviceNameRef.current; + // By id, then by label, then whatever is first. Always an entry + // from THIS list, so the id and the label the caller ends up + // sending to the native helper describe the same device — the + // pairing that #387 and #404 were both about. + const preferred = + (preferredId ? audioInputs.find((d) => d.deviceId === preferredId) : undefined) ?? + (preferredName ? audioInputs.find((d) => d.label === preferredName) : undefined); + setSelectedDeviceId(preferred?.deviceId ?? audioInputs[0].deviceId); } setIsLoading(false); } diff --git a/src/hooks/useScreenRecorder.ts b/src/hooks/useScreenRecorder.ts index 5668b0f92..14eec5ab4 100644 --- a/src/hooks/useScreenRecorder.ts +++ b/src/hooks/useScreenRecorder.ts @@ -232,6 +232,12 @@ export function useScreenRecorder(): UseScreenRecorderReturn { if (cancelled || !prefs) return; setMicrophoneEnabled(prefs.micEnabled); if (prefs.micDeviceId) setMicrophoneDeviceId(prefs.micDeviceId); + // The name matters as much as the id: the native Windows helper picks + // the microphone by NAME, and falls back to the Windows default + // endpoint when it is empty. Seeding only the id left an auto-started + // recording racing this window's own device enumeration for it, and + // losing (getopenscreen/openscreen#404). + if (prefs.micDeviceName) setMicrophoneDeviceName(prefs.micDeviceName); setWebcamEnabledState(prefs.camEnabled); if (prefs.camDeviceId) setWebcamDeviceId(prefs.camDeviceId); setSystemAudioEnabled(prefs.systemAudioEnabled); @@ -1164,6 +1170,9 @@ export function useScreenRecorder(): UseScreenRecorderReturn { if (result.webcamUnavailable) { toast.error(t("recording.cameraCaptureUnavailable")); } + if (result.microphoneDefaulted) { + toast.error(t("recording.microphoneDefaulted")); + } // Tell the user when the helper silently switched away from the default // GPU encoder; an explicit software-preferred selection needs no notice. diff --git a/src/i18n/locales/ar/editor.json b/src/i18n/locales/ar/editor.json index 02223d13d..b472ea7dc 100644 --- a/src/i18n/locales/ar/editor.json +++ b/src/i18n/locales/ar/editor.json @@ -43,6 +43,7 @@ "cameraDisconnected": "تم فصل كاميرا الويب.", "cameraNotFound": "لم يتم العثور على كاميرا.", "cameraCaptureUnavailable": "تعذّر فتح الكاميرا. يجري التسجيل بدون كاميرا.", + "microphoneDefaulted": "تعذّر تحديد الميكروفون المحدَّد؛ يجري التسجيل من الإدخال الافتراضي.", "permissionDenied": "تم رفض إذن التسجيل. يرجى السماح بتسجيل الشاشة.", "accessibilityAllowAndRetry": "اسمح بوصول تسهيلات الاستخدام لـ OpenScreen، ثم اضغط على التسجيل مرة أخرى لبدء العد التنازلي.", "selectSource": "يرجى تحديد مصدر للتسجيل" diff --git a/src/i18n/locales/en/editor.json b/src/i18n/locales/en/editor.json index 739c637b1..10d8ef618 100644 --- a/src/i18n/locales/en/editor.json +++ b/src/i18n/locales/en/editor.json @@ -43,6 +43,7 @@ "cameraDisconnected": "Webcam disconnected.", "cameraNotFound": "Camera not found.", "cameraCaptureUnavailable": "The camera could not be opened. Recording without it.", + "microphoneDefaulted": "The chosen microphone could not be identified. Recording the default input.", "permissionDenied": "Recording permission denied. Please allow screen recording.", "accessibilityAllowAndRetry": "Allow Accessibility access for OpenScreen, then press record again to start the countdown.", "selectSource": "Please select a source to record" diff --git a/src/i18n/locales/es/editor.json b/src/i18n/locales/es/editor.json index 4d8822158..1b8dadafd 100644 --- a/src/i18n/locales/es/editor.json +++ b/src/i18n/locales/es/editor.json @@ -35,6 +35,7 @@ "cameraDisconnected": "Cámara web desconectada.", "cameraNotFound": "Cámara no encontrada.", "cameraCaptureUnavailable": "No se pudo abrir la cámara. Grabando sin ella.", + "microphoneDefaulted": "No se pudo identificar el micrófono elegido. Grabando la entrada predeterminada.", "permissionDenied": "Permiso de grabación denegado. Por favor permite la grabación de pantalla.", "accessibilityAllowAndRetry": "Permite el acceso de accesibilidad para OpenScreen y luego pulsa grabar de nuevo para iniciar la cuenta atrás.", "selectSource": "Por favor selecciona una fuente para grabar" diff --git a/src/i18n/locales/fr/editor.json b/src/i18n/locales/fr/editor.json index d560fa07b..333298dd7 100644 --- a/src/i18n/locales/fr/editor.json +++ b/src/i18n/locales/fr/editor.json @@ -41,6 +41,7 @@ "cameraDisconnected": "Webcam déconnectée.", "cameraNotFound": "Caméra introuvable.", "cameraCaptureUnavailable": "Impossible d'ouvrir la caméra. Enregistrement sans elle.", + "microphoneDefaulted": "Micro choisi non identifié. Enregistrement de l'entrée par défaut.", "permissionDenied": "Permission d'enregistrement refusée. Veuillez autoriser l'enregistrement d'écran.", "accessibilityAllowAndRetry": "Autorisez l'accès Accessibilité pour OpenScreen, puis appuyez de nouveau sur enregistrer pour lancer le compte à rebours.", "selectSource": "Veuillez sélectionner une source à enregistrer" diff --git a/src/i18n/locales/it/editor.json b/src/i18n/locales/it/editor.json index ba0afe4db..0d4df5c7f 100644 --- a/src/i18n/locales/it/editor.json +++ b/src/i18n/locales/it/editor.json @@ -43,6 +43,7 @@ "cameraDisconnected": "Webcam disconnessa.", "cameraNotFound": "Fotocamera non trovata.", "cameraCaptureUnavailable": "Impossibile aprire la fotocamera. Registrazione senza di essa.", + "microphoneDefaulted": "Impossibile identificare il microfono scelto. Registrazione dall'ingresso predefinito.", "permissionDenied": "Autorizzazione di registrazione negata. Consenti la registrazione dello schermo.", "accessibilityAllowAndRetry": "Consenti l'accesso all'accessibilità per OpenScreen, poi premi di nuovo registra per avviare il conto alla rovescia.", "selectSource": "Seleziona una sorgente da registrare" diff --git a/src/i18n/locales/ja-JP/editor.json b/src/i18n/locales/ja-JP/editor.json index 946d62ebd..3b48038c1 100644 --- a/src/i18n/locales/ja-JP/editor.json +++ b/src/i18n/locales/ja-JP/editor.json @@ -44,6 +44,7 @@ "cameraDisconnected": "ウェブカメラが切断されました。", "cameraNotFound": "カメラが見つかりません。", "cameraCaptureUnavailable": "カメラを開けませんでした。カメラなしで録画します。", + "microphoneDefaulted": "選択したマイクを特定できませんでした。既定の入力を録音します。", "accessibilityAllowAndRetry": "OpenScreenにアクセシビリティアクセスを許可してから、もう一度録画を押してカウントダウンを開始してください。", "selectSource": "録画するソースを選択してください" }, diff --git a/src/i18n/locales/ko-KR/editor.json b/src/i18n/locales/ko-KR/editor.json index def66d9d4..dabd14757 100644 --- a/src/i18n/locales/ko-KR/editor.json +++ b/src/i18n/locales/ko-KR/editor.json @@ -44,6 +44,7 @@ "cameraDisconnected": "웹캠 연결이 끊어졌습니다.", "cameraNotFound": "카메라를 찾을 수 없습니다.", "cameraCaptureUnavailable": "카메라를 열 수 없습니다. 카메라 없이 녹화합니다.", + "microphoneDefaulted": "선택한 마이크를 확인할 수 없습니다. 기본 입력을 녹음합니다.", "accessibilityAllowAndRetry": "OpenScreen의 손쉬운 사용 접근을 허용한 다음, 카운트다운을 시작하려면 다시 녹화를 누르세요.", "selectSource": "녹화할 소스를 선택해 주세요" }, diff --git a/src/i18n/locales/pt-BR/editor.json b/src/i18n/locales/pt-BR/editor.json index a8870b0f7..d65fd23d2 100644 --- a/src/i18n/locales/pt-BR/editor.json +++ b/src/i18n/locales/pt-BR/editor.json @@ -43,6 +43,7 @@ "cameraDisconnected": "Webcam desconectada.", "cameraNotFound": "Câmera não encontrada.", "cameraCaptureUnavailable": "Não foi possível abrir a câmera. Gravando sem ela.", + "microphoneDefaulted": "Não foi possível identificar o microfone escolhido. Gravando a entrada padrão.", "permissionDenied": "Permissão de gravação negada. Por favor, permita a gravação de tela.", "accessibilityAllowAndRetry": "Permita o acesso de Acessibilidade para o OpenScreen e pressione gravar novamente para iniciar a contagem regressiva.", "selectSource": "Por favor, selecione uma fonte para gravar" diff --git a/src/i18n/locales/ru/editor.json b/src/i18n/locales/ru/editor.json index dd06f469f..11eeda92b 100644 --- a/src/i18n/locales/ru/editor.json +++ b/src/i18n/locales/ru/editor.json @@ -43,6 +43,7 @@ "cameraDisconnected": "Веб-камера отключена.", "cameraNotFound": "Камера не найдена.", "cameraCaptureUnavailable": "Не удалось открыть камеру. Запись идёт без неё.", + "microphoneDefaulted": "Не удалось определить выбранный микрофон. Записывается вход по умолчанию.", "permissionDenied": "Разрешение на запись запрещено. Пожалуйста, разрешите запись экрана.", "accessibilityAllowAndRetry": "Разрешите OpenScreen доступ к Универсальному доступу, затем снова нажмите запись, чтобы начать обратный отсчет.", "selectSource": "Пожалуйста, выберите источник для записи" diff --git a/src/i18n/locales/tr/editor.json b/src/i18n/locales/tr/editor.json index f19f4a0f0..b0a42ca3b 100644 --- a/src/i18n/locales/tr/editor.json +++ b/src/i18n/locales/tr/editor.json @@ -36,6 +36,7 @@ "cameraDisconnected": "Webcam bağlantısı kesildi.", "cameraNotFound": "Kamera bulunamadı.", "cameraCaptureUnavailable": "Kamera açılamadı. Kamerasız kaydediliyor.", + "microphoneDefaulted": "Seçilen mikrofon belirlenemedi. Varsayılan giriş kaydediliyor.", "accessibilityAllowAndRetry": "OpenScreen için Erişilebilirlik erişimine izin verin, ardından geri sayımı başlatmak için tekrar kayda basın.", "selectSource": "Lütfen kayıt için bir kaynak seçin" }, diff --git a/src/i18n/locales/vi/editor.json b/src/i18n/locales/vi/editor.json index 4527cb0a3..2e61a7c40 100644 --- a/src/i18n/locales/vi/editor.json +++ b/src/i18n/locales/vi/editor.json @@ -43,6 +43,7 @@ "cameraDisconnected": "Webcam bị ngắt kết nối.", "cameraNotFound": "Không tìm thấy máy ảnh.", "cameraCaptureUnavailable": "Không thể mở máy ảnh. Đang ghi mà không có máy ảnh.", + "microphoneDefaulted": "Không xác định được micrô đã chọn. Đang ghi từ đầu vào mặc định.", "permissionDenied": "Quyền ghi hình bị từ chối. Vui lòng cho phép ghi màn hình.", "accessibilityAllowAndRetry": "Cho phép OpenScreen truy cập Trợ năng, sau đó nhấn ghi lại để bắt đầu đếm ngược.", "selectSource": "Vui lòng chọn một nguồn để ghi" diff --git a/src/i18n/locales/zh-CN/editor.json b/src/i18n/locales/zh-CN/editor.json index 75098e96a..2e52b942e 100644 --- a/src/i18n/locales/zh-CN/editor.json +++ b/src/i18n/locales/zh-CN/editor.json @@ -43,6 +43,7 @@ "cameraDisconnected": "摄像头已断开连接。", "cameraNotFound": "未找到摄像头。", "cameraCaptureUnavailable": "无法打开摄像头,正在不使用摄像头录制。", + "microphoneDefaulted": "无法识别所选麦克风,正在录制默认输入设备。", "permissionDenied": "录屏权限被拒绝。请允许屏幕录制。", "accessibilityAllowAndRetry": "允许 OpenScreen 使用辅助功能权限,然后再次按录制以开始倒计时。", "selectSource": "请选择要录制的源" diff --git a/src/i18n/locales/zh-TW/editor.json b/src/i18n/locales/zh-TW/editor.json index 42ad87119..032bb91e5 100644 --- a/src/i18n/locales/zh-TW/editor.json +++ b/src/i18n/locales/zh-TW/editor.json @@ -44,6 +44,7 @@ "cameraDisconnected": "網路攝影機已中斷連線。", "cameraNotFound": "找不到攝影機。", "cameraCaptureUnavailable": "無法開啟攝影機,將在沒有攝影機的情況下錄製。", + "microphoneDefaulted": "無法辨識所選麥克風,將錄製預設輸入裝置。", "accessibilityAllowAndRetry": "允許 OpenScreen 使用輔助使用權限,然後再次按下錄製以開始倒數。", "selectSource": "請選擇要錄製的來源" }, diff --git a/src/lib/nativeWindowsRecording.ts b/src/lib/nativeWindowsRecording.ts index 5f6af14e2..e9e8c0c50 100644 --- a/src/lib/nativeWindowsRecording.ts +++ b/src/lib/nativeWindowsRecording.ts @@ -53,6 +53,12 @@ export type NativeWindowsRecordingStartResult = { * but the user has to be told, or they discover it in the editor. */ webcamUnavailable?: boolean; + /** + * A microphone was asked for but could not be named, so the helper captured + * whatever Windows calls the default input. The take is fine; the voice on it + * probably is not the one the user chose. + */ + microphoneDefaulted?: boolean; }; export function parseWindowHandleFromSourceId(sourceId?: string | null) { diff --git a/src/native/browserShim.ts b/src/native/browserShim.ts index cb1ada739..17a5e315a 100644 --- a/src/native/browserShim.ts +++ b/src/native/browserShim.ts @@ -42,6 +42,7 @@ let shimSelectedSource: ShimDesktopSource | null = null; type ShimRecordingPrefs = { micEnabled: boolean; micDeviceId: string | null; + micDeviceName: string | null; camEnabled: boolean; camDeviceId: string | null; systemAudioEnabled: boolean; @@ -51,6 +52,7 @@ const recordingPrefsStorageKey = "browser-shim-recording-prefs"; let shimRecordingPrefs: ShimRecordingPrefs = { micEnabled: false, micDeviceId: null, + micDeviceName: null, camEnabled: false, camDeviceId: null, systemAudioEnabled: false, diff --git a/technical-documentation/architecture/recording.md b/technical-documentation/architecture/recording.md index 21375d89b..f9b43486d 100644 --- a/technical-documentation/architecture/recording.md +++ b/technical-documentation/architecture/recording.md @@ -59,6 +59,8 @@ Two consequences follow, and both were once bugs: Electron resolves selected sources, devices, and paths before launching the helper. The helper does not guess a DirectShow camera: Windows receives the resolved selection. A helper error is reported explicitly rather than silently switching a Windows native feature to browser capture. +The microphone is chosen the same way and carries the same requirement: the helper resolves it by id first and by name when that finds nothing — and the id it is given is Chromium's, which names nothing outside the renderer, so the name is what actually does the work. `RecordingPrefs` therefore carries `micDeviceName` beside `micDeviceId` so a HUD rebuilt for a new take knows it without waiting on its own device enumeration. When a particular microphone was asked for — an id other than `default`, or a name — and neither finds an endpoint, the helper captures the Windows default input and says so as `microphone-defaulted` rather than doing it quietly. A request that asks for no particular microphone lands on that same default input and warns nothing: there it is the answer rather than a fallback. Name matching is deliberately strict — equal once normalized, or one containing the other, and nothing weaker. Containment is measured in whole words, and a tier that scored shared *words* has been dropped entirely. Both answered for devices that were not the same one: a requested "micro" sits inside the "microphone" that opens nearly every Windows endpoint name, and "logi" inside "logitech", so a name fitting nothing still selected a device — and silenced the fallback warning by resolving one. `npm run test:wgc-mic-selection:win` drives the real helper through those cases, because WASAPI cannot run on Linux CI. + Two things follow from Windows matching a camera by **name** while Chromium selects one by **id**. First, the renderer reads both halves off the `MediaStreamTrack` it opened rather than from separate state, so a request can never carry one camera's id beside another's name — the failure that let the HUD preview show the chosen camera while the recording captured a different one. Second, a camera the helper cannot open is a warning (`webcam-unavailable`), not a failed recording: the take continues as screen and audio, and the renderer says so at that moment instead of leaving the absence to be discovered in the editor. The DirectShow fallback negotiates the camera's own format first and only asks for RGB32 — inserting a colour converter — when that format is one the helper cannot unpack, which is the only way devices absent from Media Foundation, such as NVIDIA Broadcast, can be captured at all. ## Output files and sidecars