fix(goLive): tail-drop emitter clock — always show the freshest frame, never lag

The Node token-bucket pacer used HEAD-drop (emit frames in arrival order,
drop newer ones when over budget). Under the encoder's ~330fps burst (ffmpeg
-re does not reliably throttle YouTube-DASH webm), the viewer was watching
frames ~10s behind live → frozen / 'patah-patah' video while audio (not
rate-limited) played current = desync.

Replace it with a steady setInterval emission clock at videoFps: each tick
emits exactly ONE frame — the NEWEST buffered one — and discards everything
older (tail-drop). At most one frame is ever held, so no backlog and no lag;
the emit clock (not the encoder rate) defines playback speed. Keyframes are
never superseded so the decoder keeps getting IDRs. Audio stays in sync.
This commit is contained in:
asepharyana
2026-08-13 17:32:18 +07:00
parent c285a4c813
commit 8ee32b8df8
+46 -43
View File
@@ -375,16 +375,18 @@ export async function demux(
// NALs and flush one frame per slice, prepending the parameter sets that // NALs and flush one frame per slice, prepending the parameter sets that
// precede it, and timestamp it as ONE frame at the video frame rate. // precede it, and timestamp it as ONE frame at the video frame rate.
// PACING: a token-bucket rate limiter enforces 1x video output at the // EMISSION CLOCK: a steady setInterval at the video frame period is the
// encoder's frame rate, independent of how fast ffmpeg produces frames. // authoritative real-time clock for OUTPUT. Whatever the encoder's actual
// ffmpeg `-re` was tried here (see spawn args) but does NOT reliably // production rate (it bursts ~330fps in production because ffmpeg `-re`
// throttle a multi-stage live pipe (encoder x264 -> NUT -> demuxer): in // does not reliably throttle YouTube-DASH webm and may read a local file
// production the demuxer still emitted ~240fps while the sender consumed // instantly), we always emit exactly ONE frame per tick — the NEWEST one
// 30fps, building a 100k+ frame backlog → video frozen on the oldest // we have buffered — and discard everything older. This is TAIL-DROP: the
// buffered frame while audio (tiny, jitter-buffer recoverable) stayed // viewer always sees the freshest picture, so video and audio stay in sync
// smooth. A Node-level limiter is deterministic and never stalls the // and the in-flight buffer can never grow (we keep at most one frame). The
// ffmpeg process, so audio on fd3 keeps flowing. Surplus video frames are // previous token-bucket design used HEAD-DROP (emit in arrival order,
// DROPPED (not buffered) so the sender always emits the newest frame. // dropping later frames) which, under the encoder burst, left the viewer
// watching frames ~10s behind live → frozen / "patah-patah" video while
// audio (not rate-limited) played current → desync. Tail-drop fixes that.
let videoBuf = Buffer.alloc(0); let videoBuf = Buffer.alloc(0);
let frameCount = 0; let frameCount = 0;
let pendingNals: Buffer[] = []; let pendingNals: Buffer[] = [];
@@ -395,28 +397,13 @@ export async function demux(
// setting); it drives both RTP timestamp advance and pacing. // setting); it drives both RTP timestamp advance and pacing.
const videoFps = const videoFps =
opts.frameRate ?? (vInfo.framerate_num / vInfo.framerate_den || 30); opts.frameRate ?? (vInfo.framerate_num / vInfo.framerate_den || 30);
// token-bucket: capacity = 1s of frames; refill one token per 1000/fps ms. const emitIntervalMs = 1000 / videoFps;
const tokenIntervalMs = 1000 / videoFps;
let tokens = videoFps; // start with 1s of credit
let lastToken = Date.now();
let droppedFrames = 0;
const tryConsume = (): boolean => { // The single frame we will emit on the next tick. Only the newest survives;
const now = Date.now(); // keyframes are never superseded so the decoder always gets its IDRs.
const elapsed = now - lastToken; let latestAu: Buffer | null = null;
if (elapsed >= tokenIntervalMs) { let latestIsKey = false;
tokens = Math.min( let skippedFrames = 0; // frames superseded before they could be shown
videoFps,
tokens + Math.floor(elapsed / tokenIntervalMs),
);
lastToken = now - (elapsed % tokenIntervalMs);
}
if (tokens >= 1) {
tokens -= 1;
return true;
}
return false;
};
const flushAccessUnit = () => { const flushAccessUnit = () => {
if (pendingNals.length === 0) return; if (pendingNals.length === 0) return;
@@ -430,18 +417,28 @@ export async function demux(
pendingNals = []; pendingNals = [];
pendingHasSlice = false; pendingHasSlice = false;
pendingIsKey = false; pendingIsKey = false;
// Drop surplus frames: if no token is available, discard this AU so the // Tail-drop: keep only the newest frame. A keyframe always wins (never
// sender never emits a stale (old) frame. Keyframes are forced through // superseded by later non-keys in the same tick window) so the decoder
// even when over budget so the decoder always has a fresh IDR to recover. // keeps getting IDRs; a non-key only replaces a pending non-key.
if (!isKey && !tryConsume()) { if (isKey) {
droppedFrames++; latestAu = au;
if (droppedFrames === 1 || droppedFrames % 300 === 0) { latestIsKey = true;
console.log( } else if (latestAu === null || latestIsKey) {
`[goLive:Demuxer] drop=${droppedFrames} (pacing ${videoFps}fps; encoder burst)`, latestAu = au;
); latestIsKey = false;
} } else {
return; skippedFrames++;
} }
};
// Steady real-time emission clock. Emits the freshest buffered frame once
// per tick (≈ videoFps); never buffers more than one, so no backlog and
// no lag. This — not the encoder rate — defines playback speed.
const emitTick = () => {
if (latestAu === null) return;
const au = latestAu;
const isKey = latestIsKey;
latestAu = null;
vPipe.write({ vPipe.write({
data: au, data: au,
// One frame at videoFps: duration=1 in a 1/fps timebase → // One frame at videoFps: duration=1 in a 1/fps timebase →
@@ -458,11 +455,13 @@ export async function demux(
frameCount++; frameCount++;
if (frameCount === 1 || frameCount % 30 === 0) { if (frameCount === 1 || frameCount % 30 === 0) {
console.log( console.log(
`[goLive:Demuxer] frames=${frameCount} last=${au.length}B key=${isKey}`, `[goLive:Demuxer] frames=${frameCount} last=${au.length}B key=${isKey} skipped=${skippedFrames}`,
); );
} }
}; };
const emitTimer = setInterval(emitTick, emitIntervalMs);
if (proc.stdout) { if (proc.stdout) {
proc.stdout.on("data", (chunk: Buffer) => { proc.stdout.on("data", (chunk: Buffer) => {
videoBuf = Buffer.concat([videoBuf, chunk]); videoBuf = Buffer.concat([videoBuf, chunk]);
@@ -540,17 +539,21 @@ export async function demux(
}); });
proc.stdout.on("end", () => { proc.stdout.on("end", () => {
flushAccessUnit(); flushAccessUnit();
emitTick(); // flush the final frame if any
clearInterval(emitTimer);
vPipe.end(); vPipe.end();
aPipe.end(); aPipe.end();
}); });
} }
proc.on("close", () => { proc.on("close", () => {
clearInterval(emitTimer);
vPipe.end(); vPipe.end();
aPipe.end(); aPipe.end();
}); });
const close = () => { const close = () => {
clearInterval(emitTimer);
proc.kill("SIGTERM"); proc.kill("SIGTERM");
vPipe.end(); vPipe.end();
aPipe.end(); aPipe.end();