perf(golive/spike): binding addTrack + TS port of @dank074 media stack
Phase 1 spike: replace @dank074/discord-video-stream + node-datachannel +
node-av (1.3GB) with minimal libdatachannel N-API binding + native RTP
packetizers (H264 FU-A, RTCP SR/NACK, pacer) + pure-TS GoLive stack.
Binding v0.4: addTrack (m=audio/video SDP), TrackWrap w/ setPacketizer +
sendFrame (raw RTP to transport) + addTimestamp — verified by two-peer
handshake emitting SDP with audio(opus 120)+video(H264 101) and 8-frame
RTP roundtrip.
TS layer (src/goLive/, 21 files): CodecPayloadType, VoiceOpCodes,
GatewayOpCodes, utils, BaseMediaConnection (voice WS + DAVE + heartbeat),
VoiceConnection, StreamConnection, Streamer, WebRtcWrapper (SDP mungling,
DAVE encrypt, packetizer chain), BaseMediaStream (pacing/sync), VideoStream,
AudioStream, Demuxer (ffmpeg-spawn NUT/AnnexB, no node-av 114M binary),
Encoders, prepareStream/playStream.
Integration: screenShareController.ts now imports from ../../goLive/index.js —
prepareStream(prepared, ...) + playStream(prepared, streamer, {...}).
Tests: tests/goLive-port.test.ts (8/8 pass). tsc --noEmit clean. biome clean.
This commit is contained in:
@@ -0,0 +1,205 @@
|
||||
/**
|
||||
* 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;
|
||||
/** 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) 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));
|
||||
}
|
||||
|
||||
setPacketizer(videoCodec: string): void {
|
||||
if (!this.mediaConnection.webRtcParams) {
|
||||
throw new Error("WebRTC connection not ready");
|
||||
}
|
||||
const { audioSsrc, videoSsrc } = this.mediaConnection.webRtcParams;
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user