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) {