From ade2d63fe8cfe4c2541ac8ecb59750d7ab337c91 Mon Sep 17 00:00:00 2001 From: Jonathan Martins Date: Wed, 16 Sep 2026 10:31:05 -0300 Subject: [PATCH] Fix silent recordings: mic device selection ignored, system audio failures unreported MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two contributing issues to Windows native recordings ending up silent regardless of the mic/system-audio toggles: - The native capture helper can only match the selected microphone by its human-readable device name, but that name is blank until the renderer has been granted mic permission at least once in that session (per Chromium's device-label privacy rules). Without a name it silently falls back to the OS default recording device, ignoring whatever the user picked. Now briefly primes permission to resolve the real device name before starting native capture. - There is no renderer-side fallback for system/loopback audio (unlike microphone, which already falls back to a browser-recorded sidecar). When WASAPI loopback initialization fails, the native helper only logs a warning and continues recording video-only — nothing detected that warning, so the app would still try to treat the resulting empty/missing system-audio file as valid. Now detects that warning (isWindowsSystemAudioCaptureUnavailable), drops the broken audio path so it isn't shipped, and tells the user the recording will have no desktop audio instead of leaving them to discover a silent file. Note: could not verify against real native WASAPI failures (no capture hardware / real failure scenario reproducible in this environment) — this addresses the two concrete gaps found by static analysis, not a guaranteed fix for every possible silent-audio cause. Co-Authored-By: Claude Sonnet 5 --- electron/electron-env.d.ts | 1 + .../ipc/recording/windowsFallbacks.test.ts | 29 +++++++++++++++ electron/ipc/recording/windowsFallbacks.ts | 21 +++++++++++ electron/ipc/register/recording.ts | 16 +++++++- src/hooks/useScreenRecorder.ts | 37 +++++++++++++++++++ 5 files changed, 103 insertions(+), 1 deletion(-) diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index 1a926718f..da6830930 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -258,6 +258,7 @@ interface Window { error?: string; userNotified?: boolean; microphoneFallbackRequired?: boolean; + systemAudioCaptureUnavailable?: boolean; }>; stopNativeScreenRecording: () => Promise<{ success: boolean; diff --git a/electron/ipc/recording/windowsFallbacks.test.ts b/electron/ipc/recording/windowsFallbacks.test.ts index 512a81bef..f1fccf99d 100644 --- a/electron/ipc/recording/windowsFallbacks.test.ts +++ b/electron/ipc/recording/windowsFallbacks.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { + isWindowsSystemAudioCaptureUnavailable, shouldStartWindowsBrowserMicrophoneFallback, shouldUseWindowsBrowserMicrophoneFallback, WINDOWS_MIC_CAPTURE_MODE_ENV, @@ -102,3 +103,31 @@ describe("shouldUseWindowsBrowserMicrophoneFallback", () => { ).toBe(true); }); }); + +describe("isWindowsSystemAudioCaptureUnavailable", () => { + it("returns true when native WASAPI loopback initialization fails", () => { + expect( + isWindowsSystemAudioCaptureUnavailable( + "WARNING: Failed to initialize WASAPI loopback\nRecording started", + { capturesSystemAudio: true }, + ), + ).toBe(true); + }); + + it("returns false when system audio capture was not requested", () => { + expect( + isWindowsSystemAudioCaptureUnavailable( + "WARNING: Failed to initialize WASAPI loopback\nRecording started", + { capturesSystemAudio: false }, + ), + ).toBe(false); + }); + + it("returns false for a healthy native loopback session", () => { + expect( + isWindowsSystemAudioCaptureUnavailable("Recording started", { + capturesSystemAudio: true, + }), + ).toBe(false); + }); +}); diff --git a/electron/ipc/recording/windowsFallbacks.ts b/electron/ipc/recording/windowsFallbacks.ts index f579e0527..1705e1c72 100644 --- a/electron/ipc/recording/windowsFallbacks.ts +++ b/electron/ipc/recording/windowsFallbacks.ts @@ -31,3 +31,24 @@ export function shouldUseWindowsBrowserMicrophoneFallback( )) ); } + +const WINDOWS_SYSTEM_AUDIO_UNAVAILABLE_MARKERS = [ + "WARNING: Failed to initialize WASAPI loopback", +]; + +/** + * Unlike microphone capture, the native Windows helper has no renderer-side + * fallback for system/loopback audio — a failed WASAPI loopback session is + * silent by design (it prints a warning but keeps recording video only). We + * can still detect that warning so the app stops treating the resulting + * (empty or missing) system-audio file as valid and tells the user instead. + */ +export function isWindowsSystemAudioCaptureUnavailable( + captureOutput: string, + options?: { capturesSystemAudio?: boolean }, +) { + return ( + Boolean(options?.capturesSystemAudio) && + WINDOWS_SYSTEM_AUDIO_UNAVAILABLE_MARKERS.some((marker) => captureOutput.includes(marker)) + ); +} diff --git a/electron/ipc/register/recording.ts b/electron/ipc/register/recording.ts index 2c50e3976..f8447b8b2 100644 --- a/electron/ipc/register/recording.ts +++ b/electron/ipc/register/recording.ts @@ -83,6 +83,7 @@ import { waitForWindowsCaptureStop, } from "../recording/windows"; import { + isWindowsSystemAudioCaptureUnavailable, shouldStartWindowsBrowserMicrophoneFallback, shouldUseWindowsBrowserMicrophoneFallback, } from "../recording/windowsFallbacks"; @@ -594,6 +595,19 @@ export function registerRecordingHandlers( microphonePath = null; setWindowsMicAudioPath(null); } + // There is no renderer-side fallback for system/loopback audio: a + // failed WASAPI loopback session is otherwise silent, leaving an + // empty or missing audio file that would be treated as valid. + // Detect it and drop the path so the recording is correctly + // treated as video-only instead of shipping broken audio. + const systemAudioCaptureUnavailable = isWindowsSystemAudioCaptureUnavailable( + captureOutput, + options, + ); + if (systemAudioCaptureUnavailable) { + systemAudioPath = null; + setWindowsSystemAudioPath(null); + } setWindowsNativeCaptureActive(true); setNativeScreenRecordingActive(true); recordNativeCaptureDiagnostics({ @@ -611,7 +625,7 @@ export function registerRecordingHandlers( microphonePath, processOutput: captureOutput.trim() || undefined, }); - return { success: true, microphoneFallbackRequired }; + return { success: true, microphoneFallbackRequired, systemAudioCaptureUnavailable }; } catch (error) { recordNativeCaptureDiagnostics({ backend: "windows-wgc", diff --git a/src/hooks/useScreenRecorder.ts b/src/hooks/useScreenRecorder.ts index 459652415..72a9304e0 100644 --- a/src/hooks/useScreenRecorder.ts +++ b/src/hooks/useScreenRecorder.ts @@ -1195,6 +1195,31 @@ export function useScreenRecorder(): UseScreenRecorderReturn { (d) => d.deviceId === microphoneDeviceId && d.kind === "audioinput", ); micLabel = mic?.label || undefined; + + // Device labels are blank until the renderer has been granted mic + // permission at least once. Without a label, the native capture + // process can't match the selected device by name and silently + // falls back to the OS default mic, ignoring the user's choice. + // Briefly prime permission so we can pass the real device name. + if (!micLabel) { + let permissionStream: MediaStream | null = null; + try { + permissionStream = await navigator.mediaDevices.getUserMedia({ + audio: microphoneDeviceId + ? { deviceId: { exact: microphoneDeviceId } } + : true, + }); + const labeledDevices = await navigator.mediaDevices.enumerateDevices(); + const labeledMic = labeledDevices.find( + (d) => d.deviceId === microphoneDeviceId && d.kind === "audioinput", + ); + micLabel = labeledMic?.label || undefined; + } catch { + // Fall through - native process will use the default mic. + } finally { + permissionStream?.getTracks().forEach((track) => track.stop()); + } + } } catch { // Fall through - native process will use the default mic. } @@ -1820,6 +1845,18 @@ export function useScreenRecorder(): UseScreenRecorderReturn { ? 0 : webcamStartTime.current - mainStartedAt; + // Unlike microphone capture, there is no fallback path for + // system/loopback audio on Windows: if WASAPI couldn't open the + // loopback session, the recording will simply have no system + // audio track. Tell the user instead of leaving them to discover + // a silently muted recording. + if (nativeResult.systemAudioCaptureUnavailable) { + toast.warning( + "System audio couldn't be captured for this recording. It will be saved without desktop audio.", + { id: "recording-system-audio-unavailable", duration: 10000 }, + ); + } + // When native mic capture is unavailable or explicitly bypassed, // record mic via browser getUserMedia as a sidecar file. if (nativeResult.microphoneFallbackRequired && microphoneEnabled) {