From 123f95a838f8aa6f57a8579c16f164b2b4bcba0f Mon Sep 17 00:00:00 2001 From: EtienneLescot Date: Thu, 20 Aug 2026 12:26:02 +0200 Subject: [PATCH 1/6] fix(recording): carry the microphone's name, not just its id Reported as "I lose my microphone on the second recording", and the constant was going back to the HUD after the editor. That path destroys and rebuilds the HUD window, and on rebuild the recording prefs restored `micDeviceId` and nothing else -- the SAME asymmetry that cost the camera its identity in #387, on the path that fix did not touch. The name is not optional on Windows. `WasapiLoopbackCapture::initialize` only resolves a microphone by name when one is supplied, and otherwise takes `GetDefaultAudioEndpoint(eConsole)` -- so an empty name records whatever Windows calls the default input, which is why the take came back sounding like the wrong microphone. Nothing filled the gap in time either. The name could only come from the HUD's own `useMicrophoneDevices`, which is lazy and has to complete a getUserMedia permission round trip before it can enumerate, and a recording started from the editor auto-starts and wins that race. Two consecutive requests in one session, from the reporter's log: #1 deviceName: 'Microphone (2- Logitech PRO X Wireless Gaming Headset)' #2 deviceName: undefined So `micDeviceName` joins `micDeviceId` in the prefs SSOT, written by both windows that can pick a microphone and seeded on mount, which removes the race rather than widening the window on it. `useMicrophoneDevices` also prefers the remembered device over "first in the list", for the same reason the camera hook does. And the helper stops falling back in silence: it now reports `microphone-defaulted`, which surfaces as a toast the moment recording starts, instead of leaving the discovery to playback. Co-Authored-By: Claude Opus 5 --- electron/ipc/handlers.ts | 26 +++++++++++++++++++ .../src/wasapi_loopback_capture.cpp | 12 +++++++++ .../nativeWindowsCaptureStop.test.ts | 16 ++++++++++++ .../recording/nativeWindowsCaptureStop.ts | 13 ++++++++++ src/components/ai-edition/v4/RecStage.tsx | 14 ++++++++-- src/components/launch/LaunchWindow.tsx | 24 ++++++++++------- src/hooks/useMicrophoneDevices.ts | 25 +++++++++++++++--- src/hooks/useScreenRecorder.ts | 9 +++++++ src/i18n/locales/ar/editor.json | 1 + src/i18n/locales/en/editor.json | 1 + src/i18n/locales/es/editor.json | 1 + src/i18n/locales/fr/editor.json | 1 + src/i18n/locales/it/editor.json | 1 + src/i18n/locales/ja-JP/editor.json | 1 + src/i18n/locales/ko-KR/editor.json | 1 + src/i18n/locales/pt-BR/editor.json | 1 + src/i18n/locales/ru/editor.json | 1 + src/i18n/locales/tr/editor.json | 1 + src/i18n/locales/vi/editor.json | 1 + src/i18n/locales/zh-CN/editor.json | 1 + src/i18n/locales/zh-TW/editor.json | 1 + src/lib/nativeWindowsRecording.ts | 6 +++++ src/native/browserShim.ts | 2 ++ 23 files changed, 146 insertions(+), 14 deletions(-) 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..a65031e83 100644 --- a/electron/native/wgc-capture/src/wasapi_loopback_capture.cpp +++ b/electron/native/wgc-capture/src/wasapi_loopback_capture.cpp @@ -168,6 +168,18 @@ bool WasapiLoopbackCapture::initialize(WasapiCaptureEndpoint endpoint, const std } } + // A microphone was asked for and neither its id nor its name could name a + // device, so the recording is about to capture whatever Windows calls the + // default input. That is worth saying out loud: the caller sends an empty + // name whenever it could not resolve one in time, and the take then sounds + // like the wrong microphone with nothing anywhere explaining why + // (getopenscreen/openscreen#404). + if (endpoint == WasapiCaptureEndpoint::Microphone && !device_ && deviceName.empty()) { + std::cerr << "{\"event\":\"warning\",\"code\":\"microphone-defaulted\"," + "\"message\":\"No microphone name was supplied; capturing the default input\"}" + << std::endl; + } + if (!device_) { const EDataFlow flow = endpoint == WasapiCaptureEndpoint::SystemLoopback ? eRender : eCapture; diff --git a/electron/recording/nativeWindowsCaptureStop.test.ts b/electron/recording/nativeWindowsCaptureStop.test.ts index acbee862e..224b3d972 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,21 @@ describe("readWebcamUnavailable", () => { }); }); +describe("readMicrophoneDefaulted", () => { + it("sees the helper falling back to the default input", () => { + const output = + "WARNING: Could not resolve microphone by name; using default capture endpoint\n" + + '{"event":"warning","code":"microphone-defaulted","message":"No microphone name was supplied; 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/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..68f1c6645 100644 --- a/src/components/launch/LaunchWindow.tsx +++ b/src/components/launch/LaunchWindow.tsx @@ -160,7 +160,7 @@ export function LaunchWindow() { devices: micDevices, selectedDeviceId: selectedMicId, setSelectedDeviceId: setSelectedMicId, - } = useMicrophoneDevices(microphoneEnabled || isDeviceSettingsOpen); + } = useMicrophoneDevices(microphoneEnabled || isDeviceSettingsOpen, microphoneDeviceId); useEffect(() => { if (selectedMicId && selectedMicId !== "default") { @@ -649,10 +649,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 +667,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 +679,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 +689,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.ts b/src/hooks/useMicrophoneDevices.ts index 37591e669..94ffe2eb0 100644 --- a/src/hooks/useMicrophoneDevices.ts +++ b/src/hooks/useMicrophoneDevices.ts @@ -6,7 +6,14 @@ 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. + */ +export function useMicrophoneDevices(enabled: boolean = true, preferredDeviceId?: string) { const [devices, setDevices] = useState([]); const [selectedDeviceId, setSelectedDeviceId] = useState("default"); const [isLoading, setIsLoading] = useState(false); @@ -15,7 +22,15 @@ 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); + // 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; + }, [selectedDeviceId, preferredDeviceId]); useEffect(() => { if (!enabled) { @@ -49,7 +64,11 @@ 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 preferred = preferredId + ? audioInputs.find((d) => d.deviceId === preferredId) + : 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, From 04def6c06ec2ef38d519b44dc44643dd28f719fa Mon Sep 17 00:00:00 2001 From: EtienneLescot Date: Thu, 20 Aug 2026 12:51:22 +0200 Subject: [PATCH 2/6] fix(recording): answer the review on the microphone fix Two findings, and the first one uncovered a hole in the fix itself. The `microphone-defaulted` warning was keyed on the empty-name case, so a name that WAS supplied and matched no endpoint fell through to the default input just as silently as before. It is keyed on the outcome now -- a particular microphone was asked for and none was found -- which is the condition that actually matters to the person recording. The second: an id the current window cannot match no longer discards the choice behind it. Chromium's device ids are per-origin salted, so the id one window persisted can name nothing in the next while the microphone sits right there in the list, and falling through to the first input silently swapped it. The remembered LABEL is tried before that fallback. Keeping the persisted name beside a fallback id was the other option and would have been worse: it recreates exactly the id/name mismatch this branch exists to remove. Resolving by name picks a real entry from the current list, so the pair stays true by construction. The test fixture also described an invocation that cannot happen -- both helper lines at once, when the two are mutually exclusive. Split into the two real cases, plus the first tests this hook has had. Co-Authored-By: Claude Opus 5 --- .../src/wasapi_loopback_capture.cpp | 29 +++++---- .../nativeWindowsCaptureStop.test.ts | 13 +++- src/components/launch/LaunchWindow.tsx | 7 ++- src/hooks/useMicrophoneDevices.test.ts | 59 +++++++++++++++++++ src/hooks/useMicrophoneDevices.ts | 26 ++++++-- 5 files changed, 114 insertions(+), 20 deletions(-) create mode 100644 src/hooks/useMicrophoneDevices.test.ts diff --git a/electron/native/wgc-capture/src/wasapi_loopback_capture.cpp b/electron/native/wgc-capture/src/wasapi_loopback_capture.cpp index a65031e83..86364ab0c 100644 --- a/electron/native/wgc-capture/src/wasapi_loopback_capture.cpp +++ b/electron/native/wgc-capture/src/wasapi_loopback_capture.cpp @@ -168,19 +168,24 @@ bool WasapiLoopbackCapture::initialize(WasapiCaptureEndpoint endpoint, const std } } - // A microphone was asked for and neither its id nor its name could name a - // device, so the recording is about to capture whatever Windows calls the - // default input. That is worth saying out loud: the caller sends an empty - // name whenever it could not resolve one in time, and the take then sounds - // like the wrong microphone with nothing anywhere explaining why - // (getopenscreen/openscreen#404). - if (endpoint == WasapiCaptureEndpoint::Microphone && !device_ && deviceName.empty()) { - std::cerr << "{\"event\":\"warning\",\"code\":\"microphone-defaulted\"," - "\"message\":\"No microphone name was supplied; capturing the default input\"}" - << std::endl; - } - 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 224b3d972..8c1d48caf 100644 --- a/electron/recording/nativeWindowsCaptureStop.test.ts +++ b/electron/recording/nativeWindowsCaptureStop.test.ts @@ -101,10 +101,19 @@ describe("readWebcamUnavailable", () => { }); describe("readMicrophoneDefaulted", () => { - it("sees the helper falling back to the default input", () => { + // 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":"No microphone name was supplied; capturing the default input"}\n'; + '{"event":"warning","code":"microphone-defaulted","message":"The requested microphone could not be resolved; capturing the default input"}\n'; expect(readMicrophoneDefaulted(output)).toBe(true); }); diff --git a/src/components/launch/LaunchWindow.tsx b/src/components/launch/LaunchWindow.tsx index 68f1c6645..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, microphoneDeviceId); + } = useMicrophoneDevices( + microphoneEnabled || isDeviceSettingsOpen, + microphoneDeviceId, + microphoneDeviceName, + ); useEffect(() => { if (selectedMicId && selectedMicId !== "default") { 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 94ffe2eb0..55f059351 100644 --- a/src/hooks/useMicrophoneDevices.ts +++ b/src/hooks/useMicrophoneDevices.ts @@ -12,8 +12,17 @@ export interface MicrophoneDevice { * 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) { +export function useMicrophoneDevices( + enabled: boolean = true, + preferredDeviceId?: string, + preferredDeviceName?: string, +) { const [devices, setDevices] = useState([]); const [selectedDeviceId, setSelectedDeviceId] = useState("default"); const [isLoading, setIsLoading] = useState(false); @@ -23,6 +32,7 @@ export function useMicrophoneDevices(enabled: boolean = true, preferredDeviceId? // getUserMedia() permission stream acquired and torn down on every open. const selectedDeviceIdRef = useRef(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 @@ -30,7 +40,8 @@ export function useMicrophoneDevices(enabled: boolean = true, preferredDeviceId? useEffect(() => { selectedDeviceIdRef.current = selectedDeviceId; preferredDeviceIdRef.current = preferredDeviceId; - }, [selectedDeviceId, preferredDeviceId]); + preferredDeviceNameRef.current = preferredDeviceName; + }, [selectedDeviceId, preferredDeviceId, preferredDeviceName]); useEffect(() => { if (!enabled) { @@ -65,9 +76,14 @@ export function useMicrophoneDevices(enabled: boolean = true, preferredDeviceId? const stillAvailable = audioInputs.some((d) => d.deviceId === currentId); if ((currentId === "default" || !stillAvailable) && audioInputs.length > 0) { const preferredId = preferredDeviceIdRef.current; - const preferred = preferredId - ? audioInputs.find((d) => d.deviceId === preferredId) - : undefined; + 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); From 09923517e7fabae3afabf76d326a5cbaf53e6094 Mon Sep 17 00:00:00 2001 From: EtienneLescot Date: Thu, 20 Aug 2026 13:13:37 +0200 Subject: [PATCH 3/6] fix(recording): prove the microphone fallback on real hardware The review asked for native coverage of the `microphone-defaulted` condition, since the TypeScript tests only search fabricated helper output and WASAPI cannot run on Linux CI. Driving the real helper through the four cases immediately found that the warning did not work. A name matching nothing still resolved a device, so the fallback never happened and the warning could not fire. `scoreDeviceName` matched SUBSTRINGS: a requested "micro" sits inside the "microphone" that opens almost every Windows endpoint name, which scored 100 and won. Asking for a microphone that does not exist quietly recorded whichever one sorted first -- the very outcome this branch exists to end, reached by another road. Word matching is whole-word now. `scripts/test-windows-microphone-selection.mjs` pins all four cases: an unresolvable id with no name, a name matching no endpoint, a plain default request that must stay silent, and a real name that must resolve. Run it with `npm run test:wgc-mic-selection:win`; the happy path needs OPENSCREEN_WGC_TEST_MICROPHONE_DEVICE_NAME to name a microphone that exists on the machine. Measured on the reporter's hardware, before and after the scoring fix: name-matches-nothing defaulted=false -> defaulted=true (the other three unchanged and already correct) Co-Authored-By: Claude Opus 5 --- .../src/wasapi_loopback_capture.cpp | 36 +++- package.json | 1 + scripts/test-windows-microphone-selection.mjs | 155 ++++++++++++++++++ .../architecture/recording.md | 2 + 4 files changed, 192 insertions(+), 2 deletions(-) create mode 100644 scripts/test-windows-microphone-selection.mjs diff --git a/electron/native/wgc-capture/src/wasapi_loopback_capture.cpp b/electron/native/wgc-capture/src/wasapi_loopback_capture.cpp index 86364ab0c..6970670ce 100644 --- a/electron/native/wgc-capture/src/wasapi_loopback_capture.cpp +++ b/electron/native/wgc-capture/src/wasapi_loopback_capture.cpp @@ -65,6 +65,32 @@ std::wstring normalizeDeviceName(const std::wstring& value) { return result; } +/** + * Is `word` one of the space-separated words of `haystack`? + * + * Both sides have already been through `normalizeDeviceName`, so a word is + * exactly what sits between two spaces. + */ +bool containsWord(const std::wstring& haystack, const std::wstring& word) { + if (haystack.empty() || word.empty()) { + return false; + } + size_t pos = 0; + while (pos <= haystack.size()) { + const size_t end = haystack.find(L' ', pos); + const std::wstring candidate = + haystack.substr(pos, end == std::wstring::npos ? std::wstring::npos : end - pos); + if (candidate == word) { + return true; + } + if (end == std::wstring::npos) { + break; + } + pos = end + 1; + } + return false; +} + 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); @@ -82,15 +108,21 @@ int scoreDeviceName(const std::wstring& candidateName, const std::wstring& candi return 800; } + // Whole words from here down, never substrings. `candidate.find(word)` let a + // requested "micro" match the "microphone" that begins almost every endpoint + // name on Windows, so a name matching nothing still scored high enough to + // win -- and the recording quietly used a microphone nobody asked for, with + // the fallback warning unable to fire because a device HAD been resolved + // (getopenscreen/openscreen#404). 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) { + if (containsWord(candidate, word)) { score += 100; - } else if (id.find(word) != std::wstring::npos) { + } else if (containsWord(id, word)) { score += 50; } } 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..c06a51df8 --- /dev/null +++ b/scripts/test-windows-microphone-selection.mjs @@ -0,0 +1,155 @@ +/** + * 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 = ""; + 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", () => { + clearTimeout(stopTimer); + clearTimeout(killTimer); + fs.rmSync(outputPath, { force: true }); + resolve({ + defaulted: output.includes('"code":"microphone-defaulted"'), + selected: output.match(/"microphoneDeviceName":"([^"]*)"/)?.[1] ?? null, + }); + }); + }); +} + +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 fuzzy match must not invent one", + deviceId: "", + deviceName: "A Microphone That Is Not Here", + 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); + let ok = result.defaulted === testCase.expectDefaulted; + if (ok && testCase.expectSelectedToMatch) { + // Not string equality: WASAPI's friendly name is the app's label without + // the USB ids Chromium appends, so the app's name contains the endpoint's. + ok = Boolean(result.selected) && testCase.deviceName.includes(result.selected); + } + if (!ok) failures += 1; + console.log( + `${ok ? "PASS" : "FAIL"} ${testCase.label.padEnd(24)} defaulted=${String(result.defaulted).padEnd(5)} expected=${String(testCase.expectDefaulted).padEnd(5)} opened="${result.selected}"`, + ); + console.log(` ${testCase.why}`); +} + +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/technical-documentation/architecture/recording.md b/technical-documentation/architecture/recording.md index 21375d89b..982884fe3 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 name, and `RecordingPrefs` therefore carries `micDeviceName` beside `micDeviceId` so a HUD rebuilt for a new take knows it without waiting on its own device enumeration. A request that names no microphone, or names one matching no endpoint, makes the helper capture the Windows default input — it reports that as `microphone-defaulted` rather than doing it quietly. Name matching is by whole words: a requested "micro" matching the "microphone" that opens nearly every Windows endpoint name is how a name that fitted nothing still selected a device. `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 From e341034e368037ecaaef54cff35e6209dc7a168e Mon Sep 17 00:00:00 2001 From: EtienneLescot Date: Thu, 20 Aug 2026 13:35:26 +0200 Subject: [PATCH 4/6] fix(recording): drop the word tier from microphone matching too The camera side of this mistake (#405) showed that making the word match whole-word only fixes the instance and leaves the guessing in place, so the tier goes here as well and the two helpers state the same rule. Nothing real needed it. Every microphone on the reporter's machine resolves at 900 or above without it, because Chromium appends USB ids to the name the driver reports and the rest matches outright. What the tier bought was the ability to answer when it should have said "not this one", and saying that is what makes `microphone-defaulted` reachable. The smoke test gains the case that names the class rather than one instance: a request sharing only a BRAND with a present device -- another Logitech thing is still another device -- must resolve to nothing. npm run test:wgc-mic-selection:win 5/5 on real hardware Co-Authored-By: Claude Opus 5 --- .../src/wasapi_loopback_capture.cpp | 61 +++++-------------- scripts/test-windows-microphone-selection.mjs | 7 +++ .../architecture/recording.md | 2 +- 3 files changed, 22 insertions(+), 48 deletions(-) diff --git a/electron/native/wgc-capture/src/wasapi_loopback_capture.cpp b/electron/native/wgc-capture/src/wasapi_loopback_capture.cpp index 6970670ce..69fc58272 100644 --- a/electron/native/wgc-capture/src/wasapi_loopback_capture.cpp +++ b/electron/native/wgc-capture/src/wasapi_loopback_capture.cpp @@ -66,31 +66,21 @@ std::wstring normalizeDeviceName(const std::wstring& value) { } /** - * Is `word` one of the space-separated words of `haystack`? + * How well a candidate endpoint answers a requested name, or 0 for "not this + * one" -- which the caller must treat as a real answer. * - * Both sides have already been through `normalizeDeviceName`, so a word is - * exactly what sits between two spaces. + * 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. */ -bool containsWord(const std::wstring& haystack, const std::wstring& word) { - if (haystack.empty() || word.empty()) { - return false; - } - size_t pos = 0; - while (pos <= haystack.size()) { - const size_t end = haystack.find(L' ', pos); - const std::wstring candidate = - haystack.substr(pos, end == std::wstring::npos ? std::wstring::npos : end - pos); - if (candidate == word) { - return true; - } - if (end == std::wstring::npos) { - break; - } - pos = end + 1; - } - return false; -} - 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); @@ -108,30 +98,7 @@ int scoreDeviceName(const std::wstring& candidateName, const std::wstring& candi return 800; } - // Whole words from here down, never substrings. `candidate.find(word)` let a - // requested "micro" match the "microphone" that begins almost every endpoint - // name on Windows, so a name matching nothing still scored high enough to - // win -- and the recording quietly used a microphone nobody asked for, with - // the fallback warning unable to fire because a device HAD been resolved - // (getopenscreen/openscreen#404). - 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 (containsWord(candidate, word)) { - score += 100; - } else if (containsWord(id, word)) { - score += 50; - } - } - if (end == std::wstring::npos) { - break; - } - pos = end + 1; - } - return score; + return 0; } std::wstring getDeviceFriendlyName(IMMDevice* device) { diff --git a/scripts/test-windows-microphone-selection.mjs b/scripts/test-windows-microphone-selection.mjs index c06a51df8..4d7e28a49 100644 --- a/scripts/test-windows-microphone-selection.mjs +++ b/scripts/test-windows-microphone-selection.mjs @@ -107,6 +107,13 @@ const cases = [ deviceName: "A Microphone That Is Not Here", 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", diff --git a/technical-documentation/architecture/recording.md b/technical-documentation/architecture/recording.md index 982884fe3..0abea732e 100644 --- a/technical-documentation/architecture/recording.md +++ b/technical-documentation/architecture/recording.md @@ -59,7 +59,7 @@ 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 name, and `RecordingPrefs` therefore carries `micDeviceName` beside `micDeviceId` so a HUD rebuilt for a new take knows it without waiting on its own device enumeration. A request that names no microphone, or names one matching no endpoint, makes the helper capture the Windows default input — it reports that as `microphone-defaulted` rather than doing it quietly. Name matching is by whole words: a requested "micro" matching the "microphone" that opens nearly every Windows endpoint name is how a name that fitted nothing still selected a device. `npm run test:wgc-mic-selection:win` drives the real helper through those cases, because WASAPI cannot run on Linux CI. +The microphone is chosen the same way and carries the same requirement: the helper resolves it by name, and `RecordingPrefs` therefore carries `micDeviceName` beside `micDeviceId` so a HUD rebuilt for a new take knows it without waiting on its own device enumeration. A request that names no microphone, or names one matching no endpoint, makes the helper capture the Windows default input — it reports that as `microphone-defaulted` rather than doing it quietly. Name matching is deliberately strict — equal once normalized, or one containing the other, and nothing weaker. A tier that scored shared *words* used to bridge the rest and answered for devices that were not the same one: a requested "micro" matches the "microphone" that opens nearly every Windows endpoint name, so a name fitting nothing still selected a device and the fallback warning could not fire. `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. From 5c40f59e43a18e3ea50d8deb4a0f10d7117c28d5 Mon Sep 17 00:00:00 2001 From: EtienneLescot Date: Thu, 20 Aug 2026 13:43:44 +0200 Subject: [PATCH 5/6] fix(recording): match microphone names on word boundaries The review was right that removing the word tier did not finish the job: the containment tier scored substrings too, and it is the one that actually resolves devices. Probed on the reporter's hardware, before: requested "Micro" -> opened "Microphone (Logitech StreamCam)" requested "Logi" -> opened "Microphone (Logitech StreamCam)" Both silently, because resolving an endpoint is exactly what stops `microphone-defaulted` from firing. Containment now has to land on word boundaries, which keeps every real pairing -- Chromium's name is the driver's plus USB ids, whole words either way -- and refuses a request that is merely spelled inside a longer word. The smoke test grows the two cases that name this mistake, and stops passing on evidence it did not have: - a helper that dies, is signalled, or never starts is a failure, not a silent one; the old harness read the output and ignored how it ended - a negative case asserts the endpoints were ENUMERATED and all scored zero, so it cannot pass merely because nothing was there to match The doc said the helper resolves a microphone by name. It resolves by id first and by name when that finds nothing -- and since the id it receives is Chromium's, which names nothing outside the renderer, the name is what does the work. Both halves are stated now. npm run test:wgc-mic-selection:win 7/7 on real hardware Co-Authored-By: Claude Opus 5 --- .../src/wasapi_loopback_capture.cpp | 32 ++++++++- scripts/test-windows-microphone-selection.mjs | 69 ++++++++++++++++--- .../architecture/recording.md | 2 +- 3 files changed, 91 insertions(+), 12 deletions(-) diff --git a/electron/native/wgc-capture/src/wasapi_loopback_capture.cpp b/electron/native/wgc-capture/src/wasapi_loopback_capture.cpp index 69fc58272..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,34 @@ 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. @@ -91,10 +119,10 @@ 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; } diff --git a/scripts/test-windows-microphone-selection.mjs b/scripts/test-windows-microphone-selection.mjs index 4d7e28a49..7d5b8444a 100644 --- a/scripts/test-windows-microphone-selection.mjs +++ b/scripts/test-windows-microphone-selection.mjs @@ -65,6 +65,10 @@ function runHelper(label, microphoneDeviceId, microphoneDeviceName) { 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(); }); @@ -80,13 +84,23 @@ function runHelper(label, microphoneDeviceId, microphoneDeviceName) { }, RECORD_MS); const killTimer = setTimeout(() => proc.kill(), RECORD_MS + 6000); - proc.on("close", () => { + 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]) }), + ), }); }); }); @@ -102,11 +116,25 @@ const cases = [ }, { label: "name-matches-nothing", - why: "a name was supplied and matches no endpoint — the fuzzy match must not invent one", + 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", @@ -141,17 +169,40 @@ if (REAL_MIC_NAME) { let failures = 0; for (const testCase of cases) { const result = await runHelper(testCase.label, testCase.deviceId, testCase.deviceName); - let ok = result.defaulted === testCase.expectDefaulted; - if (ok && testCase.expectSelectedToMatch) { - // Not string equality: WASAPI's friendly name is the app's label without - // the USB ids Chromium appends, so the app's name contains the endpoint's. - ok = Boolean(result.selected) && testCase.deviceName.includes(result.selected); + + 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 (!ok) failures += 1; + 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( - `${ok ? "PASS" : "FAIL"} ${testCase.label.padEnd(24)} defaulted=${String(result.defaulted).padEnd(5)} expected=${String(testCase.expectDefaulted).padEnd(5)} opened="${result.selected}"`, + `${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( diff --git a/technical-documentation/architecture/recording.md b/technical-documentation/architecture/recording.md index 0abea732e..b6cc0f6fd 100644 --- a/technical-documentation/architecture/recording.md +++ b/technical-documentation/architecture/recording.md @@ -59,7 +59,7 @@ 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 name, and `RecordingPrefs` therefore carries `micDeviceName` beside `micDeviceId` so a HUD rebuilt for a new take knows it without waiting on its own device enumeration. A request that names no microphone, or names one matching no endpoint, makes the helper capture the Windows default input — it reports that as `microphone-defaulted` rather than doing it quietly. Name matching is deliberately strict — equal once normalized, or one containing the other, and nothing weaker. A tier that scored shared *words* used to bridge the rest and answered for devices that were not the same one: a requested "micro" matches the "microphone" that opens nearly every Windows endpoint name, so a name fitting nothing still selected a device and the fallback warning could not fire. `npm run test:wgc-mic-selection:win` drives the real helper through those cases, because WASAPI cannot run on Linux CI. +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. A request that names no microphone, or names one matching no endpoint, makes the helper capture the Windows default input — it reports that as `microphone-defaulted` rather than doing it quietly. 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. From 0827525b23dcb319d3a6e04e307535425aaa2dbe Mon Sep 17 00:00:00 2001 From: EtienneLescot Date: Thu, 20 Aug 2026 14:42:13 +0200 Subject: [PATCH 6/6] docs(recording): state the fallback-warning condition exactly The review caught the sentence claiming a request naming no microphone reports `microphone-defaulted`. It does not, and the smoke test says so: a plain `default` request expects no warning at all. The helper keys the warning on having been asked for a PARTICULAR microphone -- an id other than `default`, or a name -- and finding none. Asking for no particular one lands on the same default input, where it is the answer rather than a fallback, and warning there would cry wolf on every take that never chose a microphone. Co-Authored-By: Claude Opus 5 --- technical-documentation/architecture/recording.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/technical-documentation/architecture/recording.md b/technical-documentation/architecture/recording.md index b6cc0f6fd..f9b43486d 100644 --- a/technical-documentation/architecture/recording.md +++ b/technical-documentation/architecture/recording.md @@ -59,7 +59,7 @@ 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. A request that names no microphone, or names one matching no endpoint, makes the helper capture the Windows default input — it reports that as `microphone-defaulted` rather than doing it quietly. 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. +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.