From 115ecba7c5fdcbed284d6338e246426d340545a8 Mon Sep 17 00:00:00 2001 From: wiiiii123 Date: Thu, 7 May 2026 03:29:58 +0700 Subject: [PATCH] fix(recording): reduce native mic noise amplification --- electron/ipc/export/native-video.test.ts | 30 ++++++++++++ electron/ipc/export/native-video.ts | 51 ++++++++++++++++++++- electron/ipc/recording/audioFilters.test.ts | 14 ++++++ electron/ipc/recording/audioFilters.ts | 3 ++ electron/ipc/recording/windows.ts | 4 +- 5 files changed, 98 insertions(+), 4 deletions(-) create mode 100644 electron/ipc/recording/audioFilters.test.ts create mode 100644 electron/ipc/recording/audioFilters.ts diff --git a/electron/ipc/export/native-video.test.ts b/electron/ipc/export/native-video.test.ts index 6dae5fda..4b022f7d 100644 --- a/electron/ipc/export/native-video.test.ts +++ b/electron/ipc/export/native-video.test.ts @@ -54,6 +54,7 @@ import { buildNativeVideoAudioMuxArgs, getExperimentalNvidiaCudaExportSkipReason, getNvidiaCudaAudioExportSkipReason, + getNvidiaCudaAutoStallTimeoutMs, hasNvidiaGpuDeviceInGpuInfo, mapNvidiaCudaWrapperProgressPercentage, muxExportedVideoAudioBuffer, @@ -209,6 +210,35 @@ describe("getNvidiaCudaAudioExportSkipReason", () => { }); }); +describe("getNvidiaCudaAutoStallTimeoutMs", () => { + it("only applies the stall guard to packaged auto candidates by default", () => { + expect(getNvidiaCudaAutoStallTimeoutMs(false)).toBeNull(); + expect(getNvidiaCudaAutoStallTimeoutMs(true)).toBe(120_000); + }); + + it("allows the CUDA auto stall guard to be disabled or tuned", () => { + const envName = "RECORDLY_NVIDIA_CUDA_AUTO_STALL_TIMEOUT_MS"; + const originalValue = process.env[envName]; + + try { + process.env[envName] = "0"; + expect(getNvidiaCudaAutoStallTimeoutMs(true)).toBeNull(); + + process.env[envName] = "5000"; + expect(getNvidiaCudaAutoStallTimeoutMs(true)).toBe(10_000); + + process.env[envName] = "45000"; + expect(getNvidiaCudaAutoStallTimeoutMs(true)).toBe(45_000); + } finally { + if (originalValue === undefined) { + delete process.env[envName]; + } else { + process.env[envName] = originalValue; + } + } + }); +}); + describe("hasNvidiaGpuDeviceInGpuInfo", () => { it("detects NVIDIA GPUs by vendor id or device strings", () => { expect( diff --git a/electron/ipc/export/native-video.ts b/electron/ipc/export/native-video.ts index 264925bb..661f4aa1 100644 --- a/electron/ipc/export/native-video.ts +++ b/electron/ipc/export/native-video.ts @@ -46,6 +46,8 @@ const NVIDIA_PCI_VENDOR_ID = 0x10de; const NVIDIA_CUDA_EXPORT_ENV = "RECORDLY_EXPERIMENTAL_NVIDIA_CUDA_EXPORT"; const NVIDIA_CUDA_ALLOW_AUDIO_EXPORT_ENV = "RECORDLY_NVIDIA_CUDA_ALLOW_AUDIO_EXPORT"; const NVIDIA_CUDA_FORCE_VIDEO_ONLY_ENV = "RECORDLY_NVIDIA_CUDA_FORCE_VIDEO_ONLY"; +const NVIDIA_CUDA_AUTO_STALL_TIMEOUT_ENV = "RECORDLY_NVIDIA_CUDA_AUTO_STALL_TIMEOUT_MS"; +const DEFAULT_NVIDIA_CUDA_AUTO_STALL_TIMEOUT_MS = 120_000; type ElectronGpuDeviceLike = { vendorId?: number | string; @@ -1577,6 +1579,26 @@ function isNvidiaCudaForceVideoOnlyEnabled() { return process.env[NVIDIA_CUDA_FORCE_VIDEO_ONLY_ENV] === "1"; } +export function getNvidiaCudaAutoStallTimeoutMs( + autoCandidateActive = isPackagedNvidiaCudaExportAutoCandidateActive(), +) { + if (!autoCandidateActive) { + return null; + } + + const rawValue = process.env[NVIDIA_CUDA_AUTO_STALL_TIMEOUT_ENV]?.trim(); + if (rawValue === "0" || rawValue?.toLowerCase() === "off") { + return null; + } + + const parsed = Number(rawValue); + if (Number.isFinite(parsed) && parsed > 0) { + return Math.max(10_000, Math.round(parsed)); + } + + return DEFAULT_NVIDIA_CUDA_AUTO_STALL_TIMEOUT_MS; +} + export async function getExperimentalNvidiaCudaExportSkipReason( options: NativeStaticLayoutExportOptions, ) { @@ -2231,6 +2253,7 @@ async function runExperimentalNvidiaCudaStaticLayoutExport( const startedAt = getNowMs(); const startedAtIso = new Date().toISOString(); const timeoutMs = Math.max(20 * 60 * 1000, options.durationSec * 2000); + const stallTimeoutMs = getNvidiaCudaAutoStallTimeoutMs(); const ffmpegDirectory = path.dirname(ffmpegPath); const pathKey = process.platform === "win32" ? "Path" : "PATH"; const env = { @@ -2272,17 +2295,39 @@ async function runExperimentalNvidiaCudaStaticLayoutExport( let stderr = ""; let stderrLineBuffer = ""; let lastProgressPercentage = 0; + let stallTimedOut = false; let settled = false; const timeout = setTimeout(() => { if (settled) return; child.kill("SIGKILL"); }, timeoutMs); + let stallTimeout: ReturnType | null = null; + const clearStallTimeout = () => { + if (stallTimeout) { + clearTimeout(stallTimeout); + stallTimeout = null; + } + }; + const armStallTimeout = () => { + if (!stallTimeoutMs) { + return; + } + clearStallTimeout(); + stallTimeout = setTimeout(() => { + if (settled) return; + stallTimedOut = true; + child.kill("SIGKILL"); + }, stallTimeoutMs); + }; + armStallTimeout(); child.stdout.on("data", (chunk: Buffer) => { stdout += chunk.toString(); + armStallTimeout(); }); child.stderr.on("data", (chunk: Buffer) => { const text = chunk.toString(); + armStallTimeout(); stderr += text; stderrLineBuffer += text; const lines = stderrLineBuffer.split(/\r?\n/); @@ -2320,6 +2365,7 @@ async function runExperimentalNvidiaCudaStaticLayoutExport( session.currentProcess = null; } clearTimeout(timeout); + clearStallTimeout(); powerGuard.release(); reject(error); }); @@ -2330,6 +2376,7 @@ async function runExperimentalNvidiaCudaStaticLayoutExport( session.currentProcess = null; } clearTimeout(timeout); + clearStallTimeout(); powerGuard.release(); if (session.terminating) { @@ -2372,7 +2419,9 @@ async function runExperimentalNvidiaCudaStaticLayoutExport( const suffix = signal ? ` (signal ${signal})` : ""; reject( new Error( - stderr.trim() || + (stallTimedOut && stallTimeoutMs + ? `Experimental NVIDIA CUDA exporter stalled for ${stallTimeoutMs}ms without output` + : stderr.trim()) || stdout.trim() || `Experimental NVIDIA CUDA exporter exited with code ${code ?? "unknown"}${suffix}`, ), diff --git a/electron/ipc/recording/audioFilters.test.ts b/electron/ipc/recording/audioFilters.test.ts new file mode 100644 index 00000000..8305a994 --- /dev/null +++ b/electron/ipc/recording/audioFilters.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from "vitest"; + +import { WINDOWS_NATIVE_MIC_PRE_FILTERS } from "./audioFilters"; + +describe("Windows native mic pre-filter policy", () => { + it("keeps repair filters without automatic gain or loudness normalization", () => { + expect(WINDOWS_NATIVE_MIC_PRE_FILTERS).toContain("adeclip=threshold=1"); + expect( + WINDOWS_NATIVE_MIC_PRE_FILTERS.some((filter) => + /(^|,)(loudnorm|dynaudnorm|volume)=/i.test(filter), + ), + ).toBe(false); + }); +}); diff --git a/electron/ipc/recording/audioFilters.ts b/electron/ipc/recording/audioFilters.ts new file mode 100644 index 00000000..e765cc71 --- /dev/null +++ b/electron/ipc/recording/audioFilters.ts @@ -0,0 +1,3 @@ +// 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"]; diff --git a/electron/ipc/recording/windows.ts b/electron/ipc/recording/windows.ts index 63d4e24f..3f2e6ea3 100644 --- a/electron/ipc/recording/windows.ts +++ b/electron/ipc/recording/windows.ts @@ -23,6 +23,7 @@ import { } from "../state"; import type { AudioSyncAdjustment } from "../types"; import { moveFileWithOverwrite } from "../utils"; +import { WINDOWS_NATIVE_MIC_PRE_FILTERS } from "./audioFilters"; import { getCompanionAudioStartDelayMs, getRecordingAudioMuxTimeoutMs, @@ -32,9 +33,6 @@ import { import { emitRecordingInterrupted } from "./events"; const execFileAsync = promisify(execFile); -// Repair rare clipped mic peaks before loudness normalization so headset/driver -// overloads do not get amplified into audible pops during mux/export. -const WINDOWS_NATIVE_MIC_PRE_FILTERS = ["adeclip=threshold=1", "loudnorm=I=-16:TP=-1.5:LRA=11"]; const MIN_NATIVE_WINDOWS_VIDEO_PAD_MS = 500; export async function isNativeWindowsCaptureAvailable(): Promise {