Lag root cause: vPipe/aPipe were objectMode PassThrough HWM 128 → the pipe
held up to 128 frames ≈ 4.3s of video before backpressure reached the encoder.
The viewer was watching a 4+ second stale backlog.
Fixes (both faithful to @dank074/discord-video-stream):
1. vPipe/aPipe HWM 2 — at most ~1-2 frames in flight (~66ms @ 30fps), so the
writeFrame() backpressure pauses ffmpeg stdout almost immediately and the
whole chain (encoder → NUT → demuxer → vPipe → BaseMediaStream → WebRTC)
runs at the sender's real pace, exactly like dank's 'resume &&= vPipe.write'.
2. Wire vStream.syncStream = aStream — audio is the master clock; video
sleeps/wakes on ptsDelta like upstream newApi.js. Prevents A/V drift under
variable encoder throughput.
Per user direction ('pakai dank sebagai referensi karena itu yg berhasil'):
drop the custom setInterval/tail-drop emission clock entirely. The demuxer
now writes each access unit straight to vPipe with a monotonic PTS and lets
BaseMediaStream (ported 1:1 from @dank074) handle pacing via sleep-PTS + A/V
sync, exactly like the upstream library. The custom clocks were the source of
the blank tile (IDR delivery race) and the lag (head-drop watching 10s-old
frames).
Adds proper backpressure: pause ffmpeg stdout when vPipe.write() returns
false, resume on drain — mirrors dank's 'resume &&= vPipe.write(packet)' so the
encoder self-throttles to the WebRTC sender's real pace instead of bursting.
The tail-drop rewrite let a P-frame supersede a pending keyframe before the
emit tick fired, so the decoder never received an IDR → blank GoLive tile.
Give keyframes their own slot (pendingKey) that P-frames cannot steal, and
only emit a P-frame once at least one IDR has been shown (haveReference).
IDR is always emitted first when present so the reference re-establishes.
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.
- Demuxer.ts: deterministic token-bucket video pacing (replace unreliable ffmpeg -re which did not throttle the live multi-stage pipe — demuxer emitted ~240fps vs 30fps sender, 100k+ frame backlog, frozen video). Surplus non-key frames dropped; keyframes forced through; audio on fd3 unaffected.
- biome.json: pin noExplicitAny/noUnused* to off/warn. Biome 2.5.x (drifted via --no-frozen-lockfile) promotes these to errors and was failing the CI gate on pre-existing backend code unrelated to this change. Restores the warn-level behavior the config schema 2.2.0 expects.
Root cause (3rd iteration): ffmpeg '-re' on the demuxer does NOT reliably
throttle a multi-stage live pipe (merge ffmpeg -> encoder x264 -> NUT ->
demuxer). In production the demuxer still emitted ~240fps while the WebRTC
sender consumed 30fps, building a 100k+ frame backlog (observed: frames=197490
vs sent #24600, ~8.4 min in). The sender always emitted the OLDEST buffered
frame -> video frozen ~10 min behind live, while audio (tiny, jitter-buffer
recovered) stayed smooth. Local file/pipe tests showed -re working (30fps)
but the live YouTube/WebM pipeline did not — -re is not trustworthy here.
Fix: enforce 1x video output with a token-bucket limiter in the demuxer
(Node side), independent of ffmpeg. Capacity = 1s of frames, refill 1 token
per 1000/fps ms. Surplus non-key frames are DROPPED (never buffered) so the
sender always emits the newest frame; keyframes are forced through even over
budget so the decoder keeps a fresh IDR. The limiter does NOT stall the ffmpeg
process (unlike the earlier proc.stdout pause), so audio on fd3 keeps flowing.
Verified: tsc --noEmit clean.
Root cause (revisited): the previous gate paused proc.stdout when vPipe was
full. That stalled the SAME ffmpeg process that also writes audio on fd3, so
audio stuttered; and the ~8s backlog already built never drained → permanent
lag. Symptom: 'video still lags bad, now audio also choppy'.
Fix:
- spawn demuxer ffmpeg with -re for stream (pipe) input. Verified locally:
a 5s NUT clip demuxes in 0.088s without -re (57x burst) vs 4.539s with -re
(real-time). -re throttles the input read, which back-pressures the whole
upstream chain (encoder x264 -> merge ffmpeg -> yt-dlp) through OS pipes,
pinning production at 1x. No unbounded backlog.
- drop oldest queued frame when vPipe readableLength >= 30 (transient sender
stall guard) instead of pausing stdout — keeps video fresh and audio intact.
- removed gateSource/sourcePaused entirely.
Audio and video now pace together at 1x; video is the newest frame, not an
8-second-old one.
Root cause: prepareStream's ffmpeg consumed a YouTube VOD at download/CPU
speed (~10x real-time), so the demuxer buffered a huge frame backlog.
The sender paces at 30fps but always emitted the OLDEST buffered frames, so
the viewer saw frozen/laggy video while audio (tiny, jitter-buffer
recoverable) stayed smooth. That is exactly the 'video stuck, voice normal'
symptom reported live.
Fix: propagate vPipe backpressure UP to the demuxer's ffmpeg stdout — when
the sender can't keep up, pause the source, which stalls the demuxer and
back-pressures the encoder, pinning the whole pipeline to 1x. Also add a
realtime (-re) option for file/URL inputs (no-op for the streaming path,
which is what screen share uses).
Verified: 10s test clip encodes in 1.8s without -re vs 9.5s with it; tsc --noEmit clean.
The demuxer resolved as soon as the VIDEO init line arrived on ffmpeg stderr.
With live NUT input the audio init line ('Stream #0:1: Audio: opus') lands in a
LATER stderr chunk (NUT info-stream packets are read incrementally from the
pipe), so `return { audio: aInfo }` captured undefined → playStream skipped
AudioStream → zero audio RTP on the audio SSRC → Discord showed a static
GoLive tile even though the NUT carried opus audio.
Fix:
- wait for BOTH video and audio init lines (when audio is expected) before
resolving demux metadata, with a 3s timeout fallback
- default aInfo to opus/48kHz when withAudio instead of undefined, so the
audio stream is always exposed even if the metadata line races the return
Screen share showed a single frozen frame: the GoLive pipeline sent video
only (-"-an", h264 muxer cannot carry audio) so the audio SSRC never
transmitted and Discord kept the stream in thumbnail state.
- prepareStream: mux NUT when includeAudio (h264 muxer drops audio) and
return the actual container format
- Demuxer: support NUT input with a second output pipe (fd3) carrying
Ogg Opus; parse OGG pages into opus frames (20ms, 48kHz) emitted as
GoLiveFrames; fix metadata parsing that dropped the audio stream line
when it arrived in a later stderr chunk (early parsedMeta return)
- playStream: pipe audio.stream into AudioStream → RTP on the audio SSRC
- Encoders: -x264-params repeat-headers=1 → SPS/PPS inline before EVERY
IDR (NUT remux drops container extradata; also enables PLI recovery)
- screenShareController: includeAudio true
- tests: demuxerNut.test.ts — OGG parser unit test + real ffmpeg NUT
integration (video access units + parsed opus frames)
Demuxer emitted each AnnexB NAL as its own WebRTC frame (SPS/PPS/SEI
separate from slices) with a near-zero timestamp delta (duration=1 in a
1/90000 timebase → RTP +1/frame instead of +3000 @30fps). Discord's H264
receiver never receives a complete decodable access unit → black GoLive
tile despite frames flowing.
- Group NALs into access units: buffer param-set/SEI NALs, flush one
frame per slice with preceding parameter sets (AnnexB start codes kept
so the H264RtpPacketizer finds NAL boundaries).
- Timestamp each frame at the video frame rate: duration=1, timeBase
1/fps → BaseMediaStream frametime=1000/fps ms → RTP +clockRate/fps
(3000 @ 30fps/90kHz) and correct pacing.
- Thread explicit frameRate from playStream options (raw H264 has no
timing info; ffmpeg guesses 25fps on stderr).
- Strengthen golive-demux-live-e2e: validates every frame has a slice,
no bare param-set frames, keyframes carry SPS/PPS, timeBase 1/30.
ROOT CAUSE of empty GoLive tile (finally): prepareStream ran with
includeAudio: true + output -f h264. The h264 muxer cannot mux audio
('h264 muxer does not support any stream of type audio') → header write
fails -22 → stdout empty → Demuxer ffmpeg 'Invalid data found when
processing input' → 0 frames → black tile. Reproduced locally end-to-end
(13s backpressure delay + prepareStream + demux).
Fixes:
- screenShareController: includeAudio: false (video-only GoLive; demux
path never delivers audio anyway)
- Demuxer: pin input format -f h264 for stream inputs (raw AnnexB H264
has no magic header → auto-detect unreliable on delayed pipes)
- Streamer.signalStream: self_video: false — stop flipping on the bot's
camera in Discord (user request; screen share ≠ camera)
Verified: local repro now emits 644 frames 1280x720 (was 0); tsc/biome/
vitest all green.
Root cause of 'tile appears but content empty': demux() spooled the live
NUT/H264 input to a temp file and awaited stream 'finish' — but the merge
ffmpeg output never ends during playback, so demux deadlocked, no probe,
no transcode, 0 frames sent.
- Demuxer: pipe input straight into ffmpeg stdin (-i pipe:0), parse NAL
frames live from stdout; parse video metadata from ffmpeg stderr with a
1.5s race (fall back to H264 defaults). No spool, no await-end.
- screenShareController: pass width/height/frameRate (1280x720@30) to
playStream — matches the prepareStream encode settings, so setVideoAttributes
gets real dimensions even when ffmpeg can't report metadata on an open pipe.
- Add tests/golive-demux-live-e2e.ts: proves frames flow while input is
still open (regression test for the deadlock).