diff --git a/services/discord-gateway/native/libdatachannel-min/binding.cpp b/services/discord-gateway/native/libdatachannel-min/binding.cpp index 7e70d75..6576897 100644 --- a/services/discord-gateway/native/libdatachannel-min/binding.cpp +++ b/services/discord-gateway/native/libdatachannel-min/binding.cpp @@ -130,7 +130,9 @@ class TrackWrap : public Napi::ObjectWrap { InstanceMethod("send", &TrackWrap::Send), InstanceMethod("isOpen", &TrackWrap::IsOpen), InstanceMethod("close", &TrackWrap::Close), - InstanceMethod("onStateChange", &TrackWrap::OnStateChange), + InstanceMethod("setPacketizer", &TrackWrap::SetPacketizer), + InstanceMethod("sendFrame", &TrackWrap::SendFrame), + InstanceMethod("addTimestamp", &TrackWrap::AddTimestamp), }); trackConstructor = Napi::Persistent(func); return func; @@ -151,6 +153,7 @@ class TrackWrap : public Napi::ObjectWrap { private: static FunctionReference trackConstructor; std::shared_ptr track_; + std::shared_ptr rtpConfig_; void Send(const Napi::CallbackInfo& info) { Buffer buf = info[0].As>(); @@ -164,6 +167,78 @@ class TrackWrap : public Napi::ObjectWrap { } } + // setPacketizer(kind, ssrc, payloadType, clockRate, playoutDelayId, + // playoutDelayMin, playoutDelayMax) + // kind: "audio" | "h264" | "h265" | "av1" + // Builds the media-handler chain (packetizer → RTCP SR → NACK → pacing for + // video) exactly like @dank074's WebRtcWrapper does via node-datachannel. + void SetPacketizer(const Napi::CallbackInfo& info) { + Napi::Env env = info.Env(); + if (!track_) throw Error::New(env, "track closed"); + std::string kind = info[0].As().Utf8Value(); + uint32_t ssrc = info[1].As().Uint32Value(); + uint8_t pt = (uint8_t)info[2].As().Uint32Value(); + uint32_t clockRate = info[3].As().Uint32Value(); + uint8_t playoutDelayId = (uint8_t)info[4].As().Uint32Value(); + uint16_t playoutDelayMin = (uint16_t)info[5].As().Uint32Value(); + uint16_t playoutDelayMax = (uint16_t)info[6].As().Uint32Value(); + try { + auto cfg = std::make_shared( + ssrc, "", pt, clockRate); + cfg->playoutDelayId = playoutDelayId; + cfg->playoutDelayMin = playoutDelayMin; + cfg->playoutDelayMax = playoutDelayMax; + std::shared_ptr handler; + if (kind == "audio") { + handler = std::make_shared(cfg); + } else if (kind == "h264") { + handler = std::make_shared( + rtc::NalUnit::Separator::StartSequence, cfg); + } else if (kind == "h265") { + handler = std::make_shared( + rtc::NalUnit::Separator::StartSequence, cfg); + } else if (kind == "av1") { + handler = std::make_shared( + rtc::AV1RtpPacketizer::Packetization::Obu, cfg); + } else { + throw std::runtime_error("unknown packetizer kind: " + kind); + } + handler->addToChain(std::make_shared(cfg)); + handler->addToChain(std::make_shared()); + if (kind != "audio") { + handler->addToChain(std::make_shared( + 25.0 * 1000 * 1000, std::chrono::milliseconds(1))); + } + track_->setMediaHandler(handler); + rtpConfig_ = cfg; + } catch (const std::exception& e) { + fprintf(stderr, "[binding] setPacketizer THREW: %s\n", e.what()); + throw Error::New(env, e.what()); + } + } + + // sendFrame(buffer) — sends an ENCODED frame (AnnexB H264 / raw opus / + // OBU AV1). The media-handler chain packetizes it into RTP. + void SendFrame(const Napi::CallbackInfo& info) { + Buffer buf = info[0].As>(); + if (!track_) return; + rtc::binary data(buf.Length()); + for (size_t i = 0; i < buf.Length(); i++) data[i] = (std::byte)buf[i]; + try { + track_->send(data); + } catch (const std::exception& e) { + fprintf(stderr, "[binding] track.sendFrame THREW: %s\n", e.what()); + } + } + + // addTimestamp(delta) — advances the packetizer RTP timestamp by delta + // (clock-rate units). Called by JS after each frame, matching the + // node-datachannel contract (WebRtcWrapper does the same increment). + void AddTimestamp(const Napi::CallbackInfo& info) { + uint32_t delta = info[0].As().Uint32Value(); + if (rtpConfig_) rtpConfig_->timestamp += delta; + } + Napi::Value IsOpen(const Napi::CallbackInfo& info) { bool open = track_ && track_->isOpen(); return Boolean::New(info.Env(), open); @@ -178,7 +253,6 @@ class TrackWrap : public Napi::ObjectWrap { (void)info; } }; - class PeerConnectionWrap : public Napi::ObjectWrap { public: static Function Init(Napi::Env env) { diff --git a/services/discord-gateway/native/libdatachannel-min/test-packetizer.js b/services/discord-gateway/native/libdatachannel-min/test-packetizer.js new file mode 100644 index 0000000..5ad9c2f --- /dev/null +++ b/services/discord-gateway/native/libdatachannel-min/test-packetizer.js @@ -0,0 +1,80 @@ +// Verify setPacketizer + sendFrame: two peers connect, audio+video tracks +// packetize real encoded frames (opus + AnnexB H264), RTP flows without crash. +"use strict"; +const { PeerConnection } = require("./build/Release/datachannel_min.node"); + +function sleep(ms) { return new Promise((r) => setTimeout(r, ms)); } + +async function main() { + const pcA = new PeerConnection({ iceServers: [] }); + const pcB = new PeerConnection({ iceServers: [] }); + + const aAudio = pcA.addTrack("0", "audio"); + const aVideo = pcA.addTrack("1", "video"); + pcB.addTrack("0", "audio"); + pcB.addTrack("1", "video"); + + let states = { a: "", b: "" }; + pcA.onStateChange((s) => (states.a = s)); + pcB.onStateChange((s) => (states.b = s)); + + // A: offer (createDataChannel not needed — tracks trigger negotiation) + const offer = await pcA.createOffer(); + pcB.setRemoteDescription(offer, "offer"); + const answer = await pcB.createAnswer(offer); + pcA.setRemoteDescription(answer, "answer"); + + // Wait for connected + for (let i = 0; i < 50; i++) { + if (states.a === "connected" && states.b === "connected") break; + await sleep(100); + } + console.log("[pkt] states:", states.a, states.b); + if (states.a !== "connected" || states.b !== "connected") { + console.log("PKT TEST FAILED: not connected"); + process.exit(1); + } + + // Setup packetizers on A (sender) + aAudio.setPacketizer("audio", 1234, 120, 48000, 5, 0, 1); + aVideo.setPacketizer("h264", 5678, 101, 90000, 5, 0, 10); + + // Fake opus frame (20ms @48kHz stereo — payload can be any bytes) + const opusFrame = Buffer.alloc(160); + for (let i = 0; i < 160; i++) opusFrame[i] = i & 0xff; + + // Fake AnnexB H264 frame: SPS + PPS + IDR slice + const sps = Buffer.from([0x00, 0x00, 0x00, 0x01, 0x67, 0x42, 0xc0, 0x1e, 0xd9, 0x01, 0x40, 0x7e]); + const pps = Buffer.from([0x00, 0x00, 0x00, 0x01, 0x68, 0xce, 0x3c, 0x80]); + const idr = Buffer.from([0x00, 0x00, 0x00, 0x01, 0x65, 0x88, 0x84, 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07]); + const h264Frame = Buffer.concat([sps, pps, idr]); + + // Send 10 audio frames (20ms each) + 3 video frames (33ms each) + for (let i = 0; i < 10; i++) { + aAudio.sendFrame(opusFrame); + aAudio.addTimestamp(960); // 20ms @ 48kHz + } + for (let i = 0; i < 3; i++) { + aVideo.sendFrame(h264Frame); + aVideo.addTimestamp(3000); // 33ms @ 90kHz + } + + await sleep(500); + console.log("[pkt] after send: states:", states.a, states.b); + console.log("[pkt] audio track open:", aAudio.isOpen(), "| video track open:", aVideo.isOpen()); + const ok = states.a === "connected" && aAudio.isOpen() && aVideo.isOpen(); + console.log(ok ? "PKT TEST PASSED" : "PKT TEST FAILED"); + pcA.close(); + pcB.close(); + process.exit(ok ? 0 : 1); +} + +main().catch((e) => { + console.error("[pkt] FAILED:", e.message); + process.exit(1); +}); + +setTimeout(() => { + console.error("[pkt] TIMEOUT"); + process.exit(1); +}, 25000);