fix(recording): add windows mic diagnostics fallback

This commit is contained in:
wiiiii123
2026-05-07 04:00:46 +07:00
parent 115ecba7c5
commit 7dd827be43
8 changed files with 252 additions and 100 deletions
+24 -1
View File
@@ -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);
});
});
+7
View File
@@ -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";
}
+16 -2
View File
@@ -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([
@@ -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);
});
});
it("returns true when browser fallback is forced", () => {
expect(
shouldUseWindowsBrowserMicrophoneFallback(
"Recording started",
{ capturesMicrophone: true },
{ [WINDOWS_MIC_CAPTURE_MODE_ENV]: "fallback" },
),
).toBe(true);
});
});
+17 -2
View File
@@ -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))
);
}
}
+90 -84
View File
@@ -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) {
+52 -3
View File
@@ -59,12 +59,16 @@ function pauseRecording(
paused: boolean,
isNativeRecording: boolean,
webcamRecorder?: ReturnType<typeof createMockMediaRecorder> | null,
micFallbackRecorder?: ReturnType<typeof createMockMediaRecorder> | 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<typeof createMockMediaRecorder> | null,
micFallbackRecorder?: ReturnType<typeof createMockMediaRecorder> | 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<typeof createMockMediaRecorder> | null,
result: { success: boolean } = { success: true },
micFallbackRecorder?: ReturnType<typeof createMockMediaRecorder> | null,
): Promise<boolean> {
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<typeof createMockMediaRecorder> | null,
result: { success: boolean } = { success: true },
micFallbackRecorder?: ReturnType<typeof createMockMediaRecorder> | null,
): Promise<boolean> {
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 () => {
+12 -6
View File
@@ -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);