fix(gateway): await audio stream line before demux resolve — audio RTP was dropped by metadata race
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
This commit is contained in:
@@ -243,6 +243,24 @@ export async function demux(
|
|||||||
stream: vPipe,
|
stream: vPipe,
|
||||||
};
|
};
|
||||||
let aInfo: DemuxedStream | undefined;
|
let aInfo: DemuxedStream | undefined;
|
||||||
|
// With audio expected (NUT input), ALWAYS expose an audio stream even if
|
||||||
|
// ffmpeg's audio init line hasn't arrived in stderr yet. prepareStream
|
||||||
|
// encodes libopus into the NUT unconditionally (`-map 0:a:0? -c:a libopus`),
|
||||||
|
// so fd3 WILL carry Ogg Opus — aInfo must not stay undefined just because
|
||||||
|
// the metadata line raced the resolve. The stderr handler below upgrades
|
||||||
|
// this default with real sample_rate metadata when the line lands.
|
||||||
|
if (withAudio) {
|
||||||
|
aInfo = {
|
||||||
|
codec: AVCodecID.AV_CODEC_ID_OPUS,
|
||||||
|
codecName: "opus",
|
||||||
|
width: 0,
|
||||||
|
height: 0,
|
||||||
|
framerate_num: 0,
|
||||||
|
framerate_den: 0,
|
||||||
|
sample_rate: 48000,
|
||||||
|
stream: aPipe,
|
||||||
|
};
|
||||||
|
}
|
||||||
let stderrBuf = "";
|
let stderrBuf = "";
|
||||||
if (proc.stderr) {
|
if (proc.stderr) {
|
||||||
proc.stderr.on("data", (d: Buffer) => {
|
proc.stderr.on("data", (d: Buffer) => {
|
||||||
@@ -307,26 +325,35 @@ export async function demux(
|
|||||||
stream: aPipe,
|
stream: aPipe,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
// Resolve the metadata wait once at least one stream kind is seen;
|
// Mark that at least one stream kind was seen. Note: we must NOT
|
||||||
// keep parsing further chunks so a late audio line still lands.
|
// resolve the metadata wait on the FIRST stream kind alone. With live
|
||||||
|
// NUT input, ffmpeg can print the video init line in one stderr chunk
|
||||||
|
// and the audio init line in the NEXT chunk (NUT info-stream packets
|
||||||
|
// arrive as ffmpeg reads them from the pipe). The old code returned
|
||||||
|
// immediately on `parsedMeta=true` — the audio line then landed in the
|
||||||
|
// handler AFTER `return { audio: aInfo }` had already captured
|
||||||
|
// `undefined` → no audio RTP → static GoLive tile even though the NUT
|
||||||
|
// carried audio. Wait for BOTH kinds (when audio is expected).
|
||||||
if (seenVideo || seenAudio) parsedMeta = true;
|
if (seenVideo || seenAudio) parsedMeta = true;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wait (briefly) for ffmpeg to print its stream init lines on stderr so
|
// Wait (briefly) for ffmpeg to print its stream init lines on stderr so
|
||||||
// vInfo carries real dimensions/fps. The lines arrive with the first chunk
|
// vInfo/aInfo carry real metadata. With audio expected, wait for BOTH the
|
||||||
// — a short timeout covers slow starts; callers fall back to sensible
|
// video and audio init lines (they may arrive in separate stderr chunks on
|
||||||
// defaults when width/height are 0 anyway.
|
// live input); the timeout covers slow starts / genuinely audio-less input.
|
||||||
|
const allSeen = () =>
|
||||||
|
withAudio ? seenVideo && seenAudio : seenVideo || seenAudio;
|
||||||
await Promise.race([
|
await Promise.race([
|
||||||
new Promise<void>((resolve) => {
|
new Promise<void>((resolve) => {
|
||||||
const check = setInterval(() => {
|
const check = setInterval(() => {
|
||||||
if (parsedMeta) {
|
if (allSeen()) {
|
||||||
clearInterval(check);
|
clearInterval(check);
|
||||||
resolve();
|
resolve();
|
||||||
}
|
}
|
||||||
}, 25);
|
}, 25);
|
||||||
}),
|
}),
|
||||||
new Promise<void>((resolve) => setTimeout(resolve, 1500)),
|
new Promise<void>((resolve) => setTimeout(resolve, 3000)),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Scan stdout for AnnexB NAL units and group them into ACCESS UNITS
|
// Scan stdout for AnnexB NAL units and group them into ACCESS UNITS
|
||||||
|
|||||||
Reference in New Issue
Block a user