From 0164444dd7ac8b1602a68342a904aea7b54a4480 Mon Sep 17 00:00:00 2001 From: asepharyana Date: Sat, 15 Aug 2026 21:20:20 +0700 Subject: [PATCH] refactor(gateway): remove screen-share / GoLive feature entirely Drop the Discord Go Live (screen share) stack across the discord-gateway: - delete src/goLive/ (19 modules: Streamer, Demuxer, encoders, WebRTC wrapper, native loader, etc.) - delete native/libdatachannel-min/ N-API binding + flake native build + LD_LIBRARY_PATH wiring - delete screenShareController.ts and screen-share tests (goLive-port, golive-*, demuxerNut, screenShareInput) - mediaSource.ts: remove Invidious helpers + downloadScreenInput (YouTube full-file download) - mediaTypes.ts: drop ScreenShare* types, narrow MediaMode to 'music' and DiscordPlayerOwner to non-screen - media.handler.ts: remove screen branch, screenController/screenPlayback, voice-disconnect/reconnect accessor - commandHandler.ts: stop passing getVoiceStatus / setVoiceController into MediaHandler - media handler now only handles music; music queue/playback/status untouched Verification: tsc --noEmit clean, biome clean on touched files, no lingering goLive/screenShare refs in BE/FE/gateway. --- flake.nix | 45 +- .../native/libdatachannel-min/.gitignore | 3 - .../native/libdatachannel-min/binding.cpp | 526 -------------- .../native/libdatachannel-min/binding.gyp | 21 - .../native/libdatachannel-min/index.js | 3 - .../native/libdatachannel-min/package.json | 17 - .../libdatachannel-min/test-handshake.js | 82 --- .../libdatachannel-min/test-packetizer.js | 80 --- .../libdatachannel-min/test-rtp-capture.js | 138 ---- .../native/libdatachannel-min/test-track.js | 33 - .../src/goLive/AnnexBBitstreamReaderWriter.ts | 155 ---- .../src/goLive/AnnexBHelper.ts | 112 --- .../discord-gateway/src/goLive/AudioStream.ts | 20 - .../src/goLive/BaseMediaConnection.ts | 674 ------------------ .../src/goLive/BaseMediaStream.ts | 175 ----- .../src/goLive/CodecPayloadType.ts | 71 -- .../discord-gateway/src/goLive/Demuxer.ts | 647 ----------------- .../discord-gateway/src/goLive/Encoders.ts | 69 -- .../src/goLive/GatewayOpCodes.ts | 41 -- .../src/goLive/SPSVUIRewriter.ts | 291 -------- .../src/goLive/StreamConnection.ts | 45 -- .../discord-gateway/src/goLive/Streamer.ts | 351 --------- .../discord-gateway/src/goLive/VideoStream.ts | 20 - .../src/goLive/VoiceConnection.ts | 25 - .../src/goLive/VoiceOpCodes.ts | 38 - .../src/goLive/WebRtcWrapper.ts | 223 ------ services/discord-gateway/src/goLive/index.ts | 19 - services/discord-gateway/src/goLive/native.ts | 116 --- .../src/goLive/prepareStream.ts | 477 ------------- services/discord-gateway/src/goLive/utils.ts | 82 --- .../modules/command-handler/commandHandler.ts | 7 +- .../modules/command-handler/media.handler.ts | 121 +--- .../modules/voice-recording/mediaSource.ts | 161 +---- .../src/modules/voice-recording/mediaTypes.ts | 20 +- .../voice-recording/screenShareController.ts | 291 -------- .../discord-gateway/tests/demuxerNut.test.ts | 187 ----- .../discord-gateway/tests/goLive-port.test.ts | 97 --- .../discord-gateway/tests/golive-demux-e2e.ts | 40 -- .../tests/golive-demux-live-e2e.ts | 153 ---- .../tests/golive-pipeline-e2e.ts | 58 -- .../tests/golive-videostream-e2e.ts | 78 -- .../tests/screenShareInput.test.ts | 159 ----- 42 files changed, 14 insertions(+), 5957 deletions(-) delete mode 100644 services/discord-gateway/native/libdatachannel-min/.gitignore delete mode 100644 services/discord-gateway/native/libdatachannel-min/binding.cpp delete mode 100644 services/discord-gateway/native/libdatachannel-min/binding.gyp delete mode 100644 services/discord-gateway/native/libdatachannel-min/index.js delete mode 100644 services/discord-gateway/native/libdatachannel-min/package.json delete mode 100644 services/discord-gateway/native/libdatachannel-min/test-handshake.js delete mode 100644 services/discord-gateway/native/libdatachannel-min/test-packetizer.js delete mode 100644 services/discord-gateway/native/libdatachannel-min/test-rtp-capture.js delete mode 100644 services/discord-gateway/native/libdatachannel-min/test-track.js delete mode 100644 services/discord-gateway/src/goLive/AnnexBBitstreamReaderWriter.ts delete mode 100644 services/discord-gateway/src/goLive/AnnexBHelper.ts delete mode 100644 services/discord-gateway/src/goLive/AudioStream.ts delete mode 100644 services/discord-gateway/src/goLive/BaseMediaConnection.ts delete mode 100644 services/discord-gateway/src/goLive/BaseMediaStream.ts delete mode 100644 services/discord-gateway/src/goLive/CodecPayloadType.ts delete mode 100644 services/discord-gateway/src/goLive/Demuxer.ts delete mode 100644 services/discord-gateway/src/goLive/Encoders.ts delete mode 100644 services/discord-gateway/src/goLive/GatewayOpCodes.ts delete mode 100644 services/discord-gateway/src/goLive/SPSVUIRewriter.ts delete mode 100644 services/discord-gateway/src/goLive/StreamConnection.ts delete mode 100644 services/discord-gateway/src/goLive/Streamer.ts delete mode 100644 services/discord-gateway/src/goLive/VideoStream.ts delete mode 100644 services/discord-gateway/src/goLive/VoiceConnection.ts delete mode 100644 services/discord-gateway/src/goLive/VoiceOpCodes.ts delete mode 100644 services/discord-gateway/src/goLive/WebRtcWrapper.ts delete mode 100644 services/discord-gateway/src/goLive/index.ts delete mode 100644 services/discord-gateway/src/goLive/native.ts delete mode 100644 services/discord-gateway/src/goLive/prepareStream.ts delete mode 100644 services/discord-gateway/src/goLive/utils.ts delete mode 100644 services/discord-gateway/src/modules/voice-recording/screenShareController.ts delete mode 100644 services/discord-gateway/tests/demuxerNut.test.ts delete mode 100644 services/discord-gateway/tests/goLive-port.test.ts delete mode 100644 services/discord-gateway/tests/golive-demux-e2e.ts delete mode 100644 services/discord-gateway/tests/golive-demux-live-e2e.ts delete mode 100644 services/discord-gateway/tests/golive-pipeline-e2e.ts delete mode 100644 services/discord-gateway/tests/golive-videostream-e2e.ts delete mode 100644 services/discord-gateway/tests/screenShareInput.test.ts diff --git a/flake.nix b/flake.nix index a29fe90..6392ee7 100644 --- a/flake.nix +++ b/flake.nix @@ -11,11 +11,6 @@ let pkgs = import nixpkgs { inherit system; }; - # libdatachannel for the GoLive N-API binding. nixpkgs 0.24.1 is built - # against this host's glibc and ships both lib + dev headers, so the - # binding links cleanly inside the Nix sandbox (no manual cmake build). - libdatachannel = pkgs.libdatachannel; - # Source filter: `path:` literals do NOT respect .gitignore by default, # so a dirty local out/ (stale chunks from previous builds) leaks into # the sandbox. Filter out build artifacts explicitly. @@ -73,7 +68,7 @@ # NOTE: do NOT use `pnpm install --prod` here — it collapses the # public-hoist dir (.pnpm/node_modules) that runtime peer resolution # relies on (e.g. @lng2004/node-datachannel and @seydx/node-av-linux-x64 - # are only reachable through it), silently breaking voice/screenshare. + # are only reachable through it), silently breaking voice. # Instead we keep the full install's symlink layout and only prune # orphaned package dirs + broken symlinks. # Must run AFTER tsc (typescript is a devDep) and after native builds. @@ -167,8 +162,7 @@ WRAPPER pkgs.pkg-config pkgs.openssl pkgs.openssl.dev - libdatachannel.dev # rtc/rtc.hpp headers for the GoLive binding - pkgs.git # libdatachannel FetchContent clones from GitHub + pkgs.git # for any FetchContent-based deps during native builds pkgs.cacert ]; @@ -195,24 +189,6 @@ WRAPPER (cd "$pkg" && npm run install 2>&1 || true) fi done - echo "=== Building libdatachannel-min N-API binding ===" - # The GoLive screen-share stack uses a minimal N-API binding - # (native/libdatachannel-min) over nixpkgs libdatachannel. - ( - cd native/libdatachannel-min - # binding.gyp resolves include/lib from env (LDC_INCLUDE = .dev - # include root, LDC_LIB = lib output dir, NAPI_INCLUDE = - # node-addon-api include root). - NAPI_INCLUDE=$(find ../../node_modules/.pnpm -maxdepth 3 \ - -type d -path "*node_modules/node-addon-api" | head -1) - echo "NAPI_INCLUDE=$NAPI_INCLUDE" - LDC_INCLUDE=${libdatachannel.dev} LDC_LIB=${libdatachannel.out}/lib/libdatachannel.so.0.24.1 \ - NAPI_INCLUDE=$NAPI_INCLUDE \ - npx node-gyp rebuild 2>&1 || true - ls -la build/Release/datachannel_min.node 2>/dev/null \ - && echo "libdatachannel-min binding OK: $(stat -c%s build/Release/datachannel_min.node) bytes" \ - || echo "WARN: libdatachannel-min binding build FAILED (screen share disabled)" - ) echo "=== Compiling TypeScript ====" npx tsc 2>&1 echo "=== Fixing @/ path aliases to relative paths ===" @@ -247,22 +223,6 @@ WRAPPER mkdir -p $out/lib/gmw-discord-gateway cp -r dist node_modules package.json tsconfig.json $out/lib/gmw-discord-gateway/ - # GoLive native binding — loadNative resolves it relative to - # dist/goLive/native.js, i.e. /native/libdatachannel-min/ - # build/Release/datachannel_min.node; libdatachannel .so must sit - # next to it and be on LD_LIBRARY_PATH at runtime. - mkdir -p $out/lib/gmw-discord-gateway/native/libdatachannel-min/build/Release - cp native/libdatachannel-min/build/Release/datachannel_min.node \ - $out/lib/gmw-discord-gateway/native/libdatachannel-min/build/Release/ 2>/dev/null || true - mkdir -p $out/lib/gmw-discord-gateway/native/libdatachannel-min/build/ldc - cp -rL native/libdatachannel-min/build/ldc/libdatachannel.so* \ - $out/lib/gmw-discord-gateway/native/libdatachannel-min/build/ldc/ 2>/dev/null || true - # If the binding failed to build, screen share is simply disabled — - # the gateway itself must still start. - if [ ! -f $out/lib/gmw-discord-gateway/native/libdatachannel-min/build/Release/datachannel_min.node ]; then - echo "WARN: datachannel_min.node missing — GoLive screen share disabled in this build" - fi - # Also include drizzle migrations if they exist cp -r drizzle $out/lib/gmw-discord-gateway/ 2>/dev/null || true @@ -271,7 +231,6 @@ WRAPPER #!${pkgs.runtimeShell} cd $out/lib/gmw-discord-gateway export PATH=${pkgs.ffmpeg-headless}/bin:${pkgs.yt-dlp}/bin:\$PATH -export LD_LIBRARY_PATH=${libdatachannel.out}/lib:\$LD_LIBRARY_PATH exec ${nodejs}/bin/node dist/index.js WRAPPER chmod +x $out/bin/gmw-discord-gateway diff --git a/services/discord-gateway/native/libdatachannel-min/.gitignore b/services/discord-gateway/native/libdatachannel-min/.gitignore deleted file mode 100644 index beea512..0000000 --- a/services/discord-gateway/native/libdatachannel-min/.gitignore +++ /dev/null @@ -1,3 +0,0 @@ -node_modules/ -build/ -package-lock.json diff --git a/services/discord-gateway/native/libdatachannel-min/binding.cpp b/services/discord-gateway/native/libdatachannel-min/binding.cpp deleted file mode 100644 index 6576897..0000000 --- a/services/discord-gateway/native/libdatachannel-min/binding.cpp +++ /dev/null @@ -1,526 +0,0 @@ -// libdatachannel-min — minimal N-API binding to libdatachannel. -// Exposes ONLY what GMW GoLive needs: -// PeerConnection (offer/answer, ICE, SDP), DataChannel (signaling), -// Track send (added in media phase). -// Built against libdatachannel 0.24.0 (built from source in /tmp/ldc-build). - -#include -#include - -#include -#include -#include -#include - -using namespace Napi; - -namespace { - -std::string stateToString(rtc::PeerConnection::State s) { - switch (s) { - case rtc::PeerConnection::State::New: return "new"; - case rtc::PeerConnection::State::Connecting: return "connecting"; - case rtc::PeerConnection::State::Connected: return "connected"; - case rtc::PeerConnection::State::Disconnected: return "disconnected"; - case rtc::PeerConnection::State::Failed: return "failed"; - case rtc::PeerConnection::State::Closed: return "closed"; - default: return "unknown"; - } -} - -std::string binaryToString(const rtc::binary& data) { - // rtc::binary is std::vector in libdatachannel >= 0.21 - std::string msg(data.size(), '\0'); - for (size_t i = 0; i < data.size(); i++) { - msg[i] = static_cast(data[i]); - } - return msg; -} - -// Holds a Napi::Promise::Deferred so it can be moved into TSFN lambdas -// without invalid copies (node-addon-api 8.x Deferred is not movable). -struct DeferredHolder { - Promise::Deferred deferred; - explicit DeferredHolder(Promise::Deferred d) : deferred(d) {} -}; - -class DataChannelWrap : public Napi::ObjectWrap { - public: - static Function Init(Napi::Env env) { - Function func = DefineClass(env, "DataChannel", { - InstanceMethod("send", &DataChannelWrap::Send), - InstanceMethod("isOpen", &DataChannelWrap::IsOpen), - InstanceMethod("close", &DataChannelWrap::Close), - InstanceMethod("onMessage", &DataChannelWrap::OnMessage), - InstanceMethod("onOpen", &DataChannelWrap::OnOpen), - }); - dcConstructor = Napi::Persistent(func); - return func; - } - - // Create a JS wrapper (calls the JS constructor, returns instance). - static Object NewInstance(Napi::Env env) { - return dcConstructor.New({}); - } - - DataChannelWrap(const Napi::CallbackInfo& info) - : Napi::ObjectWrap(info) {} - - void Init(std::shared_ptr dc) { - dc_ = dc; - dc_->onMessage([this](rtc::message_variant data) { - std::string msg; - if (std::holds_alternative(data)) { - msg = binaryToString(std::get(data)); - } else { - msg = std::get(data); - } - if (msgCb_) { - msgCb_->BlockingCall([msg](Napi::Env env, Function cb) { - cb.Call({String::New(env, msg)}); - }); - } - }); - dc_->onOpen([this]() { - if (openCb_) { - openCb_->BlockingCall([](Napi::Env env, Function cb) { - cb.Call({}); - }); - } - }); - } - - private: - static FunctionReference dcConstructor; - std::shared_ptr dc_; - std::shared_ptr msgCb_; - std::shared_ptr openCb_; - - void Send(const Napi::CallbackInfo& info) { - std::string msg = info[0].As().Utf8Value(); - if (dc_) dc_->send(msg); - } - - Napi::Value IsOpen(const Napi::CallbackInfo& info) { - bool open = dc_ && dc_->isOpen(); - return Boolean::New(info.Env(), open); - } - - void Close(const Napi::CallbackInfo& info) { - if (dc_) dc_->close(); - } - - void OnMessage(const Napi::CallbackInfo& info) { - Function cb = info[0].As(); - msgCb_ = std::make_shared( - ThreadSafeFunction::New(info.Env(), cb, "dc-message", 0, 1)); - } - - void OnOpen(const Napi::CallbackInfo& info) { - Function cb = info[0].As(); - openCb_ = std::make_shared( - ThreadSafeFunction::New(info.Env(), cb, "dc-open", 0, 1)); - } -}; - -class TrackWrap : public Napi::ObjectWrap { - public: - static Function Init(Napi::Env env) { - Function func = DefineClass(env, "Track", { - InstanceMethod("send", &TrackWrap::Send), - InstanceMethod("isOpen", &TrackWrap::IsOpen), - InstanceMethod("close", &TrackWrap::Close), - InstanceMethod("setPacketizer", &TrackWrap::SetPacketizer), - InstanceMethod("sendFrame", &TrackWrap::SendFrame), - InstanceMethod("addTimestamp", &TrackWrap::AddTimestamp), - }); - trackConstructor = Napi::Persistent(func); - return func; - } - - static Object NewInstance(Napi::Env env) { - return trackConstructor.New({}); - } - - TrackWrap(const Napi::CallbackInfo& info) - : Napi::ObjectWrap(info) {} - - void Init(std::shared_ptr track, Napi::Env env) { - track_ = track; - (void)env; - } - - private: - static FunctionReference trackConstructor; - std::shared_ptr track_; - std::shared_ptr rtpConfig_; - - void Send(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.send THREW: %s\n", e.what()); - } - } - - // 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); - } - - void Close(const Napi::CallbackInfo& info) { - if (track_) track_->close(); - } - - void OnStateChange(const Napi::CallbackInfo& info) { - // libdatachannel Track has no state-change callback; kept for API parity. - (void)info; - } -}; -class PeerConnectionWrap : public Napi::ObjectWrap { - public: - static Function Init(Napi::Env env) { - Function func = DefineClass(env, "PeerConnection", { - InstanceMethod("state", &PeerConnectionWrap::State), - InstanceMethod("createOffer", &PeerConnectionWrap::CreateOffer), - InstanceMethod("createAnswer", &PeerConnectionWrap::CreateAnswer), - InstanceMethod("setRemoteDescription", - &PeerConnectionWrap::SetRemoteDescription), - InstanceMethod("close", &PeerConnectionWrap::Close), - InstanceMethod("onStateChange", &PeerConnectionWrap::OnStateChange), - InstanceMethod("createDataChannel", &PeerConnectionWrap::CreateDataChannel), - InstanceMethod("onDataChannel", &PeerConnectionWrap::OnDataChannel), - InstanceMethod("addTrack", &PeerConnectionWrap::AddTrack), - }); - return func; - } - - PeerConnectionWrap(const Napi::CallbackInfo& info) - : Napi::ObjectWrap(info) { - Napi::Env env = info.Env(); - if (!info[0].IsObject()) { - throw TypeError::New(env, "config object required"); - } - Object config = info[0].As(); - rtc::Configuration rtcConfig; - if (config.Has("iceServers")) { - Array servers = config.Get("iceServers").As(); - for (uint32_t i = 0; i < servers.Length(); i++) { - std::string url = servers.Get(i).As().Utf8Value(); - rtcConfig.iceServers.emplace_back(url); - } - } - pc_ = std::make_shared(rtcConfig); - - // IMPORTANT: register description/gathering callbacks HERE (constructor), - // BEFORE any createDataChannel call. libdatachannel only fires - // onLocalDescription for negotiations that start AFTER the callback is - // registered — if createDataChannel runs first, the offer callback never - // fires (verified in C++ spike: test3 vs test2). - pc_->onLocalDescription([this](rtc::Description desc) { - latestLocalDesc_ = std::string(desc); - fprintf(stderr, "[binding] trickle desc, %zu bytes\n", - latestLocalDesc_.size()); - }); - pc_->onGatheringStateChange([this](rtc::PeerConnection::GatheringState gs) { - fprintf(stderr, "[binding] gathering state: %d\n", (int)gs); - if (gs == rtc::PeerConnection::GatheringState::Complete) { - // Use the getter — it returns the FULL SDP including candidates after - // gathering (trickle callbacks only carry the initial fragment). - auto ld = pc_->localDescription(); - if (ld) { - latestLocalDesc_ = std::string(*ld); - fprintf(stderr, "[binding] final desc, %zu bytes\n", - latestLocalDesc_.size()); - } - resolvePendingLocalDesc_(); - } - }); - } - - private: - std::shared_ptr pc_; - std::shared_ptr stateCb_; - std::shared_ptr dcCb_; - std::string latestLocalDesc_; - std::shared_ptr pendingDescDeferred_; - std::shared_ptr pendingDescTsfn_; - - void resolvePendingLocalDesc_() { - if (!pendingDescDeferred_ || !pendingDescTsfn_) return; - auto holder = pendingDescDeferred_; - auto tsfn = pendingDescTsfn_; - pendingDescDeferred_.reset(); - pendingDescTsfn_.reset(); - std::string sdp = latestLocalDesc_; - tsfn->BlockingCall([sdp, holder](Napi::Env e, Function) { - holder->deferred.Resolve(String::New(e, sdp)); - }); - } - - Napi::Value State(const Napi::CallbackInfo& info) { - return String::New(info.Env(), - pc_ ? stateToString(pc_->state()) : "closed"); - } - - // createOffer() -> Promise — sets local description, waits for - // ICE gathering to complete (so candidates are in the SDP), resolves SDP. - Napi::Value CreateOffer(const Napi::CallbackInfo& info) { - Napi::Env env = info.Env(); - auto holder = std::make_shared(Promise::Deferred::New(env)); - if (!pc_) { - holder->deferred.Reject(Error::New(env, "peer closed").Value()); - return holder->deferred.Promise(); - } - // createDataChannel already triggers negotiation in libdatachannel 0.24 — - // if gathering already completed, resolve immediately from the cached SDP. - if (!latestLocalDesc_.empty()) { - auto tsfn = std::make_shared(ThreadSafeFunction::New( - env, Function::New(env, [](const CallbackInfo&) {}), "desc", 0, 1)); - std::string sdp = latestLocalDesc_; - tsfn->BlockingCall([sdp, holder](Napi::Env e, Function) { - holder->deferred.Resolve(String::New(e, sdp)); - }); - return holder->deferred.Promise(); - } - if (pendingDescDeferred_) { - pendingDescDeferred_->deferred.Reject( - Error::New(env, "previous negotiation still pending").Value()); - } - pendingDescDeferred_ = holder; - pendingDescTsfn_ = std::make_shared( - ThreadSafeFunction::New(env, Function::New(env, [](const CallbackInfo&) {}), - "desc", 0, 1)); - fprintf(stderr, "[binding] calling setLocalDescription(Offer)\n"); - try { - pc_->setLocalDescription(rtc::Description::Type::Offer); - fprintf(stderr, "[binding] setLocalDescription returned OK\n"); - } catch (const std::exception& e) { - pendingDescDeferred_.reset(); - fprintf(stderr, "[binding] setLocalDescription THREW: %s\n", e.what()); - throw Error::New(env, e.what()); - } - return holder->deferred.Promise(); - } - - // createAnswer(offerSdp: string) -> Promise - Napi::Value CreateAnswer(const Napi::CallbackInfo& info) { - Napi::Env env = info.Env(); - std::string offer = info[0].As().Utf8Value(); - auto holder = std::make_shared(Promise::Deferred::New(env)); - if (!pc_) { - holder->deferred.Reject(Error::New(env, "peer closed").Value()); - return holder->deferred.Promise(); - } - if (pendingDescDeferred_) { - pendingDescDeferred_->deferred.Reject( - Error::New(env, "previous negotiation still pending").Value()); - } - pendingDescDeferred_ = holder; - pendingDescTsfn_ = std::make_shared( - ThreadSafeFunction::New(env, Function::New(env, [](const CallbackInfo&) {}), - "desc", 0, 1)); - try { - pc_->setRemoteDescription( - rtc::Description(offer, rtc::Description::Type::Offer)); - fprintf(stderr, "[binding] answer: setRemoteDescription OK\n"); - // libdatachannel 0.24 AUTO-GENERATES the answer when a remote offer is - // applied (verified in C++ spike test8/9: B desc type=Answer fires - // immediately with a=setup:active). Calling setLocalDescription() again - // would OVERWRITE it with a role=actpass SDP, which A rejects with - // "Illegal role actpass in remote answer description". So we do NOT call - // setLocalDescription here — we just wait for gathering complete and - // resolve with the auto-generated answer. This also matches @dank074's - // Discord voice flow. - } catch (const std::exception& e) { - pendingDescDeferred_.reset(); - fprintf(stderr, "[binding] answer THREW: %s\n", e.what()); - holder->deferred.Reject(Error::New(env, e.what()).Value()); - } - return holder->deferred.Promise(); - } - - void SetRemoteDescription(const Napi::CallbackInfo& info) { - std::string sdp = info[0].As().Utf8Value(); - std::string type = info[1].As().Utf8Value(); - rtc::Description::Type t = (type == "answer") - ? rtc::Description::Type::Answer - : rtc::Description::Type::Offer; - if (pc_) pc_->setRemoteDescription(rtc::Description(sdp, t)); - } - - void Close(const Napi::CallbackInfo& info) { - if (pc_) pc_->close(); - } - - void OnStateChange(const Napi::CallbackInfo& info) { - Function cb = info[0].As(); - stateCb_ = std::make_shared( - ThreadSafeFunction::New(info.Env(), cb, "pc-state", 0, 1)); - std::shared_ptr pc = pc_; - pc->onStateChange([this](rtc::PeerConnection::State state) { - if (stateCb_) { - std::string s = stateToString(state); - stateCb_->BlockingCall([s](Napi::Env env, Function cb) { - cb.Call({String::New(env, s)}); - }); - } - }); - } - - Napi::Value CreateDataChannel(const Napi::CallbackInfo& info) { - Napi::Env env = info.Env(); - std::string label = info[0].As().Utf8Value(); - fprintf(stderr, "[binding] createDataChannel(%s)\n", label.c_str()); - auto dc = pc_->createDataChannel(label); - Object obj = DataChannelWrap::NewInstance(env); - DataChannelWrap::Unwrap(obj)->Init(dc); - return obj; - } - - Napi::Value AddTrack(const Napi::CallbackInfo& info) { - Napi::Env env = info.Env(); - std::string mid = info[0].As().Utf8Value(); - std::string kind = info[1].As().Utf8Value(); - if (!pc_) throw Error::New(env, "peer closed"); - fprintf(stderr, "[binding] addTrack(%s, %s) start\n", mid.c_str(), kind.c_str()); - try { - std::shared_ptr track; - if (kind == "audio") { - // Opus payload type 120 (matches @dank074 CodecPayloadType.opus) - auto desc = rtc::Description::Audio(mid); - desc.addOpusCodec(120); - track = pc_->addTrack(desc); - } else { - // All video codecs with their payload types, matching WebRtcWrapper: - // H264 101/102, H265 103/104, VP8 105/106, VP9 107/108, AV1 109/110 - auto desc = rtc::Description::Video(mid); - desc.addH264Codec(101); - desc.addRtxCodec(102, 101, 90000); - desc.addH265Codec(103); - desc.addRtxCodec(104, 103, 90000); - desc.addVP8Codec(105); - desc.addRtxCodec(106, 105, 90000); - desc.addVP9Codec(107); - desc.addRtxCodec(108, 107, 90000); - desc.addAV1Codec(109); - desc.addRtxCodec(110, 109, 90000); - track = pc_->addTrack(desc); - } - Object obj = TrackWrap::NewInstance(env); - TrackWrap::Unwrap(obj)->Init(track, env); - return obj; - } catch (const std::exception& e) { - fprintf(stderr, "[binding] addTrack THREW: %s\n", e.what()); - throw Error::New(env, e.what()); - } - } - - void OnDataChannel(const Napi::CallbackInfo& info) { - Function cb = info[0].As(); - dcCb_ = std::make_shared( - ThreadSafeFunction::New(info.Env(), cb, "dc", 0, 1)); - std::shared_ptr pc = pc_; - pc->onDataChannel([this](std::shared_ptr dc) { - if (dcCb_) { - auto dcPtr = dc; - dcCb_->BlockingCall([dcPtr](Napi::Env env, Function cb) { - Object obj = DataChannelWrap::NewInstance(env); - DataChannelWrap::Unwrap(obj)->Init(dcPtr); - cb.Call({obj}); - }); - } - }); - } -}; - -Object InitAll(Napi::Env env, Object exports) { - exports.Set("PeerConnection", PeerConnectionWrap::Init(env)); - exports.Set("DataChannel", DataChannelWrap::Init(env)); - exports.Set("Track", TrackWrap::Init(env)); - return exports; -} - -NODE_API_MODULE(libdatachannel_min, InitAll) - -// Definition for the static constructor references. -FunctionReference DataChannelWrap::dcConstructor; -FunctionReference TrackWrap::trackConstructor; - -} // namespace diff --git a/services/discord-gateway/native/libdatachannel-min/binding.gyp b/services/discord-gateway/native/libdatachannel-min/binding.gyp deleted file mode 100644 index c5ac852..0000000 --- a/services/discord-gateway/native/libdatachannel-min/binding.gyp +++ /dev/null @@ -1,21 +0,0 @@ -{ - "targets": [ - { - "target_name": "libdatachannel_min", - "sources": ["binding.cpp"], - "include_dirs": [ - " { try { return require('node-addon-api').include; } catch { return '/nonexistent'; } })())\")", - " { - stateLog.push(`A:${s}`); - log("A state:", s); - }); - pcB.onStateChange((s) => { - stateLog.push(`B:${s}`); - log("B state:", s); - }); - - // B waits for incoming DataChannel - const received = new Promise((resolve) => { - pcB.onDataChannel((dc) => { - log("B got incoming DataChannel"); - dc.onOpen(() => log("B DataChannel open")); - dc.onMessage((msg) => { - log("B received message:", msg); - dc.send("pong from B"); - resolve(msg); - }); - }); - }); - - // A creates an outgoing DataChannel - const dcA = pcA.createDataChannel("test"); - dcA.onOpen(() => { - log("A DataChannel open — sending hello"); - dcA.send("hello from A"); - }); - dcA.onMessage((msg) => { - log("A received reply:", msg); - }); - - // Offer/answer dance - log("A createOffer..."); - const offer = await pcA.createOffer(); - log("Offer SDP bytes:", offer.length); - log("B createAnswer..."); - const answer = await pcB.createAnswer(offer); - log("Answer SDP bytes:", answer.length); - const setupMatch = answer.match(/a=setup:(\S+)/); - log("Answer setup role:", setupMatch ? setupMatch[1] : "NONE"); - pcA.setRemoteDescription(answer, "answer"); - - // Wait for message roundtrip - const msg = await Promise.race([ - received, - new Promise((_, rej) => setTimeout(() => rej(new Error("TIMEOUT waiting for datachannel message")), 15000)), - ]); - - log("ROUNDTRIP OK — B got:", msg); - log("States:", stateLog.join(" | ")); - - const aState = pcA.state(); - const bState = pcB.state(); - log("Final states — A:", aState, "B:", bState); - - pcA.close(); - pcB.close(); - - if (msg !== "hello from A") throw new Error("wrong message"); - if (aState !== "connected" && aState !== "disconnected") throw new Error("A not connected: " + aState); - log("SPIKE PASSED ✅"); -} - -main().catch((e) => { - console.error("SPIKE FAILED:", e.message); - process.exit(1); -}); diff --git a/services/discord-gateway/native/libdatachannel-min/test-packetizer.js b/services/discord-gateway/native/libdatachannel-min/test-packetizer.js deleted file mode 100644 index 5ad9c2f..0000000 --- a/services/discord-gateway/native/libdatachannel-min/test-packetizer.js +++ /dev/null @@ -1,80 +0,0 @@ -// 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); diff --git a/services/discord-gateway/native/libdatachannel-min/test-rtp-capture.js b/services/discord-gateway/native/libdatachannel-min/test-rtp-capture.js deleted file mode 100644 index c6a4c5a..0000000 --- a/services/discord-gateway/native/libdatachannel-min/test-rtp-capture.js +++ /dev/null @@ -1,138 +0,0 @@ -// 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/native/libdatachannel-min/test-track.js b/services/discord-gateway/native/libdatachannel-min/test-track.js deleted file mode 100644 index 9bc02c2..0000000 --- a/services/discord-gateway/native/libdatachannel-min/test-track.js +++ /dev/null @@ -1,33 +0,0 @@ -// Verify addTrack produces SDP with audio+video media sections. -"use strict"; -const { PeerConnection } = require("./build/Release/datachannel_min.node"); - -const pc = new PeerConnection({ iceServers: [] }); -const audioTrack = pc.addTrack("0", "audio"); -const videoTrack = pc.addTrack("1", "video"); - -pc.onStateChange((s) => console.log("[test-track] state:", s)); - -pc.createOffer().then((sdp) => { - const hasAudio = /^m=audio\s/m.test(sdp); - const hasVideo = /^m=video\s/m.test(sdp); - const audioPts = sdp.match(/a=rtpmap:(\d+) opus/g) || []; - const videoPts = sdp.match(/a=rtpmap:(\d+) H264/g) || []; - console.log("[test-track] SDP bytes:", sdp.length); - console.log("[test-track] m=audio:", hasAudio, "| m=video:", hasVideo); - console.log("[test-track] opus pt:", audioPts, "| H264 pt:", videoPts); - console.log("[test-track] audio track send ok:", typeof audioTrack.send === "function"); - console.log("[test-track] video track send ok:", typeof videoTrack.send === "function"); - const ok = hasAudio && hasVideo && audioPts.length > 0 && videoPts.length > 0; - console.log(ok ? "TRACK TEST PASSED" : "TRACK TEST FAILED"); - pc.close(); - process.exit(ok ? 0 : 1); -}).catch((e) => { - console.error("[test-track] FAILED:", e.message); - process.exit(1); -}); - -setTimeout(() => { - console.error("[test-track] TIMEOUT"); - process.exit(1); -}, 20000); diff --git a/services/discord-gateway/src/goLive/AnnexBBitstreamReaderWriter.ts b/services/discord-gateway/src/goLive/AnnexBBitstreamReaderWriter.ts deleted file mode 100644 index 3770435..0000000 --- a/services/discord-gateway/src/goLive/AnnexBBitstreamReaderWriter.ts +++ /dev/null @@ -1,155 +0,0 @@ -/** - * AnnexB bitstream reader/writer (RBSP + emulation prevention) — ported - * from @dank074/discord-video-stream AnnexBBitstreamReaderWriter.js. - */ - -export class AnnexBBitstreamReader { - private _buffer: Uint8Array; - private _byteOffset = 0; - private _bitOffset = 0; - - constructor(buffer: Uint8Array) { - this._buffer = buffer; - } - - readBits(count: number): number { - if (count === 0) return 0; - let result = 0; - while (count > 0) { - if (this._byteOffset >= this._buffer.length) { - throw new Error("Bad byte offset"); - } - if ( - this._bitOffset === 0 && - this._byteOffset >= 2 && - this._buffer[this._byteOffset - 2] === 0 && - this._buffer[this._byteOffset - 1] === 0 && - this._buffer[this._byteOffset] === 3 - ) { - // Skip over emulation prevention - this._byteOffset++; - } - if (this._bitOffset === 0 && count >= 8) { - result = (result << 8) | this._buffer[this._byteOffset++]; - count -= 8; - } else { - const numBitsToRead = Math.min(count, 8 - this._bitOffset); - const mask = (1 << numBitsToRead) - 1; - const newBits = - (this._buffer[this._byteOffset] >> - (8 - this._bitOffset - numBitsToRead)) & - mask; - result = (result << numBitsToRead) | newBits; - count -= numBitsToRead; - this._bitOffset += numBitsToRead; - if (this._bitOffset === 8) { - this._bitOffset = 0; - this._byteOffset++; - } - } - } - return result; - } - - readUnsigned(bits: number): number { - return this.readBits(bits); - } - - readSigned(bits: number): number { - const unsigned = this.readUnsigned(bits); - if (unsigned & (1 << (bits - 1))) return unsigned - (1 << bits); - return unsigned; - } - - readUnsignedExpGolomb(): number { - let leading0 = 0; - while (this.readBits(1) === 0) leading0++; - return (1 << leading0) + this.readBits(leading0) - 1; - } - - readSignedExpGolomb(): number { - const unsigned = this.readUnsignedExpGolomb(); - if (unsigned % 2 === 0) return unsigned / -2; - return (unsigned + 1) / 2; - } -} - -export class AnnexBBitstreamWriter { - private _arr: number[] = []; - private _pendingByte = 0; - private _bitOffset = 0; - - toBuffer(): Buffer { - return Buffer.from(this._arr); - } - - flush(): void { - // Emulation prevention: insert 0x03 before 00 00 - if ( - this._pendingByte <= 3 && - this._arr[this._arr.length - 1] === 0 && - this._arr[this._arr.length - 2] === 0 - ) { - this._arr.push(3); - } - this._arr.push(this._pendingByte); - this._pendingByte = 0; - this._bitOffset = 0; - } - - writeBits(bits: number, count: number): void { - while (count > 0) { - if (this._bitOffset === 0) { - if (count >= 8) { - this._pendingByte = (bits >> (count - 8)) & 0xff; - count -= 8; - this.flush(); - } else { - const mask = (1 << count) - 1; - this._pendingByte |= (bits & mask) << (8 - count); - this._bitOffset = count; - count = 0; - } - } else { - const numBitsToWrite = Math.min(8 - this._bitOffset, count); - const bitsToWrite = - (bits >> (count - numBitsToWrite)) & ((1 << numBitsToWrite) - 1); - this._pendingByte |= - bitsToWrite << (8 - this._bitOffset - numBitsToWrite); - count -= numBitsToWrite; - this._bitOffset += numBitsToWrite; - if (this._bitOffset === 8) { - this._bitOffset = 0; - this.flush(); - } - } - } - } - - writeUnsigned(num: number, count: number): void { - if (num < 0) throw new Error("Expected a non-negative number"); - this.writeBits(num, count); - } - - writeSigned(num: number, count: number): void { - if (count <= 0) return; - if (count > 32) throw new Error("writeSigned supports up to 32 bits"); - const mask = - count === 32 ? 0xffffffff >>> 0 : (((1 << count) >>> 0) - 1) >>> 0; - const unsigned = (num & mask) >>> 0; - this.writeBits(unsigned, count); - } - - writeUnsignedExpGolomb(num: number): void { - if (num < 0) throw new Error("Expected a non-negative number"); - num++; - const bitCount = 32 - Math.clz32(num >>> 0); - this.writeBits(0, bitCount - 1); - this.writeBits(num, bitCount); - } - - writeSignedExpGolomb(num: number): void { - if (num < 0) this.writeUnsignedExpGolomb(-2 * num); - else this.writeUnsignedExpGolomb(2 * num - 1); - } -} diff --git a/services/discord-gateway/src/goLive/AnnexBHelper.ts b/services/discord-gateway/src/goLive/AnnexBHelper.ts deleted file mode 100644 index 9eb947e..0000000 --- a/services/discord-gateway/src/goLive/AnnexBHelper.ts +++ /dev/null @@ -1,112 +0,0 @@ -/** - * H264/H265 NAL helpers — ported from @dank074/discord-video-stream - * AnnexBHelper.js. Only the H264 parts are used by GoLive (H264 encoder), - * H265 constants kept for completeness of the port. - */ - -export enum H264NalUnitTypes { - Unspecified = 0, - CodedSliceNonIDR = 1, - CodedSlicePartitionA = 2, - CodedSlicePartitionB = 3, - CodedSlicePartitionC = 4, - CodedSliceIdr = 5, - SEI = 6, - SPS = 7, - PPS = 8, - AccessUnitDelimiter = 9, - EndOfSequence = 10, - EndOfStream = 11, - FillerData = 12, - SEIExtenstion = 13, - PrefixNalUnit = 14, - SubsetSPS = 15, -} - -export enum H265NalUnitTypes { - TRAIL_N = 0, - TRAIL_R = 1, - TSA_N = 2, - TSA_R = 3, - STSA_N = 4, - STSA_R = 5, - RADL_N = 6, - RADL_R = 7, - RASL_N = 8, - RASL_R = 9, - RSV_VCL_N10 = 10, - RSV_VCL_R11 = 11, - RSV_VCL_N12 = 12, - RSV_VCL_R13 = 13, - RSV_VCL_N14 = 14, - RSV_VCL_R15 = 15, - BLA_W_LP = 16, - BLA_W_RADL = 17, - BLA_N_LP = 18, - IDR_W_RADL = 19, - IDR_N_LP = 20, - CRA_NUT = 21, - RSV_IRAP_VCL22 = 22, - RSV_IRAP_VCL23 = 23, - RSV_VCL24 = 24, - RSV_VCL25 = 25, - RSV_VCL26 = 26, - RSV_VCL27 = 27, - RSV_VCL28 = 28, - RSV_VCL29 = 29, - RSV_VCL30 = 30, - RSV_VCL31 = 31, - VPS_NUT = 32, - SPS_NUT = 33, - PPS_NUT = 34, - AUD_NUT = 35, - EOS_NUT = 36, - EOB_NUT = 37, - FD_NUT = 38, - PREFIX_SEI_NUT = 39, - SUFFIX_SEI_NUT = 40, -} - -export const H264Helpers = { - getUnitType(frame: Uint8Array): number { - return frame[0] & 0x1f; - }, - splitHeader(frame: Uint8Array): [Uint8Array, Uint8Array] { - return [frame.subarray(0, 1), frame.subarray(1)]; - }, - isAUD(unitType: number): boolean { - return unitType === H264NalUnitTypes.AccessUnitDelimiter; - }, -}; - -export const H265Helpers = { - getUnitType(frame: Uint8Array): number { - return (frame[0] >> 1) & 0x3f; - }, - splitHeader(frame: Uint8Array): [Uint8Array, Uint8Array] { - return [frame.subarray(0, 2), frame.subarray(2)]; - }, - isAUD(unitType: number): boolean { - return unitType === H265NalUnitTypes.AUD_NUT; - }, -}; - -export const startCode3 = Buffer.from([0, 0, 1]); - -/** Split an AnnexB bitstream into NAL units (start codes stripped). */ -export function splitNalu(buf: Buffer): Buffer[] { - let temp: Buffer | null = buf; - const nalus: Buffer[] = []; - while (temp?.byteLength) { - let pos: number = temp.indexOf(startCode3); - let length = 3; - if (pos > 0 && temp[pos - 1] === 0) { - pos--; - length++; - } - const nalu = pos === -1 ? temp : temp.subarray(0, pos); - temp = pos === -1 ? null : temp.subarray(pos + length); - if (nalu.byteLength) nalus.push(nalu); - } - return nalus; -} diff --git a/services/discord-gateway/src/goLive/AudioStream.ts b/services/discord-gateway/src/goLive/AudioStream.ts deleted file mode 100644 index ea39912..0000000 --- a/services/discord-gateway/src/goLive/AudioStream.ts +++ /dev/null @@ -1,20 +0,0 @@ -/** - * AudioStream — feeds encoded opus frames into the WebRTC connection. - * Ported from @dank074/discord-video-stream AudioStream.js. - */ - -import { BaseMediaStream } from "./BaseMediaStream.js"; -import type { WebRtcConnWrapper } from "./WebRtcWrapper.js"; - -export class AudioStream extends BaseMediaStream { - _conn: WebRtcConnWrapper; - - constructor(conn: WebRtcConnWrapper, noSleep = false) { - super("audio", noSleep); - this._conn = conn; - } - - async _sendFrame(frame: Buffer, frametime: number): Promise { - this._conn.sendAudioFrame(frame, frametime); - } -} diff --git a/services/discord-gateway/src/goLive/BaseMediaConnection.ts b/services/discord-gateway/src/goLive/BaseMediaConnection.ts deleted file mode 100644 index f93615b..0000000 --- a/services/discord-gateway/src/goLive/BaseMediaConnection.ts +++ /dev/null @@ -1,674 +0,0 @@ -/** - * Base media connection for Discord GoLive — ported from - * @dank074/discord-video-stream BaseMediaConnection.js. - * - * Owns the voice WebSocket (identify/select_protocol/heartbeat/resume), - * SDP negotiation against Discord's media server, DAVE E2E voice - * (via @snazzah/davey), and speaking/video attribute signaling. - */ - -import { randomUUID } from "node:crypto"; -import { EventEmitter } from "node:events"; -import Davey from "@snazzah/davey"; -import { CodecPayloadType } from "./CodecPayloadType.js"; -import type { NativePeerConnection } from "./native.js"; -import { isNativeAvailable } from "./native.js"; -import { STREAMS_SIMULCAST } from "./utils.js"; -import { VoiceOpCodes, VoiceOpCodesBinary } from "./VoiceOpCodes.js"; -import { WebRtcConnWrapper } from "./WebRtcWrapper.js"; - -export interface MediaConnectionStatus { - hasSession: boolean; - hasToken: boolean; - started: boolean; - resuming: boolean; -} - -export interface VideoAttribute { - fps: number; - width: number; - height: number; -} - -export interface StreamerLike { - opts: Record; -} - -export class BaseMediaConnection extends EventEmitter { - interval: ReturnType | null = null; - guildId: string | null = null; - channelId: string; - botId: string; - ws: WebSocket | null = null; - status: MediaConnectionStatus; - server: string | null = null; // websocket url - token: string | null = null; - session_id: string | null = null; - protected _webRtcWrapper: WebRtcConnWrapper; - _webRtcParams: { - address: string; - port: number; - audioSsrc: number; - videoSsrc: number; - rtxSsrc: number; - supportedEncryptionModes: string[]; - } | null = null; - protected _closed = false; - ready: ((conn: WebRtcConnWrapper) => void) | null; - protected _streamer: StreamerLike; - protected _sequenceNumber = -1; - protected _daveSession: Davey.DAVESession | null = null; - protected _connectedUsers = new Set(); - protected _daveProtocolVersion = 0; - protected _davePendingTransitions = new Map(); - protected _daveDowngraded = false; - - constructor( - streamer: StreamerLike, - guildId: string | null, - botId: string, - channelId: string, - callback: ((conn: WebRtcConnWrapper) => void) | null, - ) { - super(); - this._streamer = streamer; - this.status = { - hasSession: false, - hasToken: false, - started: false, - resuming: false, - }; - this.guildId = guildId; - this.channelId = channelId; - this.botId = botId; - this.ready = callback; - this._webRtcWrapper = new WebRtcConnWrapper(this); - } - - get type(): "guild" | "call" { - return this.guildId ? "guild" : "call"; - } - - get webRtcConn(): WebRtcConnWrapper { - return this._webRtcWrapper; - } - - get webRtcParams(): BaseMediaConnection["_webRtcParams"] { - return this._webRtcParams; - } - - get streamer(): StreamerLike { - return this._streamer; - } - - /** daveChannelId — overridden in VoiceConnection (channelId) and StreamConnection (serverId - 1n). */ - get daveChannelId(): string { - throw new Error("daveChannelId not implemented"); - } - - stop(): void { - this._closed = true; - this._webRtcWrapper.close(); - this.ws?.close(); - } - - setSession(session_id: string): void { - this.session_id = session_id; - this.status.hasSession = true; - this.start(); - } - - setTokens(server: string, token: string): void { - this.token = token; - this.server = server; - this.status.hasToken = true; - this.start(); - } - - start(): void { - if (this.status.hasSession && this.status.hasToken) { - if (this.status.started) return; - this.status.started = true; - this.ws = new WebSocket(`wss://${this.server}/?v=8`); - this.ws.binaryType = "arraybuffer"; - this.ws.addEventListener("open", () => { - if (this.status.resuming) { - this.status.resuming = false; - this.resume(); - } else { - this.identify(); - } - }); - this.ws.addEventListener("error", (err) => { - console.error(err); - }); - this.ws.addEventListener("close", (e) => { - const wasStarted = this.status.started; - this.interval && clearInterval(this.interval); - this.status.started = false; - const canResume = e.code === 4015 || e.code < 4000; - if (canResume && wasStarted) { - this.status.resuming = true; - this.start(); - } else { - this._closed = true; - this._webRtcWrapper?.close(); - } - }); - this.setupEvents(); - } - } - - handleReady(d: { - ip: string; - port: number; - ssrc: number; - streams: { ssrc: number; rtx_ssrc: number }[]; - modes: string[]; - }): void { - // we hardcoded STREAMS_SIMULCAST, which will always be array of 1 - const stream = d.streams[0]; - console.log( - `[goLive:${this.constructor.name}] READY ssrc=${d.ssrc} ip=${d.ip} port=${d.port} streams=${JSON.stringify(d.streams)}`, - ); - this._webRtcParams = { - address: d.ip, - port: d.port, - audioSsrc: d.ssrc, - videoSsrc: stream.ssrc, - rtxSsrc: stream.rtx_ssrc, - supportedEncryptionModes: d.modes, - }; - } - - async handleProtocolAck(d: { - sdp?: string; - dave_protocol_version?: number; - }): Promise { - if (!("sdp" in d)) throw new Error("Only WebRTC connections are allowed"); - // DEBUG: dump Discord's real answer SDP — which payload types did it select? - console.log( - `[goLive:${this.constructor.name}] DISCORD_ANSWER_SDP ${JSON.stringify(d.sdp ?? "").slice(0, 900)}`, - ); - this._daveProtocolVersion = d.dave_protocol_version ?? 0; - this.initDave(); - // Discord's SDP is garbage — generate our own from its pieces - let ip = ""; - let port = ""; - let iceUsername = ""; - let icePassword = ""; - let fingerprint = ""; - let candidate = ""; - for (const line of (d.sdp ?? "").split("\n")) { - if (line.startsWith("c=")) ip = line; - else if (line.startsWith("a=rtcp")) port = line.split(":")[1]; - else if (line.startsWith("a=ice-ufrag")) iceUsername = line; - else if (line.startsWith("a=ice-pwd")) icePassword = line; - else if (line.startsWith("a=fingerprint")) fingerprint = line; - else if (line.startsWith("a=candidate")) candidate = line; - } - const audioPayloadType = CodecPayloadType.opus.payload_type; - const audioSection = ` -m=audio ${port} UDP/TLS/RTP/SAVPF ${audioPayloadType} -${ip} -a=extmap:1 urn:ietf:params:rtp-hdrext:ssrc-audio-level -a=extmap:3 http://www.ietf.org/id/draft-holmer-rmcat-transport-wide-cc-extensions-01 -a=setup:passive -a=mid:0 -a=maxptime:60 -a=inactive -${iceUsername} -${icePassword} -${fingerprint} -${candidate} -a=rtcp-mux -a=rtpmap:${audioPayloadType} opus/48000/2 -a=fmtp:${audioPayloadType} minptime=10;useinbandfec=1;usedtx=1 -a=rtcp-fb:${audioPayloadType} transport-cc -a=rtcp-fb:${audioPayloadType} nack -a=ice-lite -`.trim(); - const videoPayloads = Object.values(CodecPayloadType).filter( - (el) => el.type === "video", - ); - const videoPayloadTypes = videoPayloads.flatMap((el) => [ - el.payload_type, - el.rtx_payload_type ?? 0, - ]); - const videoSection = ` -m=video ${port} UDP/TLS/RTP/SAVPF ${videoPayloadTypes.join(" ")} -${ip} -a=extmap:2 http://www.webrtc.org/experiments/rtp-hdrext/abs-send-time -a=extmap:3 http://www.ietf.org/id/draft-holmer-rmcat-transport-wide-cc-extensions-01 -a=extmap:14 urn:ietf:params:rtp-hdrext:toffset -a=extmap:13 urn:3gpp:video-orientation -a=extmap:5 http://www.webrtc.org/experiments/rtp-hdrext/playout-delay -a=setup:passive -a=mid:1 -a=inactive -${iceUsername} -${icePassword} -${fingerprint} -${candidate} -a=rtcp-mux -a=ice-lite -`.trim(); - const videoRtpMap = videoPayloads - .flatMap((el) => [ - `a=rtpmap:${el.payload_type} ${el.name}/90000`, - `a=rtpmap:${el.rtx_payload_type} rtx/90000`, - `a=fmtp:${el.rtx_payload_type} apt=${el.payload_type}`, - // CRITICAL: H264 MUST advertise packetization-mode=1. The encoder - // emits baseline slices up to 8KB (>RTP MTU), so the packetizer - // fragments them into FU-A units (RFC 6184). Discord's receiver only - // reassembles FU-A when packetization-mode=1 is negotiated — without - // it the slices (type 28) are DROPPED while SPS/PPS (small single - // NALs) and Opus audio (no fragmentation) still arrive → black video - // with working audio. profile-level-id=42e01f (constrained baseline - // 3.1) matches the -profile:v baseline encoder + SPS VUI rewriter. - ...(el.name === "H264" - ? [ - `a=fmtp:${el.payload_type} level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42e01f`, - ] - : []), - `a=rtcp-fb:${el.payload_type} ccm fir`, - `a=rtcp-fb:${el.payload_type} nack`, - `a=rtcp-fb:${el.payload_type} nack pli`, - `a=rtcp-fb:${el.payload_type} goog-remb`, - `a=rtcp-fb:${el.payload_type} transport-cc`, - ]) - .join("\n"); - const builtAnswer = [audioSection, videoSection, videoRtpMap].join("\n"); - this._webRtcWrapper.webRtcConn?.setRemoteDescription(builtAnswer, "answer"); - console.log( - `[goLive:${this.constructor.name}] SELECT_PROTOCOL_ACK processed — remote answer set (${builtAnswer.length}B) video_mline=${videoPayloadTypes.join(" ")}`, - ); - this.emit("select_protocol_ack"); - } - - initDave(): void { - if (this._daveProtocolVersion) { - if (this._daveSession) { - this._daveSession.reinit( - this._daveProtocolVersion, - this.botId, - this.daveChannelId, - ); - } else { - this._daveSession = new Davey.DAVESession( - this._daveProtocolVersion, - this.botId, - this.daveChannelId, - ); - } - this.sendOpcodeBinary( - VoiceOpCodesBinary.MLS_KEY_PACKAGE, - this._daveSession.getSerializedKeyPackage(), - ); - } else if (this._daveSession) { - this._daveSession.reset(); - this._daveSession.setPassthroughMode(true, 10); - } - } - - processInvalidCommit(transitionId: number): void { - this.sendOpcode(VoiceOpCodes.MLS_INVALID_COMMIT_WELCOME, { - transition_id: transitionId, - }); - this.initDave(); - } - - executePendingTransition(transitionId: number): void { - const newVersion = this._davePendingTransitions.get(transitionId); - if (newVersion === undefined) { - console.error("Unrecognized transition ID", { transitionId }); - return; - } - const oldVersion = this._daveProtocolVersion; - this._daveProtocolVersion = newVersion; - if (oldVersion !== newVersion && newVersion === 0) { - // Downgraded - this._daveDowngraded = true; - } else if (transitionId > 0 && this._daveDowngraded) { - this._daveDowngraded = false; - this._daveSession?.setPassthroughMode(true, 10); - } - this._davePendingTransitions.delete(transitionId); - } - - setupEvents(): void { - this.ws?.addEventListener("message", async (e) => { - if (e.data instanceof ArrayBuffer) { - this.handleBinaryMessages(Buffer.from(e.data)); - return; - } - const { op, d, seq } = JSON.parse(e.data as string) as { - op: number; - // biome-ignore lint/suspicious/noExplicitAny: Discord voice WS payload is dynamically typed - d: any; - seq?: number; - }; - if (seq) this._sequenceNumber = seq; - if (op === VoiceOpCodes.READY) { - this.handleReady(d); - this.setProtocols() - .then(() => this.ready?.(this._webRtcWrapper)) - .catch((err: unknown) => { - // PC can be closed while setProtocols is in flight (stream - // teardown) — don't let that become an unhandledRejection. - console.log( - `[goLive:${this.constructor.name}] setProtocols rejected during teardown: ${err instanceof Error ? err.message : String(err)}`, - ); - }); - this.setVideoAttributes(false); - } else if (op >= 4000) { - console.error(`${this.constructor.name} connection error`, d); - } else if (op === VoiceOpCodes.HELLO) { - this.setupHeartbeat(d.heartbeat_interval); - } else if (op === VoiceOpCodes.SELECT_PROTOCOL_ACK) { - await this.handleProtocolAck(d); - } else if (op === VoiceOpCodes.SPEAKING) { - // ignore speaking updates - } else if (op === VoiceOpCodes.HEARTBEAT_ACK) { - // ignore heartbeat acknowledgements - } else if (op === VoiceOpCodes.RESUMED) { - this.status.started = true; - } else if (op === VoiceOpCodes.CLIENTS_CONNECT) { - d.user_ids.forEach((id: string) => { - this._connectedUsers.add(id); - }); - } else if (op === VoiceOpCodes.CLIENT_DISCONNECT) { - this._connectedUsers.delete(d.user_id); - } else if (op === VoiceOpCodes.DAVE_PREPARE_TRANSITION) { - this._davePendingTransitions.set(d.transition_id, d.protocol_version); - if (d.transition_id === 0) { - this.executePendingTransition(d.transition_id); - } else { - if (d.protocol_version === 0) { - this._daveSession?.setPassthroughMode(true, 120); - } - this.sendOpcode(VoiceOpCodes.DAVE_TRANSITION_READY, { - transition_id: d.transition_id, - }); - } - } else if (op === VoiceOpCodes.DAVE_EXECUTE_TRANSITION) { - this.executePendingTransition(d.transition_id); - } else if (op === VoiceOpCodes.DAVE_PREPARE_EPOCH) { - if (d.epoch === 1) { - this._daveProtocolVersion = d.protocol_version; - this.initDave(); - } - } - }); - } - - handleBinaryMessages(msg: Buffer): void { - this._sequenceNumber = msg.readUint16BE(0); - const op = msg.readUint8(2); - switch (op) { - case VoiceOpCodesBinary.MLS_EXTERNAL_SENDER: { - this._daveSession?.setExternalSender(msg.subarray(3)); - break; - } - case VoiceOpCodesBinary.MLS_PROPOSALS: { - const optype = msg.readUint8(3); - if (!this._daveSession) break; - const { commit, welcome } = this._daveSession.processProposals( - optype, - msg.subarray(4), - [...this._connectedUsers], - ); - if (commit) { - this.sendOpcodeBinary( - VoiceOpCodesBinary.MLS_COMMIT_WELCOME, - welcome ? Buffer.concat([commit, welcome]) : commit, - ); - } - break; - } - case VoiceOpCodesBinary.MLS_ANNOUNCE_COMMIT_TRANSITION: { - const transitionId = msg.readUInt16BE(3); - try { - this._daveSession?.processCommit(msg.subarray(5)); - if (transitionId) { - this._davePendingTransitions.set( - transitionId, - this._daveProtocolVersion, - ); - this.sendOpcode(VoiceOpCodes.DAVE_TRANSITION_READY, { - transition_id: transitionId, - }); - } - } catch (e) { - console.debug("MLS commit errored", e); - this.processInvalidCommit(transitionId); - } - break; - } - case VoiceOpCodesBinary.MLS_WELCOME: { - const transitionId = msg.readUInt16BE(3); - try { - this._daveSession?.processWelcome(msg.subarray(5)); - if (transitionId) { - this._davePendingTransitions.set( - transitionId, - this._daveProtocolVersion, - ); - this.sendOpcode(VoiceOpCodes.DAVE_TRANSITION_READY, { - transition_id: transitionId, - }); - } - } catch (e) { - console.debug("MLS welcome errored", e); - this.processInvalidCommit(transitionId); - } - break; - } - } - } - - get daveReady(): boolean { - return !!this._daveProtocolVersion && !!this._daveSession?.ready; - } - - get daveSession(): Davey.DAVESession | null { - return this._daveSession; - } - - setupHeartbeat(interval: number): void { - if (this.interval) { - clearInterval(this.interval); - } - this.interval = setInterval(() => { - try { - this.sendOpcode(VoiceOpCodes.HEARTBEAT, { - t: Date.now(), - seq_ack: this._sequenceNumber, - }); - } catch { - /* ignore */ - } - }, interval); - } - - sendOpcode(code: number, data: unknown): void { - if (this.ws?.readyState !== WebSocket.OPEN) return; - this.ws.send(JSON.stringify({ op: code, d: data })); - } - - sendOpcodeBinary(code: number, data: Uint8Array): void { - if (this.ws?.readyState !== WebSocket.OPEN) return; - const buf = Buffer.allocUnsafe(data.length + 1); - buf.writeUInt8(code); - Buffer.from(data).copy(buf, 1); - this.ws.send(buf); - } - - /** serverId — overridden in VoiceConnection (guildId ?? channelId) and StreamConnection (rtc_server_id). */ - get serverId(): string | null { - throw new Error("serverId not implemented"); - } - - /** identifies with media server with credentials */ - identify(): void { - if (!this.serverId) throw new Error("Server ID is null or empty"); - if (!this.session_id) throw new Error("Session ID is null or empty"); - if (!this.token) throw new Error("Token is null or empty"); - this.sendOpcode(VoiceOpCodes.IDENTIFY, { - server_id: this.serverId, - user_id: this.botId, - session_id: this.session_id, - token: this.token, - video: true, - streams: STREAMS_SIMULCAST, - max_dave_protocol_version: Davey.DAVE_PROTOCOL_VERSION ?? 0, - }); - } - - resume(): void { - if (!this.serverId) throw new Error("Server ID is null or empty"); - if (!this.session_id) throw new Error("Session ID is null or empty"); - if (!this.token) throw new Error("Token is null or empty"); - this.sendOpcode(VoiceOpCodes.RESUME, { - server_id: this.serverId, - session_id: this.session_id, - token: this.token, - seq_ack: this._sequenceNumber, - }); - } - - /** Sets protocols and ip data used for video and audio (vp8 video, opus audio). */ - async setProtocols(): Promise { - if (!this._webRtcParams) throw new Error("WebRTC parameters not set"); - if (!isNativeAvailable()) { - throw new Error( - "libdatachannel-min native binding not built — cannot start GoLive", - ); - } - const reconnect = () => { - const webRtcConn = this._webRtcWrapper.initWebRtc(); - webRtcConn.onStateChange((state) => { - console.log(`[goLive:${this.constructor.name}] pc state => ${state}`); - if (state === "closed" && !this._closed) reconnect(); - }); - this._webRtcWrapper.onLocalDescription = (sdp) => { - const rtc_connection_id = randomUUID(); - console.log( - `[goLive:${this.constructor.name}] sending SELECT_PROTOCOL (offer ${sdp.length}B, rtc_connection_id=${rtc_connection_id.slice(0, 8)})`, - ); - this.sendOpcode(VoiceOpCodes.SELECT_PROTOCOL, { - protocol: "webrtc", - codecs: Object.values(CodecPayloadType), - data: sdp, - sdp, - rtc_connection_id, - }); - }; - // createOffer (binding resolves full SDP incl. candidates after gathering) - void webRtcConn - .createOffer() - .then((sdp) => { - this._webRtcWrapper.onLocalDescription?.(sdp); - }) - .catch((err: unknown) => { - // PC closed while offer is gathering (stream teardown / reconnect) — - // swallow, the reconnect loop will start a fresh offer. - console.log( - `[goLive:${this.constructor.name}] createOffer rejected: ${err instanceof Error ? err.message : String(err)}`, - ); - }); - }; - reconnect(); - return new Promise((resolve) => { - this.once("select_protocol_ack", () => resolve()); - }); - } - - setVideoAttributes(enabled: boolean, attr?: VideoAttribute): void { - if (!this._webRtcParams) throw new Error("WebRTC parameters not set"); - const { audioSsrc, videoSsrc, rtxSsrc } = this._webRtcParams; - const payload = !enabled - ? { - audio_ssrc: audioSsrc, - video_ssrc: 0, - rtx_ssrc: 0, - streams: [], - } - : (() => { - if (!attr) throw new Error("Need to specify video attributes"); - return { - audio_ssrc: audioSsrc, - video_ssrc: videoSsrc, - rtx_ssrc: rtxSsrc, - streams: [ - { - type: "video", - rid: "100", - ssrc: videoSsrc, - active: true, - quality: 100, - rtx_ssrc: rtxSsrc, - // hardcode the max bitrate because we don't really know anyway - max_bitrate: 10000 * 1000, - max_framerate: enabled ? attr.fps : 0, - max_resolution: { - type: "fixed", - width: attr.width, - height: attr.height, - }, - }, - ], - }; - })(); - // CRITICAL: The VIDEO opcode (op 12) is what tells Discord's media server - // to actually forward the video RTP stream on video_ssrc. sendOpcode() is a - // no-op when ws.readyState !== OPEN — and in GoLive the StreamConnection's - // WebSocket can still be in CONNECTING immediately after SELECT_PROTOCOL_ACK - // (the ack listener resolves playStream, but the data channel / ws open - // handshake may lag by a few ms). A dropped op 12 → Discord never activates - // the video SSRC → black/broken video while audio (whose SPEAKING on the - // VoiceConnection already fired) plays fine. Retry until the ws is OPEN - // instead of silently dropping this mandatory signal. - this.sendOpcodeWhenOpen(VoiceOpCodes.VIDEO, payload, "VIDEO"); - } - - /** Set speaking status */ - setSpeaking(speaking: boolean): void { - if (!this._webRtcParams) throw new Error("WebRTC connection not ready"); - const payload = { - delay: 0, - speaking: speaking ? 1 : 0, - ssrc: this._webRtcParams.audioSsrc, - }; - // Same race as setVideoAttributes: SPEAKING (op 5) must reach Discord. Retry - // until the ws is OPEN rather than dropping it on a transient not-yet-open. - this.sendOpcodeWhenOpen(VoiceOpCodes.SPEAKING, payload, "SPEAKING"); - } - - /** - * Send an opcode, retrying for a short window if the WebSocket is not yet - * OPEN. Discord's media signaling (VIDEO/op12, SPEAKING/op5) is mandatory — - * a silent no-op (the default sendOpcode behaviour when ws is still - * CONNECTING) breaks GoLive video while leaving audio intact. We wait for - * the open state instead of dropping. - */ - private sendOpcodeWhenOpen(code: number, data: unknown, label: string): void { - const attempt = (triesLeft: number) => { - if (this.ws?.readyState === WebSocket.OPEN) { - this.sendOpcode(code, data); - return; - } - if (triesLeft <= 0) { - console.error( - `[goLive:${this.constructor.name}] ${label} opcode (op=${code}) DROPPED — ws never opened (state=${this.ws?.readyState ?? "null"})`, - ); - return; - } - // ws still CONNECTING (or briefly closed during reconnect) — retry. - setTimeout(() => attempt(triesLeft - 1), 50); - }; - attempt(40); // up to ~2s - } -} - -export type { NativePeerConnection }; diff --git a/services/discord-gateway/src/goLive/BaseMediaStream.ts b/services/discord-gateway/src/goLive/BaseMediaStream.ts deleted file mode 100644 index 1e1e3ee..0000000 --- a/services/discord-gateway/src/goLive/BaseMediaStream.ts +++ /dev/null @@ -1,175 +0,0 @@ -/** - * BaseMediaStream — pacing/sync for GoLive frames. Ported from - * @dank074/discord-video-stream BaseMediaStream.js, minus node-av's - * AVFrame (frames are plain objects here) and debug-level (uses the GMW - * logger instead). - */ - -import { Writable } from "node:stream"; -import { setTimeout as sleep } from "node:timers/promises"; - -export interface GoLiveFrame { - data: Buffer | null; - pts: number; - duration: number; - timeBase: { num: number; den: number }; - free?: () => void; -} - -export class BaseMediaStream extends Writable { - _pts: number | undefined; - _syncTolerance = 20; - _noSleep: boolean; - _startTime: number | undefined; - _startPts: number | undefined; - _sync = true; - _syncStream: BaseMediaStream | undefined; - _type: string; - - constructor(type: string, noSleep = false) { - super({ objectMode: true, highWaterMark: 0 }); - this._type = type; - this._noSleep = noSleep; - } - - get sync(): boolean { - return this._sync; - } - - set sync(val: boolean) { - this._sync = val; - } - - get syncStream(): BaseMediaStream | undefined { - return this._syncStream; - } - - set syncStream(stream: BaseMediaStream | undefined) { - if (stream !== undefined && this === stream.syncStream) { - throw new Error("Cannot sync 2 streams with eachother"); - } - this._syncStream = stream; - } - - get noSleep(): boolean { - return this._noSleep; - } - - set noSleep(val: boolean) { - this._noSleep = val; - if (!val) this.resetTimingCompensation(); - } - - get pts(): number | undefined { - return this._pts; - } - - get syncTolerance(): number { - return this._syncTolerance; - } - - set syncTolerance(n: number) { - if (n < 0) return; - this._syncTolerance = n; - } - - async _sendFrame(_frame: Buffer, _frametime: number): Promise { - throw new Error("Not implemented"); - } - - ptsDelta(): number | undefined { - if (this.pts !== undefined && this.syncStream?.pts !== undefined) { - return this.pts - this.syncStream.pts; - } - return undefined; - } - - isAhead(): boolean { - const delta = this.ptsDelta(); - return ( - this.syncStream?.writableEnded === false && - delta !== undefined && - delta > this.syncTolerance - ); - } - - isBehind(): boolean { - const delta = this.ptsDelta(); - return ( - this.syncStream?.writableEnded === false && - delta !== undefined && - delta < -this.syncTolerance - ); - } - - resetTimingCompensation(): void { - this._startTime = this._startPts = undefined; - } - - async _write( - frame: GoLiveFrame, - _encoding: BufferEncoding, - callback: (error?: Error | null) => void, - ): Promise { - const { data, pts, duration, timeBase } = frame; - if (!data) { - frame.free?.(); - callback(); - return; - } - const frametime = (Number(duration) / timeBase.den) * timeBase.num * 1000; - const start_sendFrame = performance.now(); - await this._sendFrame(Buffer.from(data), frametime); - const end_sendFrame = performance.now(); - this._pts = (Number(pts) / timeBase.den) * timeBase.num * 1000; - this.emit("pts", this._pts); - const sendTime = end_sendFrame - start_sendFrame; - const ratio = sendTime / frametime; - if (ratio > 1) { - // Frame takes longer to send than its frametime — warn once per 100 - if ( - this._lastWarnedRatio === undefined || - ratio > this._lastWarnedRatio - ) { - this._lastWarnedRatio = ratio; - } - } - this._startTime ??= start_sendFrame; - this._startPts ??= this._pts; - const sleepMs = Math.max( - 0, - this._pts - - this._startPts + - frametime - - (end_sendFrame - this._startTime), - ); - if (this._noSleep || sleepMs === 0) { - callback(null); - } else if (this.sync && this.isBehind()) { - // Stream is behind — don't sleep for this frame - this.resetTimingCompensation(); - callback(null); - } else if (this.sync && this.isAhead()) { - // Stream is ahead — wait until the sync stream catches up - do { - await sleep(frametime); - } while (this.sync && this.isAhead()); - this.resetTimingCompensation(); - callback(null); - } else { - await sleep(sleepMs); - callback(null); - } - frame.free?.(); - } - - _lastWarnedRatio: number | undefined; - - _destroy( - error: Error | null, - callback: (error?: Error | null) => void, - ): void { - super._destroy(error, callback); - this.syncStream = undefined; - } -} diff --git a/services/discord-gateway/src/goLive/CodecPayloadType.ts b/services/discord-gateway/src/goLive/CodecPayloadType.ts deleted file mode 100644 index 180bab6..0000000 --- a/services/discord-gateway/src/goLive/CodecPayloadType.ts +++ /dev/null @@ -1,71 +0,0 @@ -/** Payload types for Discord GoLive media — ported from @dank074/discord-video-stream. */ -export interface CodecPayloadTypeEntry { - name: string; - type: "audio" | "video"; - clockRate: number; - priority: number; - payload_type: number; - rtx_payload_type?: number; - encode?: boolean; - decode?: boolean; -} - -export const CodecPayloadType: Record = { - opus: { - name: "opus", - type: "audio", - clockRate: 48000, - priority: 1000, - payload_type: 120, - }, - H264: { - name: "H264", - type: "video", - clockRate: 90000, - priority: 1000, - payload_type: 101, - rtx_payload_type: 102, - encode: true, - decode: true, - }, - H265: { - name: "H265", - type: "video", - clockRate: 90000, - priority: 1000, - payload_type: 103, - rtx_payload_type: 104, - encode: true, - decode: true, - }, - VP8: { - name: "VP8", - type: "video", - clockRate: 90000, - priority: 1000, - payload_type: 105, - rtx_payload_type: 106, - encode: true, - decode: true, - }, - VP9: { - name: "VP9", - type: "video", - clockRate: 90000, - priority: 1000, - payload_type: 107, - rtx_payload_type: 108, - encode: true, - decode: true, - }, - AV1: { - name: "AV1", - type: "video", - clockRate: 90000, - priority: 1000, - payload_type: 109, - rtx_payload_type: 110, - encode: true, - decode: true, - }, -}; diff --git a/services/discord-gateway/src/goLive/Demuxer.ts b/services/discord-gateway/src/goLive/Demuxer.ts deleted file mode 100644 index f9d973b..0000000 --- a/services/discord-gateway/src/goLive/Demuxer.ts +++ /dev/null @@ -1,647 +0,0 @@ -/** - * Lightweight demuxer — replaces node-av's LibavDemuxer for GoLive. - * - * Spawns ffmpeg to remux input into H264 AnnexB on stdout (video only — - * screen share doesn't need to mux audio into the demuxer; audio goes - * separately). This replaces the 114MB node-av binary with a plain ffmpeg - * spawn. - * - * Each video "frame" emitted is a complete NAL sequence terminated by a - * keyframe boundary (IDR). Audio is not extracted here — for GoLive with - * audio, the NUT mux + full demuxer would be needed; screen share audio is - * handled via a separate ffmpeg instance (see getDirectScreenInput). - */ - -import { spawn } from "node:child_process"; -import { existsSync, readdirSync } from "node:fs"; -import { join } from "node:path"; -import { PassThrough } from "node:stream"; -import type { GoLiveFrame } from "./BaseMediaStream.js"; - -/** 4-byte AnnexB start code (00 00 00 01) used when building access units. */ -const startCode4 = Buffer.from([0, 0, 0, 1]); - -/** - * Resolve ffmpeg/ffprobe binary. Prefers explicit env override, then PATH, - * then a Nix-store ffmpeg-headless (the GMW flake provides it in the service - * profile, but dev shells / tests may not have it on PATH). - */ -function resolveBin(name: "ffmpeg"): string { - const override = process.env.FFMPEG_PATH; - if (override && existsSync(override)) return override; - // Nix store scan: /-ffmpeg-headless-*/bin/ - const store = "/nix/store"; - if (existsSync(store)) { - const entries = readdirSync(store); - for (const entry of entries) { - if (!entry.includes("ffmpeg-headless-")) continue; - const candidate = join(store, entry, "bin", name); - if (existsSync(candidate)) return candidate; - } - } - return name; // fall back to PATH -} - -const FFMPEG = resolveBin("ffmpeg"); - -export const AVCodecID = { - AV_CODEC_ID_H264: 27, - AV_CODEC_ID_HEVC: 173, - AV_CODEC_ID_VP8: 139, - AV_CODEC_ID_VP9: 167, - AV_CODEC_ID_AV1: 225, - AV_CODEC_ID_OPUS: 86019, -} as const; -export type AVCodecID = (typeof AVCodecID)[keyof typeof AVCodecID]; - -export const AV_PKT_FLAG_KEY = 1; - -export interface Frame { - data: Buffer | null; - pts: number; - duration: number; - timeBase: { num: number; den: number }; - flags: number; - streamIndex: number; - free(): void; -} - -export interface DemuxedStream { - codec: number; - codecName: string; - width: number; - height: number; - framerate_num: number; - framerate_den: number; - sample_rate: number; - stream: PassThrough; -} - -/** - * Probe a media file for stream info using ffmpeg's stderr (the - * ffmpeg-headless Nix package ships ffmpeg but not ffprobe). Returns - * stream descriptors in the same shape ffprobe -show_streams would. - */ -export async function probeStreams( - url: string, -): Promise>> { - return new Promise((resolve, reject) => { - const proc = spawn(FFMPEG, [ - "-hide_banner", - "-loglevel", - "info", - "-i", - url, - "-f", - "null", - "-", - ]); - let stderr = ""; - proc.stderr.on("data", (d: Buffer) => (stderr += d.toString())); - proc.on("close", () => { - // Parse "Stream #0:0: Video: h264 (High), yuv420p, 640x360, 30 fps" - const streams: Array> = []; - const re = /Stream #0:(\d+): (Video|Audio): ([^,]+)/g; - let m: RegExpExecArray | null; - // biome-ignore lint/suspicious/noAssignInExpressions: regex loop idiom - while ((m = re.exec(stderr)) !== null) { - const [full, idx, kind, codecRaw] = m; - void full; - const codecName = codecRaw.split(" ")[0].toLowerCase(); - const stream: Record = { - index: Number(idx), - codec_type: kind.toLowerCase(), - codec_name: codecName, - width: 0, - height: 0, - r_frame_rate: "0/1", - sample_rate: 0, - }; - // dimensions: "640x360" - const dim = /(\d{2,5})x(\d{2,5})/.exec(stderr.slice(m.index)); - if (dim) { - stream.width = Number(dim[1]); - stream.height = Number(dim[2]); - } - // fps: "30 fps" or "29.97 fps" - const fps = /(\d+(?:\.\d+)?) fps/.exec(stderr.slice(m.index)); - if (fps) { - const v = Number(fps[1]); - stream.r_frame_rate = `${Math.round(v * 1000)}/1000`; - } - // sample rate for audio: "48000 Hz" - const sr = /(\d+) Hz/.exec(stderr.slice(m.index)); - if (sr) stream.sample_rate = Number(sr[1]); - streams.push(stream); - } - resolve(streams); - }); - proc.on("error", (err) => reject(err)); - }); -} - -/** - * Demux input (URL string or readable stream) into video frames on a - * PassThrough. Streams input DIRECTLY into ffmpeg (no spool-to-file — the - * live NUT/H264 source never ends, so spooling deadlocks). ffmpeg emits - * AnnexB H264 on stdout; NAL units are split into frames on the fly. - * Video metadata is parsed from ffmpeg stderr during init. - */ -export async function demux( - input: string | PassThrough, - opts: { format: string; frameRate?: number }, -): Promise<{ - video: DemuxedStream | undefined; - audio: DemuxedStream | undefined; - close: () => void; -}> { - // objectMode pipes carrying one frame per item. HWM 2 keeps backpressure - // near-instant: at most ~1-2 frames in flight (~66ms @ 30fps) before the - // encoder is throttled, so the viewer sees near-live video instead of a - // multi-second backlog (the old HWM 128 held 128 frames ≈ 4.3s of lag). - // BaseMediaStream below has HWM 0, so the chain is tightly coupled to the - // WebRTC sender's real pace — faithful to @dank074/discord-video-stream. - const vPipe = new PassThrough({ objectMode: true, highWaterMark: 2 }); - const aPipe = new PassThrough({ objectMode: true, highWaterMark: 2 }); - - const isStream = typeof input !== "string"; - // NUT/matroska input (prepareStream with includeAudio) carries audio; the - // h264 path is video-only raw AnnexB. Video always goes to stdout (pipe:1); - // audio goes to fd3 (pipe:3) so stderr stays free for metadata parsing. - const containerFormat = - isStream && opts.format !== "h264" ? opts.format : null; - const withAudio = isStream && containerFormat !== null; - const args: string[] = [ - "-hide_banner", - // info level: stream init lines ("Stream #0:0: Video: h264...") go to - // stderr and are parsed for dimensions/fps. - "-loglevel", - "info", - // Real-time throttle: read piped input at 1x so the WHOLE upstream chain - // (encoder x264, merge ffmpeg, yt-dlp download) is paced by wall-clock, - // not by network/VOD speed. Without this the encoder bursts ~10x faster - // than real-time and the vPipe queue grows unboundedly — the WebRTC - // sender correctly paces 30fps but always emits the OLDEST buffered - // frame, so video freezes/lags while audio (small, jitter-buffer - // recoverable) stays smooth. Pausing proc.stdout instead (previous fix) - // stalled the same ffmpeg's fd3 audio too → audio stuttered AND the - // already-built backlog never drained. `-re` fixes production rate at - // source; a bounded backlog below still protects against sender stalls. - ...(isStream ? ["-re"] : []), - // Input format hint: raw H264 has NO magic header, so ffmpeg's - // auto-detection fails with "Invalid data found when processing input" - // whenever the first bytes arrive late/buffered. Pin the demuxer input - // format for streams (NUT for the audio-capable path). - ...(withAudio - ? ["-f", containerFormat as string] - : isStream - ? ["-f", "h264"] - : []), - "-i", - isStream ? "pipe:0" : input, - "-map", - "0:v:0", - "-c:v", - "copy", - "-f", - "h264", - "pipe:1", - ...(withAudio - ? ["-map", "0:a:0?", "-c:a", "copy", "-f", "opus", "pipe:3"] - : ["-an"]), - ]; - const proc = spawn(FFMPEG, args, { - stdio: isStream - ? withAudio - ? ["pipe", "pipe", "pipe", "pipe"] - : ["pipe", "pipe", "pipe"] - : ["ignore", "pipe", "pipe"], - }); - console.log( - `[goLive:Demuxer] spawn ffmpeg pid=${proc.pid} input=${isStream ? "stream" : input} args=${args.join(" ")}`, - ); - - // Pipe live input straight into ffmpeg stdin — never await stream end. - if (isStream && proc.stdin) { - input.pipe(proc.stdin); - input.on("error", () => proc.stdin?.destroy()); - } - - // Audio: ffmpeg writes Ogg Opus on fd3 (pipe:3). Parse OGG pages into - // opus packets and emit them as GoLiveFrames (20ms, 48kHz) on aPipe. - if (withAudio && proc.stdio[3]) { - createOggOpusDemux( - proc.stdio[3] as unknown as NodeJS.ReadableStream, - aPipe, - ); - console.log("[goLive:Demuxer] audio pipe wired (fd3 → Ogg Opus → aPipe)"); - } - - // Track which stream kinds we've seen. We must NOT stop parsing on the - // first stream found: ffmpeg can print the video line and audio line in - // separate stderr chunks (input arrives slowly), and the old - // early-return dropped the audio line forever - // → aInfo undefined → no audio RTP → static GoLive tile. - let seenVideo = false; - let seenAudio = false; - - // Parse stream metadata from ffmpeg stderr as it arrives (first chunk has - // the init lines). Fall back to H264 defaults if parsing fails. - let vInfo: DemuxedStream = { - codec: AVCodecID.AV_CODEC_ID_H264, - codecName: "h264", - width: 0, - height: 0, - framerate_num: 0, - framerate_den: 1, - sample_rate: 0, - stream: vPipe, - }; - 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 = ""; - if (proc.stderr) { - proc.stderr.on("data", (d: Buffer) => { - const text = d.toString(); - stderrBuf = (stderrBuf + text).slice(-16384); - // Surface actionable lines: ffmpeg errors + stream init lines - if (/error|invalid|no such|failed|cannot|not found|unable/i.test(text)) { - console.log( - `[goLive:Demuxer] ffmpeg stderr: ${text.trim().split("\n").slice(0, 4).join(" | ")}`, - ); - } - const streamRe = /Stream #0:(\d+): (Video|Audio): ([^,]+)/g; - let m: RegExpExecArray | null; - const found: Array<{ kind: string; codecRaw: string }> = []; - // biome-ignore lint/suspicious/noAssignInExpressions: regex loop idiom - while ((m = streamRe.exec(stderrBuf)) !== null) { - found.push({ kind: m[2], codecRaw: m[3] }); - } - if (process.env.GMW_DEMUX_DEBUG) { - console.log( - `[goLive:Demuxer] DEBUG stderrBuf=${JSON.stringify(stderrBuf.slice(0, 300))} found=${JSON.stringify(found)}`, - ); - } - const v = found.find((s) => s.kind === "Video"); - const a = found.find((s) => s.kind === "Audio"); - if (v) { - seenVideo = true; - const codecName = v.codecRaw.split(" ")[0].toLowerCase(); - const dim = /(\d{2,5})x(\d{2,5})/.exec(stderrBuf); - const fps = /(\d+(?:\.\d+)?) fps/.exec(stderrBuf); - vInfo = { - codec: - AVCodecID[ - (codecName.toUpperCase() as keyof typeof AVCodecID) ?? - "AV_CODEC_ID_H264" - ] ?? AVCodecID.AV_CODEC_ID_H264, - codecName, - width: dim ? Number(dim[1]) : 0, - height: dim ? Number(dim[2]) : 0, - framerate_num: fps ? Math.round(Number(fps[1]) * 1000) : 0, - framerate_den: fps ? 1000 : 1, - sample_rate: 0, - stream: vPipe, - }; - } - if (a) { - seenAudio = true; - const codecName = a.codecRaw.split(" ")[0].toLowerCase(); - const sr = /(\d+) Hz/.exec(stderrBuf); - aInfo = { - codec: - AVCodecID[ - (codecName.toUpperCase() as keyof typeof AVCodecID) ?? - "AV_CODEC_ID_OPUS" - ] ?? AVCodecID.AV_CODEC_ID_OPUS, - codecName, - width: 0, - height: 0, - framerate_num: 0, - framerate_den: 0, - sample_rate: sr ? Number(sr[1]) : 0, - stream: aPipe, - }; - } - // Mark that at least one stream kind was seen. Note: we must NOT - // 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). - // (parsedMeta removed — we now wait for both via allSeen() below.) - }); - } - - // Wait (briefly) for ffmpeg to print its stream init lines on stderr so - // vInfo/aInfo carry real metadata. With audio expected, wait for BOTH the - // video and audio init lines (they may arrive in separate stderr chunks on - // live input); the timeout covers slow starts / genuinely audio-less input. - const allSeen = () => - withAudio ? seenVideo && seenAudio : seenVideo || seenAudio; - await Promise.race([ - new Promise((resolve) => { - const check = setInterval(() => { - if (allSeen()) { - clearInterval(check); - resolve(); - } - }, 25); - }), - new Promise((resolve) => setTimeout(resolve, 3000)), - ]); - - // Scan stdout for AnnexB NAL units and group them into ACCESS UNITS - // (one picture). Discord's H264 decoder requires a complete access unit — - // parameter sets + slice — inside a single RTP frame. Emitting each NAL - // as its own frame (SPS/PPS/SEI separate from the slice) makes the decoder - // unable to produce ANY picture: production showed a black GoLive tile - // despite frames flowing (5892B slices + 4B PPS + 33B SPS as separate - // frames, each with a near-zero RTP timestamp delta). We therefore buffer - // NALs and flush one frame per slice, prepending the parameter sets that - // precede it, and timestamp it as ONE frame at the video frame rate. - - // EMISSION: faithful to @dank074/discord-video-stream — the demuxer does NOT - // pace. It writes each access unit straight to vPipe (objectMode, HWM 128) - // with a monotonically increasing PTS; BaseMediaStream (ported 1:1 from dank) - // then paces playback via sleep-PTS + A/V sync and applies backpressure when - // the WebRTC sender can't keep up. This is what works upstream; the custom - // setInterval/tail-drop clocks we tried broke IDR delivery → blank tiles. - let videoBuf = Buffer.alloc(0); - let frameCount = 0; - let pendingNals: Buffer[] = []; - let pendingHasSlice = false; - let pendingIsKey = false; - // Raw H264 streams carry no timing info — ffmpeg's h264 demuxer guesses - // 25fps on stderr. Prefer the caller's explicit frameRate (the encode - // setting); it drives both RTP timestamp advance and pacing. - const videoFps = - opts.frameRate ?? (vInfo.framerate_num / vInfo.framerate_den || 30); - - // Write one frame to the video pipe with backpressure: if the pipe buffer is - // full, pause ffmpeg's stdout so the encoder self-throttles (instead of - // building an unbounded backlog). Resumed on drain. Faithful to dank's - // `resume &&= vPipe.write(packet)` in LibavDemuxer. - const writeFrame = (frame: { - data: Buffer; - pts: number; - duration: number; - timeBase: { num: number; den: number }; - flags: number; - streamIndex: number; - free: () => void; - }): void => { - const ok = vPipe.write(frame); - if (!ok) proc.stdout?.pause(); - }; - - const flushAccessUnit = () => { - if (pendingNals.length === 0) return; - // AnnexB access unit: 00 00 00 01 + NAL for every buffered NAL. The - // packetizer (H264RtpPacketizer, StartSequence separator) needs the - // start codes to find NAL boundaries inside the frame. - const parts: Buffer[] = []; - for (const n of pendingNals) parts.push(startCode4, n); - const au = Buffer.concat(parts); - const isKey = pendingIsKey; - pendingNals = []; - pendingHasSlice = false; - pendingIsKey = false; - // Write directly to the video pipe (with backpressure via writeFrame). - // PTS advances one frame per output frame at videoFps (duration=1 in a - // 1/fps timebase) so BaseMediaStream computes the correct frametime and the - // WebRTC RTP timestamp advances by clockRate/fps per frame (3000 @ 30fps / - // 90kHz). Keyframes carry AV_PKT_FLAG_KEY so the decoder re-establishes a - // reference. - writeFrame({ - data: au, - pts: frameCount, - duration: 1, - timeBase: { num: 1, den: videoFps }, - flags: isKey ? AV_PKT_FLAG_KEY : 0, - streamIndex: 0, - free: () => {}, - }); - frameCount++; - if (frameCount === 1 || frameCount % 30 === 0) { - console.log( - `[goLive:Demuxer] frames=${frameCount} last=${au.length}B key=${isKey}`, - ); - } - }; - - if (proc.stdout) { - vPipe.on("drain", () => proc.stdout?.resume()); - proc.stdout.on("data", (chunk: Buffer) => { - videoBuf = Buffer.concat([videoBuf, chunk]); - // Find start codes (00 00 01 or 00 00 00 01) and split NALs - let start = 0; - // If buffer starts with zeros, that's the first start code — emit from there - while (start < videoBuf.length) { - let scPos = -1; - for (let i = start + 1; i < videoBuf.length - 2; i++) { - if ( - videoBuf[i] === 0 && - videoBuf[i + 1] === 0 && - videoBuf[i + 2] === 1 - ) { - scPos = i + 3; - break; - } - } - if (scPos === -1) break; - // Emit the NAL from `start` to `scPos` (but skip the start code bytes at `start`) - if (start < scPos) { - let nalStart = start; - // Skip start code bytes for the NAL itself (00 00 01) - if ( - videoBuf[nalStart] === 0 && - videoBuf[nalStart + 1] === 0 && - videoBuf[nalStart + 2] === 1 - ) { - nalStart += 3; - } else if ( - nalStart + 3 < scPos && - videoBuf[nalStart] === 0 && - videoBuf[nalStart + 1] === 0 && - videoBuf[nalStart + 2] === 0 && - videoBuf[nalStart + 3] === 1 - ) { - nalStart += 4; - } - const nal = videoBuf.subarray(nalStart, scPos); - // Trim trailing zero bytes (from start code overlap) - let end = nal.length; - while (end > 0 && nal[end - 1] === 0) end--; - if (end > 0) { - const nalTrimmed = nal.subarray(0, end); - const nalType = nalTrimmed[0] & 0x1f; - const isSlice = nalType === 1 || nalType === 5; - if (isSlice) { - // A new slice while one is pending closes the previous - // access unit (x264 emits one slice per frame). - if (pendingHasSlice) flushAccessUnit(); - pendingNals.push(Buffer.from(nalTrimmed)); - pendingHasSlice = true; - if (nalType === 5) pendingIsKey = true; - } else { - // Parameter-set / SEI / AUD / filler NAL. After a slice these - // belong to the NEXT access unit — flush the completed frame. - if (pendingHasSlice) flushAccessUnit(); - pendingNals.push(Buffer.from(nalTrimmed)); - } - } - } - // Skip the 00 00 01 at scPos-3 to find next - start = scPos; - // But the next start code needs at least 3 bytes - if (start > videoBuf.length - 3) break; - } - // Keep remaining bytes (potential partial NAL or start code) - if (start > 0 && start < videoBuf.length) { - videoBuf = videoBuf.subarray(start); - } else if (videoBuf.length > 4) { - // No full NAL found, but avoid unbounded growth - // Keep a sliding window - videoBuf = videoBuf.subarray(videoBuf.length - 3); - } - }); - // Backpressure (faithful to dank's `resume &&= vPipe.write`): when the - // downstream pipe's buffer is full, stop reading from ffmpeg's stdout so - // the encoder self-throttles instead of building an unbounded backlog. - // Resume on drain. - vPipe.on("drain", () => proc.stdout?.resume()); - proc.stdout.on("end", () => { - flushAccessUnit(); - vPipe.end(); - aPipe.end(); - }); - } - - proc.on("close", () => { - vPipe.end(); - aPipe.end(); - }); - - const close = () => { - proc.kill("SIGTERM"); - vPipe.end(); - aPipe.end(); - }; - - return { video: vInfo, audio: aInfo, close }; -} - -/** - * Parse an Ogg Opus byte stream (as written by ffmpeg's `-f opus` muxer) - * into individual opus packets and push them onto `out` as GoLiveFrames - * (duration 960 @ 48kHz = 20ms, matching the OpusRtpPacketizer clock). - * - * OGG page structure: - * "OggS" | ver(1) | header_type(1) | granule(8 LE) | serial(4) | seq(4) | - * crc(4) | page_segments(1) | segment_table[n] | payload - * Lacing: a value < 255 ends a packet; 255 continues it (0.5KB chunk). - * The first packet is OpusHead (19B) — skipped, as is OpusTags. - */ -function createOggOpusDemux( - input: NodeJS.ReadableStream, - out: PassThrough, -): void { - let buf = Buffer.alloc(0); - // Packets assembled from lacing; packetParts accumulates across pages - // when a packet spans a page boundary (continued flag / 255 lacing). - let packetParts: Buffer[] = []; - let headerDone = false; - let frameIndex = 0; - - const emitPacket = (packet: Buffer) => { - if (!headerDone) { - // First packet = OpusHead ("OpusHead"), second = OpusTags. Skip both. - const magic = packet.toString("latin1", 0, 8); - if (magic === "OpusHead" || magic === "OpusTags") return; - headerDone = true; - } - out.write({ - data: packet, - pts: frameIndex * 960, - duration: 960, - timeBase: { num: 1, den: 48000 }, - free: () => {}, - } satisfies GoLiveFrame); - frameIndex++; - }; - - const processPages = () => { - while (true) { - // Sync to "OggS" - const sync = buf.indexOf("OggS", 0, "latin1"); - if (sync === -1) { - // Keep the tail (partial sync pattern) for the next chunk - buf = buf.length > 3 ? buf.subarray(buf.length - 3) : buf; - return; - } - if (sync > 0) buf = buf.subarray(sync); - if (buf.length < 27) return; // need full page header - const numSeg = buf[26]; - if (buf.length < 27 + numSeg) return; // need segment table - let payloadLen = 0; - for (let i = 0; i < numSeg; i++) payloadLen += buf[27 + i]; - if (buf.length < 27 + numSeg + payloadLen) return; // need payload - - const headerType = buf[5]; - // Extract packets from the payload using lacing values - let off = 27 + numSeg; - for (let i = 0; i < numSeg; i++) { - const lace = buf[27 + i]; - const part = buf.subarray(off, off + lace); - off += lace; - packetParts.push(Buffer.from(part)); - if (lace < 255) { - const packet = Buffer.concat(packetParts); - packetParts = []; - if ((headerType & 0x01) === 0) { - // Not a continuation page → packet starts here - emitPacket(packet); - } else if (headerDone) { - // Continued page — packet body, emit directly - emitPacket(packet); - } - // (header packets on continuation pages are dropped) - } - } - buf = buf.subarray(off); - if (buf.length === 0) return; - } - }; - - input.on("data", (chunk: Buffer) => { - buf = Buffer.concat([buf, chunk]); - processPages(); - }); - input.on("end", () => { - out.end(); - }); - input.on("error", () => { - out.end(); - }); -} diff --git a/services/discord-gateway/src/goLive/Encoders.ts b/services/discord-gateway/src/goLive/Encoders.ts deleted file mode 100644 index 8a516ad..0000000 --- a/services/discord-gateway/src/goLive/Encoders.ts +++ /dev/null @@ -1,69 +0,0 @@ -/** - * Lightweight encoders config — ported from @dank074/discord-video-stream - * encoders/software.js. Only software (libx264) is needed for GoLive. - */ - -export interface EncoderSettings { - name: string; - options: string[]; - outFilters?: string[]; - globalOptions?: string[]; -} - -export interface EncoderSet { - H264: EncoderSettings; - H265: EncoderSettings; - VP8: EncoderSettings; - VP9: EncoderSettings; - AV1: EncoderSettings; -} - -/** Software x264 encoder. Matches @dank074's software() defaults. */ -export function software( - opts: { - x264?: { preset?: string; tune?: string }; - x265?: { preset?: string; tune?: string }; - } = {}, -): () => EncoderSet { - const { x264, x265 } = opts; - const { preset: x264Preset = "superfast", tune: x264Tune = "zerolatency" } = - x264 ?? {}; - const { preset: x265Preset = "superfast", tune: x265Tune } = x265 ?? {}; - return () => ({ - H264: { - name: "libx264", - // -profile:v baseline is REQUIRED: the SDP advertises - // profile-level-id=42e01f (constrained baseline) and Discord's - // receiver decodes with that profile. x264's default is High — a - // High-profile bitstream against a baseline SDP negotiation fails to - // decode → black GoLive tile (production bug, fixed 2026-08-12). - // zerolatency matches @dank074 (no lookahead — correct for live). - options: [ - "-forced-idr 1", - "-profile:v baseline", - // repeat-headers: SPS/PPS inline BEFORE EVERY IDR, not just the - // first. Required for the NUT container path (NUT stores extradata - // in the header and `-c:v copy` remux loses it → decoder sees - // "non-existing PPS 0 referenced" → no picture) and lets Discord's - // decoder recover after any PLI/keyframe request mid-stream. - "-x264-params", - "repeat-headers=1", - `-tune ${x264Tune}`, - `-preset ${x264Preset}`, - ], - }, - H265: { - name: "libx265", - options: [ - "-forced-idr 1", - ...(x265Tune ? [`-tune ${x265Tune}`] : []), - `-preset ${x265Preset}`, - ], - }, - VP8: { name: "libvpx", options: ["-deadline 20000"] }, - VP9: { name: "libvpx-vp9", options: ["-deadline 20000"] }, - AV1: { name: "libsvtav1", options: [] }, - }); -} - -export const Encoders = { software }; diff --git a/services/discord-gateway/src/goLive/GatewayOpCodes.ts b/services/discord-gateway/src/goLive/GatewayOpCodes.ts deleted file mode 100644 index b9fd579..0000000 --- a/services/discord-gateway/src/goLive/GatewayOpCodes.ts +++ /dev/null @@ -1,41 +0,0 @@ -/** Discord gateway opcodes used by Streamer — ported from @dank074/discord-video-stream. */ -export enum GatewayOpCodes { - DISPATCH = 0, - HEARTBEAT = 1, - IDENTIFY = 2, - PRESENCE_UPDATE = 3, - VOICE_STATE_UPDATE = 4, - VOICE_SERVER_PING = 5, - RESUME = 6, - RECONNECT = 7, - REQUEST_GUILD_MEMBERS = 8, - INVALID_SESSION = 9, - HELLO = 10, - HEARTBEAT_ACK = 11, - CALL_CONNECT = 13, - GUILD_SUBSCRIPTIONS = 14, - LOBBY_CONNECT = 15, - LOBBY_DISCONNECT = 16, - LOBBY_VOICE_STATES_UPDATE = 17, - STREAM_CREATE = 18, - STREAM_DELETE = 19, - STREAM_WATCH = 20, - STREAM_PING = 21, - STREAM_SET_PAUSED = 22, - REQUEST_GUILD_APPLICATION_COMMANDS = 24, - EMBEDDED_ACTIVITY_LAUNCH = 25, - EMBEDDED_ACTIVITY_CLOSE = 26, - EMBEDDED_ACTIVITY_UPDATE = 27, - REQUEST_FORUM_UNREADS = 28, - REMOTE_COMMAND = 29, - GET_DELETED_ENTITY_IDS_NOT_MATCHING_HASH = 30, - REQUEST_SOUNDBOARD_SOUNDS = 31, - SPEED_TEST_CREATE = 32, - SPEED_TEST_DELETE = 33, - REQUEST_LAST_MESSAGES = 34, - SEARCH_RECENT_MEMBERS = 35, - REQUEST_CHANNEL_STATUSES = 36, - GUILD_SUBSCRIPTIONS_BULK = 37, - GUILD_CHANNELS_RESYNC = 38, - REQUEST_CHANNEL_MEMBER_COUNT = 39, -} diff --git a/services/discord-gateway/src/goLive/SPSVUIRewriter.ts b/services/discord-gateway/src/goLive/SPSVUIRewriter.ts deleted file mode 100644 index 0ca5097..0000000 --- a/services/discord-gateway/src/goLive/SPSVUIRewriter.ts +++ /dev/null @@ -1,291 +0,0 @@ -/** - * H264 SPS VUI rewriter — ported from @dank074/discord-video-stream - * SPSVUIRewriter.js. Rewrites the SPS so Discord's receiver applies - * bitstream restrictions (max_num_reorder_frames=0, max_dec_frame_buffering - * bounded) — required for low-latency GoLive decode. - */ - -import { - AnnexBBitstreamReader, - AnnexBBitstreamWriter, -} from "./AnnexBBitstreamReaderWriter.js"; - -export function rewriteSPSVUI(buffer: Uint8Array): Buffer { - const reader = new AnnexBBitstreamReader(buffer.subarray(1)); - const writer = new AnnexBBitstreamWriter(); - const readBit = (n = 1) => reader.readBits(n); - const writeBit = (v: number, n = 1) => writer.writeBits(v, n); - const readU = (n: number) => reader.readUnsigned(n); - const writeU = (v: number, n: number) => writer.writeUnsigned(v, n); - const readUE = () => reader.readUnsignedExpGolomb(); - const writeUE = (v: number) => writer.writeUnsignedExpGolomb(v); - const readSE = () => reader.readSignedExpGolomb(); - const writeSE = (v: number) => writer.writeSignedExpGolomb(v); - - // Rewrite the NAL header - writeU(buffer[0], 8); - const profile_idc = readU(8); - writeU(profile_idc, 8); - const constraint_flags = readU(8); - writeU(constraint_flags, 8); - const level_idc = readU(8); - writeU(level_idc, 8); - const seq_parameter_set_id = readUE(); - writeUE(seq_parameter_set_id); - - // If profile in high profiles, additional fields - const highProfiles = new Set([ - 100, 110, 122, 244, 44, 83, 86, 118, 128, 138, 144, - ]); - if (highProfiles.has(profile_idc)) { - const chroma_format_idc = readUE(); - writeUE(chroma_format_idc); - if (chroma_format_idc === 3) { - const separate_colour_plane_flag = readBit(1); - writeBit(separate_colour_plane_flag, 1); - } - const bit_depth_luma_minus8 = readUE(); - writeUE(bit_depth_luma_minus8); - const bit_depth_chroma_minus8 = readUE(); - writeUE(bit_depth_chroma_minus8); - const qpprime_y_zero_transform_bypass_flag = readBit(1); - writeBit(qpprime_y_zero_transform_bypass_flag, 1); - const seq_scaling_matrix_present_flag = readBit(1); - writeBit(seq_scaling_matrix_present_flag, 1); - if (seq_scaling_matrix_present_flag) { - const scalingCount = chroma_format_idc !== 3 ? 8 : 12; - for (let i = 0; i < scalingCount; i++) { - const seq_scaling_list_present_flag = readBit(1); - writeBit(seq_scaling_list_present_flag, 1); - if (seq_scaling_list_present_flag) { - const size = i < 6 ? 16 : 64; - let lastScale = 8; - let nextScale = 8; - for (let j = 0; j < size; j++) { - const delta = readSE(); - writeSE(delta); - nextScale = (lastScale + delta + 256) % 256; - if (nextScale !== 0) lastScale = nextScale; - } - } - } - } - } - - const log2_max_frame_num_minus4 = readUE(); - writeUE(log2_max_frame_num_minus4); - const pic_order_cnt_type = readUE(); - writeUE(pic_order_cnt_type); - if (pic_order_cnt_type === 0) { - const log2_max_pic_order_cnt_lsb_minus4 = readUE(); - writeUE(log2_max_pic_order_cnt_lsb_minus4); - } else if (pic_order_cnt_type === 1) { - const delta_pic_order_always_zero_flag = readBit(1); - writeBit(delta_pic_order_always_zero_flag, 1); - const offset_for_non_ref_pic = readSE(); - writeSE(offset_for_non_ref_pic); - const offset_for_top_to_bottom_field = readSE(); - writeSE(offset_for_top_to_bottom_field); - const num_ref_frames_in_pic_order_cnt_cycle = readUE(); - writeUE(num_ref_frames_in_pic_order_cnt_cycle); - for (let i = 0; i < num_ref_frames_in_pic_order_cnt_cycle; i++) { - const offset_for_ref_frame = readSE(); - writeSE(offset_for_ref_frame); - } - } - const max_num_ref_frames = readUE(); - writeUE(max_num_ref_frames); - const gaps_in_frame_num_value_allowed_flag = readBit(1); - writeBit(gaps_in_frame_num_value_allowed_flag, 1); - const pic_width_in_mbs_minus1 = readUE(); - writeUE(pic_width_in_mbs_minus1); - const pic_height_in_map_units_minus1 = readUE(); - writeUE(pic_height_in_map_units_minus1); - const frame_mbs_only_flag = readBit(1); - writeBit(frame_mbs_only_flag, 1); - if (frame_mbs_only_flag === 0) { - const mb_adaptive_frame_field_flag = readBit(1); - writeBit(mb_adaptive_frame_field_flag, 1); - } - const direct_8x8_inference_flag = readBit(1); - writeBit(direct_8x8_inference_flag, 1); - const frame_cropping_flag = readBit(1); - writeBit(frame_cropping_flag, 1); - if (frame_cropping_flag) { - const frame_crop_left_offset = readUE(); - writeUE(frame_crop_left_offset); - const frame_crop_right_offset = readUE(); - writeUE(frame_crop_right_offset); - const frame_crop_top_offset = readUE(); - writeUE(frame_crop_top_offset); - const frame_crop_bottom_offset = readUE(); - writeUE(frame_crop_bottom_offset); - } - - // https://webrtc.googlesource.com/src/+/5f2c9278f35e47ff72eb191669d473b7400c9f3e/common_video/h264/sps_vui_rewriter.cc#283 - function addBitstreamRestriction() { - // motion_vectors_over_pic_boundaries_flag: u(1) — Default is 1 when not present. - writeBit(1, 1); - // max_bytes_per_pic_denom: ue(v) — Default is 2 when not present. - writeUE(2); - // max_bits_per_mb_denom: ue(v) — Default is 1 when not present. - writeUE(1); - // log2_max_mv_length_horizontal / vertical — both default to 16. - writeUE(16); - writeUE(16); - // IMPORTANT: max_num_reorder_frames must be 0 for low latency. - writeUE(0); - writeUE(max_num_ref_frames); - } - - const vui_parameters_present_flag = readBit(1); - writeBit(1, 1); - // If no VUI exists, write one - if (!vui_parameters_present_flag) { - // aspect_ratio_info_present_flag, overscan_info_present_flag. Both u(1). - writeBit(0, 2); - // video_signal_type_present_flag, u(1) — write 0, ignore color space. - writeBit(0, 1); - // chroma_loc_info_present_flag, timing_info_present_flag, - // nal_hrd_parameters_present_flag, vcl_hrd_parameters_present_flag, - // pic_struct_present_flag — all u(1) - writeBit(0, 5); - // bitstream_restriction_flag: u(1) - writeBit(1, 1); - addBitstreamRestriction(); - } else { - // VUI parsing and copying - const aspect_ratio_info_present_flag = readBit(1); - writeBit(aspect_ratio_info_present_flag, 1); - if (aspect_ratio_info_present_flag) { - const aspect_ratio_idc = readU(8); - writeU(aspect_ratio_idc, 8); - if (aspect_ratio_idc === 255) { - const sar_width = readU(16); - writeU(sar_width, 16); - const sar_height = readU(16); - writeU(sar_height, 16); - } - } - const overscan_info_present_flag = readBit(1); - writeBit(overscan_info_present_flag, 1); - if (overscan_info_present_flag) { - const overscan_appropriate_flag = readBit(1); - writeBit(overscan_appropriate_flag, 1); - } - // Read the video signal type, but don't copy it - const video_signal_type_present_flag = readBit(1); - writeBit(0, 1); - if (video_signal_type_present_flag) { - readBit(3); // _video_format - readBit(1); // _video_full_range_flag - const colour_description_present_flag = readBit(1); - if (colour_description_present_flag) { - readU(8); // _colour_primaries - readU(8); // _transfer_characteristics - readU(8); // _matrix_coeffs - } - } - const chroma_loc_info_present_flag = readBit(1); - writeBit(chroma_loc_info_present_flag, 1); - if (chroma_loc_info_present_flag) { - const chroma_sample_loc_type_top_field = readUE(); - writeUE(chroma_sample_loc_type_top_field); - const chroma_sample_loc_type_bottom_field = readUE(); - writeUE(chroma_sample_loc_type_bottom_field); - } - const timing_info_present_flag = readBit(1); - writeBit(timing_info_present_flag, 1); - if (timing_info_present_flag) { - const num_units_in_tick = readU(32); - writeU(num_units_in_tick, 32); - const time_scale = readU(32); - writeU(time_scale, 32); - const fixed_frame_rate_flag = readBit(1); - writeBit(fixed_frame_rate_flag, 1); - } - const nal_hrd_parameters_present_flag = readBit(1); - writeBit(nal_hrd_parameters_present_flag, 1); - if (nal_hrd_parameters_present_flag) { - // hrd_parameters() - const cpb_cnt_minus1 = readUE(); - writeUE(cpb_cnt_minus1); - const bit_rate_scale = readBit(4); - writeBit(bit_rate_scale, 4); - const cpb_size_scale = readBit(4); - writeBit(cpb_size_scale, 4); - for (let i = 0; i <= cpb_cnt_minus1; i++) { - const bit_rate_value_minus1 = readUE(); - writeUE(bit_rate_value_minus1); - const cpb_size_value_minus1 = readUE(); - writeUE(cpb_size_value_minus1); - const cbr_flag = readBit(1); - writeBit(cbr_flag, 1); - } - const initial_cpb_removal_delay_length_minus1 = readBit(5); - writeBit(initial_cpb_removal_delay_length_minus1, 5); - const cpb_removal_delay_length_minus1 = readBit(5); - writeBit(cpb_removal_delay_length_minus1, 5); - const dpb_output_delay_length_minus1 = readBit(5); - writeBit(dpb_output_delay_length_minus1, 5); - const time_offset_length = readBit(5); - writeBit(time_offset_length, 5); - } - const vcl_hrd_parameters_present_flag = readBit(1); - writeBit(vcl_hrd_parameters_present_flag, 1); - if (vcl_hrd_parameters_present_flag) { - // hrd_parameters() - const cpb_cnt_minus1 = readUE(); - writeUE(cpb_cnt_minus1); - const bit_rate_scale = readBit(4); - writeBit(bit_rate_scale, 4); - const cpb_size_scale = readBit(4); - writeBit(cpb_size_scale, 4); - for (let i = 0; i <= cpb_cnt_minus1; i++) { - const bit_rate_value_minus1 = readUE(); - writeUE(bit_rate_value_minus1); - const cpb_size_value_minus1 = readUE(); - writeUE(cpb_size_value_minus1); - const cbr_flag = readBit(1); - writeBit(cbr_flag, 1); - } - const initial_cpb_removal_delay_length_minus1 = readBit(5); - writeBit(initial_cpb_removal_delay_length_minus1, 5); - const cpb_removal_delay_length_minus1 = readBit(5); - writeBit(cpb_removal_delay_length_minus1, 5); - const dpb_output_delay_length_minus1 = readBit(5); - writeBit(dpb_output_delay_length_minus1, 5); - const time_offset_length = readBit(5); - writeBit(time_offset_length, 5); - } - if (nal_hrd_parameters_present_flag || vcl_hrd_parameters_present_flag) { - const low_delay_hrd_flag = readBit(1); - writeBit(low_delay_hrd_flag, 1); - } - const pic_struct_present_flag = readBit(1); - writeBit(pic_struct_present_flag, 1); - const bitstream_restriction_flag = readBit(1); - writeBit(1, 1); - if (!bitstream_restriction_flag) { - addBitstreamRestriction(); - } else { - const motion_vectors_over_pic_boundaries_flag = readBit(1); - writeBit(motion_vectors_over_pic_boundaries_flag, 1); - const max_bytes_per_pic_denom = readUE(); - writeUE(max_bytes_per_pic_denom); - const max_bits_per_mb_denom = readUE(); - writeUE(max_bits_per_mb_denom); - const log2_max_mv_length_horizontal = readUE(); - writeUE(log2_max_mv_length_horizontal); - const log2_max_mv_length_vertical = readUE(); - writeUE(log2_max_mv_length_vertical); - readUE(); // _num_reorder_frames - writeUE(0); - readUE(); // _max_dec_frame_buffering - writeUE(max_num_ref_frames); - } - } - writeBit(1, 1); // rbsp_stop_one_bit - writer.flush(); - return writer.toBuffer(); -} diff --git a/services/discord-gateway/src/goLive/StreamConnection.ts b/services/discord-gateway/src/goLive/StreamConnection.ts deleted file mode 100644 index 427c79a..0000000 --- a/services/discord-gateway/src/goLive/StreamConnection.ts +++ /dev/null @@ -1,45 +0,0 @@ -/** - * StreamConnection — GoLive stream connection (screen share). - * Ported from @dank074/discord-video-stream StreamConnection.js. - */ - -import { BaseMediaConnection } from "./BaseMediaConnection.js"; -import { VoiceOpCodes } from "./VoiceOpCodes.js"; - -export class StreamConnection extends BaseMediaConnection { - _streamKey: string | null = null; - _serverId: string | null = null; - - setSpeaking(speaking: boolean): void { - if (!this.webRtcParams) throw new Error("WebRTC connection not ready"); - this.sendOpcode(VoiceOpCodes.SPEAKING, { - delay: 0, - speaking: speaking ? 2 : 0, - ssrc: this.webRtcParams.audioSsrc, - }); - } - - get daveChannelId(): string { - if (this._serverId === null) { - throw new Error("Server ID not set (this shouldn't happen)"); - } - const channelId = BigInt(this._serverId) - 1n; - return channelId.toString(); - } - - get serverId(): string | null { - return this._serverId; - } - - set serverId(id: string | null) { - this._serverId = id; - } - - get streamKey(): string | null { - return this._streamKey; - } - - set streamKey(value: string | null) { - this._streamKey = value; - } -} diff --git a/services/discord-gateway/src/goLive/Streamer.ts b/services/discord-gateway/src/goLive/Streamer.ts deleted file mode 100644 index d6779cb..0000000 --- a/services/discord-gateway/src/goLive/Streamer.ts +++ /dev/null @@ -1,351 +0,0 @@ -/** - * Streamer — gateway-level GoLive controller. Ported from - * @dank074/discord-video-stream Streamer.js. - * - * Drives the Discord gateway (VOICE_STATE_UPDATE, STREAM_CREATE, ...) and - * hands back a VoiceConnection / StreamConnection once the media server - * session is ready. - */ - -import { EventEmitter } from "node:events"; -import { GatewayOpCodes } from "./GatewayOpCodes.js"; -import type { NativePeerConnection } from "./native.js"; -import { StreamConnection } from "./StreamConnection.js"; -import { generateStreamKey, parseStreamKey } from "./utils.js"; -import { VoiceConnection } from "./VoiceConnection.js"; -import type { WebRtcConnWrapper } from "./WebRtcWrapper.js"; - -/** Minimal surface of a discord.js-selfbot-v13 client used by Streamer. */ -export interface StreamerClientLike { - user: { id: string; username?: string } | null; - token: string | null; - on( - event: "raw", - listener: (packet: { t: string; d: unknown }) => void, - ): unknown; - ws: { - broadcast(data: { op: number; d: unknown }): void; - }; - guilds?: { - // biome-ignore lint/suspicious/noExplicitAny: discord.js-selfbot client shape is dynamic - fetch(id: string): Promise; - }; -} - -/** Minimal channel shape accepted by joinVoiceChannel. */ -export interface VoiceChannelLike { - id: string; - type: string; - guildId?: string | null; -} - -export class Streamer { - _voiceConnection: VoiceConnection | null = null; - _client: StreamerClientLike; - _gatewayEmitter = new EventEmitter(); - - constructor(client: StreamerClientLike) { - this._client = client; - // listen for gateway dispatch events - this.client.on("raw", (packet) => { - const t = packet.t as string; - if ( - t === "STREAM_CREATE" || - t === "STREAM_SERVER_UPDATE" || - t === "VOICE_STATE_UPDATE" || - t === "VOICE_SERVER_UPDATE" - ) { - console.log( - `[goLive:Streamer] raw dispatch ${t}`, - JSON.stringify(packet.d).slice(0, 220), - ); - } - this._gatewayEmitter.emit(t, packet.d); - }); - } - - get client(): StreamerClientLike { - return this._client; - } - - get opts(): Record { - return {}; - } - - get voiceConnection(): VoiceConnection | null { - return this._voiceConnection; - } - - sendOpcode(code: number, data: unknown): void { - // Direct instrumentation — bypasses the bootstrap debug filter (which - // drops messages without [VOICE / [ffmpeg / error / stream). - console.log( - `[goLive:Streamer] sendOpcode op=${code} d=${JSON.stringify(data)}`, - ); - this.client.ws.broadcast({ op: code, d: data }); - } - - joinVoiceChannel(channel: VoiceChannelLike): Promise { - let guildId: string | null = null; - if ( - channel.type === "GUILD_STAGE_VOICE" || - channel.type === "GUILD_VOICE" - ) { - guildId = channel.guildId ?? null; - } - return this.joinVoice(guildId, channel.id); - } - - /** - * Joins a voice channel and resolves with the WebRtcConnWrapper when the - * media session is ready. - */ - joinVoice( - guild_id: string | null, - channel_id: string, - ): Promise { - return new Promise((resolve, reject) => { - if (!this.client.user) { - reject(new Error("Client not logged in")); - return; - } - const user_id = this.client.user.id; - const voiceConn = new VoiceConnection( - this, - guild_id, - user_id, - channel_id, - (conn) => { - resolve(conn); - }, - ); - this._voiceConnection = voiceConn; - this._gatewayEmitter.on( - "VOICE_STATE_UPDATE", - (d: { user_id: string; session_id: string }) => { - if (user_id !== d.user_id) return; - voiceConn.setSession(d.session_id); - }, - ); - this._gatewayEmitter.on( - "VOICE_SERVER_UPDATE", - (d: { - guild_id: string | null; - channel_id?: string; - endpoint: string; - token: string; - }) => { - if (guild_id !== d.guild_id) return; - // channel_id is not set for guild voice calls - if (d.channel_id && channel_id !== d.channel_id) return; - voiceConn.setTokens(d.endpoint, d.token); - }, - ); - this.signalVideo(false); - }); - } - - /** Create a GoLive stream (screen share) on top of the voice connection. */ - createStream(): Promise { - return new Promise((resolve, reject) => { - if (!this.client.user) { - reject(new Error("Client not logged in")); - return; - } - if (!this.voiceConnection) { - reject( - new Error("cannot start stream without first joining voice channel"), - ); - return; - } - const { - guildId: clientGuildId, - channelId: clientChannelId, - session_id, - } = this.voiceConnection; - const clientUserId = this.client.user.id; - if (!session_id) throw new Error("Session doesn't exist yet"); - const streamConn = new StreamConnection( - this, - clientGuildId, - clientUserId, - clientChannelId, - (conn) => { - clearTimeout(streamTimeout); - clearInterval(retryInterval); - resolve(conn); - }, - ); - this.voiceConnection.streamConnection = streamConn; - - // Attach listeners BEFORE the first signal so a fast dispatch can't - // be lost between signalStream() and listener registration. - const onStreamCreate = (d: { - stream_key: string; - rtc_server_id: string; - }) => { - const { channelId, guildId, userId } = parseStreamKey(d.stream_key); - if ( - clientGuildId !== guildId || - clientChannelId !== channelId || - clientUserId !== userId - ) { - return; - } - streamConn.serverId = d.rtc_server_id; - streamConn.streamKey = d.stream_key; - streamConn.setSession(session_id); - }; - const onStreamServerUpdate = (d: { - stream_key: string; - endpoint: string; - token: string; - }) => { - const { channelId, guildId, userId } = parseStreamKey(d.stream_key); - if ( - clientGuildId !== guildId || - clientChannelId !== channelId || - clientUserId !== userId - ) { - return; - } - streamConn.setTokens(d.endpoint, d.token); - }; - this._gatewayEmitter.on("STREAM_CREATE", onStreamCreate); - this._gatewayEmitter.on("STREAM_SERVER_UPDATE", onStreamServerUpdate); - - const cleanup = () => { - clearTimeout(streamTimeout); - clearInterval(retryInterval); - this._gatewayEmitter.removeListener("STREAM_CREATE", onStreamCreate); - this._gatewayEmitter.removeListener( - "STREAM_SERVER_UPDATE", - onStreamServerUpdate, - ); - }; - const streamTimeout = setTimeout(() => { - cleanup(); - reject( - new Error( - "Timed out waiting for STREAM_CREATE/STREAM_SERVER_UPDATE from Discord (stream handshake) — voice media session may not be active", - ), - ); - }, 12_000); - - // Discord sometimes drops the STREAM_CREATE request silently (upstream - // issue #217/#219) — resend a few times instead of giving up after one. - let attempt = 0; - const retryInterval = setInterval(() => { - attempt += 1; - if (attempt >= 4) { - clearInterval(retryInterval); - return; - } - console.log( - `[goLive:Streamer] createStream: retrying STREAM_CREATE (attempt ${attempt + 1}/4)`, - ); - this.signalStream(); - }, 3_000); - console.log( - `[goLive:Streamer] createStream: sending STREAM_CREATE (attempt 1/4)`, - ); - this.signalStream(); - }); - } - - async setStreamPreview(image: Buffer): Promise { - if (!this.client.token) throw new Error("Please login :)"); - if (!this.voiceConnection?.streamConnection?.guildId) return; - const data = `data:image/jpeg;base64,${image.toString("base64")}`; - const { guildId } = this.voiceConnection.streamConnection; - if (!this.client.guilds) return; - const server = await this.client.guilds.fetch(guildId); - // biome-ignore lint/suspicious/noExplicitAny: discord.js-selfbot dynamic - (server as any).members.me?.voice?.postPreview(data); - } - - stopStream(): void { - const stream = this.voiceConnection?.streamConnection; - if (!stream) return; - stream.stop(); - this.signalStopStream(); - this.voiceConnection.streamConnection = null; - this._gatewayEmitter.removeAllListeners("STREAM_CREATE"); - this._gatewayEmitter.removeAllListeners("STREAM_SERVER_UPDATE"); - } - - leaveVoice(): void { - this.voiceConnection?.stop(); - this.signalLeaveVoice(); - this._voiceConnection = null; - this._gatewayEmitter.removeAllListeners("VOICE_STATE_UPDATE"); - this._gatewayEmitter.removeAllListeners("VOICE_SERVER_UPDATE"); - } - - signalVideo(video_enabled: boolean): void { - if (!this.voiceConnection) return; - const { guildId: guild_id, channelId: channel_id } = this.voiceConnection; - this.sendOpcode(GatewayOpCodes.VOICE_STATE_UPDATE, { - guild_id: guild_id, - channel_id, - self_mute: false, - self_deaf: true, - self_video: video_enabled, - }); - } - - signalStream(): void { - if (!this.voiceConnection) return; - const { - type, - guildId: guild_id, - channelId: channel_id, - botId: user_id, - } = this.voiceConnection; - // Un-deafen before requesting the stream (mimic real client). Do NOT - // set self_video: true — that flips on the bot's camera in Discord - // (visible to everyone); screen share should not enable the camera. - this.sendOpcode(GatewayOpCodes.VOICE_STATE_UPDATE, { - guild_id, - channel_id, - self_mute: false, - self_deaf: false, - self_video: false, - }); - this.sendOpcode(GatewayOpCodes.STREAM_CREATE, { - type, - guild_id, - channel_id, - preferred_region: null, - }); - this.sendOpcode(GatewayOpCodes.STREAM_SET_PAUSED, { - stream_key: generateStreamKey(type, guild_id, channel_id, user_id), - paused: false, - }); - } - - signalStopStream(): void { - if (!this.voiceConnection) return; - const { - type, - guildId: guild_id, - channelId: channel_id, - botId: user_id, - } = this.voiceConnection; - this.sendOpcode(GatewayOpCodes.STREAM_DELETE, { - stream_key: generateStreamKey(type, guild_id, channel_id, user_id), - }); - } - - signalLeaveVoice(): void { - this.sendOpcode(GatewayOpCodes.VOICE_STATE_UPDATE, { - guild_id: null, - channel_id: null, - self_mute: true, - self_deaf: false, - self_video: false, - }); - } -} - -export type { NativePeerConnection }; diff --git a/services/discord-gateway/src/goLive/VideoStream.ts b/services/discord-gateway/src/goLive/VideoStream.ts deleted file mode 100644 index b110705..0000000 --- a/services/discord-gateway/src/goLive/VideoStream.ts +++ /dev/null @@ -1,20 +0,0 @@ -/** - * VideoStream — feeds encoded H264 frames into the WebRTC connection. - * Ported from @dank074/discord-video-stream VideoStream.js. - */ - -import { BaseMediaStream } from "./BaseMediaStream.js"; -import type { WebRtcConnWrapper } from "./WebRtcWrapper.js"; - -export class VideoStream extends BaseMediaStream { - _conn: WebRtcConnWrapper; - - constructor(conn: WebRtcConnWrapper, noSleep = false) { - super("video", noSleep); - this._conn = conn; - } - - async _sendFrame(frame: Buffer, frametime: number): Promise { - this._conn.sendVideoFrame(frame, frametime); - } -} diff --git a/services/discord-gateway/src/goLive/VoiceConnection.ts b/services/discord-gateway/src/goLive/VoiceConnection.ts deleted file mode 100644 index 4275ae2..0000000 --- a/services/discord-gateway/src/goLive/VoiceConnection.ts +++ /dev/null @@ -1,25 +0,0 @@ -/** - * VoiceConnection — guild/DM voice channel GoLive connection. - * Ported from @dank074/discord-video-stream VoiceConnection.js. - */ - -import { BaseMediaConnection } from "./BaseMediaConnection.js"; -import type { StreamConnection } from "./StreamConnection.js"; - -export class VoiceConnection extends BaseMediaConnection { - streamConnection: StreamConnection | null = null; - - get daveChannelId(): string { - return this.channelId; - } - - get serverId(): string | null { - // for guild vc it is the guild id, for dm voice it is the channel id - return this.guildId ?? this.channelId; - } - - stop(): void { - super.stop(); - this.streamConnection?.stop(); - } -} diff --git a/services/discord-gateway/src/goLive/VoiceOpCodes.ts b/services/discord-gateway/src/goLive/VoiceOpCodes.ts deleted file mode 100644 index 0d1d355..0000000 --- a/services/discord-gateway/src/goLive/VoiceOpCodes.ts +++ /dev/null @@ -1,38 +0,0 @@ -/** Discord voice WebSocket opcodes — ported from @dank074/discord-video-stream. */ -export enum VoiceOpCodes { - IDENTIFY = 0, - SELECT_PROTOCOL = 1, - READY = 2, - HEARTBEAT = 3, - SELECT_PROTOCOL_ACK = 4, - SPEAKING = 5, - HEARTBEAT_ACK = 6, - RESUME = 7, - HELLO = 8, - RESUMED = 9, - CLIENTS_CONNECT = 11, - VIDEO = 12, - CLIENT_DISCONNECT = 13, - SESSION_UPDATE = 14, - MEDIA_SINK_WANTS = 15, - VOICE_BACKEND_VERSION = 16, - CHANNEL_OPTIONS_UPDATE = 17, - FLAGS = 18, - SPEED_TEST = 19, - PLATFORM = 20, - DAVE_PREPARE_TRANSITION = 21, - DAVE_EXECUTE_TRANSITION = 22, - DAVE_TRANSITION_READY = 23, - DAVE_PREPARE_EPOCH = 24, - MLS_INVALID_COMMIT_WELCOME = 31, -} - -/** Binary voice WebSocket opcodes (DAVE / MLS). */ -export enum VoiceOpCodesBinary { - MLS_EXTERNAL_SENDER = 25, - MLS_KEY_PACKAGE = 26, - MLS_PROPOSALS = 27, - MLS_COMMIT_WELCOME = 28, - MLS_ANNOUNCE_COMMIT_TRANSITION = 29, - MLS_WELCOME = 30, -} diff --git a/services/discord-gateway/src/goLive/WebRtcWrapper.ts b/services/discord-gateway/src/goLive/WebRtcWrapper.ts deleted file mode 100644 index ca36107..0000000 --- a/services/discord-gateway/src/goLive/WebRtcWrapper.ts +++ /dev/null @@ -1,223 +0,0 @@ -/** - * WebRTC connection wrapper for GoLive — ported from - * @dank074/discord-video-stream WebRtcWrapper.js, with the media stack - * (packetizers, RTCP SR/NACK, pacing) provided by the libdatachannel-min - * binding instead of node-datachannel's JS-exposed media classes. - */ - -import { - H264Helpers, - H264NalUnitTypes, - splitNalu, - startCode3, -} from "./AnnexBHelper.js"; -import { CodecPayloadType } from "./CodecPayloadType.js"; -import type { NativePeerConnection, NativeTrack } from "./native.js"; -import { loadNative } from "./native.js"; -import { rewriteSPSVUI } from "./SPSVUIRewriter.js"; -import { normalizeVideoCodec } from "./utils.js"; - -export type WebRtcVideoCodec = "H264" | "H265" | "VP8" | "VP9" | "AV1"; - -export interface WebRtcParams { - address: string; - port: number; - audioSsrc: number; - videoSsrc: number; - rtxSsrc: number; - supportedEncryptionModes: string[]; -} - -/** Minimal surface of the media connection that WebRtcWrapper drives. */ -export interface VideoAttribute { - fps: number; - width: number; - height: number; -} - -export interface MediaConnectionLike { - daveReady: boolean; - daveSession: { - encryptOpus(frame: Buffer): Buffer; - encrypt(mediaType: number, codec: number, frame: Buffer): Buffer; - } | null; - webRtcParams: WebRtcParams | null; - setSpeaking(speaking: boolean): void; - setVideoAttributes(enabled: boolean, attr?: VideoAttribute): void; -} - -/** Media types used by DAVE encryption (from @dank074). */ -export enum DaveMediaType { - AUDIO = 0, - VIDEO = 1, -} - -/** DAVE codec ids (from @dank074). */ -export enum DaveCodec { - UNKNOWN = 0, - VP8 = 2, - VP9 = 3, - H264 = 4, - H265 = 5, - AV1 = 6, -} - -export class WebRtcConnWrapper { - private _mediaConn: MediaConnectionLike; - private _webRtcConn: NativePeerConnection | null = null; - private _audioTrack: NativeTrack | null = null; - private _videoTrack: NativeTrack | null = null; - private _videoCodec: WebRtcVideoCodec | null = null; - private _videoFrameLog = 0; - /** Assigned by BaseMediaConnection to send the gathered SDP to Discord. */ - onLocalDescription: ((sdp: string) => void) | null = null; - - constructor(mediaConn: MediaConnectionLike) { - this._mediaConn = mediaConn; - } - - initWebRtc(): NativePeerConnection { - const native = loadNative(); - this._webRtcConn = new native.PeerConnection({ - iceServers: ["stun:stun.l.google.com:19302"], - }); - // Track mids must match @dank074: "0" audio, "1" video. - this._audioTrack = this._webRtcConn.addTrack("0", "audio"); - this._videoTrack = this._webRtcConn.addTrack("1", "video"); - return this._webRtcConn; - } - - close(): void { - this._webRtcConn?.close(); - this._webRtcConn = null; - } - - get webRtcConn(): NativePeerConnection | null { - return this._webRtcConn; - } - - get ready(): boolean { - return this._webRtcConn?.state() === "connected"; - } - - get mediaConnection(): MediaConnectionLike { - return this._mediaConn; - } - - sendAudioFrame(frame: Buffer, frametime: number): void { - if (!this.ready || !this._audioTrack) return; - const clockRate = CodecPayloadType.opus.clockRate; - if (this.mediaConnection.daveReady && this.mediaConnection.daveSession) { - frame = this.mediaConnection.daveSession.encryptOpus(frame); - } - this._audioTrack.sendFrame(frame); - this._audioTrack.addTimestamp(Math.round((frametime * clockRate) / 1000)); - } - - sendVideoFrame(frame: Buffer, frametime: number): void { - if (!this.ready || !this._videoTrack) { - if (this._videoFrameLog === 0) { - console.log( - `[goLive:WebRtc] sendVideoFrame DROPPED ready=${this.ready} track=${this._videoTrack !== null}`, - ); - this._videoFrameLog++; - } - return; - } - const clockRate = CodecPayloadType[this._videoCodec ?? "H264"].clockRate; - if (this._videoCodec === "H264") { - let spsRewritten = false; - const nalus = splitNalu(frame).map((el) => { - if (H264Helpers.getUnitType(el) === H264NalUnitTypes.SPS) { - spsRewritten = true; - return rewriteSPSVUI(el); - } - return el; - }); - if (spsRewritten) - frame = Buffer.concat(nalus.flatMap((el) => [startCode3, el])); - } - if (this.mediaConnection.daveReady && this.mediaConnection.daveSession) { - let daveCodec = DaveCodec.UNKNOWN; - switch (this._videoCodec) { - case "H264": - daveCodec = DaveCodec.H264; - break; - case "H265": - daveCodec = DaveCodec.H265; - break; - case "VP8": - daveCodec = DaveCodec.VP8; - break; - case "VP9": - daveCodec = DaveCodec.VP9; - break; - case "AV1": - daveCodec = DaveCodec.AV1; - break; - default: - break; - } - frame = this.mediaConnection.daveSession.encrypt( - DaveMediaType.VIDEO, - daveCodec, - frame, - ); - } - this._videoTrack.sendFrame(frame); - this._videoTrack.addTimestamp(Math.round((frametime * clockRate) / 1000)); - this._videoFrameLog++; - if (this._videoFrameLog === 1 || this._videoFrameLog % 30 === 0) { - console.log( - `[goLive:WebRtc] sendVideoFrame #${this._videoFrameLog} bytes=${frame.length} ready=${this.ready}`, - ); - } - } - - setPacketizer(videoCodec: string): void { - if (!this.mediaConnection.webRtcParams) { - throw new Error("WebRTC connection not ready"); - } - const { audioSsrc, videoSsrc } = this.mediaConnection.webRtcParams; - console.log( - `[goLive:WebRtc] setPacketizer(${videoCodec}) audioSsrc=${audioSsrc} videoSsrc=${videoSsrc} rtxSsrc=${this.mediaConnection.webRtcParams.rtxSsrc}`, - ); - this._videoCodec = normalizeVideoCodec(videoCodec); - // Audio packetizer: opus 120 @ 48kHz, playout delay ext id 5 (like @dank074) - this._audioTrack?.setPacketizer( - "audio", - audioSsrc, - CodecPayloadType.opus.payload_type, - CodecPayloadType.opus.clockRate, - 5, - 0, - 1, - ); - // Video packetizer: H264/H265/AV1 with their payload types - const codecEntry = CodecPayloadType[this._videoCodec]; - if (!codecEntry) { - throw new Error(`Packetizer not implemented for ${this._videoCodec}`); - } - const nativeKind = - this._videoCodec === "H264" - ? "h264" - : this._videoCodec === "H265" - ? "h265" - : this._videoCodec === "AV1" - ? "av1" - : (() => { - throw new Error( - `Packetizer not implemented for ${this._videoCodec}`, - ); - })(); - this._videoTrack?.setPacketizer( - nativeKind, - videoSsrc, - codecEntry.payload_type, - codecEntry.clockRate, - 5, - 0, - 10, - ); - } -} diff --git a/services/discord-gateway/src/goLive/index.ts b/services/discord-gateway/src/goLive/index.ts deleted file mode 100644 index dcca2a4..0000000 --- a/services/discord-gateway/src/goLive/index.ts +++ /dev/null @@ -1,19 +0,0 @@ -/** - * goLive public API — re-exports the ported @dank074 modules. - * Drop-in replacement for `@dank074/discord-video-stream` in - * screenShareController.ts. - */ - -export { AudioStream } from "./AudioStream.js"; -export { BaseMediaConnection } from "./BaseMediaConnection.js"; -export { BaseMediaStream } from "./BaseMediaStream.js"; -export { CodecPayloadType } from "./CodecPayloadType.js"; -export { demux } from "./Demuxer.js"; -export { Encoders } from "./Encoders.js"; -export { playStream, prepareStream } from "./prepareStream.js"; -export { StreamConnection } from "./StreamConnection.js"; -export { Streamer } from "./Streamer.js"; -export { normalizeVideoCodec } from "./utils.js"; -export { VideoStream } from "./VideoStream.js"; -export { VoiceConnection } from "./VoiceConnection.js"; -export { WebRtcConnWrapper } from "./WebRtcWrapper.js"; diff --git a/services/discord-gateway/src/goLive/native.ts b/services/discord-gateway/src/goLive/native.ts deleted file mode 100644 index ac7fc2d..0000000 --- a/services/discord-gateway/src/goLive/native.ts +++ /dev/null @@ -1,116 +0,0 @@ -/** - * Loader + typings for the minimal libdatachannel N-API binding - * (native/libdatachannel-min). The binding exposes ONLY what GoLive needs: - * PeerConnection, DataChannel, Track (raw RTP + media packetizer chain). - * - * The .node file is built by node-gyp against libdatachannel 0.24.0 (built - * from source — nixpkgs 0.24.1 is glibc-incompatible with this host). It is - * NOT shipped via npm; the Nix flake builds it as part of the gateway. - */ - -export interface NativeTrack { - /** Send a RAW RTP/RTCP packet (no media handler installed). */ - send(buffer: Uint8Array): void; - /** Send an ENCODED frame; the packetizer chain turns it into RTP. */ - sendFrame(buffer: Uint8Array): void; - /** Advance the packetizer RTP timestamp by delta (clock-rate units). */ - addTimestamp(delta: number): void; - /** Install the media-handler chain (packetizer → RTCP SR → NACK → pacing). */ - setPacketizer( - kind: "audio" | "h264" | "h265" | "av1", - ssrc: number, - payloadType: number, - clockRate: number, - playoutDelayId: number, - playoutDelayMin: number, - playoutDelayMax: number, - ): void; - isOpen(): boolean; - close(): void; -} - -export interface NativePeerConnection { - /** mid must be "0" (audio) or "1" (video) — matches @dank074's track defs. */ - addTrack(mid: string, kind: "audio" | "video"): NativeTrack; - /** Resolves with the full SDP (incl. candidates) after gathering completes. */ - createOffer(): Promise; - /** Resolves with the auto-generated answer SDP. */ - createAnswer(offerSdp: string): Promise; - setRemoteDescription(sdp: string, type: "offer" | "answer"): void; - state(): string; - close(): void; - onStateChange(cb: (state: string) => void): void; -} - -export interface NativeBinding { - PeerConnection: new (config: { - iceServers: string[]; - }) => NativePeerConnection; - DataChannel: unknown; - Track: unknown; -} - -let cached: NativeBinding | null = null; - -/** Load the native binding. Throws only if the .node is truly missing — - * callers (screen share) guard with `isNativeAvailable()`. */ -export function loadNative(): NativeBinding { - if (cached) return cached; - // Resolve relative to this file: src/goLive/ → native/libdatachannel-min/ - const candidates = [ - new URL( - "../../native/libdatachannel-min/build/Release/datachannel_min.node", - import.meta.url, - ), - new URL( - "../../../native/libdatachannel-min/build/Release/datachannel_min.node", - import.meta.url, - ), - ]; - let lastErr: unknown; - for (const url of candidates) { - try { - // @ts-expect-error — .node modules are not typed; dynamic require via file URL - const mod = process.dlopen ? null : null; - void mod; - const nativePath = url.pathname; - // eslint-disable-next-line @typescript-eslint/no-require-imports - const req = createRequire(import.meta.url); - const binding = req(nativePath) as NativeBinding; - if (typeof binding.PeerConnection === "function") { - cached = binding; - return binding; - } - } catch (e) { - lastErr = e; - } - } - // Fallback: plain relative require (tsx / jest environments) - try { - const req = createRequire(import.meta.url); - const binding = req( - "../../native/libdatachannel-min/build/Release/datachannel_min.node", - ) as NativeBinding; - if (typeof binding.PeerConnection === "function") { - cached = binding; - return binding; - } - } catch (e) { - lastErr = e; - } - throw new Error( - `libdatachannel-min native binding not built (${String(lastErr)}). Run: cd native/libdatachannel-min && npx node-gyp rebuild`, - ); -} - -import { createRequire } from "node:module"; - -/** True when the native binding is built — screen share stays disabled otherwise. */ -export function isNativeAvailable(): boolean { - try { - loadNative(); - return true; - } catch { - return false; - } -} diff --git a/services/discord-gateway/src/goLive/prepareStream.ts b/services/discord-gateway/src/goLive/prepareStream.ts deleted file mode 100644 index a730df4..0000000 --- a/services/discord-gateway/src/goLive/prepareStream.ts +++ /dev/null @@ -1,477 +0,0 @@ -/** - * prepareStream & playStream — ported from @dank074/discord-video-stream - * newApi.js (Encoders/prepareStream/playStream), but uses `child_process.spawn` - * + ffmpeg CLI args directly instead of fluent-ffmpeg + node-av. - * - * Replaces the @dank074 video pipeline entirely: - * input (URL or Readable) → ffmpeg spawn → H264 AnnexB frames - * → Demuxer stream → VideoStream/AudioStream → WebRtcConnWrapper - */ - -import { type ChildProcess, spawn } from "node:child_process"; -import { existsSync, readdirSync } from "node:fs"; -import { join } from "node:path"; -import { PassThrough, type Readable } from "node:stream"; -import { AudioStream } from "./AudioStream.js"; -import { demux } from "./Demuxer.js"; -import { type EncoderSettings, Encoders } from "./Encoders.js"; -import { VideoStream } from "./VideoStream.js"; -import type { WebRtcConnWrapper } from "./WebRtcWrapper.js"; - -export interface PrepareStreamResult { - command: ChildProcess; - output: PassThrough; - encoder: () => Record; - options: Record; - videoCodec: string; - width: number; - height: number; - frameRate?: number; - includeAudio: boolean; - /** Container the encoder muxes to: "nut" (audio-capable) or "h264" (raw). */ - format: "nut" | "h264"; -} - -function isFiniteNonZero(n: unknown): n is number { - return typeof n === "number" && !!n && Number.isFinite(n); -} - -const DEFAULT_HEADERS = { - "User-Agent": - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/107.0.0.0 Safari/537.36", - Connection: "keep-alive", -}; - -/** Resolve ffmpeg binary (env override → PATH → Nix store ffmpeg-headless). */ -function resolveFfmpeg(): string { - if (process.env.FFMPEG_PATH && existsSync(process.env.FFMPEG_PATH)) { - return process.env.FFMPEG_PATH; - } - const store = "/nix/store"; - if (existsSync(store)) { - const entries = readdirSync(store); - for (const entry of entries) { - if (!entry.includes("ffmpeg-headless-")) continue; - const candidate = join(store, entry, "bin", "ffmpeg"); - if (existsSync(candidate)) return candidate; - } - } - return "ffmpeg"; -} - -const FFMPEG_BIN = resolveFfmpeg(); - -/** - * prepareStream — build an ffmpeg command (as spawn args + PassThrough output) - * that transcodes the input into a pipe we can demux. Mirrors @dank074's - * prepareStream but produces a raw spawn instead of a fluent-ffmpeg command. - */ -export function prepareStream( - input: string | Readable, - options: Record = {}, -): PrepareStreamResult { - const mergedOptions = { - noTranscoding: false, - width: isFiniteNonZero(options.width) - ? Math.round(options.width as number) - : -2, - height: isFiniteNonZero(options.height) - ? Math.round(options.height as number) - : -2, - frameRate: - isFiniteNonZero(options.frameRate) && (options.frameRate as number) > 0 - ? options.frameRate - : undefined, - videoCodec: (options.videoCodec as string) ?? "H264", - bitrateVideo: - isFiniteNonZero(options.bitrateVideo) && - (options.bitrateVideo as number) > 0 - ? Math.round(options.bitrateVideo as number) - : 5000, - bitrateVideoMax: - isFiniteNonZero(options.bitrateVideoMax) && - (options.bitrateVideoMax as number) > 0 - ? Math.round(options.bitrateVideoMax as number) - : 7000, - bitrateAudio: - isFiniteNonZero(options.bitrateAudio) && - (options.bitrateAudio as number) > 0 - ? Math.round(options.bitrateAudio as number) - : 128, - includeAudio: options.includeAudio ?? true, - encoder: - (options.encoder as () => Record) ?? - Encoders.software(), - customHeaders: { - ...DEFAULT_HEADERS, - ...(options.customHeaders as Record | undefined), - }, - customInputOptions: (options.customInputOptions as string[]) ?? [], - customFfmpegFlags: (options.customFfmpegFlags as string[]) ?? [], - minimizeLatency: options.minimizeLatency ?? false, - // realtime: throttle ffmpeg's INPUT read to 1x so the encoder tracks - // wall-clock instead of slurping a VOD at network speed. Without this, - // a YouTube screen share downloads the whole clip fast and the encoder - // produces a ~10x frame backlog that the demuxer buffers unboundedly — - // the sender paces at 30fps but always emits the OLDEST buffered frames, - // so the viewer sees frozen/laggy video while audio (tiny, jitter- - // buffer recoverable) stays smooth. That is the "video stuck, voice - // normal" symptom. `-re` caps the pipeline at 1x end-to-end. Default on - // because this prepareStream is only used for screen share (VOD URLs). - realtime: options.realtime ?? true, - }; - - const output = new PassThrough(); - - const args: string[] = [ - "-hide_banner", - "-loglevel", - "error", - // Real-time throttle: read the input at 1x so the encoder paces with - // wall-clock and does NOT force-duplicate frames to fill -r 30. This - // applies to BOTH URL and live-Pipe (Readable) inputs: a screen-share - // pipe (yt-dlp merge → ffmpeg → stdout) is delivered at NETWORK speed - // (bursts, stalls) and is NOT self-paced — without -re the encoder slurps - // it instantly and, when the merge stalls, x264 -r 30 repeats the last - // held frame ~30x → the viewer sees ~1fps while WebRTC still pushes - // 30fps. -re reads the pipe at the stream's native PTS rate so each - // output frame is a fresh picture. (Previous builds only added -re for - // string URLs, so the screen-share pipe got none — root of the 1fps - // symptom.) - ...(mergedOptions.realtime ? ["-re"] : []), - ...(typeof input === "string" ? ["-i", input] : ["-i", "pipe:0"]), - ...mergedOptions.customInputOptions, - ]; - - if (mergedOptions.minimizeLatency) { - args.push("-fflags", "nobuffer", "-analyzeduration", "0"); - } - - if (typeof input === "string" && input.startsWith("http")) { - const headerStr = Object.entries(mergedOptions.customHeaders) - .map(([k, v]) => `${k}: ${v}`) - .join("\r\n"); - args.push( - "-headers", - headerStr, - "-reconnect", - "1", - "-reconnect_at_eof", - "1", - "-reconnect_streamed", - "1", - "-reconnect_delay_max", - "4294", - ); - } - - // Video - args.push("-map", "0:v:0"); - if (mergedOptions.noTranscoding) { - args.push("-c:v", "copy"); - } else { - args.push(`-vf`, `scale=${mergedOptions.width}:${mergedOptions.height}`); - if (mergedOptions.frameRate) - args.push("-r", String(mergedOptions.frameRate)); - const enc = mergedOptions.encoder()[mergedOptions.videoCodec]; - if (!enc) - throw new Error( - `Encoder settings not specified for ${mergedOptions.videoCodec}`, - ); - // Encoder options are declared as single strings like "-forced-idr 1"; - // spawn needs each flag and value as separate argv entries. - const encOptions = enc.options.flatMap((opt) => - opt.split(/\s+/).filter(Boolean), - ); - args.push( - "-b:v", - `${mergedOptions.bitrateVideo}k`, - "-maxrate:v", - `${mergedOptions.bitrateVideoMax}k`, - "-bufsize:v", - `${Math.round(mergedOptions.bitrateVideo / 2)}k`, - "-bf", - "0", - "-pix_fmt", - "yuv420p", - "-force_key_frames", - "expr:gte(t,n_forced*1)", - "-c:v", - enc.name, - ...encOptions, - ...(enc.globalOptions ?? []).flatMap((opt) => - opt.split(/\s+/).filter(Boolean), - ), - ); - } - - // Audio: transcode to libopus. NUT muxer on stdout carries video (H264 - // AnnexB) + audio (Ogg Opus) as ONE stream into the Demuxer, which re-splits - // them via a child ffmpeg -f nut -i pipe:0 -c:v copy -f h264 pipe:1 ... . The - // Demuxer's start-code scan runs on THAT child ffmpeg's stdout (pure H264), - // NOT on NUT — so NAL type 5 (IDR) is parsed correctly. (Outputting raw - // h264+opus on two pipes directly was tried and broke: the audio pipe was - // never attached to the demuxer's input, so audio RTP never flowed.) - if (mergedOptions.includeAudio) { - args.push( - "-map", - "0:a:0?", - "-c:a", - "libopus", - "-b:a", - `${mergedOptions.bitrateAudio}k`, - "-ar", - "48000", - "-ac", - "2", - ); - } else { - args.push("-an"); - } - - // NUT muxer carries video+audio; the raw h264 muxer cannot ("h264 muxer - // does not support any stream of type audio" -> header write fails -> - // empty stdout -> black tile). Audio delivery requires NUT. - const outFormat = mergedOptions.includeAudio ? "nut" : "h264"; - args.push("-f", outFormat, "pipe:1"); - - const isUrl = typeof input === "string"; - const proc: ChildProcess = isUrl - ? spawn(FFMPEG_BIN, args, { stdio: ["ignore", "pipe", "pipe"] }) - : spawn(FFMPEG_BIN, args, { stdio: ["pipe", "pipe", "pipe"] }); - - if (proc.stdin && !isUrl) { - // Race guard: the merge ffmpeg may have already exited (transient 403 - // or stream death) before this function attaches its listeners — the - // input's 'end'/'error' events then fire into the void and the encoder - // stdin NEVER receives EOF, leaving an encoder that waits forever and a - // screen share that shows a black tile with zero frames. Check the - // terminal state eagerly and EOF the encoder immediately. - if (input.readableEnded || input.destroyed) { - proc.stdin.end(); - } else { - input.on("data", (chunk: Buffer) => proc.stdin?.write(chunk)); - input.on("end", () => proc.stdin?.end()); - input.on("error", () => proc.stdin?.destroy()); - } - } - - // Safety: proc may error before playStream attaches a demux listener on - // `output`. A no-op listener here prevents an unhandled 'error' event - // on the PassThrough from crashing the gateway on ffmpeg spawn failure. - output.on("error", () => {}); - proc.stdout?.pipe(output); - proc.stderr?.on("data", () => { - /* swallow ffmpeg stderr */ - }); - proc.on("error", (err) => { - // spawn failed (e.g. ffmpeg missing). If someone is consuming output - // (demux attaches an 'error' listener) propagate; otherwise just end. - if (output.listenerCount("error") > 0) { - output.destroy(err); - } else { - output.end(); - } - }); - proc.on("close", () => { - output.end(); - }); - - return { - command: proc, - output, - encoder: mergedOptions.encoder, - options: mergedOptions, - videoCodec: mergedOptions.videoCodec, - width: mergedOptions.width, - height: mergedOptions.height, - frameRate: mergedOptions.frameRate, - includeAudio: !!mergedOptions.includeAudio, - format: outFormat, - }; -} - -export interface PlayStreamOptions { - type?: "go-live" | "video"; - format?: string; - width?: number | ((v: unknown) => number); - height?: number | ((v: unknown) => number); - frameRate?: number | ((v: unknown) => number); - readrateInitialBurst?: number; - streamPreview?: boolean; -} - -/** - * playStream — demux the prepareStream output and pipe frames into the - * WebRTC connection's video/audio streams. Resolves when the video stream - * ends (natural EOF or the ffmpeg command is killed via cleanup/stop). - */ -export async function playStream( - prepared: PrepareStreamResult, - streamer: { createStream: () => Promise }, - options: PlayStreamOptions = {}, -): Promise { - const conn = await streamer.createStream(); - console.log("[goLive:playStream] createStream resolved"); - - const { - video, - audio, - close: demuxClose, - } = await demux(prepared.output, { - format: options.format ?? prepared.format ?? "nut", - frameRate: - typeof options.frameRate === "number" ? options.frameRate : undefined, - }); - console.log( - `[goLive:playStream] demux done codec=${video?.codecName ?? "?"} ${video?.width ?? 0}x${video?.height ?? 0} fps=${video ? video.framerate_num / video.framerate_den || 30 : 30} audio=${audio?.codecName ?? "none"}`, - ); - - if (!video) throw new Error("No video stream in media"); - - conn.setPacketizer(video.codecName); - conn.mediaConnection.setSpeaking(true); - console.log( - `[goLive:playStream] setPacketizer(${video.codecName}) + setSpeaking done`, - ); - - const w = - typeof options.width === "function" - ? options.width(video) - : (options.width ?? video.width); - const h = - typeof options.height === "function" - ? options.height(video) - : (options.height ?? video.height); - const fr = - typeof options.frameRate === "function" - ? options.frameRate(video) - : (options.frameRate ?? - (video.framerate_num / video.framerate_den || 30)); - - conn.mediaConnection.setVideoAttributes(true, { - width: Math.round(w), - height: Math.round(h), - fps: Math.round(fr), - }); - - const vStream = new VideoStream(conn); - video.stream.pipe(vStream); - - // Audio: Discord's GoLive pipeline expects RTP on the audio SSRC too — - // a video-only stream (zero audio packets) shows a static tile/thumbnail - // instead of live video. Pipe opus frames from the demuxer (silence is - // injected at the encoder when the source has no audio track). - let aStream: AudioStream | undefined; - if (audio) { - aStream = new AudioStream(conn); - audio.stream.pipe(aStream); - console.log( - `[goLive:playStream] audio stream attached (${audio.codecName})`, - ); - // NOTE: NO syncStream wiring here. Upstream dank sets - // `vStream.syncStream = aStream` because node-av provides real PTS from the - // NUT container, so ptsDelta() is meaningful (both streams in media time). - // Our raw-h264 demuxer synthesizes PTS per-stream from frame indexes in - // DIFFERENT timebases (video 1/fps, audio 1/48000). If audio starts late - // (ffmpeg audio init, Ogg header), ptsDelta stays positive forever → - // isAhead() → video sleeps in a loop → video freezes after ~1s. Per-stream - // sleep-PTS pacing alone keeps both at 1000ms/s, which is correct without - // a shared clock. (If real PTS is ever added, re-enable syncStream.) - } - - const cleanup = () => { - try { - prepared.command.kill("SIGTERM"); - } catch { - /* already dead */ - } - demuxClose(); - try { - conn.mediaConnection.setSpeaking(false); - conn.mediaConnection.setVideoAttributes(false); - } catch { - /* connection already torn down */ - } - }; - - // First-frame watchdog: if the encoder never delivers a single frame - // (dead merge input, empty stream, codec mismatch), fail fast instead of - // "playing" a black tile forever. The demuxer resolves with fallback - // metadata even when no frame ever arrives, so this timeout is the only - // place that detects "started but nothing flowing". - let firstFrameTimer: NodeJS.Timeout | null = null; - let gotFirstFrame = false; - const firstFrame = new Promise((resolve, reject) => { - firstFrameTimer = setTimeout(() => { - if (!gotFirstFrame) { - cleanup(); - reject( - new Error( - "No video frames within 10s of stream start — input stream failed", - ), - ); - } - }, 10000); - video.stream.once("data", () => { - gotFirstFrame = true; - if (firstFrameTimer) clearTimeout(firstFrameTimer); - resolve(); - }); - }); - - return new Promise((resolve, reject) => { - let settled = false; - const settle = (fn: () => void) => () => { - if (settled) return; - settled = true; - if (firstFrameTimer) clearTimeout(firstFrameTimer); - fn(); - }; - - vStream.once("finish", () => { - settle(() => { - cleanup(); - if (!gotFirstFrame) { - reject(new Error("Screen video stream ended without any frame")); - } else { - resolve(); - } - })(); - }); - vStream.once("error", () => { - settle(() => { - cleanup(); - if (!gotFirstFrame) { - reject(new Error("Screen video stream errored before first frame")); - } else { - resolve(); - } - })(); - }); - // The stream may end without ever producing a frame (input was - // silently dead) — surface that instead of resolving "successfully". - video.stream.once("end", () => { - settle(() => { - cleanup(); - if (!gotFirstFrame) { - reject(new Error("Screen video stream ended before any frame")); - } else { - resolve(); - } - })(); - }); - // Watchdog timeout: no frame arrived within 10s — fail fast instead of - // "playing" a black tile forever. cleanup() kills the encoder so the - // vStream finish/error handlers above still fire, but the settled guard - // ensures this rejection wins. - firstFrame.catch((err) => { - settle(() => { - cleanup(); - reject(err); - })(); - }); - }); -} - -export { Encoders }; diff --git a/services/discord-gateway/src/goLive/utils.ts b/services/discord-gateway/src/goLive/utils.ts deleted file mode 100644 index c9a3e8e..0000000 --- a/services/discord-gateway/src/goLive/utils.ts +++ /dev/null @@ -1,82 +0,0 @@ -/** GoLive helpers — ported from @dank074/discord-video-stream/utils.js. */ - -export function normalizeVideoCodec( - codec: string, -): "H264" | "H265" | "VP8" | "VP9" | "AV1" { - if (/H\.?264|AVC/i.test(codec)) return "H264"; - if (/H\.?265|HEVC/i.test(codec)) return "H265"; - if (/VP(8|9)/i.test(codec)) return codec.toUpperCase() as "VP8" | "VP9"; - if (/AV1/i.test(codec)) return "AV1"; - throw new Error(`Unknown codec: ${codec}`); -} - -/** - * The available video streams are sent by the client on connection to the - * voice gateway using OpCode Identify (0); the server replies with the ssrc - * and rtxssrc for each available stream using OpCode Ready (2). RID - * distinguishes simulcast streams of the same video source — we only send one - * quality stream, so a single entry is hardcoded. - */ -export const STREAMS_SIMULCAST = [{ type: "screen", rid: "100", quality: 100 }]; - -export const max_int16bit = 2 ** 16; -export const max_int32bit = 2 ** 32; - -export function isFiniteNonZero(n: unknown): n is number { - return typeof n === "number" && !!n && Number.isFinite(n); -} - -export interface ParsedStreamKey { - type: "guild" | "call"; - channelId: string; - guildId: string | null; - userId: string; -} - -export function parseStreamKey(streamKey: string): ParsedStreamKey { - const streamKeyArray = streamKey.split(":"); - const type = streamKeyArray.shift(); - if (type !== "guild" && type !== "call") { - throw new Error(`Invalid stream key type: ${type}`); - } - if ( - (type === "guild" && streamKeyArray.length < 3) || - (type === "call" && streamKey.length < 2) - ) { - throw new Error(`Invalid stream key: ${streamKey}`); - } - let guildId: string | null = null; - if (type === "guild") { - guildId = streamKeyArray.shift() ?? null; - } - const channelId = streamKeyArray.shift(); - const userId = streamKeyArray.shift(); - if (!channelId || !userId) { - throw new Error(`Invalid stream key: ${streamKey}`); - } - return { type, channelId, guildId, userId }; -} - -export function generateStreamKey( - type: "guild" | "call", - guildId: string | null, - channelId: string, - userId: string, -): string { - return `${type}${type === "guild" ? `:${guildId}` : ""}:${channelId}:${userId}`; -} - -export interface VoiceChannelLike { - type: string; - id: string; - guildId?: string | null; -} - -export function isVoiceChannel(channel: VoiceChannelLike): boolean { - return ( - channel.type === "DM" || - channel.type === "GROUP_DM" || - channel.type === "GUILD_STAGE_VOICE" || - channel.type === "GUILD_VOICE" - ); -} diff --git a/services/discord-gateway/src/modules/command-handler/commandHandler.ts b/services/discord-gateway/src/modules/command-handler/commandHandler.ts index 7ccac58..d9d3bbc 100644 --- a/services/discord-gateway/src/modules/command-handler/commandHandler.ts +++ b/services/discord-gateway/src/modules/command-handler/commandHandler.ts @@ -83,12 +83,7 @@ export class CommandHandler { // Create domain-specific handlers with their dependencies this.voiceHandler = new VoiceHandler(client, voiceController); - this.mediaHandler = new MediaHandler(client, () => - voiceController.getStatus(), - ); - // Give media handler access to disconnect/reconnect voice around screen - // share (GoLive needs its own WebRTC connection). - this.mediaHandler.setVoiceController(() => voiceController); + this.mediaHandler = new MediaHandler(); this.guildHandler = new GuildHandler(client); this.moderationHandler = new ModerationHandler(client); diff --git a/services/discord-gateway/src/modules/command-handler/media.handler.ts b/services/discord-gateway/src/modules/command-handler/media.handler.ts index 19c9ecb..698608d 100644 --- a/services/discord-gateway/src/modules/command-handler/media.handler.ts +++ b/services/discord-gateway/src/modules/command-handler/media.handler.ts @@ -1,6 +1,5 @@ import { randomUUID } from "node:crypto"; import { StreamType } from "@discordjs/voice"; -import type { Client } from "discord.js-selfbot-v13"; import type { CommandMessage, CommandReply } from "../../shared/index.js"; import { createChildLogger } from "../../shared/logger/index.js"; import { @@ -13,10 +12,6 @@ import type { MediaQueueItem, } from "../voice-recording/mediaTypes.js"; import { discordPlayer } from "../voice-recording/player.js"; -import { - ScreenShareController, - type ScreenShareVoiceStatus, -} from "../voice-recording/screenShareController.js"; import { setMediaStatusKey } from "./mediaStatusSink.js"; // --------------------------------------------------------------------------- @@ -84,17 +79,8 @@ function buildStatusPayload(): MediaStatusPayload { export class MediaHandler { private logger = createChildLogger("media-handler"); - private screenController: ScreenShareController | null = null; - private screenPlayback: { stop(): void } | null = null; - constructor( - private readonly client: Client | null = null, - private readonly getVoiceStatus: () => ScreenShareVoiceStatus = () => ({ - connected: false, - activeGuildId: null, - activeChannelId: null, - }), - ) { + constructor() { // Register auto-advance on natural track end. advanceQueue mutates the // module-level currentTrackItem/queue, so we must re-publish the status // key afterward: otherwise the backend's Redis `media:status` cache (and @@ -108,31 +94,11 @@ export class MediaHandler { }); } - /** - * Give MediaHandler access to the VoiceController so screen-share can - * disconnect/reconnect the @discordjs audio connection around a GoLive - * stream (Discord allows only one voice session per user). - */ - private voiceControllerAccessor: - | (() => { - disconnectGuild(guildId: string): Promise; - connect(guildId: string, channelId: string): Promise; - getStatus(): { - activeGuildId: string | null; - activeChannelId: string | null; - }; - }) - | null = null; - - setVoiceController(accessor: typeof this.voiceControllerAccessor): void { - this.voiceControllerAccessor = accessor; - } - /** * Persist the latest media state to Redis so the backend/frontend see queue - * advances that happen outside a command (natural track end, screen-share - * done). CommandHandler owns the Redis status-key writes for command-triggered - * changes; this covers the side-effect-only path. + * advances that happen outside a command (natural track end). CommandHandler + * owns the Redis status-key writes for command-triggered changes; this + * covers the side-effect-only path. */ private publishStatus(): void { try { @@ -152,7 +118,6 @@ export class MediaHandler { async handleMediaQueue(cmd: CommandMessage): Promise> { // Accept both `url` (canonical) and `source` (legacy FE) for resilience. const url = String(cmd.payload.url ?? cmd.payload.source ?? "").trim(); - const mode: MediaMode = cmd.payload.mode === "screen" ? "screen" : "music"; const requestedBy = String(cmd.payload.requestedBy ?? "unknown"); if (!url) { @@ -175,74 +140,6 @@ export class MediaHandler { }; } - // Screen share (GoLive) path — bypasses the audio queue entirely. - if (mode === "screen") { - try { - if (!this.client) { - return { - id: cmd.id, - success: false, - data: null, - error: "Gateway client not initialized", - }; - } - if (!this.screenController) { - this.screenController = new ScreenShareController( - this.client, - this.getVoiceStatus, - // releaseVoice — disconnect the @discordjs/voice connection so the - // dank074 Streamer can take over (Discord: one voice session/user). - async (status) => { - const vc = this.voiceControllerAccessor?.(); - const guildId = status.activeGuildId ?? null; - if (vc && guildId) { - await vc.disconnectGuild(guildId); - } - }, - // restoreVoice — reconnect the @discordjs audio connection after - // the stream ends so mic/listen keep working. - async (status) => { - const vc = this.voiceControllerAccessor?.(); - if (vc && status.activeGuildId && status.activeChannelId) { - await vc.connect(status.activeGuildId, status.activeChannelId); - } - }, - ); - } - const playback = await this.screenController.start(url); - this.screenPlayback = playback; - currentTrackItem = { - id: randomUUID(), - source: url, - title: url, - kind: "url", - mode: "screen", - requestedBy, - addedAt: Date.now(), - status: "playing", - }; - playback.done - .catch((err) => { - this.logger.error( - { error: err instanceof Error ? err.message : String(err) }, - "Screen playback promise rejected", - ); - }) - .finally(() => { - this.screenPlayback = null; - if (currentTrackItem?.mode === "screen") { - currentTrackItem = null; - } - }); - this.logger.info({ url }, "Screen share started"); - return { id: cmd.id, success: true, data: buildStatusPayload() }; - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - this.logger.error({ error: message }, "Screen share failed to start"); - return { id: cmd.id, success: false, data: null, error: message }; - } - } - // Lightweight metadata fetch for display — the full resolve happens in playNext let title: string = url; let duration: number | undefined; @@ -264,7 +161,7 @@ export class MediaHandler { source: url, title, kind: "url" as const, - mode, + mode: "music", requestedBy, addedAt: Date.now(), status: "queued", @@ -308,12 +205,6 @@ export class MediaHandler { } async handleMediaStop(cmd: CommandMessage): Promise> { - // Stop screen share if active — playback.done.finally clears the item. - this.screenPlayback?.stop(); - this.screenPlayback = null; - if (currentTrackItem?.mode === "screen") { - currentTrackItem = null; - } discordPlayer.stop("music"); currentTrackItem = null; mediaQueue.length = 0; // Clear entire queue @@ -394,7 +285,7 @@ export class MediaHandler { // Music playback: transcode once to high-quality OggOpus (48kHz stereo, // 192kbps) with volume baked into the encode. This avoids the double // lossy encode that inlineVolume would cause and gives Discord the - // cleanest possible stream. Screen share bypasses this entirely. + // cleanest possible stream. const transcoded = transcodeToHighQualityOgg( resolution.stream, discordPlayer.getMusicVolume(), diff --git a/services/discord-gateway/src/modules/voice-recording/mediaSource.ts b/services/discord-gateway/src/modules/voice-recording/mediaSource.ts index c63fc4e..f23e744 100644 --- a/services/discord-gateway/src/modules/voice-recording/mediaSource.ts +++ b/services/discord-gateway/src/modules/voice-recording/mediaSource.ts @@ -1,14 +1,5 @@ import { type ChildProcess, spawn } from "node:child_process"; -import { - chmodSync, - existsSync, - mkdtempSync, - readdirSync, - readFileSync, - rmSync, - statSync, - writeFileSync, -} from "node:fs"; +import { chmodSync, existsSync, readFileSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { PassThrough, type Readable } from "node:stream"; @@ -232,7 +223,7 @@ function buildNotInstalledError(): Error { * in account's cookies. The path is configurable via GMW_YT_COOKIES_PATH * (default: the BWS-provided file the deploy writes to /etc/.../ytcookies.txt). * If the file doesn't exist we pass nothing and fall back to anon (YouTube - * may 403 — screen share will fail gracefully, not crash). + * may 403 — playback will fail gracefully, not crash). */ var _cachedCookiePath: string | null = null; function buildCookieArgs(): string[] { @@ -267,7 +258,7 @@ function buildCookieArgs(): string[] { // Never hand the ORIGINAL system file to yt-dlp: recent yt-dlp rewrites // the cookie file on close (`--cookies` implies write-back). The system // file is owned by another user (root/deploy) and the service user - // cannot write it → PermissionError → yt-dlp exits 1 → screen share + // cannot write it → PermissionError → yt-dlp exits 1 → playback // fails for every attempt. Copy to a per-run temp file (like the env // branch above) so write-back lands somewhere we own; if the original // is not readable we fall back to anonymous (YouTube may 403 → the @@ -305,44 +296,6 @@ function buildCookieArgs(): string[] { return []; } -/** Invidious instances for anon YouTube fetch (fallback when cookies 403). */ -export const INVIDIOUS_INSTANCES = [ - "yewtu.be", - "yewtu.nanomorph.dev", - "invidious.snopyta.org", - "invidious.kavin.rocks", -]; - -/** True if url is a YouTube watch URL (youtu.be / youtube.com/watch). */ -export function isYoutubeWatchUrl(url: string): boolean { - try { - const u = new URL(url); - return ( - u.hostname === "youtu.be" || - (u.hostname === "www.youtube.com" && u.pathname === "/watch") || - (u.hostname === "youtube.com" && u.pathname === "/watch") - ); - } catch { - return false; - } -} - -/** Rewrite a YouTube watch URL to an invidious instance (anon, no bot-check). */ -export function toInvidiousUrl(url: string, instance: string): string { - try { - const u = new URL(url); - if (u.hostname === "youtu.be") { - const id = u.pathname.slice(1); - return `https://${instance}/watch?v=${id}`; - } - const id = u.searchParams.get("v"); - if (id) return `https://${instance}/watch?v=${id}`; - return url; - } catch { - return url; - } -} - // --------------------------------------------------------------------------- // Public API // --------------------------------------------------------------------------- @@ -505,114 +458,6 @@ export function resolveMediaUrl( }); } -/** - * Download the merged video+audio media for screen share to a TEMP FILE - * first, then return the file path. - * - * Screen-share playback does NOT stream from yt-dlp stdout anymore: the - * merge feed is delivered at network speed (bursts + stalls), and a live - * pipe defeats ffmpeg's `-re` throttle (the input PTS timeline is - * unreliable), so the encoder force-duplicates held frames → ~1fps video. - * Downloading the FULL clip to a file first gives the encoder a clean, - * monotonic-PTS input, where `-re` (applied in prepareStream for string - * inputs) pacing is proven reliable. - * - * @returns absolute path of the completed media file (caller should delete - * it via cleanup after playback ends). - */ -export function downloadScreenInput(url: string): Promise { - return new Promise((resolve, reject) => { - // Temp dir per run (world-writable like /tmp) so parallel/retry runs - // never collide on merge fragments. - const tmpDir = mkdtempSync(join(tmpdir(), "gmw-ytdlp-")); - chmodSync(tmpDir, 0o1777); - - const cookieArgs = buildCookieArgs(); - const args = [ - "-f", - "bestvideo[protocol^=http]+bestaudio[protocol^=http]/best[protocol^=http]/best", - // File output (NOT `-o -`): yt-dlp merges DASH fragments into a real - // container with clean timestamps, which is what -re needs to pace. - "-o", - join(tmpDir, "media.%(ext)s"), - "--no-playlist", - "--no-warnings", - "--no-progress", - ...cookieArgs, - url, - ]; - - logger.info({ url }, "Downloading full media for screen share input"); - - const proc = spawn("yt-dlp", args, { - stdio: ["ignore", "pipe", "pipe"], - }); - - activeProcesses.add(proc); - - let stderrBuf = ""; - const MAX_STDERR = 4096; - proc.stderr?.on("data", (chunk: Buffer) => { - if (stderrBuf.length < MAX_STDERR) { - stderrBuf += chunk.toString("utf8"); - } - }); - - let settled = false; - const failOnce = (message: string) => { - if (settled) return; - settled = true; - activeProcesses.delete(proc); - rmSync(tmpDir, { recursive: true, force: true }); - reject(new Error(message)); - }; - - proc.on("error", (err: NodeJS.ErrnoException) => { - failOnce(`yt-dlp failed to start: ${err.message}`); - }); - - let procFinished = false; - proc.on("close", (code) => { - procFinished = true; - activeProcesses.delete(proc); - if (code !== 0) { - const detail = stderrBuf.trim() ? `: ${stderrBuf.trim()}` : ""; - failOnce(`yt-dlp download failed (exit ${code})${detail}`); - return; - } - // Find the media file yt-dlp wrote (skip .part / .ytdl temp state). - let mediaPath: string | null = null; - for (const entry of readdirSync(tmpDir)) { - if (entry.endsWith(".part") || entry.endsWith(".ytdl")) continue; - mediaPath = join(tmpDir, entry); - break; - } - if (mediaPath && existsSync(mediaPath) && statSync(mediaPath).size > 0) { - settled = true; - resolve(mediaPath); - } else { - failOnce("yt-dlp finished but produced no media file"); - } - }); - - // Safety net: a stalled download must not hang the gateway forever. - const timer = setTimeout( - () => { - try { - proc.kill("SIGTERM"); - } catch { - /* already dead */ - } - if (!procFinished) { - failOnce("yt-dlp download timed out (10 min)"); - } - }, - 10 * 60 * 1000, - ); - proc.once("close", () => clearTimeout(timer)); - }); -} - /** * Extract metadata (title, duration, thumbnail) from a media URL * without downloading the audio stream. diff --git a/services/discord-gateway/src/modules/voice-recording/mediaTypes.ts b/services/discord-gateway/src/modules/voice-recording/mediaTypes.ts index 959d615..0de0a93 100644 --- a/services/discord-gateway/src/modules/voice-recording/mediaTypes.ts +++ b/services/discord-gateway/src/modules/voice-recording/mediaTypes.ts @@ -1,7 +1,7 @@ import type { Readable } from "node:stream"; import type { StreamType } from "@discordjs/voice"; -export type MediaMode = "music" | "screen"; +export type MediaMode = "music"; export type MediaSourceKind = | "url" | "local" @@ -51,23 +51,7 @@ export interface MusicPlayer { play(source: ResolvedMediaSource): MusicPlayback; } -export interface ScreenSharePlayback { - done: Promise; - stop(): void; -} - -export interface ScreenShareVoiceStatus { - connected: boolean; - activeGuildId: string | null; - activeChannelId: string | null; -} - -export interface ScreenShareController { - isActive(): boolean; - start(source: string): Promise; -} - -export type DiscordPlayerOwner = "none" | "browser-bridge" | "music" | "screen"; +export type DiscordPlayerOwner = "none" | "browser-bridge" | "music"; export interface DiscordPlayOptions { inputType?: StreamType; diff --git a/services/discord-gateway/src/modules/voice-recording/screenShareController.ts b/services/discord-gateway/src/modules/voice-recording/screenShareController.ts deleted file mode 100644 index b187f23..0000000 --- a/services/discord-gateway/src/modules/voice-recording/screenShareController.ts +++ /dev/null @@ -1,291 +0,0 @@ -import { rmSync } from "node:fs"; -import { dirname } from "node:path"; -import type { Client } from "discord.js-selfbot-v13"; -import { createChildLogger } from "@/shared/logger/index"; -import { - Encoders, - normalizeVideoCodec, - playStream, - prepareStream, - Streamer, -} from "../../goLive/index.js"; -import { - downloadScreenInput, - INVIDIOUS_INSTANCES, - isYoutubeWatchUrl, - toInvidiousUrl, -} from "./mediaSource.js"; -import type { ScreenSharePlayback } from "./mediaTypes.js"; -import { discordPlayer } from "./player.js"; - -const logger = createChildLogger("screen-share"); - -export interface ScreenShareVoiceStatus { - connected: boolean; - activeGuildId: string | null; - activeChannelId: string | null; -} - -/** - * Discord Go Live (screenshare) via @dank074/discord-video-stream. - * - * Pipeline: - * URL (YouTube, dll.) → yt-dlp direct video URL → ffmpeg (H264 720p30) → - * playStream({ type: "go-live" }) → Discord voice channel as Go Live. - * - * Restored from the pre-microservices implementation (commit d50ce86, - * src/media/screenShareController.ts) — the interface survived in - * mediaTypes.ts but the implementation was lost during the split. - */ -export class ScreenShareController { - private logger = createChildLogger("screen-share"); - private streamer: Streamer | null = null; - private active: ScreenSharePlayback | null = null; - - constructor( - private readonly client: Client, - private readonly getVoiceStatus: () => ScreenShareVoiceStatus, - /** Disconnect the @discordjs/voice connection so the Streamer can take - * over the voice channel (Discord allows only ONE voice session per user - * — two connections collide and the Streamer never gets VOICE_SERVER_UPDATE). */ - private readonly releaseVoice: ( - status: ScreenShareVoiceStatus, - ) => void | Promise, - /** Reconnect the @discordjs/voice connection after the stream ends. */ - private readonly restoreVoice: ( - status: ScreenShareVoiceStatus, - ) => void | Promise, - ) {} - - isActive(): boolean { - return this.active !== null; - } - - /** - * Resolve the screen-share input with retry (downloads the FULL media to a - * temp file; returns the file path). - * - * Transient YouTube 403s kill a download BEFORE completion; we validate - * the finished file and retry with a FRESH yt-dlp run (signed DASH URLs - * expire quickly). Downloading to a file (instead of streaming the merge - * pipe) gives the encoder a monotonic-PTS input, so ffmpeg `-re` pacing - * in prepareStream actually works (it does NOT on live pipes — the root - * of the ~1fps video). - */ - private async resolveInputWithRetry(source: string): Promise { - const MAX_ATTEMPTS = 3; - let lastError: Error | null = null; - - // YouTube may 403 even with account cookies (IP-bound session / bot check - // on VPS IP). When the source is a YouTube URL and cookies fail, fall back - // to anon Invidious mirror instances — no auth needed. - const isYt = isYoutubeWatchUrl(source); - let invidiousIdx = 0; - - for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) { - // On a 403 against YouTube, try the next Invidious instance for this attempt. - if ( - isYt && - lastError && - /403|bot|Sign in|not a bot|access denied|permission|EACCES|cookie/i.test( - lastError.message, - ) && - invidiousIdx < INVIDIOUS_INSTANCES.length - ) { - const inst = INVIDIOUS_INSTANCES[invidiousIdx]; - this.logger.warn( - { attempt, instance: inst, error: lastError.message }, - "YouTube blocked (403); falling back to Invidious mirror", - ); - source = toInvidiousUrl(source, inst); - invidiousIdx++; - } - - try { - const mediaPath = await downloadScreenInput(source); - this.logger.info( - { mediaPath, attempt }, - "Screen input downloaded to file", - ); - return mediaPath; - } catch (err) { - lastError = err instanceof Error ? err : new Error(String(err)); - this.logger.warn( - { - attempt, - maxAttempts: MAX_ATTEMPTS, - error: lastError.message, - }, - "Screen input download failed; retrying with fresh yt-dlp", - ); - if (attempt < MAX_ATTEMPTS) { - await new Promise((r) => setTimeout(r, 1500 * attempt)); - } - } - } - - throw ( - lastError ?? - new Error("Screen input resolution failed after multiple attempts") - ); - } - - async start(source: string): Promise { - const status = this.getVoiceStatus(); - if (!status.connected || !status.activeGuildId || !status.activeChannelId) { - throw new Error("Connect to a voice channel before sharing screen"); - } - - if (this.active || discordPlayer.getOwner() !== "none") { - throw new Error("Another media mode is active"); - } - - try { - const input = await this.resolveInputWithRetry(source); - if (!this.streamer) { - this.streamer = new Streamer(this.client); - } - - const guild = this.client.guilds.cache.get(status.activeGuildId); - const channel = guild?.channels.cache.get(status.activeChannelId); - if ( - !channel || - (channel.type !== "GUILD_VOICE" && channel.type !== "GUILD_STAGE_VOICE") - ) { - throw new Error( - `Voice channel ${status.activeChannelId} not found for screen share`, - ); - } - - // Free the @discordjs/voice connection BEFORE the Streamer joins, so - // the user has only one voice session (Discord requirement). - await this.releaseVoice(status); - - await Promise.race([ - this.streamer.joinVoiceChannel(channel), - new Promise((_, reject) => - setTimeout( - () => - reject( - new Error("Timed out joining voice channel for screen share"), - ), - 15000, - ), - ), - ]); - - const prepared = prepareStream(input, { - encoder: Encoders.software({ x264: { preset: "superfast" } }), - width: 1280, - height: 720, - frameRate: 30, - bitrateVideo: 2500, - bitrateVideoMax: 4000, - // GoLive with audio: the encoder muxes to NUT (video h264 + opus - // audio) so the audio SSRC carries RTP too. Discord's GoLive - // pipeline expects audio — a video-only stream shows a static - // tile/thumbnail instead of live video. When the source has no - // audio track, the encoder's `-map 0:a:0?` yields no audio stream - // and the demuxer simply reports none (video still flows). - includeAudio: true, - videoCodec: normalizeVideoCodec("H264"), - }); - const { command } = prepared; - - // The downloaded temp media lives in a per-run tmpdir; remove it once - // playback is done (natural end, failure, or user stop). The tmpdir is - // the parent of the media file, so deleting it removes the file too. - const cleanupTempMedia = () => { - try { - if (typeof input === "string") { - rmSync(dirname(input), { recursive: true, force: true }); - } - } catch { - /* best-effort — tmp dirs are world-writable, leak is bounded */ - } - }; - - let stopped = false; - // Restore the @discordjs/voice connection after the stream ends (both - // natural end and failure), so the user can keep using audio/mic. - const restoreAfter = () => { - if (!stopped) { - stopped = true; - try { - command.kill("SIGTERM"); - } catch { - /* already dead */ - } - } - cleanupTempMedia(); - try { - this.streamer?.voiceConnection?.stop(); - } catch { - /* already gone */ - } - if (this.restoreVoice) { - // Best-effort restore after a short delay. Discord often needs the - // Streamer's session fully torn down before @discordjs/voice can - // re-join; if that races, the reconnect times out — the FE shows - // disconnected and the user just clicks Connect again. This is an - // accepted UX tradeoff for GoLive (single voice session per user). - setTimeout(() => { - Promise.resolve(this.restoreVoice(status)).catch((err) => { - this.logger.warn( - { error: err instanceof Error ? err.message : String(err) }, - "Failed to restore voice connection after screen share (user can reconnect manually)", - ); - }); - }, 5000); - } - }; - const done = playStream(prepared, this.streamer, { - type: "go-live", - width: 1280, - height: 720, - frameRate: 30, - }) - .catch((err: unknown) => { - // Never let a stream failure become an unhandledRejection — that - // crashed the whole gateway. Log + surface via the done promise. - const message = err instanceof Error ? err.message : String(err); - this.logger.error( - { error: message, source }, - "Screen stream failed during playback", - ); - }) - .finally(() => { - restoreAfter(); - this.active = null; - }); - this.active = { - done, - stop: () => { - if (stopped) return; - stopped = true; - try { - command.kill("SIGTERM"); - } catch { - /* already dead */ - } - cleanupTempMedia(); - // Leave the voice channel the Streamer joined (its own connection). - try { - this.streamer?.voiceConnection?.stop(); - } catch { - /* already gone */ - } - this.active = null; - }, - }; - - logger.info({ source }, "Screen share started"); - return this.active; - } catch (error) { - this.active = null; - const message = error instanceof Error ? error.message : String(error); - logger.error({ error: message, source }, "Screen stream failed"); - throw error; - } - } -} diff --git a/services/discord-gateway/tests/demuxerNut.test.ts b/services/discord-gateway/tests/demuxerNut.test.ts deleted file mode 100644 index 0fa0aa7..0000000 --- a/services/discord-gateway/tests/demuxerNut.test.ts +++ /dev/null @@ -1,187 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { existsSync, mkdtempSync, readdirSync, rmSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { PassThrough } from "node:stream"; -import { demux } from "../src/goLive/Demuxer.js"; - -/** Same resolution as Demuxer.resolveBin: env override → Nix store scan. */ -function resolveFfmpeg(): string | null { - const override = process.env.FFMPEG_PATH; - if (override && existsSync(override)) return override; - const store = "/nix/store"; - if (existsSync(store)) { - for (const entry of readdirSync(store)) { - if (!entry.includes("ffmpeg-headless-")) continue; - const candidate = join(store, entry, "bin", "ffmpeg"); - if (existsSync(candidate)) return candidate; - } - } - return existsSync("/usr/bin/ffmpeg") ? "/usr/bin/ffmpeg" : null; -} - -/** - * Ogg Opus byte stream built by hand: OpusHead (19B) + a few 20ms opus - * frames, wrapped in valid Ogg pages (CRC 0 — the parser ignores CRC). - */ -function buildOggOpusBytes(frames: number): Buffer { - const opusHead = Buffer.from([ - 0x4f, 0x70, 0x75, 0x73, 0x48, 0x65, 0x61, 0x64, // "OpusHead" - 0x01, 0x02, 0x38, 0x01, 0x80, 0xbb, 0x00, 0x00, - 0x00, 0x00, 0x00, // version 1, 2ch, 48000 - ]); - // A minimal valid opus data packet: TOC 0xFC (48kHz stereo 20ms) + payload - const dataPacket = Buffer.alloc(20, 0); - dataPacket[0] = 0xfc; - const makePage = ( - seq: number, - headerType: number, - serial: number, - packets: Buffer[], - ): Buffer => { - const segmentTable: number[] = []; - const payloadParts: Buffer[] = []; - for (const p of packets) { - let remaining = p.length; - let off = 0; - do { - const chunk = Math.min(255, remaining); - segmentTable.push(chunk); - payloadParts.push(p.subarray(off, off + chunk)); - off += chunk; - remaining -= chunk; - } while (remaining > 0); - } - const payload = Buffer.concat(payloadParts); - const header = Buffer.alloc(27 + segmentTable.length); - header.write("OggS", 0, "latin1"); - header[4] = 0; // version - header[5] = headerType; - header.writeUInt32LE(0, 6); // granule (unused) - header.writeUInt32LE(0, 10); - header.writeUInt32LE(serial, 14); - header.writeUInt32LE(seq, 18); - header.writeUInt32LE(0, 22); // crc (ignored) - header[26] = segmentTable.length; - for (let i = 0; i < segmentTable.length; i++) header[27 + i] = segmentTable[i]; - return Buffer.concat([header, payload]); - }; - const pages: Buffer[] = []; - const serial = 0x1234; - let seq = 0; - // Page 0: BOS + OpusHead (19 bytes, single lacing) - pages.push(makePage(seq++, 0x02, serial, [opusHead])); - // Pages 1+: data packets, a few per page - const perPage = 3; - for (let i = 0; i < frames; i += perPage) { - const pkts = []; - for (let j = 0; j < perPage && i + j < frames; j++) pkts.push(dataPacket); - pages.push(makePage(seq++, 0x00, serial, pkts)); - } - return Buffer.concat(pages); -} - -describe("Demuxer NUT path with audio", () => { - it("emits video access units AND parsed opus audio frames", async () => { - // Real NUT file (video h264 + opus) produced by ffmpeg — generated once - // in this test via ffmpeg, skipped if ffmpeg is unavailable. - const ffmpeg = resolveFfmpeg(); - if (!ffmpeg) { - console.warn("ffmpeg not found — skipping NUT integration case"); - return; - } - const dir = mkdtempSync(join(tmpdir(), "gmw-nut-test-")); - const inWebm = join(dir, "in.webm"); - const inNut = join(dir, "in.nut"); - try { - // Build a tiny webm (vpx + opus) then remux to NUT h264+opus — mirrors - // prepareStream(includeAudio) output. - const { spawnSync } = await import("node:child_process"); - const gen = spawnSync( - ffmpeg, - [ - "-hide_banner", "-loglevel", "error", - "-f", "lavfi", "-i", "testsrc2=size=160x120:rate=10:duration=3", - "-f", "lavfi", "-i", "sine=frequency=440:duration=3", - "-c:v", "libvpx-vp9", "-b:v", "100k", "-pix_fmt", "yuv420p", - "-c:a", "libopus", "-b:a", "48k", "-f", "webm", "-y", inWebm, - ], - { timeout: 20000 }, - ); - if (gen.status !== 0) { - console.warn("ffmpeg webm gen failed — skipping", gen.stderr?.toString().slice(0, 200)); - return; - } - const enc = spawnSync( - ffmpeg, - [ - "-hide_banner", "-loglevel", "error", "-i", inWebm, - "-map", "0:v:0", "-c:v", "libx264", "-profile:v", "baseline", - "-x264-params", "repeat-headers=1", "-preset", "superfast", - "-pix_fmt", "yuv420p", "-g", "10", "-forced-idr", "1", - "-map", "0:a:0?", "-c:a", "libopus", "-b:a", "48k", "-ar", "48000", "-ac", "2", - "-f", "nut", "-y", inNut, - ], - { timeout: 20000 }, - ); - if (enc.status !== 0) { - console.warn("ffmpeg nut gen failed — skipping", enc.stderr?.toString().slice(0, 200)); - return; - } - const { readFileSync } = await import("node:fs"); - // demux() takes string | PassThrough — wrap the file read. Drip the - // bytes in quickly (well within demux's 1.5s metadata window) so ffmpeg - // prints both stream lines before demux() resolves. - const input = new PassThrough(); - const nutBytes = readFileSync(inNut); - const CHUNK = Math.max(1024, Math.floor(nutBytes.length / 20)); - let off = 0; - const drip = setInterval(() => { - if (off >= nutBytes.length) { - clearInterval(drip); - input.end(); - return; - } - input.write(nutBytes.subarray(off, off + CHUNK)); - off += CHUNK; - }, 10); - const { video, audio, close } = await demux(input, { - format: "nut", - frameRate: 10, - }); - const vFrames: number[] = []; - const aFrames: number[] = []; - video?.stream.on("data", (f: { data: Buffer | null; flags: number }) => { - if (f.data) vFrames.push(f.data.length); - }); - audio?.stream.on("data", (f: { data: Buffer | null; duration: number }) => { - if (f.data) aFrames.push(f.duration); - }); - await new Promise((resolve) => { - video?.stream.on("end", resolve); - setTimeout(resolve, 6000); - }); - close(); - expect(vFrames.length).toBeGreaterThan(0); - // ~10fps × 1s of video → at least 5 access units - expect(vFrames.length).toBeGreaterThanOrEqual(5); - // ~50 opus frames/sec of audio - expect(aFrames.length).toBeGreaterThan(10); - // opus frames are 20ms (duration 960 @ 48kHz) - expect(aFrames[0]).toBe(960); - } finally { - rmSync(dir, { recursive: true, force: true }); - } - }, 30000); - - it("parses a hand-built Ogg Opus stream into frames", async () => { - // Feed the OGG bytes via the demux's audio path is not directly - // exposed — instead verify the parser contract through the NUT path is - // covered above; here we sanity-check the byte layout our parser reads. - const bytes = buildOggOpusBytes(7); - expect(bytes.subarray(0, 4).toString("latin1")).toBe("OggS"); - // 7 frames + header across pages - expect(bytes.includes(Buffer.from("OpusHead"))).toBe(true); - expect(bytes.subarray(28, 36).toString("latin1")).toBe("OpusHead"); - }); -}); diff --git a/services/discord-gateway/tests/goLive-port.test.ts b/services/discord-gateway/tests/goLive-port.test.ts deleted file mode 100644 index 11ddc05..0000000 --- a/services/discord-gateway/tests/goLive-port.test.ts +++ /dev/null @@ -1,97 +0,0 @@ -/** - * goLive port smoke tests — verify the TS layer (no native binding needed - * for these; native is covered by the C++/node test-packetizer.js). - */ -import { describe, expect, it } from "vitest"; -import { H264Helpers } from "../src/goLive/AnnexBHelper.js"; -import { AVCodecID } from "../src/goLive/Demuxer.js"; -import { - BaseMediaStream, - CodecPayloadType, - Encoders, - normalizeVideoCodec, -} from "../src/goLive/index.js"; -import { rewriteSPSVUI } from "../src/goLive/SPSVUIRewriter.js"; - -describe("goLive port: codec + encoders", () => { - it("normalizeVideoCodec maps aliases to canonical names", () => { - expect(normalizeVideoCodec("H.264")).toBe("H264"); - expect(normalizeVideoCodec("AVC")).toBe("H264"); - expect(normalizeVideoCodec("h265")).toBe("H265"); - expect(normalizeVideoCodec("vp8")).toBe("VP8"); - expect(normalizeVideoCodec("av1")).toBe("AV1"); - }); - - it("software encoder exposes x264 libx264 baseline zerolatency", () => { - const enc = Encoders.software()(); - expect(enc.H264.name).toBe("libx264"); - expect(enc.H264.options).toContain("-preset superfast"); - expect(enc.H264.options).toContain("-tune zerolatency"); - // Baseline profile is REQUIRED to match the SDP's profile-level-id=42e01f - // (constrained baseline) — High-profile bitstreams fail to decode → black - expect(enc.H264.options).toContain("-profile:v baseline"); - }); - - it("CodecPayloadType has opus + H264 entries", () => { - expect(CodecPayloadType.opus).toBeDefined(); - expect(CodecPayloadType.H264).toBeDefined(); - }); -}); - -describe("goLive port: annexb + sps rewriter", () => { - it("H264Helpers detects NAL unit types", () => { - const nal = Buffer.from([0x67, 0x42, 0x00, 0x1e]); // SPS - expect(H264Helpers.getUnitType(nal)).toBe(7); // SPS type - expect(H264Helpers.getUnitType(Buffer.from([0x65, 0x88]))).toBe(5); // IDR - }); - - it("rewriteSPSVUI returns a buffer for valid SPS", () => { - const sps = Buffer.from([ - 0x67, 0x42, 0x00, 0x1e, 0x96, 0x54, 0x05, 0x01, 0xec, 0x80, - ]); - expect(() => rewriteSPSVUI(sps)).not.toThrow(); - }); -}); - -describe("goLive port: streams", () => { - it("BaseMediaStream accepts plain frame objects", () => { - // BaseMediaStream is abstract — use a concrete subclass that no-ops the - // packetizer hook. - class TestStream extends BaseMediaStream { - async _sendFrame(_frame: Buffer, _frametime: number): Promise { - /* no-op */ - } - } - const stream = new TestStream("video"); - const frame = { - data: Buffer.from([1, 2, 3]), - pts: 0, - duration: 40, - timeBase: { num: 1, den: 48000 }, - flags: 0, - streamIndex: 0, - free: () => {}, - }; - expect(() => stream.write(frame)).not.toThrow(); - stream.end(); - }); -}); - -describe("goLive port: demuxer codec ids", () => { - it("maps H264/HEVC/opus AVCodecID values", () => { - expect(AVCodecID.AV_CODEC_ID_H264).toBe(27); - expect(AVCodecID.AV_CODEC_ID_HEVC).toBe(173); - expect(AVCodecID.AV_CODEC_ID_OPUS).toBe(86019); - }); -}); - -describe("goLive port: prepareStream option merge", () => { - it("merges default options into the descriptor (no ffmpeg spawn)", () => { - // Import the merge logic directly via the module; prepareStream spawns - // ffmpeg so we verify the descriptors it would build by checking the - // encoder + option functions that prepareStream uses. - const enc = Encoders.software()(); - expect(enc.H264.options).toContain("-forced-idr 1"); - expect(normalizeVideoCodec("H264")).toBe("H264"); - }); -}); diff --git a/services/discord-gateway/tests/golive-demux-e2e.ts b/services/discord-gateway/tests/golive-demux-e2e.ts deleted file mode 100644 index 56f49aa..0000000 --- a/services/discord-gateway/tests/golive-demux-e2e.ts +++ /dev/null @@ -1,40 +0,0 @@ -// Phase 2 E2E: Demuxer on a real ffmpeg-generated H264 file. -// Run: npx tsx tests/golive-demux-e2e.ts - -import { createReadStream } from "node:fs"; -import { demux } from "../src/goLive/Demuxer.js"; - -const input = process.argv[2] ?? "/tmp/sample.h264"; -const { video, close } = await demux(createReadStream(input), { - format: "h264", -}); - -console.log( - "video:", - JSON.stringify({ - codecName: video.codecName, - width: video.width, - height: video.height, - duration: video.duration, - fps: Math.round(video.framerate_num / video.framerate_den), - }), -); - -let count = 0; -let keyframes = 0; -let bytes = 0; -video.stream.on("data", (frame: { data: Buffer; keyframe: boolean }) => { - count++; - bytes += frame.data.length; - if (frame.keyframe) keyframes++; -}); -video.stream.on("end", () => { - console.log(`frames: ${count} (${keyframes} keyframes), ${bytes} bytes`); - close(); - process.exit(0); -}); -video.stream.on("error", (e: unknown) => { - console.error("stream error:", e); - close(); - process.exit(1); -}); diff --git a/services/discord-gateway/tests/golive-demux-live-e2e.ts b/services/discord-gateway/tests/golive-demux-live-e2e.ts deleted file mode 100644 index 8dd8f63..0000000 --- a/services/discord-gateway/tests/golive-demux-live-e2e.ts +++ /dev/null @@ -1,153 +0,0 @@ -// Regression test: demux must emit frames from a LIVE stream that never -// ends (the NUT/H264 merge output during playback). The old implementation -// spooled the whole stream to a file first → deadlocked forever → 0 frames. -// v2: also validates ACCESS-UNIT grouping — each emitted frame must be a -// complete picture (parameter sets + slice), never a bare SPS/PPS/SEI NAL, -// and must be timestamped at the video frame rate (RTP +clockRate/fps). -// Run: npx tsx tests/golive-demux-live-e2e.ts [ffmpeg-path] -import { spawn } from "node:child_process"; -import { PassThrough } from "node:stream"; -import { demux } from "../src/goLive/Demuxer.js"; - -const FFMPEG = process.argv[2] ?? "ffmpeg"; - -// 1) Generate a 2s H264 test clip to a temp file -const clip = "/tmp/golive-live-test.h264"; -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, - ], - { stdio: ["ignore", "ignore", "pipe"] }, - ); - let err = ""; - p.stderr?.on("data", (d: Buffer) => (err += d.toString())); - p.on("close", (code) => (code === 0 ? resolve() : reject(new Error(err)))); -}); - -// 2) Feed the clip through a PassThrough but DON'T end it (live semantics), -// with a small pause after the first chunk so demux has time to emit. -const input = new PassThrough(); -const demuxPromise = demux(input, { format: "h264", frameRate: 30 }); -const { video, close } = await demuxPromise; -if (!video) { - console.error("FAIL: demux returned no video stream"); - process.exit(1); -} - -interface Emitted { - data: Buffer; - duration: number; - timeBase: { num: number; den: number }; - flags: number; -} -const frames: Emitted[] = []; -video.stream.on("data", (frame: Emitted) => { - frames.push(frame); -}); - -const fs = await import("node:fs"); -const buf = fs.readFileSync(clip); -const chunkSize = 16384; -for (let i = 0; i < buf.length; i += chunkSize) { - input.write(buf.subarray(i, i + chunkSize)); - if (i === 0) await new Promise((r) => setTimeout(r, 1500)); -} -// Stream still open — if the old spool logic was here we'd never emit. -await new Promise((r) => setTimeout(r, 500)); - -// 3) Validate access-unit structure -const nalTypes = (frame: Buffer): number[] => { - const out: number[] = []; - let i = 0; - while (i < frame.length - 3) { - if (frame[i] === 0 && frame[i + 1] === 0 && frame[i + 2] === 1) { - const start = i; - let j = i + 3; - if (frame[j - 4] === 0 && j >= 4) { - // 4-byte start code already consumed by i pointing at the 3-byte tail - } - while (j < frame.length - 3) { - if (frame[j] === 0 && frame[j + 1] === 0 && frame[j + 2] === 1) break; - j++; - } - const nal = frame.subarray(start + 3, j); - if (nal.length > 0) out.push(nal[0] & 0x1f); - i = j; - } else { - i++; - } - } - return out; -}; - -let bareParamSetFrames = 0; -let framesWithoutSlice = 0; -let keyframesWithParamSets = 0; -let keyframesWithoutParamSets = 0; -for (const f of frames) { - const types = nalTypes(f.data); - const hasSlice = types.some((t) => t === 1 || t === 5); - 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) - ) { - bareParamSetFrames++; - } - if (isKey && hasParams) keyframesWithParamSets++; - if (isKey && !hasParams) keyframesWithoutParamSets++; -} - -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}`, -); -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)", - ); - 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)", - ); - close(); - process.exit(1); -} -input.end(); -await new Promise((r) => setTimeout(r, 300)); -close(); -console.log("PASS: live stream demux works + access units grouped correctly"); -process.exit(0); diff --git a/services/discord-gateway/tests/golive-pipeline-e2e.ts b/services/discord-gateway/tests/golive-pipeline-e2e.ts deleted file mode 100644 index 042006a..0000000 --- a/services/discord-gateway/tests/golive-pipeline-e2e.ts +++ /dev/null @@ -1,58 +0,0 @@ -// Phase 2 E2E: full pipeline prepareStream → demux → frame stream. -// Run: npx tsx tests/golive-pipeline-e2e.ts - -import { demux } from "../src/goLive/Demuxer.js"; -import { Encoders } from "../src/goLive/Encoders.js"; -import { prepareStream } from "../src/goLive/prepareStream.js"; -import { normalizeVideoCodec } from "../src/goLive/utils.js"; - -// Use a real ffmpeg-generated video file as input (from sample generation). -const input = process.argv[2] ?? "/tmp/sample.h264"; - -const prepared = prepareStream(input, { - encoder: Encoders.software({ x264: { preset: "superfast" } }), - width: 640, - height: 360, - frameRate: 25, - bitrateVideo: 500, - bitrateVideoMax: 800, - includeAudio: false, - videoCodec: normalizeVideoCodec("H264"), -}); - -console.log( - "prepareStream ok, videoCodec:", - prepared.videoCodec, - "size:", - prepared.width, - "x", - prepared.height, -); - -const { video, close } = await demux(prepared.output, { format: "h264" }); -console.log("demux video:", video?.codecName, video?.width, "x", video?.height); - -let frames = 0; -let keyframes = 0; -video.stream.on("data", (f: { keyframe?: boolean }) => { - frames++; - if (f.keyframe) keyframes++; -}); -video.stream.on("end", () => { - console.log(`pipeline frames: ${frames} (${keyframes} keyframes)`); - close(); - prepared.command.kill("SIGTERM"); - process.exit(frames > 0 ? 0 : 1); -}); -video.stream.on("error", (e: unknown) => { - console.error("pipeline error:", e); - close(); - prepared.command.kill("SIGTERM"); - process.exit(1); -}); -setTimeout(() => { - console.log("timeout after 30s — killing"); - close(); - prepared.command.kill("SIGTERM"); - process.exit(2); -}, 30000); diff --git a/services/discord-gateway/tests/golive-videostream-e2e.ts b/services/discord-gateway/tests/golive-videostream-e2e.ts deleted file mode 100644 index 4cb7b68..0000000 --- a/services/discord-gateway/tests/golive-videostream-e2e.ts +++ /dev/null @@ -1,78 +0,0 @@ -// Phase 2 E2E: demux → VideoStream → native packetizer chain (local pair). -// Run: npx tsx tests/golive-videostream-e2e.ts - -import { createReadStream } from "node:fs"; -import { demux } from "../src/goLive/Demuxer.js"; -import { loadNative } from "../src/goLive/native.js"; -import { VideoStream } from "../src/goLive/VideoStream.js"; - -async function main() { - const native = loadNative(); - const { PeerConnection } = native; - - const pcA = new PeerConnection({ iceServers: [] }); - const pcB = new PeerConnection({ iceServers: [] }); - - pcA.onStateChange(() => {}); - pcB.onStateChange(() => {}); - - // Both peers declare audio+video tracks (exact passing test-packetizer - // pattern — tracks trigger negotiation). - pcA.addTrack("0", "audio"); - pcA.addTrack("1", "video"); - pcB.addTrack("0", "audio"); - const trackB = pcB.addTrack("1", "video"); - if (!trackB) throw new Error("no track from addTrack"); - - const track = trackB; - // NOTE: setPacketizer is called AFTER connected (see below) — calling it - // before negotiation breaks the offer (libdatachannel negotiation state). - - const offer = await pcA.createOffer(); - console.log("T1 offer"); - pcB.setRemoteDescription(offer, "offer"); - const answer = await pcB.createAnswer(offer); - console.log("T2 answer"); - pcA.setRemoteDescription(answer, "answer"); - - await new Promise((r) => setTimeout(r, 1500)); - console.log("T3 states:", pcA.state(), "/", pcB.state()); - - // Discord-style SSRC/payload: H264 101 @ 90kHz, playout ext id 5 - track.setPacketizer("h264", 0x1234, 101, 90000, 5, 0, 10); - - const { video, close } = await demux(createReadStream("/tmp/sample.h264"), { - format: "h264", - }); - console.log("video stream:", video.codecName, video.width, "x", video.height); - - const conn = { - sendVideoFrame: (frame: Buffer, frametime: number) => { - track.sendFrame(frame); - track.addTimestamp(Math.round((frametime * 90000) / 1000)); - }, - } as unknown as { sendVideoFrame(frame: Buffer, frametime: number): void }; - - const vStream = new VideoStream(conn as never); - let sent = 0; - const origSend = conn.sendVideoFrame; - conn.sendVideoFrame = (frame: Buffer, frametime: number) => { - sent++; - origSend(frame, frametime); - }; - - video.stream.pipe(vStream); - await new Promise((r) => setTimeout(r, 4000)); - - console.log(`sent ${sent} frames via VideoStream; B state=${pcB.state()}`); - const ok = sent > 0 && pcB.state() === "connected"; - close(); - pcA.close(); - pcB.close(); - process.exit(ok ? 0 : 1); -} - -main().catch((e) => { - console.error("E2E failed:", e); - process.exit(1); -}); diff --git a/services/discord-gateway/tests/screenShareInput.test.ts b/services/discord-gateway/tests/screenShareInput.test.ts deleted file mode 100644 index 04acd77..0000000 --- a/services/discord-gateway/tests/screenShareInput.test.ts +++ /dev/null @@ -1,159 +0,0 @@ -// ═══════════════════════════════════════════════════════════════════════════════ -// Screen share input resolution tests -// -// downloadScreenInput downloads the FULL merged video+audio media to a temp -// file first (yt-dlp `-o /media.%(ext)s`), then returns the file -// path. Feeding a FILE path (not a live stdout pipe) to prepareStream is -// what makes ffmpeg `-re` pacing reliable — a pipe has unreliable PTS and -// caused the ~1fps force-duplication symptom. -// -// yt-dlp is faked via a PATH shim script so the test does not hit the -// network or need real binaries. -// ═══════════════════════════════════════════════════════════════════════════════ - -import { - chmodSync, - mkdtempSync, - readFileSync, - rmSync, - writeFileSync, -} from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { afterAll, beforeAll, describe, expect, it } from "vitest"; - -import { downloadScreenInput } from "../src/modules/voice-recording/mediaSource.js"; - -// ─── fake bin dir ────────────────────────────────────────────────────────────── -let fakeBinDir: string | null = null; -const realPath = process.env.PATH; - -// Bash shim: find the -o pattern, substitute the extension, write 4096 bytes. -// Escaped as a separate string to keep the TS template literal simple. -const ytShimBody = `prev="" -out="" -for a in "$@"; do - if [ "$prev" = "-o" ]; then out="$a"; fi - prev="$a" -done -if [ "$GMW_FAKE_YTDLP_FAIL" = "1" ]; then - echo "ERROR: [youtube] ...: 403 Forbidden (access denied)" >&2 - exit 8 -fi -target=$(printf '%s' "$out" | sed 's/%(ext)s/.mp4/') -head -c 4096 /dev/urandom > "$target" -exit 0 -`; -const ytShim = `#!/usr/bin/env bash\n${ytShimBody}`; - -const ytShimDump = `#!/usr/bin/env bash -printf '%s\\n' "$*" >> "$GMW_FAKE_YTDLP_DUMP_ARGS" -${ytShimBody} -`; - -beforeAll(() => { - fakeBinDir = mkdtempSync(join(tmpdir(), "gmw-fake-bins-")); - writeFileSync(join(fakeBinDir, "yt-dlp"), ytShim); - chmodSync(join(fakeBinDir, "yt-dlp"), 0o755); - process.env.PATH = `${fakeBinDir}:${process.env.PATH}`; -}); - -afterAll(() => { - if (fakeBinDir) { - rmSync(fakeBinDir, { recursive: true, force: true }); - } - process.env.PATH = realPath; -}); - -// ─── tests ───────────────────────────────────────────────────────────────────── -describe("downloadScreenInput", () => { - it("downloads media to a temp file and returns its path", async () => { - const mediaPath = await downloadScreenInput("https://youtu.be/abc"); - expect(typeof mediaPath).toBe("string"); - expect(mediaPath).toMatch(/gmw-ytdlp-/); - const size = readFileSync(mediaPath).length; - expect(size).toBeGreaterThan(0); - // The fake yt-dlp writes 4096 bytes → the file must deliver them. - expect(size).toBe(4096); - }); - - it("rejects when yt-dlp fails (transient 403) so the controller retries", async () => { - process.env.GMW_FAKE_YTDLP_FAIL = "1"; - try { - await expect(downloadScreenInput("https://youtu.be/abc")).rejects.toThrow( - /403|exit 8|failed/i, - ); - } finally { - delete process.env.GMW_FAKE_YTDLP_FAIL; - } - }); - - it("passes a file -o pattern (NOT `-o -`) to yt-dlp", async () => { - const argsDump = join( - tmpdir(), - `gmw-ytargs-${process.pid}-${Date.now()}.txt`, - ); - process.env.GMW_FAKE_YTDLP_DUMP_ARGS = argsDump; - const realPath2 = process.env.PATH; - const dir = fakeBinDir as unknown as string; - const existing = join(dir, "yt-dlp"); - writeFileSync(existing, ytShimDump); - chmodSync(existing, 0o755); - try { - const mediaPath = await downloadScreenInput("https://youtu.be/abc"); - expect(readFileSync(mediaPath).length).toBeGreaterThan(0); - await new Promise((r) => setTimeout(r, 100)); - const args = readFileSync(argsDump, "utf8").trim(); - expect(args).not.toContain("-o -"); - expect(args).toContain("-o "); - expect(args).toMatch(/gmw-ytdlp-/); - } finally { - delete process.env.GMW_FAKE_YTDLP_DUMP_ARGS; - rmSync(argsDump, { force: true }); - process.env.PATH = realPath2; - } - }); - - it("copies the on-disk cookies to a temp file instead of handing yt-dlp the original path", async () => { - // Regression: recent yt-dlp rewrites `--cookies` file on close. If we hand - // it the original system file (root-owned, not writable by the service - // user), save-back throws PermissionError → exit 1 → screen share fails. - // The copy lives in tmpdir where the service user owns it. - const cookieDir = mkdtempSync(join(tmpdir(), "gmw-fake-cookies-")); - const cookiePath = join(cookieDir, "ytcookies.txt"); - writeFileSync( - cookiePath, - "# Netscape HTTP Cookie File\n.youtube.com\tTRUE\t/\tTRUE\t0\tLOGIN_INFO\tabc123\n", - ); - const argsDump = join( - tmpdir(), - `gmw-ytargs-${process.pid}-${Date.now()}.txt`, - ); - process.env.GMW_FAKE_YTDLP_DUMP_ARGS = argsDump; - process.env.GMW_YT_COOKIES_PATH = cookiePath; - const realPath2 = process.env.PATH; - const dir = fakeBinDir as unknown as string; - const existing = join(dir, "yt-dlp"); - writeFileSync(existing, ytShimDump); - chmodSync(existing, 0o755); - try { - await downloadScreenInput("https://youtu.be/abc"); - await new Promise((r) => setTimeout(r, 100)); - const args = readFileSync(argsDump, "utf8").trim(); - expect(args).toContain("--cookies"); - const cookieArg = args - .split(/\s+/) - .at(args.split(/\s+/).indexOf("--cookies") + 1); - expect(cookieArg).toBeDefined(); - expect(cookieArg).not.toBe(cookiePath); // never the original system file - expect(cookieArg).toMatch(/gmw-ytcookies\.\d+\.txt/); // per-process temp copy - expect(cookieArg).not.toMatch(/^\/etc\//); - } finally { - delete process.env.GMW_FAKE_YTDLP_DUMP_ARGS; - delete process.env.GMW_YT_COOKIES_PATH; - rmSync(argsDump, { force: true }); - rmSync(cookieDir, { recursive: true, force: true }); - process.env.PATH = realPath2; - } - }); -});