From b1a2249f36a71fe82697529a97e89d74d487e011 Mon Sep 17 00:00:00 2001 From: ZGaetano Date: Thu, 4 Jun 2026 04:49:53 +0000 Subject: [PATCH] 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. --- services/capture/src/capture-manager.js | 41 ++++++++++++++++++++++--- 1 file changed, 36 insertions(+), 5 deletions(-) diff --git a/services/capture/src/capture-manager.js b/services/capture/src/capture-manager.js index 29fe7ac..5878a24 100644 --- a/services/capture/src/capture-manager.js +++ b/services/capture/src/capture-manager.js @@ -732,7 +732,7 @@ class CaptureManager { ], isNetwork: false, bridgeProcess: fcPipeProcess, /* capture-manager pipes this to ffmpeg stdin */ - audioFifo: null, + audioFifo: audioFifoPath, /* flushed just before ffmpeg opens it (A/V align) */ interlaced: fcInterlaced, audioInputIndex: 1, /* audio FIFO is ffmpeg input 1 */ _fcPipeProcess: fcPipeProcess, /* stored for clean stop */ @@ -1030,16 +1030,47 @@ exit "$BMXRC" sourceType, sourceBackend, device, port, board, sourceUrl, listen, listenPort, streamKey, }); - // ── Pre-roll: discard initial unstable frames ──────────────────────────── - if (bridgeProcess && (sourceType === 'deltacast' || sourceType === 'blackmagic' || sourceType === 'sdi')) { + // ── Pre-roll + A/V alignment ───────────────────────────────────────────── + // 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`); - // Attach temporary drain listener. - bridgeProcess.stdout.on('data', () => {}); + bridgeProcess.stdout.on('data', () => {}); await new Promise(r => setTimeout(r, PRE_ROLL_SECONDS * 1000)); bridgeProcess.stdout.removeAllListeners('data'); 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 recordingStartedAt = Date.now();