diff --git a/services/discord-gateway/native/libdatachannel-min/test-rtp-capture.js b/services/discord-gateway/native/libdatachannel-min/test-rtp-capture.js new file mode 100644 index 0000000..c6a4c5a --- /dev/null +++ b/services/discord-gateway/native/libdatachannel-min/test-rtp-capture.js @@ -0,0 +1,138 @@ +// Two-peer RTP capture test: A sends REAL H264 frames through the binding's +// packetizer chain to B over localhost. tcpdump (run externally on lo) captures +// the RTP; a Python script reassembles AnnexB and ffmpeg decodes it. +// +// Usage: +// node test-rtp-capture.js mode = "a" (sender) | "b" (receiver) +// Sender writes the negotiated SDP pieces to /tmp/rtp-a.sdp /tmp/rtp-b.sdp +// Receiver listens and keeps alive. +"use strict"; +const { PeerConnection } = require("./build/Release/datachannel_min.node"); +const fs = require("fs"); + +const mode = process.argv[2] || "a"; +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +async function main() { + const pc = new PeerConnection({ iceServers: [] }); + const audio = pc.addTrack("0", "audio"); + const video = pc.addTrack("1", "video"); + + if (mode === "a") { + // Sender: generate offer, hand to B via files, get B's answer + const offer = await pc.createOffer(); + fs.writeFileSync("/tmp/rtp-offer.sdp", offer); + console.log("[a] offer written", offer.length, "bytes"); + + // wait for B to write its answer + for (let i = 0; i < 300; i++) { + if (fs.existsSync("/tmp/rtp-answer.sdp")) break; + await sleep(200); + } + const answer = fs.readFileSync("/tmp/rtp-answer.sdp", "utf8"); + pc.setRemoteDescription(answer, "answer"); + + // wait connected + for (let i = 0; i < 50; i++) { + if (pc.state() === "connected") break; + await sleep(100); + } + console.log("[a] state:", pc.state()); + if (pc.state() !== "connected") { + console.log("[a] FAILED not connected"); + process.exit(1); + } + + // Setup packetizer like the gateway does + video.setPacketizer("h264", 5678, 101, 90000, 5, 0, 10); + + // Real H264 AnnexB (baseline) — read from file created by ffmpeg + const data = fs.readFileSync("/tmp/rtp-input.h264"); + console.log("[a] input h264 bytes:", data.length); + + // Split into NAL units by start codes, then group into access units + // the same way Demuxer does (param sets + one slice per frame). + const start3 = Buffer.from([0, 0, 1]); + const start4 = Buffer.from([0, 0, 0, 1]); + const nals = []; + let i = 0; + while (i < data.length) { + let start = -1; + let startLen = 0; + for (let j = i; j < data.length - 3; j++) { + if (data[j] === 0 && data[j + 1] === 0 && data[j + 2] === 1) { + start = j; + startLen = 3; + if (j > 0 && data[j - 1] === 0) { + start = j - 1; + startLen = 4; + } + break; + } + } + if (start === -1) break; + if (start > i) { + nals.push(data.subarray(i, start)); + } + i = start + startLen; + } + console.log("[a] NALs:", nals.length); + + // Group: buffer param sets, flush on slice (like Demuxer.flushAccessUnit) + let pending = []; + let frameCount = 0; + const flush = () => { + if (pending.length === 0) return; + const parts = pending.map((n) => Buffer.concat([start4, n])); + const au = Buffer.concat(parts); + pending = []; + video.sendFrame(au); + video.addTimestamp(3000); // 30fps @ 90kHz + frameCount++; + }; + for (const n of nals) { + const t = n[0] & 0x1f; + if (t === 1 || t === 5) { + flush(); // previous AU closed by this slice + pending.push(n); + } else { + pending.push(n); // param set / SEI + } + } + flush(); + console.log("[a] sent frames:", frameCount); + await sleep(3000); // let packets flow + console.log("[a] done"); + pc.close(); + process.exit(0); + } else { + // Receiver: read offer, answer, keep alive + for (let i = 0; i < 300; i++) { + if (fs.existsSync("/tmp/rtp-offer.sdp")) break; + await sleep(200); + } + const offer = fs.readFileSync("/tmp/rtp-offer.sdp", "utf8"); + pc.setRemoteDescription(offer, "offer"); + const answer = await pc.createAnswer(offer); + fs.writeFileSync("/tmp/rtp-answer.sdp", answer); + console.log("[b] answer written"); + for (let i = 0; i < 50; i++) { + if (pc.state() === "connected") break; + await sleep(100); + } + console.log("[b] state:", pc.state()); + await sleep(10000); // hold while sender streams + console.log("[b] done"); + pc.close(); + process.exit(0); + } +} + +main().catch((e) => { + console.error("FAILED:", e.message); + process.exit(1); +}); +setTimeout(() => { + console.error("TIMEOUT"); + process.exit(1); +}, 30000); diff --git a/services/discord-gateway/tests/golive-demux-live-e2e.ts b/services/discord-gateway/tests/golive-demux-live-e2e.ts index 20da726..8dd8f63 100644 --- a/services/discord-gateway/tests/golive-demux-live-e2e.ts +++ b/services/discord-gateway/tests/golive-demux-live-e2e.ts @@ -17,10 +17,22 @@ await new Promise((resolve, reject) => { const p = spawn( FFMPEG, [ - "-hide_banner", "-loglevel", "error", - "-f", "lavfi", "-i", "testsrc=size=640x360:rate=30:duration=2", - "-c:v", "libx264", "-preset", "ultrafast", "-pix_fmt", "yuv420p", - "-f", "h264", clip, + "-hide_banner", + "-loglevel", + "error", + "-f", + "lavfi", + "-i", + "testsrc=size=640x360:rate=30:duration=2", + "-c:v", + "libx264", + "-preset", + "ultrafast", + "-pix_fmt", + "yuv420p", + "-f", + "h264", + clip, ], { stdio: ["ignore", "ignore", "pipe"] }, ); @@ -95,7 +107,10 @@ for (const f of frames) { const hasParams = types.some((t) => t === 7 || t === 8); const isKey = (f.flags & 1) !== 0; if (!hasSlice) framesWithoutSlice++; - if (types.length === 1 && (types[0] === 7 || types[0] === 8 || types[0] === 6)) { + if ( + types.length === 1 && + (types[0] === 7 || types[0] === 8 || types[0] === 6) + ) { bareParamSetFrames++; } if (isKey && hasParams) keyframesWithParamSets++; @@ -106,20 +121,28 @@ console.log( `metadata: ${video.codecName} ${video.width}x${video.height} fps=${video.framerate_num}/${video.framerate_den}`, ); console.log(`frames while stream OPEN (not ended): ${frames.length}`); -console.log(`frames w/o slice NAL: ${framesWithoutSlice}, bare param-set frames: ${bareParamSetFrames}`); -console.log(`keyframes with SPS/PPS: ${keyframesWithParamSets}, without: ${keyframesWithoutParamSets}`); +console.log( + `frames w/o slice NAL: ${framesWithoutSlice}, bare param-set frames: ${bareParamSetFrames}`, +); +console.log( + `keyframes with SPS/PPS: ${keyframesWithParamSets}, without: ${keyframesWithoutParamSets}`, +); if (frames.length === 0) { console.error("FAIL: no frames emitted while input still open (deadlock)"); close(); process.exit(1); } if (bareParamSetFrames > 0 || framesWithoutSlice > 0) { - console.error("FAIL: demux emitted bare parameter-set frames (must group into access units)"); + console.error( + "FAIL: demux emitted bare parameter-set frames (must group into access units)", + ); close(); process.exit(1); } if (frames.some((f) => f.duration !== 1 || f.timeBase.den !== 30)) { - console.error("FAIL: frame duration/timeBase not 1/30 (RTP timestamp advance wrong)"); + console.error( + "FAIL: frame duration/timeBase not 1/30 (RTP timestamp advance wrong)", + ); close(); process.exit(1); }