diff --git a/src/client/Streamer.ts b/src/client/Streamer.ts index 0a75dbc..a86ec62 100644 --- a/src/client/Streamer.ts +++ b/src/client/Streamer.ts @@ -46,11 +46,11 @@ export class Streamer { this.client.user.id, channel_id, options ?? {}, - (voiceUdp) => { - resolve(voiceUdp); + (udp) => { + udp.mediaConnection.setProtocols().then(() => resolve(udp)) } ); - this.signalVideo(guild_id, channel_id, false); + this.signalVideo(false); }); } @@ -67,18 +67,15 @@ export class Streamer { return; } - this.signalStream( - this.voiceConnection.guildId, - this.voiceConnection.channelId - ); + this.signalStream(); this.voiceConnection.streamConnection = new StreamConnection( this.voiceConnection.guildId, this.client.user.id, this.voiceConnection.channelId, options ?? {}, - (voiceUdp) => { - resolve(voiceUdp); + (udp) => { + udp.mediaConnection.setProtocols().then(() => resolve(udp)) } ); }); @@ -91,7 +88,7 @@ export class Streamer { stream.stop(); - this.signalStopStream(stream.guildId, stream.channelId); + this.signalStopStream(); this.voiceConnection.streamConnection = undefined; } @@ -104,7 +101,13 @@ export class Streamer { this._voiceConnection = undefined; } - public signalVideo(guild_id: string, channel_id: string, video_enabled: boolean): void { + 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, channel_id, @@ -114,7 +117,13 @@ export class Streamer { }); } - public signalStream(guild_id: string, channel_id: string): void { + public signalStream(): void { + if (!this.voiceConnection) + return; + const { + guildId: guild_id, + channelId: channel_id + } = this.voiceConnection; this.sendOpcode(GatewayOpCodes.STREAM_CREATE, { type: "guild", guild_id, @@ -128,7 +137,13 @@ export class Streamer { }); } - public signalStopStream(guild_id: string, channel_id: string): void { + public signalStopStream(): void { + if (!this.voiceConnection) + return; + const { + guildId: guild_id, + channelId: channel_id + } = this.voiceConnection; this.sendOpcode(GatewayOpCodes.STREAM_DELETE, { stream_key: `guild:${guild_id}:${channel_id}:${this.client.user!.id}` }); diff --git a/src/client/packet/AudioPacketizer.ts b/src/client/packet/AudioPacketizer.ts index a2fbdbd..8f9cfb2 100644 --- a/src/client/packet/AudioPacketizer.ts +++ b/src/client/packet/AudioPacketizer.ts @@ -1,9 +1,10 @@ import { MediaUdp } from "../voice/MediaUdp.js"; import { BaseMediaPacketizer } from "./BaseMediaPacketizer.js"; +import { CodecPayloadType } from "../voice/BaseMediaConnection.js"; export class AudioPacketizer extends BaseMediaPacketizer { constructor(connection: MediaUdp) { - super(connection, 0x78); + super(connection, CodecPayloadType.opus.payload_type); this.srInterval = 5 * 1000 / 20; // ~5 seconds for 20ms frame time } diff --git a/src/client/packet/BaseMediaPacketizer.ts b/src/client/packet/BaseMediaPacketizer.ts index ea57ccb..8d3552f 100644 --- a/src/client/packet/BaseMediaPacketizer.ts +++ b/src/client/packet/BaseMediaPacketizer.ts @@ -13,7 +13,7 @@ let sodium: Promise | undefined; export class BaseMediaPacketizer { private _loggerRtcpSr = new Log("packetizer:rtcp-sr"); - private _ssrc: number; + private _ssrc?: number; private _payloadType: number; private _mtu: number; private _sequence: number; @@ -40,11 +40,10 @@ export class BaseMediaPacketizer { this._mtu = 1200; this._extensionEnabled = extensionEnabled; - this._ssrc = 0; this._srInterval = 512; // Sane fallback value for interval } - public get ssrc(): number + public get ssrc(): number | undefined { return this._ssrc; } @@ -129,6 +128,9 @@ export class BaseMediaPacketizer { } public makeRtpHeader(isLastPacket: boolean = true): Buffer { + if (!this._ssrc) + throw new Error("SSRC is not set"); + const packetHeader = Buffer.alloc(12); packetHeader[0] = 2 << 6 | ((this._extensionEnabled ? 1 : 0) << 4); // set version and flags @@ -143,6 +145,9 @@ export class BaseMediaPacketizer { } public async makeRtcpSenderReport(): Promise { + if (!this._ssrc) + throw new Error("SSRC is not set"); + const packetHeader = Buffer.allocUnsafe(8); packetHeader[0] = 0x80; // RFC1889 v2, no padding, no reception report count diff --git a/src/client/packet/VideoPacketizerAnnexB.ts b/src/client/packet/VideoPacketizerAnnexB.ts index 50337b8..1c67f28 100644 --- a/src/client/packet/VideoPacketizerAnnexB.ts +++ b/src/client/packet/VideoPacketizerAnnexB.ts @@ -7,6 +7,7 @@ import { } from "../processing/AnnexBHelper.js"; import { extensions } from "../../utils.js"; import { splitNalu } from "../processing/AnnexBHelper.js"; +import { CodecPayloadType } from "../voice/BaseMediaConnection.js"; /** * Annex B format @@ -60,8 +61,8 @@ import { splitNalu } from "../processing/AnnexBHelper.js"; class VideoPacketizerAnnexB extends BaseMediaPacketizer { private _nalFunctions: AnnexBHelpers; - constructor(connection: MediaUdp, nalFunctions: AnnexBHelpers) { - super(connection, 0x65, true); + constructor(connection: MediaUdp, payloadType: number, nalFunctions: AnnexBHelpers) { + super(connection, payloadType, true); this.srInterval = 5 * connection.mediaConnection.streamOptions.fps * 3; // ~5 seconds, assuming ~3 packets per frame this._nalFunctions = nalFunctions; } @@ -157,7 +158,7 @@ class VideoPacketizerAnnexB extends BaseMediaPacketizer { export class VideoPacketizerH264 extends VideoPacketizerAnnexB { constructor(connection: MediaUdp) { - super(connection, H264Helpers); + super(connection, CodecPayloadType.H264.payload_type, H264Helpers); } /** * The FU indicator octet has the following format: @@ -213,7 +214,7 @@ export class VideoPacketizerH264 extends VideoPacketizerAnnexB { export class VideoPacketizerH265 extends VideoPacketizerAnnexB { constructor(connection: MediaUdp) { - super(connection, H265Helpers); + super(connection, CodecPayloadType.H265.payload_type, H265Helpers); } /** * The FU indicator octet has the following format: diff --git a/src/client/packet/VideoPacketizerVP8.ts b/src/client/packet/VideoPacketizerVP8.ts index 4841ad2..cb8446c 100644 --- a/src/client/packet/VideoPacketizerVP8.ts +++ b/src/client/packet/VideoPacketizerVP8.ts @@ -1,6 +1,7 @@ import { extensions, max_int16bit } from "../../utils.js"; import { MediaUdp } from "../voice/MediaUdp.js"; import { BaseMediaPacketizer } from "./BaseMediaPacketizer.js"; +import { CodecPayloadType } from "../voice/BaseMediaConnection.js"; /** * VP8 payload format @@ -10,7 +11,7 @@ export class VideoPacketizerVP8 extends BaseMediaPacketizer { private _pictureId: number; constructor(connection: MediaUdp) { - super(connection, 0x65, true); + super(connection, CodecPayloadType.VP8.payload_type, true); this._pictureId = 0; this.srInterval = 5 * connection.mediaConnection.streamOptions.fps * 3; // ~5 seconds, assuming ~3 packets per frame } diff --git a/src/client/voice/BaseMediaConnection.ts b/src/client/voice/BaseMediaConnection.ts index fb1b963..659945e 100644 --- a/src/client/voice/BaseMediaConnection.ts +++ b/src/client/voice/BaseMediaConnection.ts @@ -5,6 +5,7 @@ import { MediaUdp } from "./MediaUdp.js"; import { normalizeVideoCodec, STREAMS_SIMULCAST, SupportedEncryptionModes, SupportedVideoCodec } from "../../utils.js"; import type { ReadyMessage, SelectProtocolAck } from "./VoiceMessageTypes.js"; import WebSocket from 'ws'; +import EventEmitter from "node:events"; type VoiceConnectionStatus = { @@ -14,6 +15,27 @@ type VoiceConnectionStatus = resuming: boolean; } +export const CodecPayloadType = { + "opus": { + name: "opus", type: "audio", priority: 1000, payload_type: 120 + }, + "H264": { + name: "H264", type: "video", priority: 1000, payload_type: 101, rtx_payload_type: 102, encode: true, decode: true + }, + "H265": { + name: "H265", type: "video", priority: 1000, payload_type: 103, rtx_payload_type: 104, encode: true, decode: true + }, + "VP8": { + name: "VP8", type: "video", priority: 1000, payload_type: 105, rtx_payload_type: 106, encode: true, decode: true + }, + "VP9": { + name: "VP9", type: "video", priority: 1000, payload_type: 107, rtx_payload_type: 108, encode: true, decode: true + }, + "AV1": { + name: "AV1", type: "video", priority: 1000, payload_type: 107, rtx_payload_type: 108, encode: true, decode: true + } +} + export interface StreamOptions { /** * Video output width @@ -76,7 +98,7 @@ const defaultStreamOptions: StreamOptions = { forceChacha20Encryption: false, } -export abstract class BaseMediaConnection { +export abstract class BaseMediaConnection extends EventEmitter { private interval: NodeJS.Timeout | null = null; public udp: MediaUdp; public guildId: string; @@ -97,8 +119,10 @@ export abstract class BaseMediaConnection { public secretkeyAes256: Promise | null = null; public secretkeyChacha20: sp.CryptographyKey | null = null; private _streamOptions: StreamOptions; + private _supportedEncryptionMode?: SupportedEncryptionModes[]; constructor(guildId: string, botId: string, channelId: string, options: Partial, callback: (udp: MediaUdp) => void) { + super(); this.status = { hasSession: false, hasToken: false, @@ -194,23 +218,13 @@ export abstract class BaseMediaConnection { this.ssrc = d.ssrc; this.address = d.ip; this.port = d.port; - - // select encryption mode - // From Discord docs: - // You must support aead_xchacha20_poly1305_rtpsize. You should prefer to use aead_aes256_gcm_rtpsize when it is available. - if(d.modes.includes(SupportedEncryptionModes.AES256) && !this.streamOptions.forceChacha20Encryption) { - this.udp.encryptionMode = SupportedEncryptionModes.AES256 - } else { - this.udp.encryptionMode = SupportedEncryptionModes.XCHACHA20 - } + this._supportedEncryptionMode = d.modes; // we hardcoded the STREAMS_SIMULCAST, which will always be array of 1 const stream = d.streams[0]; this.videoSsrc = stream.ssrc; this.rtxSsrc = stream.rtx_ssrc; - - this.udp.audioPacketizer.ssrc = this.ssrc; - this.udp.videoPacketizer.ssrc = this.videoSsrc; + this.udp.updatePacketizer(); } handleProtocolAck(d: SelectProtocolAck): void { @@ -224,9 +238,7 @@ export abstract class BaseMediaConnection { false, ["encrypt"] ); this.secretkeyChacha20 = new sp.CryptographyKey(this.secretkey); - - this.ready(this.udp); - this.udp.ready = true; + this.emit("select_protocol_ack"); } setupEvents(): void { @@ -235,7 +247,7 @@ export abstract class BaseMediaConnection { if (op == VoiceOpCodes.READY) { // ready this.handleReady(d); - this.sendVoice(); + this.sendVoice().then(() => this.ready(this.udp)); this.setVideoStatus(false); } else if (op >= 4000) { @@ -306,24 +318,34 @@ export abstract class BaseMediaConnection { ** Uses vp8 for video ** Uses opus for audio */ - setProtocols(ip: string, port: number): void { - this.sendOpcode(VoiceOpCodes.SELECT_PROTOCOL, { - protocol: "udp", - codecs: [ - { name: "opus", type: "audio", priority: 1000, payload_type: 120 }, - { name: normalizeVideoCodec(this.streamOptions.videoCodec), type: "video", priority: 1000, payload_type: 101, rtx_payload_type: 102, encode: true, decode: true} - //{ name: "VP8", type: "video", priority: 3000, payload_type: 103, rtx_payload_type: 104, encode: true, decode: true } - //{ name: "VP9", type: "video", priority: 3000, payload_type: 105, rtx_payload_type: 106 }, - ], - data: { + setProtocols(): Promise { + const { ip, port } = this.udp; + // select encryption mode + // From Discord docs: + // You must support aead_xchacha20_poly1305_rtpsize. You should prefer to use aead_aes256_gcm_rtpsize when it is available. + if ( + this._supportedEncryptionMode!.includes(SupportedEncryptionModes.AES256) && + !this.streamOptions.forceChacha20Encryption + ) { + this.udp.encryptionMode = SupportedEncryptionModes.AES256 + } else { + this.udp.encryptionMode = SupportedEncryptionModes.XCHACHA20 + } + return new Promise((resolve) => { + this.sendOpcode(VoiceOpCodes.SELECT_PROTOCOL, { + protocol: "udp", + codecs: Object.values(CodecPayloadType), + data: { + address: ip, + port: port, + mode: this.udp.encryptionMode + }, address: ip, port: port, mode: this.udp.encryptionMode - }, - address: ip, - port: port, - mode: this.udp.encryptionMode - }); + }); + this.once("select_protocol_ack", () => resolve()); + }) } /* @@ -372,10 +394,6 @@ export abstract class BaseMediaConnection { ** Start media connection */ public sendVoice(): Promise { - return new Promise((resolve, reject) => { - this.udp.createUdp().then(() => { - resolve(); - }); - }) + return this.udp.createUdp(); } } \ No newline at end of file diff --git a/src/client/voice/MediaUdp.ts b/src/client/voice/MediaUdp.ts index 7355ca3..e0f0f6c 100644 --- a/src/client/voice/MediaUdp.ts +++ b/src/client/voice/MediaUdp.ts @@ -30,31 +30,15 @@ export class MediaUdp { private _nonce: number; private _socket: udpCon.Socket | null = null; private _ready: boolean = false; - private _audioPacketizer: BaseMediaPacketizer; - private _videoPacketizer: BaseMediaPacketizer; + private _audioPacketizer?: BaseMediaPacketizer; + private _videoPacketizer?: BaseMediaPacketizer; private _encryptionMode: SupportedEncryptionModes | undefined; + private _ip?: string; + private _port?: number; constructor(voiceConnection: BaseMediaConnection) { this._nonce = 0; - this._mediaConnection = voiceConnection; - this._audioPacketizer = new AudioPacketizer(this); - - const videoCodec = normalizeVideoCodec(this.mediaConnection.streamOptions.videoCodec); - switch (videoCodec) - { - case "H264": - this._videoPacketizer = new VideoPacketizerH264(this); - break; - case "H265": - this._videoPacketizer = new VideoPacketizerH265(this); - break; - case "VP8": - this._videoPacketizer = new VideoPacketizerVP8(this); - break; - default: - throw new Error(`Packetizer not implemented for ${videoCodec}`) - } } public getNewNonceBuffer(): Buffer { @@ -66,11 +50,12 @@ export class MediaUdp { } public get audioPacketizer(): BaseMediaPacketizer { - return this._audioPacketizer; + return this._audioPacketizer!; } public get videoPacketizer(): BaseMediaPacketizer { - return this._videoPacketizer; + // This will never be undefined anyway, so it's safe + return this._videoPacketizer!; } public get mediaConnection(): BaseMediaConnection { @@ -85,6 +70,16 @@ export class MediaUdp { this._encryptionMode = mode; } + public get ip() + { + return this._ip; + } + + public get port() + { + return this._port; + } + public async sendAudioFrame(frame: Buffer, frametime: number): Promise { if(!this.ready) return; await this.audioPacketizer.sendFrame(frame, frametime); @@ -95,6 +90,27 @@ export class MediaUdp { await this.videoPacketizer.sendFrame(frame, frametime); } + public updatePacketizer(): void { + this._audioPacketizer = new AudioPacketizer(this); + this._audioPacketizer.ssrc = this._mediaConnection.ssrc!; + const videoCodec = normalizeVideoCodec(this.mediaConnection.streamOptions.videoCodec); + switch (videoCodec) + { + case "H264": + this._videoPacketizer = new VideoPacketizerH264(this); + break; + case "H265": + this._videoPacketizer = new VideoPacketizerH265(this); + break; + case "VP8": + this._videoPacketizer = new VideoPacketizerVP8(this); + break; + default: + throw new Error(`Packetizer not implemented for ${videoCodec}`) + } + this._videoPacketizer.ssrc = this._mediaConnection.videoSsrc!; + } + public sendPacket(packet: Buffer): Promise { return new Promise((resolve, reject) => { try { @@ -143,7 +159,9 @@ export class MediaUdp { } try { const packet = parseLocalPacket(message); - this._mediaConnection.setProtocols(packet.ip, packet.port); + this._ip = packet.ip; + this._port = packet.port; + this._ready = true; } catch(e) { reject(e) } resolve(); diff --git a/src/client/voice/VoiceMessageTypes.ts b/src/client/voice/VoiceMessageTypes.ts index 39fcbe3..f881693 100644 --- a/src/client/voice/VoiceMessageTypes.ts +++ b/src/client/voice/VoiceMessageTypes.ts @@ -1,8 +1,10 @@ +import type { SupportedEncryptionModes } from "../../utils.js" + export type ReadyMessage = { ssrc: number, ip: string, port: number, - modes: string[], + modes: SupportedEncryptionModes[], experiments: string[], streams: StreamInfo[] } diff --git a/src/index.ts b/src/index.ts index 6c1f2d6..a35cfce 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,4 @@ export * from './client/index.js'; export * from './media/index.js'; +export * as NewApi from './media/newApi.js'; export * as Utils from './utils.js'; diff --git a/src/media/newApi.ts b/src/media/newApi.ts new file mode 100644 index 0000000..2d82ea3 --- /dev/null +++ b/src/media/newApi.ts @@ -0,0 +1,397 @@ +import ffmpeg from 'fluent-ffmpeg'; +import { demux } from './LibavDemuxer.js'; +import { PassThrough, type Readable } from "node:stream"; +import type { SupportedVideoCodec } from '../utils.js'; +import type { MediaUdp, Streamer } from '../client/index.js'; +import { VideoStream } from './VideoStream.js'; +import { AudioStream } from './AudioStream.js'; +import { isFiniteNonZero } from '../utils.js'; +import { AVCodecID } from './LibavCodecId.js'; + +export type EncoderOptions = { + /** + * Video width + */ + width: number, + + /** + * Video height + */ + height: number, + + /** + * Video frame rate + */ + frameRate?: number, + + /** + * Video codec + */ + videoCodec: SupportedVideoCodec, + + /** + * Video average bitrate in kbps + */ + bitrateVideo: number, + + /** + * Video max bitrate in kbps + */ + bitrateVideoMax: number, + + /** + * Audio bitrate in kbps + */ + bitrateAudio: number, + + /** + * Enable audio output + */ + includeAudio: boolean, + + /** + * Enable hardware accelerated decoding + */ + hardwareAcceleratedDecoding: boolean, + + /** + * Add some options to minimize latency + */ + minimizeLatency: boolean, + + /** + * Preset for x264 and x265 + */ + h26xPreset: "ultrafast" | "superfast" | "veryfast" | "faster" | "fast" | "medium" | "slow" | "slower" | "veryslow" | "placebo", + + /** + * Custom headers for HTTP requests + */ + customHeaders: Record +} + +export function prepareStream( + input: string | Readable, + options: Partial = {} +) { + const defaultOptions = { + // negative values = resize by aspect ratio, see https://trac.ffmpeg.org/wiki/Scaling + width: -2, + height: -2, + frameRate: undefined, + videoCodec: "H264", + bitrateVideo: 5000, + bitrateVideoMax: 7000, + bitrateAudio: 128, + includeAudio: true, + hardwareAcceleratedDecoding: false, + minimizeLatency: false, + h26xPreset: "ultrafast", + customHeaders: { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/107.0.0.0 Safari/537.3", + "Connection": "keep-alive", + } + } satisfies EncoderOptions; + + function mergeOptions(opts: Partial) { + return { + width: + isFiniteNonZero(opts.width) ? Math.round(opts.width) : defaultOptions.width, + + height: + isFiniteNonZero(opts.height) ? Math.round(opts.height) : defaultOptions.height, + + frameRate: + isFiniteNonZero(opts.frameRate) && opts.frameRate > 0 + ? opts.frameRate + : defaultOptions.frameRate, + + videoCodec: + opts.videoCodec ?? defaultOptions.videoCodec, + + bitrateVideo: + isFiniteNonZero(opts.bitrateVideo) && opts.bitrateVideo > 0 + ? Math.round(opts.bitrateVideo) + : defaultOptions.bitrateVideo, + + bitrateVideoMax: + isFiniteNonZero(opts.bitrateVideoMax) && opts.bitrateVideoMax > 0 + ? Math.round(opts.bitrateVideoMax) + : defaultOptions.bitrateVideoMax, + + bitrateAudio: + isFiniteNonZero(opts.bitrateAudio) && opts.bitrateAudio > 0 + ? Math.round(opts.bitrateAudio) + : defaultOptions.bitrateAudio, + + includeAudio: + opts.includeAudio ?? defaultOptions.includeAudio, + + hardwareAcceleratedDecoding: + opts.hardwareAcceleratedDecoding ?? defaultOptions.hardwareAcceleratedDecoding, + + minimizeLatency: + opts.minimizeLatency ?? defaultOptions.minimizeLatency, + + h26xPreset: + opts.h26xPreset ?? defaultOptions.h26xPreset, + + customHeaders: { + ...defaultOptions.customHeaders, ...opts.customHeaders + } + } satisfies EncoderOptions + } + + const mergedOptions = mergeOptions(options); + + let isHttpUrl = false; + let isHls = false; + + if (typeof input === "string") { + isHttpUrl = input.startsWith('http') || input.startsWith('https'); + isHls = input.includes('m3u'); + } + + const output = new PassThrough(); + + // command creation + const command = ffmpeg(input) + .output(output) + .addOption('-loglevel', '0') + + // input options + let { hardwareAcceleratedDecoding, minimizeLatency, customHeaders } = mergedOptions; + if (hardwareAcceleratedDecoding) + command.inputOption('-hwaccel', 'auto'); + + if (minimizeLatency) { + command.addOptions([ + '-fflags nobuffer', + '-analyzeduration 0' + ]) + } + + if (isHttpUrl) { + command.inputOption('-headers', + Object.entries(customHeaders).map((k, v) => `${k}: ${v}`).join("\r\n") + ); + if (!isHls) { + command.inputOptions([ + '-reconnect 1', + '-reconnect_at_eof 1', + '-reconnect_streamed 1', + '-reconnect_delay_max 4294' + ]); + } + } + + // general output options + command.outputFormat("matroska"); + + // video setup + let { + width, height, frameRate, bitrateVideo, bitrateVideoMax, videoCodec, h26xPreset + } = mergedOptions; + command.map("0:v"); + command.videoFilter(`scale=${width}:${height}`) + + if (frameRate) + command.fpsOutput(frameRate); + + command.addOutputOption([ + "-b:v", `${bitrateVideo}k`, + "-maxrate:v", `${bitrateVideoMax}k`, + "-bf", "0", + "-pix_fmt", "yuv420p", + "-force_key_frames", "expr:gte(t,n_forced*1)" + ]); + + switch (videoCodec) { + case 'AV1': + command + .videoCodec("libsvtav1") + break; + case 'VP8': + command + .videoCodec("libvpx") + .outputOption('-deadline', 'realtime'); + break; + case 'VP9': + command + .videoCodec("libvpx-vp9") + .outputOption('-deadline', 'realtime'); + break; + case 'H264': + command + .videoCodec("libx264") + .outputOptions([ + '-tune zerolatency', + `-preset ${h26xPreset}`, + '-profile:v baseline', + ]); + break; + case 'H265': + command + .videoCodec("libx265") + .outputOptions([ + '-tune zerolatency', + `-preset ${h26xPreset}`, + '-profile:v main', + ]); + break; + } + + // audio setup + let { includeAudio, bitrateAudio } = mergedOptions; + if (includeAudio) + command + .map("0:a?") + .audioChannels(2) + /* + * I don't have much surround sound material to test this with, + * if you do and you have better settings for this, feel free to + * contribute! + */ + .addOutputOption("-lfe_mix_level 1") + .audioFrequency(48000) + .audioCodec("libopus") + .audioBitrate(`${bitrateAudio}k`); + + command.run(); + return { command, output } +} + +export type PlayStreamOptions = { + /** + * Set stream type as "Go Live" or camera stream + */ + type: "go-live" | "camera", + + /** + * Override video width sent to Discord. + * DO NOT SPECIFY UNLESS YOU KNOW WHAT YOU'RE DOING! + */ + width: number, + + /** + * Override video height sent to Discord. + * DO NOT SPECIFY UNLESS YOU KNOW WHAT YOU'RE DOING! + */ + height: number, + + /** + * Override video frame rate sent to Discord. + * DO NOT SPECIFY UNLESS YOU KNOW WHAT YOU'RE DOING! + */ + frameRate: number, + + /** + * Enable RTCP Sender Report for synchronization + */ + rtcpSenderReportEnabled: boolean, + + /** + * Force the use of ChaCha20 encryption. Faster on CPUs without AES-NI + */ + forceChacha20Encryption: boolean +} + +export async function playStream( + input: Readable, streamer: Streamer, options: Partial = {}) +{ + if (!streamer.voiceConnection) + throw new Error("Bot is not connected to a voice channel"); + + const { video, audio } = await demux(input); + if (!video) + throw new Error("No video stream in media"); + + const videoCodecMap: Record = { + [AVCodecID.AV_CODEC_ID_H264]: "H264", + [AVCodecID.AV_CODEC_ID_H265]: "H265", + [AVCodecID.AV_CODEC_ID_VP8]: "VP8", + [AVCodecID.AV_CODEC_ID_VP9]: "VP9", + [AVCodecID.AV_CODEC_ID_AV1]: "AV1" + } + const defaultOptions = { + type: "go-live", + width: video.width, + height: video.height, + frameRate: video.framerate_num / video.framerate_den, + rtcpSenderReportEnabled: true, + forceChacha20Encryption: false + } satisfies PlayStreamOptions; + + function mergeOptions(opts: Partial) + { + return { + type: + opts.type ?? defaultOptions.type, + + width: + isFiniteNonZero(opts.width) && opts.width > 0 + ? Math.round(opts.width) + : defaultOptions.width, + + height: + isFiniteNonZero(opts.height) && opts.height > 0 + ? Math.round(opts.height) + : defaultOptions.height, + + frameRate: Math.round( + isFiniteNonZero(opts.frameRate) && opts.frameRate > 0 + ? Math.round(opts.frameRate) + : defaultOptions.frameRate + ), + + rtcpSenderReportEnabled: + opts.rtcpSenderReportEnabled ?? defaultOptions.rtcpSenderReportEnabled, + + forceChacha20Encryption: + opts.forceChacha20Encryption ?? defaultOptions.forceChacha20Encryption + } satisfies PlayStreamOptions + } + + const mergedOptions = mergeOptions(options); + + let udp: MediaUdp; + let stopStream; + if (mergedOptions.type == "go-live") + { + udp = await streamer.createStream(); + stopStream = () => streamer.stopStream(); + } + else + { + udp = streamer.voiceConnection.udp; + streamer.signalVideo(true); + stopStream = () => streamer.signalVideo(false); + } + udp.mediaConnection.streamOptions = { + width: mergedOptions.width, + height: mergedOptions.height, + videoCodec: videoCodecMap[video.codec], + fps: mergedOptions.frameRate, + rtcpSenderReportEnabled: mergedOptions.rtcpSenderReportEnabled, + forceChacha20Encryption: mergedOptions.forceChacha20Encryption + } + await udp.mediaConnection.setProtocols(); + udp.updatePacketizer(); // TODO: put all packetizers here when we remove the old API + udp.mediaConnection.setSpeaking(true); + udp.mediaConnection.setVideoStatus(true); + + const vStream = new VideoStream(udp); + video.stream.pipe(vStream); + if (audio) + { + const aStream = new AudioStream(udp); + audio.stream.pipe(aStream); + vStream.syncStream = aStream; + aStream.syncStream = vStream; + } + vStream.once("finish", () => { + stopStream(); + udp.mediaConnection.setSpeaking(false); + udp.mediaConnection.setVideoStatus(false); + }); +} diff --git a/src/utils.ts b/src/utils.ts index f5453df..dbcad21 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -31,3 +31,7 @@ export const extensions = [{ id: 5, len: 2, val: 0}]; export const max_int16bit = 2 ** 16; export const max_int32bit = 2 ** 32; + +export function isFiniteNonZero(n: number | undefined): n is number { + return !!n && isFinite(n); +}