diff --git a/services/capture/src/capture-manager.js b/services/capture/src/capture-manager.js index 9e77cdc..aba129f 100644 --- a/services/capture/src/capture-manager.js +++ b/services/capture/src/capture-manager.js @@ -1078,13 +1078,26 @@ exit "$BMXRC" if (sourceAudioChannels && wantAudioChannels) audioChannels = effAudioChannels; // ── Pre-roll: discard initial unstable frames ──────────────────────────── - if (bridgeProcess && (sourceType === 'deltacast' || sourceType === 'blackmagic' || sourceType === 'sdi')) { + // CRITICAL A/V-SYNC NOTE: this drains ONLY the VIDEO pipe (fc_pipe stdout). + // The audio FIFO is opened by ffmpeg, not here, so it keeps BUFFERING during + // the drain — when ffmpeg starts it reads that buffered pre-roll audio, + // making the audio stream ~PRE_ROLL_SECONDS longer than video. Synced to the + // video length that surplus audio is compressed → a slight pitch-up. + // + // In STANDBY mode the framecache slot has been warm for a long time, so there + // are NO unstable startup frames to discard — skip the asymmetric drain + // entirely and let ffmpeg open video (fc_pipe) and audio (FIFO) together so + // both streams start from the same instant. Only the legacy on-demand spawn + // (cold slot) still needs the brief drain. + 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.`); + } else if (bridgeProcess) { + console.log('[capture] standby/warm slot — skipping video-only pre-roll to keep A/V aligned'); } const startedAt = new Date().toISOString();