diff --git a/services/discord-gateway/src/goLive/Demuxer.ts b/services/discord-gateway/src/goLive/Demuxer.ts index d639480..ca98fec 100644 --- a/services/discord-gateway/src/goLive/Demuxer.ts +++ b/services/discord-gateway/src/goLive/Demuxer.ts @@ -365,6 +365,32 @@ export async function demux( // frames, each with a near-zero RTP timestamp delta). We therefore buffer // 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. + + // BACKPRESSURE: the encoder (prepareStream ffmpeg) produces frames at the + // download/CPU rate, which for a fast VOD is ~10x real-time. The sender + // (BaseMediaStream) paces at 30fps. Without a gate, the demuxer buffers an + // unbounded backlog and the sender always emits the OLDEST frames → the + // viewer sees frozen/laggy video while audio (tiny, jitter-buffer + // recoverable) stays smooth. That is the "video stuck, voice normal" + // symptom. So we propagate vPipe backpressure UP to the demuxer's ffmpeg + // stdout: when vPipe is full we pause it, which stalls the demuxer, which + // stalls its stdin, which back-pressures the encoder, pinning the whole + // pipeline to 1x. This is the real-time throttle for the streaming path + // (-re only works for file/URL inputs; screen share is always a pipe). + let sourcePaused = false; + const gateSource = (ok: boolean) => { + if (!ok && !sourcePaused) { + sourcePaused = true; + proc.stdout?.pause(); + } + }; + vPipe.on("drain", () => { + if (sourcePaused) { + sourcePaused = false; + proc.stdout?.resume(); + } + }); + let videoBuf = Buffer.alloc(0); let frameCount = 0; let pendingNals: Buffer[] = []; @@ -388,7 +414,7 @@ export async function demux( pendingNals = []; pendingHasSlice = false; pendingIsKey = false; - vPipe.write({ + const ok = vPipe.write({ data: au, // One frame at videoFps: duration=1 in a 1/fps timebase → // BaseMediaStream computes frametime=1000/fps ms → the RTP timestamp @@ -401,6 +427,9 @@ export async function demux( streamIndex: 0, free: () => {}, }); + // vPipe is full (sender can't keep up) → pause the demuxer's ffmpeg + // stdout so the backlog can't grow. Resumed on 'drain' above. + gateSource(ok); frameCount++; if (frameCount === 1 || frameCount % 30 === 0) { console.log( diff --git a/services/discord-gateway/src/goLive/prepareStream.ts b/services/discord-gateway/src/goLive/prepareStream.ts index 2c490c2..dfa19be 100644 --- a/services/discord-gateway/src/goLive/prepareStream.ts +++ b/services/discord-gateway/src/goLive/prepareStream.ts @@ -109,6 +109,16 @@ export function prepareStream( customInputOptions: (options.customInputOptions as string[]) ?? [], customFfmpegFlags: (options.customFfmpegFlags as string[]) ?? [], minimizeLatency: options.minimizeLatency ?? false, + // realtime: throttle ffmpeg's INPUT read to 1x so the encoder tracks + // wall-clock instead of slurping a VOD at network speed. Without this, + // a YouTube screen share downloads the whole clip fast and the encoder + // produces a ~10x frame backlog that the demuxer buffers unboundedly — + // the sender paces at 30fps but always emits the OLDEST buffered frames, + // so the viewer sees frozen/laggy video while audio (tiny, jitter- + // buffer recoverable) stays smooth. That is the "video stuck, voice + // normal" symptom. `-re` caps the pipeline at 1x end-to-end. Default on + // because this prepareStream is only used for screen share (VOD URLs). + realtime: options.realtime ?? true, }; const output = new PassThrough(); @@ -117,6 +127,10 @@ export function prepareStream( "-hide_banner", "-loglevel", "error", + // Real-time throttle: when the input is a URL/VOD, read it at 1x so the + // encoder paces with wall-clock (see `realtime` option above). For live + // pipe (PassThrough) input we DON'T add -re — the producer already paces. + ...(mergedOptions.realtime && typeof input === "string" ? ["-re"] : []), ...(typeof input === "string" ? ["-i", input] : ["-i", "pipe:0"]), ...mergedOptions.customInputOptions, ];