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.
Root cause of "langsung left": YouTube bot-block/403 on u_c1tRmj7E4 (live
stream, LOGIN_REQUIRED) made yt-dlp timeout in resolveInputWithRetry (12s).
The timeout handler did cleanup() (removing once() listeners) THEN
tee.destroy(new Error(...)) — the PassThrough emitted 'error' with NO
listener left → unhandled stream 'error' event → uncaughtException →
gracefulShutdown → bot left voice.
Fix:
- resolveInputWithRetry: tee.destroy() silently after cleanup (error carried
in the rejection only); add permanent no-op tee.on('error') safety.
- prepareStream: output.on('error') no-op so ffmpeg spawn failure before
playStream attaches a demux listener never crashes the gateway.
- bootstrap: serialize uncaughtException/ClientError/DB errors with
{err, errorMsg, stack} (pino only serializes the 'err' magic key — the old
{error: err} key printed {} so crashes were invisible).
Revert 392bc35: streaming raw h264 video + opus on separate pipes broke
because prepareStream.output (pipe:1) feeds the Demuxer, but the opus
pipe:3 was never attached to the Demuxer's input — so for audio-capable
streams the Demuxer saw format=h264 (video-only) and emitted -an,
dropping audio RTP.
Correct design (from f1aa08c): prepareStream muxes video+audio into NUT
on a SINGLE pipe:1. The Demuxer then spawns a child ffmpeg that
demuxes NUT → -f h264 pipe:1 (pure AnnexB, start-code scan sees real
IDR type 5) + -f opus pipe:3 (Ogg Opus via createOggOpusDemux). The
start-code parser never touches NUT framing — it runs on the child
ffmpeg's clean h264 stdout.
Root cause: prepareStream muxed video+audio into a NUT container on pipe:1.
The Demuxer scans pipe:1 for AnnexB start codes (00 00 01) to split NAL
units into access units and classify keyframes (nal_type 5). NUT container
framing bytes sat in the stream and were scanned as NALs — NAL type 0
(NUT header) instead of 5 (IDR) → every frame classified key=false →
Discord decoder never got a decodable frame → static/black GoLive tile.
Fix: output raw H264 AnnexB on pipe:1 (demuxer target) and Ogg Opus on
fd3/pipe:3 for audio. NUT is only needed for *input* parsing (single
pipe carries both streams); output is demuxed into separate raw streams.
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)
Root cause (2026-08-12 11:50 test): merge ffmpeg hit a transient YouTube
403 and exited code 8 BEFORE prepareStream attached its input listeners
(voice release+join takes ~10s). The input's end/error events fired into
the void, the encoder stdin never received EOF, demux resolved with
fallback 0x0 metadata, setSpeaking fired anyway → stream 'started' with
zero frames for 8+ minutes (black tile, both ffmpeg processes hung).
Fixes:
- mediaSource: pass yt-dlp http_headers (UA/referer) to the merge ffmpeg
via -headers to suppress transient 403s; destroy the returned stream
with an error when the merge exits non-zero before producing bytes.
- screenShareController: resolveInputWithRetry — tee the merge stream and
wait for the first readable byte (12s timeout) before proceeding; on
error/EOF/timeout retry the whole resolution with a FRESH yt-dlp run
(signed DASH URLs expire fast) up to 3 attempts. Stuck merges get
EPIPE via input.destroy() so no process leaks per attempt.
- prepareStream: race guard — if the input already ended/destroyed before
listeners attach, EOF the encoder stdin immediately; first-frame
watchdog in playStream rejects 'started but nothing flowing' after 10s
instead of resolving with a silent black stream.
Tests: +2 (merge-fail zero-byte terminal state, -headers forwarding).
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.