refactor: vendor content as regular files (remove git submodules) so CI builds don't need GitHub auth

This commit is contained in:
asepharyana
2026-07-02 02:38:04 +07:00
parent 7b106fe562
commit 134674d7dd
452 changed files with 75011 additions and 4 deletions
+48
View File
@@ -0,0 +1,48 @@
type GatewayEventGeneric<Type extends string = string, Data = unknown> = {
t: Type;
d: Data;
};
export namespace GatewayEvent {
export type VoiceStateUpdate = GatewayEventGeneric<
"VOICE_STATE_UPDATE",
{
user_id: string;
session_id: string;
}
>;
export type VoiceServerUpdate = GatewayEventGeneric<
"VOICE_SERVER_UPDATE",
{
guild_id: string;
channel_id?: string;
endpoint: string;
token: string;
}
>;
export type StreamCreate = GatewayEventGeneric<
"STREAM_CREATE",
{
stream_key: string;
rtc_server_id: string;
}
>;
export type StreamServerUpdate = GatewayEventGeneric<
"STREAM_SERVER_UPDATE",
{
stream_key: string;
endpoint: string;
token: string;
}
>;
}
export type GatewayEvent =
| GatewayEvent.VoiceStateUpdate
| GatewayEvent.VoiceServerUpdate
| GatewayEvent.StreamCreate
| GatewayEvent.StreamServerUpdate;
export type GatewayEventMap = {
[E in GatewayEvent as E["t"]]: [E["d"]];
};
@@ -0,0 +1,40 @@
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,
}
+259
View File
@@ -0,0 +1,259 @@
import { EventEmitter } from "node:events";
import { VoiceConnection } from "./voice/VoiceConnection.js";
import { StreamConnection } from "./voice/StreamConnection.js";
import { GatewayOpCodes } from "./GatewayOpCodes.js";
import type {
Client,
DMChannel,
GroupDMChannel,
VoiceBasedChannel,
} from "discord.js-selfbot-v13";
import type { GatewayEvent, GatewayEventMap } from "./GatewayEvents.js";
import type { WebRtcConnWrapper } from "./voice/WebRtcWrapper.js";
import { generateStreamKey, parseStreamKey } from "../utils.js";
export class Streamer {
private _voiceConnection?: VoiceConnection;
private _client: Client;
private _gatewayEmitter = new EventEmitter<GatewayEventMap>();
constructor(client: Client) {
this._client = client;
//listen for messages
this.client.on("raw", (packet: GatewayEvent) => {
// @ts-expect-error I don't know how to make this work with TypeScript, so whatever
this._gatewayEmitter.emit(packet.t, packet.d);
});
}
public get client(): Client {
return this._client;
}
public get opts() {
return {};
}
public get voiceConnection(): VoiceConnection | undefined {
return this._voiceConnection;
}
public sendOpcode(code: number, data: unknown): void {
this.client.ws.broadcast({
op: code,
d: data,
});
}
public joinVoiceChannel(
channel: DMChannel | GroupDMChannel | VoiceBasedChannel,
): Promise<WebRtcConnWrapper> {
let guildId: string | null = null;
if (
channel.type === "GUILD_STAGE_VOICE" ||
channel.type === "GUILD_VOICE"
) {
guildId = channel.guildId;
}
return this.joinVoice(guildId, channel.id);
}
/**
* Joins a voice channel and returns a WebRtcConnWrapper object.
* @param guild_id the guild id of the voice channel. If null, it will join a DM voice channel.
* @param channel_id the channel id of the voice channel
* @returns the WebRtcConnWrapper object
* @throws Error if the client is not logged in
*/
public joinVoice(
guild_id: string | null,
channel_id: string,
): Promise<WebRtcConnWrapper> {
return new Promise<WebRtcConnWrapper>((resolve, reject) => {
if (!this.client.user) {
reject("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) => {
if (user_id !== d.user_id) return;
voiceConn.setSession(d.session_id);
});
this._gatewayEmitter.on("VOICE_SERVER_UPDATE", (d) => {
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);
});
}
public createStream(): Promise<WebRtcConnWrapper> {
return new Promise<WebRtcConnWrapper>((resolve, reject) => {
if (!this.client.user) {
reject("Client not logged in");
return;
}
if (!this.voiceConnection) {
reject("cannot start stream without first joining voice channel");
return;
}
this.signalStream();
const {
guildId: clientGuildId,
channelId: clientChannelId,
session_id,
} = this.voiceConnection;
const { id: clientUserId } = this.client.user;
if (!session_id) throw new Error("Session doesn't exist yet");
const streamConn = new StreamConnection(
this,
clientGuildId,
clientUserId,
clientChannelId,
(conn) => {
resolve(conn);
},
);
this.voiceConnection.streamConnection = streamConn;
this._gatewayEmitter.on("STREAM_CREATE", (d) => {
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);
});
this._gatewayEmitter.on("STREAM_SERVER_UPDATE", (d) => {
const { channelId, guildId, userId } = parseStreamKey(d.stream_key);
if (
clientGuildId !== guildId ||
clientChannelId !== channelId ||
clientUserId !== userId
)
return;
streamConn.setTokens(d.endpoint, d.token);
});
});
}
public async setStreamPreview(image: Buffer): Promise<void> {
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;
const server = await this.client.guilds.fetch(guildId);
await server.members.me?.voice.postPreview(data);
}
public stopStream(): void {
const stream = this.voiceConnection?.streamConnection;
if (!stream) return;
stream.stop();
this.signalStopStream();
this.voiceConnection.streamConnection = undefined;
this._gatewayEmitter.removeAllListeners("STREAM_CREATE");
this._gatewayEmitter.removeAllListeners("STREAM_SERVER_UPDATE");
}
public leaveVoice(): void {
this.voiceConnection?.stop();
this.signalLeaveVoice();
this._voiceConnection = undefined;
this._gatewayEmitter.removeAllListeners("VOICE_STATE_UPDATE");
this._gatewayEmitter.removeAllListeners("VOICE_SERVER_UPDATE");
}
public 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,
});
}
public signalStream(): void {
if (!this.voiceConnection) return;
const {
type,
guildId: guild_id,
channelId: channel_id,
botId: user_id,
} = this.voiceConnection;
const streamKey = generateStreamKey(type, guild_id, channel_id, user_id);
this.sendOpcode(GatewayOpCodes.STREAM_CREATE, {
type,
guild_id,
channel_id,
preferred_region: null,
});
this.sendOpcode(GatewayOpCodes.STREAM_SET_PAUSED, {
stream_key: streamKey,
paused: false,
});
}
public signalStopStream(): void {
if (!this.voiceConnection) return;
const {
type,
guildId: guild_id,
channelId: channel_id,
botId: user_id,
} = this.voiceConnection;
const streamKey = generateStreamKey(type, guild_id, channel_id, user_id);
this.sendOpcode(GatewayOpCodes.STREAM_DELETE, {
stream_key: streamKey,
});
}
public signalLeaveVoice(): void {
this.sendOpcode(GatewayOpCodes.VOICE_STATE_UPDATE, {
guild_id: null,
channel_id: null,
self_mute: true,
self_deaf: false,
self_video: false,
});
}
}
+3
View File
@@ -0,0 +1,3 @@
export * from "./voice/index.js";
export * from "./GatewayOpCodes.js";
export * from "./Streamer.js";
@@ -0,0 +1,148 @@
export class AnnexBBitstreamReader {
private _buffer: Buffer;
private _byteOffset = 0;
private _bitOffset = 0;
constructor(buffer: Buffer) {
this._buffer = buffer;
}
public readBits(count: 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) {
// We're byte aligned, read whole bytes and push in
result = (result << 8) | this._buffer[this._byteOffset++];
count -= 8;
} else {
// Read just enough to get us to the next byte
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;
}
public readUnsigned(bits: number) {
return this.readBits(bits);
}
public readSigned(bits: number) {
const unsigned = this.readUnsigned(bits);
if (unsigned & (1 << (bits - 1))) return unsigned - (1 << bits);
return unsigned;
}
public readUnsignedExpGolomb() {
let leading0 = 0;
while (this.readBits(1) === 0) leading0++;
return (1 << leading0) + this.readBits(leading0) - 1;
}
public readSignedExpGolomb() {
// Mapping: x <= 0 => -2x, x > 0 => 2x - 1
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;
public toBuffer() {
return Buffer.from(this._arr);
}
public flush() {
// Write the pending byte into the array and reset, taking care of emulation prevention
if (
this._pendingByte <= 3 &&
this._arr.at(-1) === 0 &&
this._arr.at(-2) === 0
)
this._arr.push(3);
this._arr.push(this._pendingByte);
this._pendingByte = 0;
this._bitOffset = 0;
}
public writeBits(bits: number, count: number) {
while (count > 0) {
if (this._bitOffset === 0) {
if (count >= 8) {
// We're byte aligned and has more than 1 byte left to write, write a whole byte
this._pendingByte = (bits >> (count - 8)) & 0xff;
count -= 8;
this.flush();
} else {
// We have less than 1 byte, write the rest in
const mask = (1 << count) - 1;
this._pendingByte |= (bits & mask) << (8 - count);
this._bitOffset = count;
count = 0;
}
} else {
// Write the minimum number of bits to get us byte aligned again
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();
}
}
}
}
public writeUnsigned(num: number, count: number) {
if (num < 0) throw new Error("Expected a non-negative number");
this.writeBits(num, count);
}
public writeSigned(num: number, count: number) {
if (count <= 0) return;
if (count > 32) throw new Error("writeSigned supports up to 32 bits");
// Build mask for `count` bits. Handle 32-bit as a special case.
const mask =
count === 32 ? 0xffffffff >>> 0 : (((1 << count) >>> 0) - 1) >>> 0;
// Convert to two's-complement unsigned representation and write
const unsigned = (num & mask) >>> 0;
this.writeBits(unsigned, count);
}
public writeUnsignedExpGolomb(num: number) {
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);
}
public writeSignedExpGolomb(num: number) {
if (num < 0) this.writeUnsignedExpGolomb(-2 * num);
else this.writeUnsignedExpGolomb(2 * num - 1);
}
}
@@ -0,0 +1,134 @@
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,
RSV_NVCL41 = 41,
RSV_NVCL42 = 42,
RSV_NVCL43 = 43,
RSV_NVCL44 = 44,
RSV_NVCL45 = 45,
RSV_NVCL46 = 46,
RSV_NVCL47 = 47,
UNSPEC48 = 48,
UNSPEC49 = 49,
UNSPEC50 = 50,
UNSPEC51 = 51,
UNSPEC52 = 52,
UNSPEC53 = 53,
UNSPEC54 = 54,
UNSPEC55 = 55,
UNSPEC56 = 56,
UNSPEC57 = 57,
UNSPEC58 = 58,
UNSPEC59 = 59,
UNSPEC60 = 60,
UNSPEC61 = 61,
UNSPEC62 = 62,
UNSPEC63 = 63,
}
export interface AnnexBHelpers {
getUnitType(frame: Buffer): number;
splitHeader(frame: Buffer): [Buffer, Buffer];
isAUD(unitType: number): boolean;
}
export const H264Helpers: AnnexBHelpers = {
getUnitType(frame) {
return frame[0] & 0x1f;
},
splitHeader(frame) {
return [frame.subarray(0, 1), frame.subarray(1)];
},
isAUD(unitType) {
return unitType === H264NalUnitTypes.AccessUnitDelimiter;
},
};
export const H265Helpers: AnnexBHelpers = {
getUnitType(frame) {
return (frame[0] >> 1) & 0x3f;
},
splitHeader(frame) {
return [frame.subarray(0, 2), frame.subarray(2)];
},
isAUD(unitType) {
return unitType === H265NalUnitTypes.AUD_NUT;
},
};
export const startCode3 = Buffer.from([0, 0, 1]);
export function splitNalu(buf: 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;
}
@@ -0,0 +1,332 @@
import {
AnnexBBitstreamReader,
AnnexBBitstreamWriter,
} from "./AnnexBBitstreamReaderWriter.js";
export function rewriteSPSVUI(buffer: 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;
// scaling_list(size)
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: ue(v)
// log2_max_mv_length_vertical: ue(v)
// Both default to 16 when not present.
writeUE(16);
writeUE(16);
// ********* IMPORTANT! **********
// max_num_reorder_frames: ue(v)
writeUE(0);
// max_dec_frame_buffering: ue(v)
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).
// Just write 0 here because I'm not gonna bother myself with color space and whatnot
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) {
// Extended_SAR
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) {
const _video_format = readBit(3);
// writeBit(video_format, 3);
const _video_full_range_flag = readBit(1);
// writeBit(video_full_range_flag, 1);
const colour_description_present_flag = readBit(1);
// writeBit(colour_description_present_flag, 1);
if (colour_description_present_flag) {
const _colour_primaries = readU(8);
// writeU(colour_primaries, 8);
const _transfer_characteristics = readU(8);
// writeU(transfer_characteristics, 8);
const _matrix_coeffs = readU(8);
// writeU(matrix_coeffs, 8);
}
}
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);
const _num_reorder_frames = readUE();
writeUE(0);
const _max_dec_frame_buffering = readUE();
writeUE(max_num_ref_frames);
}
}
writeBit(1, 1); // rbsp_stop_one_bit
writer.flush();
// return the rewritten RBSP as a buffer
return writer.toBuffer();
}
@@ -0,0 +1,640 @@
import Davey from "@snazzah/davey";
import EventEmitter from "node:events";
import { Log } from "debug-level";
import { randomUUID } from "node:crypto";
import { CodecPayloadType } from "./CodecPayloadType.js";
import { WebRtcConnWrapper } from "./WebRtcWrapper.js";
import { VoiceOpCodes, VoiceOpCodesBinary } from "./VoiceOpCodes.js";
import {
STREAMS_SIMULCAST,
type SupportedEncryptionModes,
} from "../../utils.js";
import type {
Message,
GatewayRequest,
GatewayResponse,
} from "./VoiceMessageTypes.js";
import type { Streamer } from "../Streamer.js";
type VoiceConnectionStatus = {
hasSession: boolean;
hasToken: boolean;
started: boolean;
resuming: boolean;
};
type WebRtcParameters = {
address: string;
port: number;
audioSsrc: number;
videoSsrc: number;
rtxSsrc: number;
supportedEncryptionModes: SupportedEncryptionModes[];
};
type ValueOf<T> = T extends (infer U)[]
? U
: T extends Record<string, infer U>
? U
: never;
export type VideoAttributes = {
width: number;
height: number;
fps: number;
};
export abstract class BaseMediaConnection extends EventEmitter {
private interval: NodeJS.Timeout | null = null;
public guildId: string | null = null;
public channelId: string;
public botId: string;
public ws: WebSocket | null = null;
public status: VoiceConnectionStatus;
public server: string | null = null; //websocket url
public token: string | null = null;
public session_id: string | null = null;
private _webRtcWrapper;
private _webRtcParams: WebRtcParameters | null = null;
private _closed = false;
public ready: (conn: WebRtcConnWrapper) => void;
private _streamer: Streamer;
private _sequenceNumber = -1;
private _daveSession: Davey.DaveSession | undefined;
private _connectedUsers = new Set<string>();
private _daveProtocolVersion = 0;
private _davePendingTransitions = new Map<number, number>();
private _daveDowngraded = false;
private _logger = new Log("conn");
private _loggerDave = new Log("conn:dave");
constructor(
streamer: Streamer,
guildId: string | null,
botId: string,
channelId: string,
callback: (conn: WebRtcConnWrapper) => void,
) {
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);
}
public abstract get serverId(): string | null;
public get type(): "guild" | "call" {
return this.guildId ? "guild" : "call";
}
public get webRtcConn() {
return this._webRtcWrapper;
}
public get webRtcParams() {
return this._webRtcParams;
}
public get streamer() {
return this._streamer;
}
public abstract get daveChannelId(): string;
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 {
/*
** Connection can only start once both
** session description and tokens have been gathered
*/
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 === 4_015 || e.code < 4_000;
if (canResume && wasStarted) {
this.status.resuming = true;
this.start();
} else {
this._closed = true;
this._webRtcWrapper?.close();
}
});
this.setupEvents();
}
}
handleReady(d: Message.Ready): void {
// we hardcoded the STREAMS_SIMULCAST, which will always be array of 1
const stream = d.streams[0];
this._webRtcParams = {
address: d.ip,
port: d.port,
audioSsrc: d.ssrc,
videoSsrc: stream.ssrc,
rtxSsrc: stream.rtx_ssrc,
supportedEncryptionModes: d.modes,
};
}
async handleProtocolAck(d: Message.SelectProtocolAck) {
if (!("sdp" in d)) throw new Error("Only WebRTC connections are allowed");
this._daveProtocolVersion = d.dave_protocol_version;
this.initDave();
// Discord's SDP is absolute garbage...Generate one ourselves
let ip = "",
port = "",
iceUsername = "",
icePassword = "",
fingerprint = "",
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,
]);
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}`,
`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");
this._webRtcWrapper.webRtcConn?.setRemoteDescription(
[audioSection, videoSection, videoRtpMap].join("\n"),
"answer",
);
this.emit("select_protocol_ack");
}
initDave() {
if (this._daveProtocolVersion) {
if (this._daveSession) {
this._daveSession.reinit(
this._daveProtocolVersion,
this.botId,
this.daveChannelId,
);
this._loggerDave.debug(`Reinitialized DAVE`, {
user_id: this.botId,
channel_id: this.daveChannelId,
});
} else {
this._daveSession = new Davey.DAVESession(
this._daveProtocolVersion,
this.botId,
this.daveChannelId,
);
this._loggerDave.debug(`Initialized DAVE`, {
user_id: this.botId,
channel_id: 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) {
this._loggerDave.debug("Invalid commit received, reinitializing DAVE", {
transitionId,
});
this.sendOpcode(VoiceOpCodes.MLS_INVALID_COMMIT_WELCOME, {
transition_id: transitionId,
});
this.initDave();
}
executePendingTransition(transitionId: number) {
const newVersion = this._davePendingTransitions.get(transitionId);
if (newVersion === undefined) {
this._loggerDave.error("Unrecognized transition ID", { transitionId });
return;
}
const oldVersion = this._daveProtocolVersion;
this._daveProtocolVersion = newVersion;
if (oldVersion !== newVersion && newVersion === 0) {
// Downgraded
this._daveDowngraded = true;
this._loggerDave.debug("Downgraded to non-E2E voice call");
} else if (transitionId > 0 && this._daveDowngraded) {
this._daveDowngraded = false;
this._daveSession?.setPassthroughMode(true, 10);
this._loggerDave.debug("Upgraded to E2E voice call");
}
this._davePendingTransitions.delete(transitionId);
this._loggerDave.debug(`Pending transition ID ${transitionId} executed`, {
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 GatewayResponse;
if (seq) this._sequenceNumber = seq;
if (op === VoiceOpCodes.READY) {
// ready
this.handleReady(d);
this.setProtocols().then(() => this.ready(this._webRtcWrapper));
this.setVideoAttributes(false);
} else if (op >= 4000) {
console.error(`Error ${this.constructor.name} connection`, d);
} else if (op === VoiceOpCodes.HELLO) {
this.setupHeartbeat(d.heartbeat_interval);
} else if (op === VoiceOpCodes.SELECT_PROTOCOL_ACK) {
// session description
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) => {
this._connectedUsers.add(id);
});
} else if (op === VoiceOpCodes.CLIENT_DISCONNECT) {
this._connectedUsers.delete(d.user_id);
} else if (op === VoiceOpCodes.DAVE_PREPARE_TRANSITION) {
this._loggerDave.debug("Preparing for DAVE transition", d);
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) {
this._loggerDave.debug("Preparing for DAVE epoch", d);
if (d.epoch === 1) {
this._daveProtocolVersion = d.protocol_version;
this.initDave();
}
} else {
//console.log("unhandled voice event", {op, d});
}
});
}
handleBinaryMessages(msg: Buffer) {
this._sequenceNumber = msg.readUint16BE(0);
const op = msg.readUint8(2);
this._logger.trace(`Handling binary message with op ${op}`, { op });
switch (op) {
case VoiceOpCodesBinary.MLS_EXTERNAL_SENDER: {
this._daveSession?.setExternalSender(msg.subarray(3));
this._loggerDave.debug("Set MLS external sender");
break;
}
case VoiceOpCodesBinary.MLS_PROPOSALS: {
const optype = msg.readUint8(3);
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,
);
}
this._loggerDave.debug("Processed MLS proposal");
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,
});
}
this._loggerDave.debug("MLS commit processed", { transitionId });
} catch (e) {
this._loggerDave.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,
});
}
this._loggerDave.debug("MLS welcome processed", { transitionId });
} catch (e) {
this._loggerDave.debug("MLS welcome errored", e);
this.processInvalidCommit(transitionId);
}
break;
}
}
}
public get daveReady() {
return this._daveProtocolVersion && this._daveSession?.ready;
}
public get daveSession() {
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 {}
}, interval);
}
sendOpcode<T extends GatewayRequest>(code: T["op"], data: T["d"]): void {
if (this.ws?.readyState !== WebSocket.OPEN) return;
this.ws.send(
JSON.stringify({
op: code,
d: data,
}),
);
}
sendOpcodeBinary(code: VoiceOpCodesBinary, data: Buffer) {
if (this.ws?.readyState !== WebSocket.OPEN) return;
const buf = Buffer.allocUnsafe(data.length + 1);
buf.writeUInt8(code);
data.copy(buf, 1);
this.ws.send(buf);
}
/*
** 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.
** Uses vp8 for video
** Uses opus for audio
*/
public async setProtocols(): Promise<void> {
if (!this._webRtcParams) throw new Error("WebRTC parameters not set");
// if (
// this._webRtcParams.supportedEncryptionModes.includes(SupportedEncryptionModes.AES256) &&
// !this._streamer.opts.forceChacha20Encryption
// ) {
// encryptionMode = SupportedEncryptionModes.AES256
// } else {
// encryptionMode = SupportedEncryptionModes.XCHACHA20
// }
const reconnect = () => {
const webRtcConn = this._webRtcWrapper.initWebRtc();
webRtcConn.onStateChange((state) => {
if (state === "closed" && !this._closed) reconnect();
});
webRtcConn.onLocalDescription((sdp) => {
const rtc_connection_id = randomUUID();
this.sendOpcode(VoiceOpCodes.SELECT_PROTOCOL, {
protocol: "webrtc",
codecs: Object.values(CodecPayloadType) as ValueOf<
typeof CodecPayloadType
>[],
data: sdp,
sdp: sdp,
rtc_connection_id,
});
});
webRtcConn.setLocalDescription();
};
reconnect();
return new Promise((resolve) => {
this.once("select_protocol_ack", () => resolve());
});
}
/*
* Sets video attributes (width, height, frame rate).
* enabled -> video on or off
* attr -> video attributes
* video and rtx sources are set to ssrc + 1 and ssrc + 2
*/
public setVideoAttributes(enabled: false): void;
public setVideoAttributes(enabled: true, attr: VideoAttributes): void;
public setVideoAttributes(enabled: boolean, attr?: VideoAttributes): void {
if (!this._webRtcParams) throw new Error("WebRTC parameters not set");
const { audioSsrc, videoSsrc, rtxSsrc } = this._webRtcParams;
if (!enabled) {
this.sendOpcode(VoiceOpCodes.VIDEO, {
audio_ssrc: audioSsrc,
video_ssrc: 0,
rtx_ssrc: 0,
streams: [],
});
} else {
if (!attr) throw new Error("Need to specify video attributes");
this.sendOpcode(VoiceOpCodes.VIDEO, {
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,
},
},
],
});
}
}
/*
** Set speaking status
** speaking -> speaking status on or off
*/
public setSpeaking(speaking: boolean): void {
if (!this._webRtcParams) throw new Error("WebRTC connection not ready");
this.sendOpcode(VoiceOpCodes.SPEAKING, {
delay: 0,
speaking: speaking ? 1 : 0,
ssrc: this._webRtcParams.audioSsrc,
});
}
}
@@ -0,0 +1,59 @@
export const CodecPayloadType = {
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,
},
} as const;
@@ -0,0 +1,171 @@
import udpCon from 'node:dgram';
import { isIP } from 'node:net';
import { AudioPacketizer } from '../packet/AudioPacketizer.js';
import {
VideoPacketizerH264,
VideoPacketizerH265
} from '../packet/VideoPacketizerAnnexB.js';
import { VideoPacketizerVP8 } from '../packet/VideoPacketizerVP8.js';
import { normalizeVideoCodec } from '../../utils.js';
import type { BaseMediaPacketizer } from '../packet/BaseMediaPacketizer.js';
import type { BaseMediaConnection } from './BaseMediaConnection.js';
// credit to discord.js
function parseLocalPacket(message: Buffer) {
const packet = Buffer.from(message);
const ip = packet.subarray(8, packet.indexOf(0, 8)).toString('utf8');
if (!isIP(ip)) {
throw new Error('Malformed IP address');
}
const port = packet.readUInt16BE(packet.length - 2);
return { ip, port };
}
export class MediaUdp {
private _mediaConnection: BaseMediaConnection;
private _socket: udpCon.Socket | null = null;
private _ready = false;
private _audioPacketizer?: BaseMediaPacketizer;
private _videoPacketizer?: BaseMediaPacketizer;
private _ip?: string;
private _port?: number;
constructor(voiceConnection: BaseMediaConnection) {
this._mediaConnection = voiceConnection;
}
public get audioPacketizer(): BaseMediaPacketizer | undefined {
return this._audioPacketizer;
}
public get videoPacketizer(): BaseMediaPacketizer | undefined {
// This will never be undefined anyway, so it's safe
return this._videoPacketizer;
}
public get mediaConnection(): BaseMediaConnection {
return this._mediaConnection;
}
public get ip()
{
return this._ip;
}
public get port()
{
return this._port;
}
public async sendAudioFrame(frame: Buffer, frametime: number): Promise<void> {
if(!this.ready) return;
await this.audioPacketizer?.sendFrame(frame, frametime);
}
public async sendVideoFrame(frame: Buffer, frametime: number): Promise<void> {
if(!this.ready) return;
await this.videoPacketizer?.sendFrame(frame, frametime);
}
public setPacketizer(videoCodec: string): void {
if (!this.mediaConnection.webRtcParams)
throw new Error("WebRTC connection not ready");
const { audioSsrc, videoSsrc } = this.mediaConnection.webRtcParams;
this._audioPacketizer = new AudioPacketizer(this, audioSsrc);
switch (normalizeVideoCodec(videoCodec))
{
case "H264":
this._videoPacketizer = new VideoPacketizerH264(this, videoSsrc);
break;
case "H265":
this._videoPacketizer = new VideoPacketizerH265(this, videoSsrc);
break;
case "VP8":
this._videoPacketizer = new VideoPacketizerVP8(this, videoSsrc);
break;
default:
throw new Error(`Packetizer not implemented for ${videoCodec}`)
}
}
public sendPacket(packet: Buffer): Promise<void> {
if (!this.mediaConnection.webRtcParams)
throw new Error("WebRTC connection not ready");
const { address, port } = this.mediaConnection.webRtcParams;
return new Promise<void>((resolve, reject) => {
try {
this._socket?.send(packet, 0, packet.length, port, address, (error, bytes) => {
if (error) {
console.log("ERROR", error);
reject(error);
}
resolve();
});
} catch(e) {reject(e)}
});
}
handleIncoming(buf: unknown): void {
//console.log("RECEIVED PACKET", buf);
}
public get ready(): boolean {
return this._ready;
}
public set ready(val: boolean) {
this._ready = val;
}
public stop(): void {
try {
this.ready = false;
this._socket?.disconnect();
}catch(e) {}
}
public createUdp(): Promise<void> {
if (!this.mediaConnection.webRtcParams)
throw new Error("WebRTC connection not ready");
const { audioSsrc, address, port } = this.mediaConnection.webRtcParams;
return new Promise<void>((resolve, reject) => {
this._socket = udpCon.createSocket('udp4');
this._socket.on('error', (error: Error) => {
console.error("Error connecting to media udp server", error);
reject(error);
});
this._socket.once('message', (message) => {
if (message.readUInt16BE(0) !== 2) {
reject('wrong handshake packet for udp')
}
try {
const packet = parseLocalPacket(message);
this._ip = packet.ip;
this._port = packet.port;
this._ready = true;
} catch(e) { reject(e) }
resolve();
this._socket?.on('message', this.handleIncoming);
});
const blank = Buffer.alloc(74);
blank.writeUInt16BE(1, 0);
blank.writeUInt16BE(70, 2);
blank.writeUInt32BE(audioSsrc, 4);
this._socket.send(blank, 0, blank.length, port, address, (error, bytes) => {
if (error) {
reject(error)
}
});
});
}
}
@@ -0,0 +1,38 @@
import { VoiceOpCodes } from "../voice/VoiceOpCodes.js";
import { BaseMediaConnection } from "./BaseMediaConnection.js";
export class StreamConnection extends BaseMediaConnection {
private _streamKey: string | null = null;
private _serverId: string | null = null;
public override 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,
});
}
public override get daveChannelId() {
if (this._serverId === null)
throw new Error("Server ID not set (this shouldn't happen)");
const channelId = BigInt(this._serverId) - 1n;
return channelId.toString();
}
public override get serverId(): string | null {
return this._serverId;
}
public set serverId(id: string) {
this._serverId = id;
}
public get streamKey(): string | null {
return this._streamKey;
}
public set streamKey(value: string) {
this._streamKey = value;
}
}
@@ -0,0 +1,19 @@
import { BaseMediaConnection } from "./BaseMediaConnection.js";
import type { StreamConnection } from "./StreamConnection.js";
export class VoiceConnection extends BaseMediaConnection {
public streamConnection?: StreamConnection;
public override get daveChannelId() {
return this.channelId;
}
public override get serverId(): string {
return this.guildId ?? this.channelId; // for guild vc it is the guild id, for dm voice it is the channel id
}
public override stop(): void {
super.stop();
this.streamConnection?.stop();
}
}
@@ -0,0 +1,263 @@
import type { VoiceOpCodes } from "./VoiceOpCodes.js";
import type { SupportedEncryptionModes } from "../../utils.js";
type StreamInfo = {
active: boolean;
quality: number;
rid: string;
ssrc: number;
rtx_ssrc: number;
/**
* always "video" from what I observed
*/
type: string;
};
type SimulcastInfo = {
type: string;
rid: string;
quality: number;
};
type CodecPayloadType =
| {
name: string;
type: "audio";
priority: number;
payload_type: number;
}
| {
name: string;
type: "video";
priority: number;
payload_type: number;
rtx_payload_type: number;
encode: boolean;
decode: boolean;
};
export namespace Message {
// Request messages
export type Identify = {
server_id: string;
user_id: string;
session_id: string;
token: string;
video: boolean;
streams: SimulcastInfo[];
max_dave_protocol_version?: number;
};
export type Resume = {
server_id: string;
session_id: string;
token: string;
seq_ack: number;
};
export type Heartbeat = {
t: number;
seq_ack?: number;
};
export type SelectProtocol =
| {
protocol: "udp";
codecs: CodecPayloadType[];
data: {
address: string;
port: number;
mode: SupportedEncryptionModes;
};
}
| {
protocol: "webrtc";
codecs: CodecPayloadType[];
data: string;
sdp: string;
rtc_connection_id: string;
};
export type Video = {
audio_ssrc: number;
video_ssrc: number;
rtx_ssrc: number;
streams: {
type: "video";
rid: string;
ssrc: number;
active: boolean;
quality: number;
rtx_ssrc: number;
max_bitrate: number;
max_framerate: number;
max_resolution: {
type: "fixed";
width: number;
height: number;
};
}[];
};
// Response messages
export type Hello = {
heartbeat_interval: number;
};
export type Ready = {
ssrc: number;
ip: string;
port: number;
modes: SupportedEncryptionModes[];
experiments: string[];
streams: StreamInfo[];
};
export type Speaking = {
speaking: 0 | 1 | 2;
delay: number;
ssrc: number;
};
export type SelectProtocolAck = {
audio_codec: string;
video_codec: string;
dave_protocol_version: number;
} & (
| {
secret_key: number[];
mode: string;
}
| {
media_session_id: number;
sdp: string;
}
);
export type HeartbeatAck = {
t: number;
};
export type ClientsConnect = {
user_ids: string[];
};
export type ClientDisconnect = {
user_id: string;
};
export type DavePrepareTransition = {
transition_id: number;
protocol_version: number;
};
export type DaveExecuteTransition = {
transition_id: number;
};
export type DaveTransitionReady = {
transition_id: number;
};
export type DavePrepareEpoch = {
epoch: number;
protocol_version: number;
};
export type MlsInvalidCommitWelcome = {
transition_id: number;
};
}
export namespace GatewayResponse {
type Generic<
Op extends VoiceOpCodes,
T extends Record<string, unknown> | null,
> = {
op: Op;
d: T;
seq?: number;
};
export type Hello = Generic<VoiceOpCodes.HELLO, Message.Hello>;
export type Ready = Generic<VoiceOpCodes.READY, Message.Ready>;
export type Resumed = Generic<VoiceOpCodes.RESUMED, null>;
export type Speaking = Generic<VoiceOpCodes.SPEAKING, Message.Speaking>;
export type SelectProtocolAck = Generic<
VoiceOpCodes.SELECT_PROTOCOL_ACK,
Message.SelectProtocolAck
>;
export type HeartbeatAck = Generic<
VoiceOpCodes.HEARTBEAT_ACK,
Message.HeartbeatAck
>;
export type ClientsConnect = Generic<
VoiceOpCodes.CLIENTS_CONNECT,
Message.ClientsConnect
>;
export type ClientDisconnect = Generic<
VoiceOpCodes.CLIENT_DISCONNECT,
Message.ClientDisconnect
>;
export type DavePrepareTransition = Generic<
VoiceOpCodes.DAVE_PREPARE_TRANSITION,
Message.DavePrepareTransition
>;
export type DaveExecuteTransition = Generic<
VoiceOpCodes.DAVE_EXECUTE_TRANSITION,
Message.DaveExecuteTransition
>;
export type DavePrepareEpoch = Generic<
VoiceOpCodes.DAVE_PREPARE_EPOCH,
Message.DavePrepareEpoch
>;
}
export type GatewayResponse =
| GatewayResponse.Hello
| GatewayResponse.Ready
| GatewayResponse.Resumed
| GatewayResponse.Speaking
| GatewayResponse.SelectProtocolAck
| GatewayResponse.HeartbeatAck
| GatewayResponse.ClientsConnect
| GatewayResponse.ClientDisconnect
| GatewayResponse.DavePrepareTransition
| GatewayResponse.DaveExecuteTransition
| GatewayResponse.DavePrepareEpoch;
export namespace GatewayRequest {
type Generic<
Op extends VoiceOpCodes,
T extends Record<string, unknown> | null,
> = {
op: Op;
d: T;
};
export type Identify = Generic<VoiceOpCodes.IDENTIFY, Message.Identify>;
export type Resume = Generic<VoiceOpCodes.RESUME, Message.Resume>;
export type Heartbeat = Generic<VoiceOpCodes.HEARTBEAT, Message.Heartbeat>;
export type SelectProtocol = Generic<
VoiceOpCodes.SELECT_PROTOCOL,
Message.SelectProtocol
>;
export type Video = Generic<VoiceOpCodes.VIDEO, Message.Video>;
export type Speaking = Generic<VoiceOpCodes.SPEAKING, Message.Speaking>;
export type DaveTransitionReady = Generic<
VoiceOpCodes.DAVE_TRANSITION_READY,
Message.DaveTransitionReady
>;
export type MlsInvalidCommitWelcome = Generic<
VoiceOpCodes.MLS_INVALID_COMMIT_WELCOME,
Message.MlsInvalidCommitWelcome
>;
}
export type GatewayRequest =
| GatewayRequest.Identify
| GatewayRequest.Resume
| GatewayRequest.Heartbeat
| GatewayRequest.SelectProtocol
| GatewayRequest.Video
| GatewayRequest.Speaking
| GatewayRequest.DaveTransitionReady
| GatewayRequest.MlsInvalidCommitWelcome;
@@ -0,0 +1,36 @@
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,
}
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,
}
@@ -0,0 +1,213 @@
import {
PeerConnection,
Audio,
Video,
PacingHandler,
RtpPacketizer,
H264RtpPacketizer,
H265RtpPacketizer,
AV1RtpPacketizer,
RtpPacketizationConfig,
RtcpNackResponder,
RtcpSrReporter,
type Track,
} from "@lng2004/node-datachannel";
import { Codec, MediaType } from "@snazzah/davey";
import { CodecPayloadType } from "./CodecPayloadType.js";
import { normalizeVideoCodec, type SupportedVideoCodec } from "../../utils.js";
import {
splitNalu,
H264Helpers,
H264NalUnitTypes,
startCode3,
} from "../processing/AnnexBHelper.js";
import { rewriteSPSVUI } from "../processing/SPSVUIRewriter.js";
import type { BaseMediaConnection } from "./BaseMediaConnection.js";
export class WebRtcConnWrapper {
private _mediaConn: BaseMediaConnection;
private _webRtcConn?: PeerConnection;
private _audioDef: Audio;
private _videoDef: Video;
private _audioTrack?: Track;
private _videoTrack?: Track;
private _audioPacketizer?: RtpPacketizer;
private _videoPacketizer?: RtpPacketizer;
private _videoCodec?: SupportedVideoCodec;
constructor(mediaConn: BaseMediaConnection) {
this._mediaConn = mediaConn;
this._audioDef = new Audio("0", "SendRecv");
this._videoDef = new Video("1", "SendRecv");
this._audioDef.addOpusCodec(CodecPayloadType.opus.payload_type);
for (const {
name,
payload_type,
rtx_payload_type,
clockRate,
} of Object.values(CodecPayloadType).filter((el) => el.type === "video")) {
switch (name) {
case "H264":
this._videoDef.addH264Codec(payload_type);
break;
case "H265":
this._videoDef.addH265Codec(payload_type);
break;
case "VP8":
this._videoDef.addVP8Codec(payload_type);
break;
case "VP9":
this._videoDef.addVP9Codec(payload_type);
break;
case "AV1":
this._videoDef.addAV1Codec(payload_type);
break;
}
this._videoDef.addRTXCodec(rtx_payload_type, payload_type, clockRate);
}
}
public initWebRtc() {
this._webRtcConn = new PeerConnection("", {
iceServers: ["stun:stun.l.google.com:19302"],
});
this._audioTrack = this._webRtcConn.addTrack(this._audioDef);
this._videoTrack = this._webRtcConn.addTrack(this._videoDef);
this._setMediaHandler();
return this._webRtcConn;
}
private _setMediaHandler() {
if (this._audioPacketizer)
this._audioTrack?.setMediaHandler(this._audioPacketizer);
if (this._videoPacketizer)
this._videoTrack?.setMediaHandler(this._videoPacketizer);
}
public close() {
this._webRtcConn?.close();
}
public get webRtcConn() {
return this._webRtcConn;
}
public get ready() {
return this._webRtcConn?.state() === "connected";
}
public get mediaConnection() {
return this._mediaConn;
}
public sendAudioFrame(frame: Buffer, frametime: number) {
if (!this.ready) return;
if (!this._audioPacketizer) return;
const { rtpConfig } = this._audioPacketizer;
const { clockRate } = rtpConfig;
if (this.mediaConnection.daveReady)
frame = this.mediaConnection.daveSession!.encryptOpus(frame);
this._audioTrack?.sendMessageBinary(frame);
rtpConfig.timestamp += Math.round((frametime * clockRate) / 1000);
}
public sendVideoFrame(frame: Buffer, frametime: number) {
if (!this.ready) return;
if (!this._videoPacketizer) return;
const { rtpConfig } = this._videoPacketizer;
const { clockRate } = rtpConfig;
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) {
let daveCodec = Codec.UNKNOWN;
switch (this._videoCodec) {
case "H264":
daveCodec = Codec.H264;
break;
case "H265":
daveCodec = Codec.H265;
break;
case "VP8":
daveCodec = Codec.VP8;
break;
case "VP9":
daveCodec = Codec.VP9;
break;
case "AV1":
daveCodec = Codec.AV1;
break;
}
frame = this.mediaConnection.daveSession!.encrypt(
MediaType.VIDEO,
daveCodec,
frame,
);
}
this._videoTrack?.sendMessageBinary(frame);
rtpConfig.timestamp += Math.round((frametime * clockRate) / 1000);
}
public setPacketizer(videoCodec: string): void {
if (!this.mediaConnection.webRtcParams)
throw new Error("WebRTC connection not ready");
const { audioSsrc, videoSsrc } = this.mediaConnection.webRtcParams;
const rtpConfigAudio = new RtpPacketizationConfig(
audioSsrc,
"",
CodecPayloadType.opus.payload_type,
CodecPayloadType.opus.clockRate,
);
rtpConfigAudio.playoutDelayId = 5;
rtpConfigAudio.playoutDelayMin = 0;
rtpConfigAudio.playoutDelayMax = 1;
this._audioPacketizer = new RtpPacketizer(rtpConfigAudio);
this._audioPacketizer.addToChain(new RtcpSrReporter(rtpConfigAudio));
this._audioPacketizer.addToChain(new RtcpNackResponder());
this._videoCodec = normalizeVideoCodec(videoCodec);
const rtpConfigVideo = new RtpPacketizationConfig(
videoSsrc,
"",
CodecPayloadType[this._videoCodec].payload_type,
CodecPayloadType[this._videoCodec].clockRate,
);
rtpConfigVideo.playoutDelayId = 5;
rtpConfigVideo.playoutDelayMin = 0;
rtpConfigVideo.playoutDelayMax = 10;
switch (this._videoCodec) {
case "H264":
this._videoPacketizer = new H264RtpPacketizer(
"StartSequence",
rtpConfigVideo,
);
break;
case "H265":
this._videoPacketizer = new H265RtpPacketizer(
"StartSequence",
rtpConfigVideo,
);
break;
case "AV1":
this._videoPacketizer = new AV1RtpPacketizer("Obu", rtpConfigVideo);
break;
default:
throw new Error(`Packetizer not implemented for ${this._videoCodec}`);
}
this._videoPacketizer.addToChain(new RtcpSrReporter(rtpConfigVideo));
this._videoPacketizer.addToChain(new RtcpNackResponder());
this._videoPacketizer.addToChain(new PacingHandler(25 * 1000 * 1000, 1));
this._setMediaHandler();
}
}
+5
View File
@@ -0,0 +1,5 @@
export * from "./VoiceConnection.js";
export * from "./VoiceOpCodes.js";
// export * from './MediaUdp.js';
export * from "./StreamConnection.js";
export * from "./BaseMediaConnection.js";