mirror of
https://github.com/webadderallorg/Recordly.git
synced 2026-09-27 00:05:39 +00:00
- Always route audio through aresample=async=1:first_pts=0 filter during muxing on both macOS and Windows, even when duration delta is small. Previously skipped when delta ≤50ms, which left progressive clock drift (from CPU load) completely uncorrected. - Lower sync detection threshold from 50ms to 20ms so tempo correction kicks in earlier. - Use explicit 48kHz AudioContext sample rate in both browser recording and export rendering to prevent sample rate mismatch drift. - Reduce MediaRecorder timeslice from 1000ms to 250ms to reduce chunk loss under CPU pressure. - Tighten export audio sync: seek threshold 300ms→150ms, tolerance 15ms→8ms, correction window 2s→0.5s, max adjustment ±8%→±12%. - Lower companion audio start delay threshold from 50ms to 25ms.
323 lines
8.5 KiB
TypeScript
323 lines
8.5 KiB
TypeScript
import type { ChildProcessWithoutNullStreams } from "node:child_process";
|
|
import { constants as fsConstants } from "node:fs";
|
|
import fs from "node:fs/promises";
|
|
import { execFile } from "node:child_process";
|
|
import { promisify } from "node:util";
|
|
import { BrowserWindow } from "electron";
|
|
import { getFfmpegBinaryPath } from "../ffmpeg/binary";
|
|
import {
|
|
getAudioSyncAdjustment,
|
|
appendSyncedAudioFilter,
|
|
normalizePauseSegments,
|
|
buildPausedAudioFilter,
|
|
} from "../ffmpeg/filters";
|
|
import type { PauseSegment, AudioSyncAdjustment } from "../types";
|
|
import {
|
|
setWindowsCaptureProcess,
|
|
windowsCaptureOutputBuffer,
|
|
windowsCaptureTargetPath,
|
|
windowsNativeCaptureActive,
|
|
setWindowsNativeCaptureActive,
|
|
windowsCaptureStopRequested,
|
|
setWindowsCaptureStopRequested,
|
|
selectedSource,
|
|
} from "../state";
|
|
import { moveFileWithOverwrite } from "../utils";
|
|
import { probeMediaDurationSeconds } from "./diagnostics";
|
|
import { emitRecordingInterrupted } from "./events";
|
|
import { getWindowsCaptureExePath } from "../paths/binaries";
|
|
|
|
const execFileAsync = promisify(execFile);
|
|
|
|
export async function isNativeWindowsCaptureAvailable(): Promise<boolean> {
|
|
if (process.platform !== "win32") return false;
|
|
|
|
const os = await import("node:os");
|
|
const [major, , build] = os.release().split(".").map(Number);
|
|
const supported = major >= 10 && build >= 19041;
|
|
if (!supported) return false;
|
|
|
|
try {
|
|
await fs.access(getWindowsCaptureExePath(), fsConstants.X_OK);
|
|
} catch {
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
export function waitForWindowsCaptureStart(proc: ChildProcessWithoutNullStreams) {
|
|
return new Promise<void>((resolve, reject) => {
|
|
const timer = setTimeout(() => {
|
|
cleanup();
|
|
reject(new Error("Timed out waiting for native Windows capture to start"));
|
|
}, 12000);
|
|
|
|
let stdoutBuffer = "";
|
|
const onStdout = (chunk: Buffer) => {
|
|
stdoutBuffer += chunk.toString();
|
|
if (stdoutBuffer.includes("Recording started")) {
|
|
cleanup();
|
|
resolve();
|
|
}
|
|
};
|
|
|
|
const onError = (error: Error) => {
|
|
cleanup();
|
|
reject(error);
|
|
};
|
|
|
|
const onExit = (code: number | null) => {
|
|
cleanup();
|
|
reject(
|
|
new Error(
|
|
windowsCaptureOutputBuffer.trim() ||
|
|
`Native Windows capture exited before recording started (code ${code ?? "unknown"})`,
|
|
),
|
|
);
|
|
};
|
|
|
|
const cleanup = () => {
|
|
clearTimeout(timer);
|
|
proc.stdout.off("data", onStdout);
|
|
proc.off("error", onError);
|
|
proc.off("exit", onExit);
|
|
};
|
|
|
|
proc.stdout.on("data", onStdout);
|
|
proc.once("error", onError);
|
|
proc.once("exit", onExit);
|
|
});
|
|
}
|
|
|
|
export function waitForWindowsCaptureStop(proc: ChildProcessWithoutNullStreams) {
|
|
return new Promise<string>((resolve, reject) => {
|
|
const onClose = (code: number | null) => {
|
|
cleanup();
|
|
const match = windowsCaptureOutputBuffer.match(/Recording stopped\. Output path: (.+)/);
|
|
if (match?.[1]) {
|
|
resolve(match[1].trim());
|
|
return;
|
|
}
|
|
if (code === 0 && windowsCaptureTargetPath) {
|
|
resolve(windowsCaptureTargetPath);
|
|
return;
|
|
}
|
|
reject(
|
|
new Error(
|
|
windowsCaptureOutputBuffer.trim() ||
|
|
`Native Windows capture exited with code ${code ?? "unknown"}`,
|
|
),
|
|
);
|
|
};
|
|
|
|
const onError = (error: Error) => {
|
|
cleanup();
|
|
reject(error);
|
|
};
|
|
|
|
const cleanup = () => {
|
|
proc.off("close", onClose);
|
|
proc.off("error", onError);
|
|
};
|
|
|
|
proc.once("close", onClose);
|
|
proc.once("error", onError);
|
|
});
|
|
}
|
|
|
|
export function attachWindowsCaptureLifecycle(proc: ChildProcessWithoutNullStreams) {
|
|
proc.once("close", () => {
|
|
const wasActive = windowsNativeCaptureActive;
|
|
setWindowsCaptureProcess(null);
|
|
|
|
if (!wasActive || windowsCaptureStopRequested) {
|
|
return;
|
|
}
|
|
|
|
setWindowsNativeCaptureActive(false);
|
|
setWindowsCaptureStopRequested(false);
|
|
|
|
const sourceName = selectedSource?.name ?? "Screen";
|
|
BrowserWindow.getAllWindows().forEach((window) => {
|
|
if (!window.isDestroyed()) {
|
|
window.webContents.send("recording-state-changed", {
|
|
recording: false,
|
|
sourceName,
|
|
});
|
|
}
|
|
});
|
|
|
|
emitRecordingInterrupted("capture-stopped", "Recording stopped unexpectedly.");
|
|
});
|
|
}
|
|
|
|
export async function muxNativeWindowsVideoWithAudio(
|
|
videoPath: string,
|
|
systemAudioPath: string | null,
|
|
micAudioPath: string | null,
|
|
pauseSegments: PauseSegment[] = [],
|
|
) {
|
|
const ffmpegPath = getFfmpegBinaryPath();
|
|
const inputs: string[] = ["-i", videoPath];
|
|
const audioInputs: string[] = [];
|
|
const audioFilePaths: string[] = [];
|
|
|
|
for (const [label, audioPath] of [
|
|
["system", systemAudioPath],
|
|
["mic", micAudioPath],
|
|
] as const) {
|
|
if (!audioPath) continue;
|
|
try {
|
|
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);
|
|
continue;
|
|
}
|
|
inputs.push("-i", audioPath);
|
|
audioInputs.push(label);
|
|
audioFilePaths.push(audioPath);
|
|
} catch {
|
|
console.warn(`[mux-win] Skipping ${label} audio: file not accessible (${audioPath})`);
|
|
}
|
|
}
|
|
|
|
if (audioInputs.length === 0) return;
|
|
|
|
const videoDuration = await probeMediaDurationSeconds(videoPath);
|
|
const audioAdjustments: Map<string, AudioSyncAdjustment> = new Map();
|
|
|
|
if (videoDuration > 0) {
|
|
for (let i = 0; i < audioFilePaths.length; i++) {
|
|
const audioDuration = await probeMediaDurationSeconds(audioFilePaths[i]);
|
|
const adjustment = getAudioSyncAdjustment(videoDuration, audioDuration);
|
|
audioAdjustments.set(audioInputs[i], adjustment);
|
|
if (adjustment.mode === "tempo") {
|
|
console.log(
|
|
`[mux-win] ${audioInputs[i]} audio differs from video by ${adjustment.durationDeltaMs}ms — applying tempo ratio ${adjustment.tempoRatio.toFixed(6)}`,
|
|
);
|
|
} else if (adjustment.mode === "delay" && adjustment.delayMs > 0) {
|
|
console.log(
|
|
`[mux-win] ${audioInputs[i]} audio appears to start late by ${adjustment.delayMs}ms — adding leading silence`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
const mixedOutputPath = `${videoPath}.muxed.mp4`;
|
|
const normalizedPauseSegments = normalizePauseSegments(pauseSegments);
|
|
const systemAdjustment = audioAdjustments.get("system") ?? {
|
|
mode: "none",
|
|
delayMs: 0,
|
|
tempoRatio: 1,
|
|
durationDeltaMs: 0,
|
|
};
|
|
const micAdjustment = audioAdjustments.get("mic") ?? {
|
|
mode: "none",
|
|
delayMs: 0,
|
|
tempoRatio: 1,
|
|
durationDeltaMs: 0,
|
|
};
|
|
|
|
if (audioInputs.length === 2) {
|
|
const filterParts: string[] = [];
|
|
const systemPauseFilter = buildPausedAudioFilter(
|
|
"1:a",
|
|
"system_trimmed",
|
|
normalizedPauseSegments,
|
|
);
|
|
const micPauseFilter = buildPausedAudioFilter(
|
|
"2:a",
|
|
"mic_trimmed",
|
|
normalizedPauseSegments,
|
|
);
|
|
|
|
if (systemPauseFilter) {
|
|
filterParts.push(systemPauseFilter);
|
|
}
|
|
if (micPauseFilter) {
|
|
filterParts.push(micPauseFilter);
|
|
}
|
|
|
|
const systemLabel = systemPauseFilter ? "[system_trimmed]" : "[1:a]";
|
|
const micLabel = micPauseFilter ? "[mic_trimmed]" : "[2:a]";
|
|
|
|
appendSyncedAudioFilter(filterParts, systemLabel, "s", systemAdjustment);
|
|
appendSyncedAudioFilter(filterParts, micLabel, "m", micAdjustment);
|
|
filterParts.push("[s][m]amix=inputs=2:duration=longest:normalize=0[aout]");
|
|
|
|
await execFileAsync(
|
|
ffmpegPath,
|
|
[
|
|
"-y",
|
|
...inputs,
|
|
"-filter_complex",
|
|
filterParts.join(";"),
|
|
"-map",
|
|
"0:v:0",
|
|
"-map",
|
|
"[aout]",
|
|
"-c:v",
|
|
"copy",
|
|
"-c:a",
|
|
"aac",
|
|
"-b:a",
|
|
"192k",
|
|
"-shortest",
|
|
mixedOutputPath,
|
|
],
|
|
{ timeout: 120000, maxBuffer: 10 * 1024 * 1024 },
|
|
);
|
|
} else {
|
|
const pauseFilter = buildPausedAudioFilter("1:a", "trimmed_audio", normalizedPauseSegments);
|
|
const singleAdjustment = audioAdjustments.get(audioInputs[0]) ?? {
|
|
mode: "none",
|
|
delayMs: 0,
|
|
tempoRatio: 1,
|
|
durationDeltaMs: 0,
|
|
};
|
|
|
|
// Always route through the filter graph so that aresample=async=1 is
|
|
// applied. This corrects progressive clock drift between video and
|
|
// audio tracks that a simple duration comparison cannot detect.
|
|
const filterParts: string[] = [];
|
|
if (pauseFilter) {
|
|
filterParts.push(pauseFilter);
|
|
}
|
|
const srcLabel = pauseFilter ? "[trimmed_audio]" : "[1:a]";
|
|
appendSyncedAudioFilter(filterParts, srcLabel, "aout", singleAdjustment);
|
|
|
|
await execFileAsync(
|
|
ffmpegPath,
|
|
[
|
|
"-y",
|
|
...inputs,
|
|
"-filter_complex",
|
|
filterParts.join(";"),
|
|
"-map",
|
|
"0:v:0",
|
|
"-map",
|
|
"[aout]",
|
|
"-c:v",
|
|
"copy",
|
|
"-c:a",
|
|
"aac",
|
|
"-b:a",
|
|
"192k",
|
|
"-shortest",
|
|
mixedOutputPath,
|
|
],
|
|
{ timeout: 120000, maxBuffer: 10 * 1024 * 1024 },
|
|
);
|
|
}
|
|
|
|
await moveFileWithOverwrite(mixedOutputPath, videoPath);
|
|
|
|
for (const audioPath of [systemAudioPath, micAudioPath]) {
|
|
if (audioPath) {
|
|
await fs.rm(audioPath, { force: true }).catch(() => undefined);
|
|
}
|
|
}
|
|
}
|