From 7dd827be43d288e69a15f5227b20dd6c2d0f1d3e Mon Sep 17 00:00:00 2001 From: wiiiii123 Date: Thu, 7 May 2026 04:00:46 +0700 Subject: [PATCH] fix(recording): add windows mic diagnostics fallback --- electron/ipc/recording/audioFilters.test.ts | 25 ++- electron/ipc/recording/audioFilters.ts | 7 + electron/ipc/recording/windows.ts | 18 +- .../ipc/recording/windowsFallbacks.test.ts | 36 +++- electron/ipc/recording/windowsFallbacks.ts | 19 +- electron/ipc/register/recording.ts | 174 +++++++++--------- src/hooks/useScreenRecorder.test.ts | 55 +++++- src/hooks/useScreenRecorder.ts | 18 +- 8 files changed, 252 insertions(+), 100 deletions(-) diff --git a/electron/ipc/recording/audioFilters.test.ts b/electron/ipc/recording/audioFilters.test.ts index 8305a994..0436bb4d 100644 --- a/electron/ipc/recording/audioFilters.test.ts +++ b/electron/ipc/recording/audioFilters.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from "vitest"; -import { WINDOWS_NATIVE_MIC_PRE_FILTERS } from "./audioFilters"; +import { + RECORDING_AUDIO_SIDECAR_DEBUG_ENV, + shouldKeepRecordingAudioSidecars, + WINDOWS_NATIVE_MIC_PRE_FILTERS, +} from "./audioFilters"; describe("Windows native mic pre-filter policy", () => { it("keeps repair filters without automatic gain or loudness normalization", () => { @@ -11,4 +15,23 @@ describe("Windows native mic pre-filter policy", () => { ), ).toBe(false); }); + + it("keeps native audio sidecars only when explicitly requested", () => { + expect(shouldKeepRecordingAudioSidecars({})).toBe(false); + expect( + shouldKeepRecordingAudioSidecars({ + [RECORDING_AUDIO_SIDECAR_DEBUG_ENV]: "1", + }), + ).toBe(true); + expect( + shouldKeepRecordingAudioSidecars({ + [RECORDING_AUDIO_SIDECAR_DEBUG_ENV]: "true", + }), + ).toBe(true); + expect( + shouldKeepRecordingAudioSidecars({ + [RECORDING_AUDIO_SIDECAR_DEBUG_ENV]: "off", + }), + ).toBe(false); + }); }); diff --git a/electron/ipc/recording/audioFilters.ts b/electron/ipc/recording/audioFilters.ts index e765cc71..72bc3915 100644 --- a/electron/ipc/recording/audioFilters.ts +++ b/electron/ipc/recording/audioFilters.ts @@ -1,3 +1,10 @@ // Keep native mic capture dry by default. Automatic loudness normalization // amplified wireless-headset noise and WASAPI discontinuities during beta tests. export const WINDOWS_NATIVE_MIC_PRE_FILTERS = ["adeclip=threshold=1"]; + +export const RECORDING_AUDIO_SIDECAR_DEBUG_ENV = "RECORDLY_KEEP_RECORDING_AUDIO_SIDECARS"; + +export function shouldKeepRecordingAudioSidecars(env: NodeJS.ProcessEnv = process.env) { + const value = env[RECORDING_AUDIO_SIDECAR_DEBUG_ENV]?.trim().toLowerCase(); + return value === "1" || value === "true" || value === "yes" || value === "on"; +} diff --git a/electron/ipc/recording/windows.ts b/electron/ipc/recording/windows.ts index 3f2e6ea3..72d641e5 100644 --- a/electron/ipc/recording/windows.ts +++ b/electron/ipc/recording/windows.ts @@ -23,7 +23,11 @@ import { } from "../state"; import type { AudioSyncAdjustment } from "../types"; import { moveFileWithOverwrite } from "../utils"; -import { WINDOWS_NATIVE_MIC_PRE_FILTERS } from "./audioFilters"; +import { + RECORDING_AUDIO_SIDECAR_DEBUG_ENV, + shouldKeepRecordingAudioSidecars, + WINDOWS_NATIVE_MIC_PRE_FILTERS, +} from "./audioFilters"; import { getCompanionAudioStartDelayMs, getRecordingAudioMuxTimeoutMs, @@ -222,6 +226,7 @@ export async function muxNativeWindowsVideoWithAudio( micAudioPath: string | null, ) { const ffmpegPath = getFfmpegBinaryPath(); + const keepAudioSidecars = shouldKeepRecordingAudioSidecars(); const inputs: string[] = ["-i", videoPath]; const audioInputs: string[] = []; const audioFilePaths: string[] = []; @@ -235,7 +240,9 @@ export async function muxNativeWindowsVideoWithAudio( const stat = await fs.stat(audioPath); if (stat.size <= 0) { console.warn(`[mux-win] Skipping ${label} audio: file is empty (${audioPath})`); - await fs.rm(audioPath, { force: true }).catch(() => undefined); + if (!keepAudioSidecars) { + await fs.rm(audioPath, { force: true }).catch(() => undefined); + } continue; } inputs.push("-i", audioPath); @@ -387,6 +394,13 @@ export async function muxNativeWindowsVideoWithAudio( throw error; } + if (keepAudioSidecars) { + console.log( + `[mux-win] Keeping native audio sidecars because ${RECORDING_AUDIO_SIDECAR_DEBUG_ENV} is enabled`, + ); + return; + } + for (const audioPath of [systemAudioPath, micAudioPath]) { if (audioPath) { await Promise.all([ diff --git a/electron/ipc/recording/windowsFallbacks.test.ts b/electron/ipc/recording/windowsFallbacks.test.ts index 9945210e..f8586c97 100644 --- a/electron/ipc/recording/windowsFallbacks.test.ts +++ b/electron/ipc/recording/windowsFallbacks.test.ts @@ -1,8 +1,30 @@ import { describe, expect, it } from "vitest"; -import { shouldUseWindowsBrowserMicrophoneFallback } from "./windowsFallbacks"; +import { + shouldStartWindowsBrowserMicrophoneFallback, + shouldUseWindowsBrowserMicrophoneFallback, + WINDOWS_MIC_CAPTURE_MODE_ENV, +} from "./windowsFallbacks"; describe("shouldUseWindowsBrowserMicrophoneFallback", () => { + it("can be forced before native capture starts", () => { + expect( + shouldStartWindowsBrowserMicrophoneFallback( + { capturesMicrophone: true }, + { [WINDOWS_MIC_CAPTURE_MODE_ENV]: "browser" }, + ), + ).toBe(true); + }); + + it("does not force fallback when microphone capture was not requested", () => { + expect( + shouldStartWindowsBrowserMicrophoneFallback( + { capturesMicrophone: false }, + { [WINDOWS_MIC_CAPTURE_MODE_ENV]: "browser" }, + ), + ).toBe(false); + }); + it("returns true when native Windows mic initialization fails", () => { expect( shouldUseWindowsBrowserMicrophoneFallback( @@ -20,4 +42,14 @@ describe("shouldUseWindowsBrowserMicrophoneFallback", () => { ), ).toBe(false); }); -}); \ No newline at end of file + + it("returns true when browser fallback is forced", () => { + expect( + shouldUseWindowsBrowserMicrophoneFallback( + "Recording started", + { capturesMicrophone: true }, + { [WINDOWS_MIC_CAPTURE_MODE_ENV]: "fallback" }, + ), + ).toBe(true); + }); +}); diff --git a/electron/ipc/recording/windowsFallbacks.ts b/electron/ipc/recording/windowsFallbacks.ts index b8e6e709..d758abe7 100644 --- a/electron/ipc/recording/windowsFallbacks.ts +++ b/electron/ipc/recording/windowsFallbacks.ts @@ -1,11 +1,26 @@ const WINDOWS_MIC_CAPTURE_INIT_WARNING = "WARNING: Failed to initialize WASAPI mic capture"; +export const WINDOWS_MIC_CAPTURE_MODE_ENV = "RECORDLY_WINDOWS_MIC_CAPTURE"; + +export function shouldStartWindowsBrowserMicrophoneFallback( + options?: { capturesMicrophone?: boolean }, + env: NodeJS.ProcessEnv = process.env, +) { + if (!options?.capturesMicrophone) { + return false; + } + + const mode = env[WINDOWS_MIC_CAPTURE_MODE_ENV]?.trim().toLowerCase(); + return mode === "browser" || mode === "fallback" || mode === "renderer"; +} export function shouldUseWindowsBrowserMicrophoneFallback( captureOutput: string, options?: { capturesMicrophone?: boolean }, + env: NodeJS.ProcessEnv = process.env, ) { return ( Boolean(options?.capturesMicrophone) && - captureOutput.includes(WINDOWS_MIC_CAPTURE_INIT_WARNING) + (shouldStartWindowsBrowserMicrophoneFallback(options, env) || + captureOutput.includes(WINDOWS_MIC_CAPTURE_INIT_WARNING)) ); -} \ No newline at end of file +} diff --git a/electron/ipc/register/recording.ts b/electron/ipc/register/recording.ts index f8ecd1d0..c7d95ca4 100644 --- a/electron/ipc/register/recording.ts +++ b/electron/ipc/register/recording.ts @@ -38,6 +38,7 @@ import { getWindowsCaptureExePath, } from "../paths/binaries"; import { rememberApprovedLocalReadPath } from "../project/manager"; +import { shouldKeepRecordingAudioSidecars } from "../recording/audioFilters"; import { getCompanionAudioFallbackInfo, getFileSizeIfPresent, @@ -65,7 +66,10 @@ import { waitForWindowsCaptureStart, waitForWindowsCaptureStop, } from "../recording/windows"; -import { shouldUseWindowsBrowserMicrophoneFallback } from "../recording/windowsFallbacks"; +import { + shouldStartWindowsBrowserMicrophoneFallback, + shouldUseWindowsBrowserMicrophoneFallback, +} from "../recording/windowsFallbacks"; import { cachedSystemCursorAssets, cachedSystemCursorAssetsSourceMtimeMs, @@ -123,11 +127,7 @@ import { windowsPendingVideoPath, windowsSystemAudioPath, } from "../state"; -import type { - CursorTelemetryPoint, - NativeMacRecordingOptions, - SelectedSource, -} from "../types"; +import type { CursorTelemetryPoint, NativeMacRecordingOptions, SelectedSource } from "../types"; import { getMacPrivacySettingsUrl, getRecordingsDir, @@ -191,6 +191,11 @@ async function cleanupWindowsOrphanedMicAudioPath(filePath: string | null) { return; } + if (shouldKeepRecordingAudioSidecars()) { + console.log(`[recording] Keeping orphaned native mic sidecar for diagnostics: ${filePath}`); + return; + } + await fs.rm(filePath, { force: true }).catch(() => undefined); } @@ -238,6 +243,8 @@ export function registerRecordingHandlers( let systemAudioPath: string | null = null; let microphonePath: string | null = null; let orphanedMicAudioPath: string | null = null; + const browserMicFallbackRequested = + shouldStartWindowsBrowserMicrophoneFallback(options); const resolvedDisplay = resolveWindowsCaptureDisplay( source, getScreen().getAllDisplays(), @@ -266,7 +273,7 @@ export function registerRecordingHandlers( setWindowsSystemAudioPath(systemAudioPath); } - if (options?.capturesMicrophone) { + if (options?.capturesMicrophone && !browserMicFallbackRequested) { microphonePath = path.join(recordingsDir, `recording-${timestamp}.mic.wav`); config.captureMic = true; config.micOutputPath = microphonePath; @@ -274,6 +281,9 @@ export function registerRecordingHandlers( config.micDeviceName = options.microphoneLabel; } setWindowsMicAudioPath(microphonePath); + } else if (browserMicFallbackRequested) { + config.captureMic = false; + setWindowsMicAudioPath(null); } recordNativeCaptureDiagnostics({ @@ -312,10 +322,9 @@ export function registerRecordingHandlers( }); await waitForWindowsCaptureStart(wcProc); - const microphoneFallbackRequired = shouldUseWindowsBrowserMicrophoneFallback( - captureOutput, - options, - ); + const microphoneFallbackRequired = + browserMicFallbackRequested || + shouldUseWindowsBrowserMicrophoneFallback(captureOutput, options); if (microphoneFallbackRequired) { orphanedMicAudioPath = microphonePath; setWindowsOrphanedMicAudioPath(orphanedMicAudioPath); @@ -1034,92 +1043,89 @@ export function registerRecordingHandlers( } }); - ipcMain.handle( - "mux-native-windows-recording", - async (_event, expectedDurationMs?: number) => { - const videoPath = windowsPendingVideoPath; - const orphanedMicAudioPath = windowsOrphanedMicAudioPath; - setWindowsPendingVideoPath(null); - setWindowsOrphanedMicAudioPath(null); + ipcMain.handle("mux-native-windows-recording", async (_event, expectedDurationMs?: number) => { + const videoPath = windowsPendingVideoPath; + const orphanedMicAudioPath = windowsOrphanedMicAudioPath; + setWindowsPendingVideoPath(null); + setWindowsOrphanedMicAudioPath(null); - if (!videoPath) { - return { success: false, message: "No native Windows video pending for mux" }; + if (!videoPath) { + return { success: false, message: "No native Windows video pending for mux" }; + } + + try { + try { + const padding = await extendNativeWindowsVideoToDuration( + videoPath, + expectedDurationMs, + ); + if (padding.padded) { + console.log( + `[mux-win] Extended native Windows video to ${padding.durationSeconds.toFixed(3)}s using the final frame`, + ); + } + } catch (paddingError) { + console.warn( + "[mux-win] Failed to extend native Windows video duration:", + paddingError, + ); } - try { - try { - const padding = await extendNativeWindowsVideoToDuration( - videoPath, - expectedDurationMs, - ); - if (padding.padded) { - console.log( - `[mux-win] Extended native Windows video to ${padding.durationSeconds.toFixed(3)}s using the final frame`, - ); - } - } catch (paddingError) { - console.warn( - "[mux-win] Failed to extend native Windows video duration:", - paddingError, - ); - } - - if (windowsSystemAudioPath || windowsMicAudioPath) { - await muxNativeWindowsVideoWithAudio( - videoPath, - windowsSystemAudioPath, - windowsMicAudioPath, - ); - setWindowsSystemAudioPath(null); - setWindowsMicAudioPath(null); - } - - recordNativeCaptureDiagnostics({ - backend: "windows-wgc", - phase: "mux", - outputPath: videoPath, - fileSizeBytes: await getFileSizeIfPresent(videoPath), - }); - await cleanupWindowsOrphanedMicAudioPath(orphanedMicAudioPath); - return await finalizeStoredVideo(videoPath); - } catch (error) { - console.error("Failed to mux native Windows recording:", error); - recordNativeCaptureDiagnostics({ - backend: "windows-wgc", - phase: "mux", - outputPath: videoPath, - systemAudioPath: windowsSystemAudioPath, - microphonePath: windowsMicAudioPath, - fileSizeBytes: await getFileSizeIfPresent(videoPath), - error: String(error), - }); + if (windowsSystemAudioPath || windowsMicAudioPath) { + await muxNativeWindowsVideoWithAudio( + videoPath, + windowsSystemAudioPath, + windowsMicAudioPath, + ); setWindowsSystemAudioPath(null); setWindowsMicAudioPath(null); - await cleanupWindowsOrphanedMicAudioPath(orphanedMicAudioPath); - try { - return await finalizeStoredVideo(videoPath); - } catch { - try { - await validateRecordedVideo(videoPath); - return { - success: false, - path: videoPath, - message: "Failed to mux native Windows recording", - error: String(error), - }; - } catch { - // The fallback path is not safely playable; surface the original mux error. - } + } + recordNativeCaptureDiagnostics({ + backend: "windows-wgc", + phase: "mux", + outputPath: videoPath, + fileSizeBytes: await getFileSizeIfPresent(videoPath), + }); + await cleanupWindowsOrphanedMicAudioPath(orphanedMicAudioPath); + return await finalizeStoredVideo(videoPath); + } catch (error) { + console.error("Failed to mux native Windows recording:", error); + recordNativeCaptureDiagnostics({ + backend: "windows-wgc", + phase: "mux", + outputPath: videoPath, + systemAudioPath: windowsSystemAudioPath, + microphonePath: windowsMicAudioPath, + fileSizeBytes: await getFileSizeIfPresent(videoPath), + error: String(error), + }); + setWindowsSystemAudioPath(null); + setWindowsMicAudioPath(null); + await cleanupWindowsOrphanedMicAudioPath(orphanedMicAudioPath); + try { + return await finalizeStoredVideo(videoPath); + } catch { + try { + await validateRecordedVideo(videoPath); return { success: false, + path: videoPath, message: "Failed to mux native Windows recording", error: String(error), }; + } catch { + // The fallback path is not safely playable; surface the original mux error. } + + return { + success: false, + message: "Failed to mux native Windows recording", + error: String(error), + }; } - }, - ); + } + }); ipcMain.handle("start-ffmpeg-recording", async (_, source: SelectedSource) => { if (ffmpegCaptureProcess) { diff --git a/src/hooks/useScreenRecorder.test.ts b/src/hooks/useScreenRecorder.test.ts index 4395a9b1..ac133712 100644 --- a/src/hooks/useScreenRecorder.test.ts +++ b/src/hooks/useScreenRecorder.test.ts @@ -59,12 +59,16 @@ function pauseRecording( paused: boolean, isNativeRecording: boolean, webcamRecorder?: ReturnType | null, + micFallbackRecorder?: ReturnType | null, ): boolean { if (!recording || paused) return false; if (isNativeRecording) { if (webcamRecorder?.state === "recording") { webcamRecorder.pause(); } + if (micFallbackRecorder?.state === "recording") { + micFallbackRecorder.pause(); + } return true; } if (recorder.state === "recording") { @@ -83,12 +87,16 @@ function resumeRecording( paused: boolean, isNativeRecording: boolean, webcamRecorder?: ReturnType | null, + micFallbackRecorder?: ReturnType | null, ): boolean { if (!recording || !paused) return false; if (isNativeRecording) { if (webcamRecorder?.state === "paused") { webcamRecorder.resume(); } + if (micFallbackRecorder?.state === "paused") { + micFallbackRecorder.resume(); + } return true; } if (recorder.state === "paused") { @@ -104,6 +112,7 @@ function resumeRecording( async function pauseNativeRecording( webcamRecorder?: ReturnType | null, result: { success: boolean } = { success: true }, + micFallbackRecorder?: ReturnType | null, ): Promise { if (!result.success) { return false; @@ -112,6 +121,9 @@ async function pauseNativeRecording( if (webcamRecorder?.state === "recording") { webcamRecorder.pause(); } + if (micFallbackRecorder?.state === "recording") { + micFallbackRecorder.pause(); + } return true; } @@ -119,6 +131,7 @@ async function pauseNativeRecording( async function resumeNativeRecording( webcamRecorder?: ReturnType | null, result: { success: boolean } = { success: true }, + micFallbackRecorder?: ReturnType | null, ): Promise { if (!result.success) { return false; @@ -127,6 +140,9 @@ async function resumeNativeRecording( if (webcamRecorder?.state === "paused") { webcamRecorder.resume(); } + if (micFallbackRecorder?.state === "paused") { + micFallbackRecorder.resume(); + } return true; } @@ -324,6 +340,15 @@ describe("useScreenRecorder state machine", () => { expect(webcam.state).toBe("paused"); }); + it("pauses browser mic fallback during native recording pause", () => { + const micFallback = createMockMediaRecorder("recording"); + + const result = pauseRecording(recorder, true, false, true, null, micFallback); + + expect(result).toBe(true); + expect(micFallback.state).toBe("paused"); + }); + it("skips webcam pause when webcam is not recording", () => { const webcam = createMockMediaRecorder("inactive"); @@ -378,6 +403,16 @@ describe("useScreenRecorder state machine", () => { expect(webcam.state).toBe("recording"); }); + it("resumes browser mic fallback during native recording resume", () => { + const micFallback = createMockMediaRecorder("recording"); + micFallback.pause(); + + const result = resumeRecording(recorder, true, true, true, null, micFallback); + + expect(result).toBe(true); + expect(micFallback.state).toBe("recording"); + }); + it("skips webcam resume when webcam is not paused", () => { recorder.pause(); const webcam = createMockMediaRecorder("inactive"); @@ -489,26 +524,40 @@ describe("useScreenRecorder state machine", () => { it("native recording pauses webcam only after native pause succeeds", async () => { const webcam = createMockMediaRecorder("recording"); + const micFallback = createMockMediaRecorder("recording"); - const pausedResult = await pauseNativeRecording(webcam); + const pausedResult = await pauseNativeRecording(webcam, { success: true }, micFallback); expect(pausedResult).toBe(true); expect(webcam.state).toBe("paused"); + expect(micFallback.state).toBe("paused"); expect(recorder.pause).not.toHaveBeenCalled(); - const resumedResult = await resumeNativeRecording(webcam); + const resumedResult = await resumeNativeRecording( + webcam, + { success: true }, + micFallback, + ); expect(resumedResult).toBe(true); expect(webcam.state).toBe("recording"); + expect(micFallback.state).toBe("recording"); expect(recorder.resume).not.toHaveBeenCalled(); }); it("native recording leaves webcam state alone when native pause fails", async () => { const webcam = createMockMediaRecorder("recording"); + const micFallback = createMockMediaRecorder("recording"); - const pausedResult = await pauseNativeRecording(webcam, { success: false }); + const pausedResult = await pauseNativeRecording( + webcam, + { success: false }, + micFallback, + ); expect(pausedResult).toBe(false); expect(webcam.state).toBe("recording"); expect(webcam.pause).not.toHaveBeenCalled(); + expect(micFallback.state).toBe("recording"); + expect(micFallback.pause).not.toHaveBeenCalled(); }); it("stops native capture before awaiting webcam finalization", async () => { diff --git a/src/hooks/useScreenRecorder.ts b/src/hooks/useScreenRecorder.ts index 562e7f3c..9a4ef895 100644 --- a/src/hooks/useScreenRecorder.ts +++ b/src/hooks/useScreenRecorder.ts @@ -1037,14 +1037,14 @@ export function useScreenRecorder(): UseScreenRecorderReturn { ? 0 : webcamStartTime.current - mainStartedAt; - // When native mic capture is unavailable (macOS < 14), record mic - // via browser getUserMedia so it can be saved as a sidecar file. + // When native mic capture is unavailable or explicitly bypassed, + // record mic via browser getUserMedia as a sidecar file. if (nativeResult.microphoneFallbackRequired && microphoneEnabled) { void logNativeCaptureDiagnostics("start-browser-microphone-fallback"); - toast.warning( - "Native microphone capture is unavailable. Using browser microphone fallback for this recording.", - { id: MICROPHONE_FALLBACK_TOAST_ID, duration: 8000 }, - ); + toast.warning("Using browser microphone fallback for this recording.", { + id: MICROPHONE_FALLBACK_TOAST_ID, + duration: 8000, + }); try { const micStream = await navigator.mediaDevices.getUserMedia({ audio: microphoneDeviceId @@ -1431,6 +1431,9 @@ export function useScreenRecorder(): UseScreenRecorderReturn { if (webcamRecorder.current?.state === "recording") { webcamRecorder.current.pause(); } + if (micFallbackRecorder.current?.state === "recording") { + micFallbackRecorder.current.pause(); + } const boundaryMs = Date.now(); markRecordingPaused(boundaryMs); setPaused(true); @@ -1476,6 +1479,9 @@ export function useScreenRecorder(): UseScreenRecorderReturn { if (webcamRecorder.current?.state === "paused") { webcamRecorder.current.resume(); } + if (micFallbackRecorder.current?.state === "paused") { + micFallbackRecorder.current.resume(); + } const boundaryMs = Date.now(); markRecordingResumed(boundaryMs); setPaused(false);