fix(capture): align A/V at record start (kill leading silence + length drift)

Root cause of 'silent first ~1s then clean' + ~0.5% audio-too-long: in standby
the bridge keeps filling the audio FIFO while the idle-preview consumes only
video, so when recording starts ffmpeg reads a ~0.5s backlog of stale audio,
AND the video-only pre-roll discards video frames the audio never had.

Fix: (1) skip the video-only pre-roll in standby (warm slot = no unstable
frames), (2) drain the audio FIFO non-blocking immediately before ffmpeg opens
it, so audio starts at the live edge aligned with the first real video frame.
This commit is contained in:
Zac Gaetano 2026-06-04 04:49:53 +00:00
parent fffb6b63b5
commit b1a2249f36

View file

@ -732,7 +732,7 @@ class CaptureManager {
], ],
isNetwork: false, isNetwork: false,
bridgeProcess: fcPipeProcess, /* capture-manager pipes this to ffmpeg stdin */ bridgeProcess: fcPipeProcess, /* capture-manager pipes this to ffmpeg stdin */
audioFifo: null, audioFifo: audioFifoPath, /* flushed just before ffmpeg opens it (A/V align) */
interlaced: fcInterlaced, interlaced: fcInterlaced,
audioInputIndex: 1, /* audio FIFO is ffmpeg input 1 */ audioInputIndex: 1, /* audio FIFO is ffmpeg input 1 */
_fcPipeProcess: fcPipeProcess, /* stored for clean stop */ _fcPipeProcess: fcPipeProcess, /* stored for clean stop */
@ -1030,16 +1030,47 @@ exit "$BMXRC"
sourceType, sourceBackend, device, port, board, sourceUrl, listen, listenPort, streamKey, sourceType, sourceBackend, device, port, board, sourceUrl, listen, listenPort, streamKey,
}); });
// ── Pre-roll: discard initial unstable frames ──────────────────────────── // ── Pre-roll + A/V alignment ─────────────────────────────────────────────
if (bridgeProcess && (sourceType === 'deltacast' || sourceType === 'blackmagic' || sourceType === 'sdi')) { // The pre-roll drains the VIDEO pipe (fc_pipe) to discard unstable startup
// frames. In STANDBY the framecache slot is already warm, so there are no
// unstable frames — skip the video drain (draining only video while audio
// keeps buffering is exactly what offset the streams, giving "silent first
// second then clean").
if (bridgeProcess && !_standbyMode
&& (sourceType === 'deltacast' || sourceType === 'blackmagic' || sourceType === 'sdi')) {
console.log(`[capture] pre-rolling: discarding ${PRE_ROLL_SECONDS}s of frames`); console.log(`[capture] pre-rolling: discarding ${PRE_ROLL_SECONDS}s of frames`);
// Attach temporary drain listener.
bridgeProcess.stdout.on('data', () => {}); bridgeProcess.stdout.on('data', () => {});
await new Promise(r => setTimeout(r, PRE_ROLL_SECONDS * 1000)); await new Promise(r => setTimeout(r, PRE_ROLL_SECONDS * 1000));
bridgeProcess.stdout.removeAllListeners('data'); bridgeProcess.stdout.removeAllListeners('data');
console.log(`[capture] pre-roll complete.`); console.log(`[capture] pre-roll complete.`);
} }
// FLUSH STALE AUDIO immediately before ffmpeg opens the FIFO. During standby
// the bridge keeps writing audio into the named FIFO while the idle-preview
// consumes only video, so the FIFO holds up to a full pipe buffer (~0.5s) of
// stale audio. Draining it here (right before the record ffmpeg attaches)
// makes audio start at the live edge, time-aligned with the first video
// frame — eliminating the leading silence + the ~0.5% audio-length surplus.
if (audioFifo) {
try {
const fsSync = await import('node:fs');
const fd = fsSync.openSync(audioFifo, fsSync.constants.O_RDONLY | fsSync.constants.O_NONBLOCK);
const tmp = Buffer.allocUnsafe(1 << 20);
let drained = 0;
for (;;) {
let n = 0;
try { n = fsSync.readSync(fd, tmp, 0, tmp.length, null); }
catch (e) { if (e.code === 'EAGAIN') break; throw e; }
if (n <= 0) break;
drained += n;
}
fsSync.closeSync(fd);
console.log(`[capture] flushed ${drained} bytes of stale standby audio before record`);
} catch (e) {
console.warn(`[capture] audio FIFO pre-flush skipped: ${e.message}`);
}
}
const startedAt = new Date().toISOString(); const startedAt = new Date().toISOString();
const recordingStartedAt = Date.now(); const recordingStartedAt = Date.now();