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
+18
View File
@@ -0,0 +1,18 @@
name: Biome Lint & Format Check
on:
push:
pull_request:
jobs:
quality:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Biome
uses: biomejs/setup-biome@v2
with:
version: 2.3.8
- name: Run Biome
run: biome ci .
@@ -0,0 +1,30 @@
name: Publish pkg.pr.new
on: [push, pull_request]
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 9
- name: Install Node.js
uses: actions/setup-node@v4
with:
node-version: 22
cache: "pnpm"
- name: Install dependencies
run: pnpm install
- name: Build
run: pnpm build
- name: Publish
run: pnpx pkg-pr-new publish
+6
View File
@@ -0,0 +1,6 @@
node_modules
dist
package-lock.json
#example/src/config.json
examples/*/node_modules
examples/*/dist
+88
View File
@@ -0,0 +1,88 @@
# Performance related tweaks
## `ultrafast` shouldn't be used for x264/5
In our testing, the `ultrafast` preset produces a lot of bitrate spikes, causing the stream to stutter. `superfast` and below seems to keep it under control pretty well. Do not use `ultrafast`. Previous versions of the library has `ultrafast` as the default, which has been changed after the testing.
## Transport encryption methods
> [!NOTE]
> This is no longer accurate as of [#195](https://github.com/Discord-RE/Discord-video-stream/pull/195), which replaces the custom UDP connection with standard WebRTC. This is kept here for historical purposes only.
On CPUs without AES acceleration (very old x86 CPUs, certain ARM SoCs on single board computers, certain VMs that don't expose AES acceleration capability), the default encryption method (AES-256-GCM) might not be fast enough to handle high frame-rate + high bitrate streams.
In such cases, you can enable the `forceChacha20Encryption` option on the `Streamer` instance (`streamer.opts.forceChacha20Encryption = true`) before starting a stream, to force the use of the faster Chacha20-Poly1305 encryption method. For even higher performance, also install the optional [`sodium-native`](https://www.npmjs.com/package/sodium-native) package to use the faster native version instead of the WASM version.
Below are some benchmark results of the two encryption methods in various circumstances, for reference purposes only. All benchmarks are performed on a Ryzen 5 5600H.
<details>
<summary>AES-256-GCM, with AES acceleration</summary>
```
PS C:\> openssl speed -elapsed -aead -evp aes-256-gcm
You have chosen to measure elapsed time instead of user CPU time.
Doing AES-256-GCM ops for 3s on 2 size blocks: 19046296 AES-256-GCM ops in 3.00s
Doing AES-256-GCM ops for 3s on 31 size blocks: 15299030 AES-256-GCM ops in 3.00s
Doing AES-256-GCM ops for 3s on 136 size blocks: 13580376 AES-256-GCM ops in 3.00s
Doing AES-256-GCM ops for 3s on 1024 size blocks: 7691855 AES-256-GCM ops in 3.00s
Doing AES-256-GCM ops for 3s on 8192 size blocks: 1648811 AES-256-GCM ops in 3.00s
Doing AES-256-GCM ops for 3s on 16384 size blocks: 863115 AES-256-GCM ops in 3.00s
version: 3.4.0
built on: Tue Oct 22 23:27:41 2024 UTC
options: bn(64,64)
compiler: cl /Z7 /Fdossl_static.pdb /Gs0 /GF /Gy /MD /W3 /wd4090 /nologo /O2 -DL_ENDIAN -DOPENSSL_PIC -D"OPENSSL_BUILDING_OPENSSL" -D"OPENSSL_SYS_WIN32" -D"WIN32_LEAN_AND_MEAN" -D"UNICODE" -D"_UNICODE" -D"_CRT_SECURE_NO_DEPRECATE" -D"_WINSOCK_DEPRECATED_NO_WARNINGS" -D"NDEBUG" -D_WINSOCK_DEPRECATED_NO_WARNINGS -D_WIN32_WINNT=0x0502
CPUINFO: OPENSSL_ia32cap=0xfed8320b078bffff:0x400684219c97a9
The 'numbers' are in 1000s of bytes per second processed.
type 2 bytes 31 bytes 136 bytes 1024 bytes 8192 bytes 16384 bytes
AES-256-GCM 12693.30k 158089.98k 615233.56k 2625486.51k 4500852.95k 4712187.99k
```
</details>
<details>
<summary>AES-256-GCM, without AES acceleration</summary>
```
PS C:\> openssl speed -elapsed -aead -evp aes-256-gcm
You have chosen to measure elapsed time instead of user CPU time.
Doing AES-256-GCM ops for 3s on 2 size blocks: 6947831 AES-256-GCM ops in 3.00s
Doing AES-256-GCM ops for 3s on 31 size blocks: 4875037 AES-256-GCM ops in 3.00s
Doing AES-256-GCM ops for 3s on 136 size blocks: 3132696 AES-256-GCM ops in 3.00s
Doing AES-256-GCM ops for 3s on 1024 size blocks: 821006 AES-256-GCM ops in 3.00s
Doing AES-256-GCM ops for 3s on 8192 size blocks: 113769 AES-256-GCM ops in 3.00s
Doing AES-256-GCM ops for 3s on 16384 size blocks: 57074 AES-256-GCM ops in 3.00s
version: 3.4.0
built on: Tue Oct 22 23:27:41 2024 UTC
options: bn(64,64)
compiler: cl /Z7 /Fdossl_static.pdb /Gs0 /GF /Gy /MD /W3 /wd4090 /nologo /O2 -DL_ENDIAN -DOPENSSL_PIC -D"OPENSSL_BUILDING_OPENSSL" -D"OPENSSL_SYS_WIN32" -D"WIN32_LEAN_AND_MEAN" -D"UNICODE" -D"_UNICODE" -D"_CRT_SECURE_NO_DEPRECATE" -D"_WINSOCK_DEPRECATED_NO_WARNINGS" -D"NDEBUG" -D_WINSOCK_DEPRECATED_NO_WARNINGS -D_WIN32_WINNT=0x0502
CPUINFO: OPENSSL_ia32cap=0xfcd83209078bffff:0x0 env:~0x200000200000000
The 'numbers' are in 1000s of bytes per second processed.
type 2 bytes 31 bytes 136 bytes 1024 bytes 8192 bytes 16384 bytes
AES-256-GCM 4630.34k 50358.60k 142015.55k 280143.33k 310561.70k 311596.27k
```
</details>
<details>
<summary>Chacha20-Poly1305</summary>
```
PS C:\> openssl speed -elapsed -aead -evp chacha20-poly1305
You have chosen to measure elapsed time instead of user CPU time.
Doing ChaCha20-Poly1305 ops for 3s on 2 size blocks: 8312139 ChaCha20-Poly1305 ops in 3.00s
Doing ChaCha20-Poly1305 ops for 3s on 31 size blocks: 7801222 ChaCha20-Poly1305 ops in 3.00s
Doing ChaCha20-Poly1305 ops for 3s on 136 size blocks: 5436377 ChaCha20-Poly1305 ops in 3.00s
Doing ChaCha20-Poly1305 ops for 3s on 1024 size blocks: 4182141 ChaCha20-Poly1305 ops in 3.00s
Doing ChaCha20-Poly1305 ops for 3s on 8192 size blocks: 903567 ChaCha20-Poly1305 ops in 3.00s
Doing ChaCha20-Poly1305 ops for 3s on 16384 size blocks: 472556 ChaCha20-Poly1305 ops in 3.00s
version: 3.4.0
built on: Tue Oct 22 23:27:41 2024 UTC
options: bn(64,64)
compiler: cl /Z7 /Fdossl_static.pdb /Gs0 /GF /Gy /MD /W3 /wd4090 /nologo /O2 -DL_ENDIAN -DOPENSSL_PIC -D"OPENSSL_BUILDING_OPENSSL" -D"OPENSSL_SYS_WIN32" -D"WIN32_LEAN_AND_MEAN" -D"UNICODE" -D"_UNICODE" -D"_CRT_SECURE_NO_DEPRECATE" -D"_WINSOCK_DEPRECATED_NO_WARNINGS" -D"NDEBUG" -D_WINSOCK_DEPRECATED_NO_WARNINGS -D_WIN32_WINNT=0x0502
CPUINFO: OPENSSL_ia32cap=0xfed8320b078bffff:0x400684219c97a9
The 'numbers' are in 1000s of bytes per second processed.
type 2 bytes 31 bytes 136 bytes 1024 bytes 8192 bytes 16384 bytes
ChaCha20-Poly1305 5539.58k 80585.77k 246284.90k 1427504.13k 2465696.49k 2580785.83k
```
</details>
+296
View File
@@ -0,0 +1,296 @@
# Discord self-bot video
[![pkg.pr.new](https://pkg.pr.new/badge/Discord-RE/Discord-video-stream)](https://pkg.pr.new/~/Discord-RE/Discord-video-stream)
Fork: [Discord-video-experiment](https://github.com/mrjvs/Discord-video-experiment)
> [!CAUTION]
> Using any kind of automation programs on your account can result in your account getting permanently banned by Discord. Use at your own risk
## Features
- Playing video & audio in a voice channel (`Go Live`, or webcam video)
## Implementation
What I implemented and what I did not.
### Video codecs
- [ ] VP8 (once supported, removed for maintainability)
- [ ] VP9
- [X] H.264
- [X] H.265
- [ ] AV1
### Packet types
- [X] RTP (sending of realtime data)
- [ ] RTX (retransmission)
### Connection types
- [X] Regular Voice Connection
- [X] Go Live
### Encryption
- [X] Transport Encryption
- [X] [End-to-end Encryption](https://github.com/dank074/Discord-video-stream/issues/102)
### Extras
- [X] Figure out RTP header extensions (discord specific) (discord seems to use [one-byte RTP header extension](https://www.rfc-editor.org/rfc/rfc8285.html#section-4.2))
Extensions supported by Discord (taken from the webrtc sdp exchange)
```
"a=extmap:1 urn:ietf:params:rtp-hdrext:ssrc-audio-level"
"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:4 urn:ietf:params:rtp-hdrext:sdes:mid"
"a=extmap:5 http://www.webrtc.org/experiments/rtp-hdrext/playout-delay"
"a=extmap:6 http://www.webrtc.org/experiments/rtp-hdrext/video-content-type"
"a=extmap:7 http://www.webrtc.org/experiments/rtp-hdrext/video-timing"
"a=extmap:8 http://www.webrtc.org/experiments/rtp-hdrext/color-space"
"a=extmap:10 urn:ietf:params:rtp-hdrext:sdes:rtp-stream-id"
"a=extmap:11 urn:ietf:params:rtp-hdrext:sdes:repaired-rtp-stream-id"
"a=extmap:13 urn:3gpp:video-orientation"
"a=extmap:14 urn:ietf:params:rtp-hdrext:toffset"
```
## Requirements
For full functionality, this library requires an FFmpeg build with `libzmq` enabled. Here is our recommendation:
- Windows & Linux: [BtbN's FFmpeg Builds](https://github.com/BtbN/FFmpeg-Builds)
- macOS (Intel): [evermeet.cx](https://evermeet.cx/ffmpeg/)
- macOS (Apple Silicon): Install from Homebrew
## Usage
Install the package, alongside its peer-dependency discord.js-selfbot-v13:
```
npm install @dank074/discord-video-stream@latest
npm install discord.js-selfbot-v13@latest
```
> [!IMPORTANT]
> This library makes use of native dependencies (`node-av` and `node-datachannel`). If you use package managers that don't run install scripts by default (`pnpm`, `bun`, etc.), you'll need to allow running install scripts for `node-av` and `node-datachannel` for proper operation.
Create a new Streamer, and pass it a selfbot Client
```typescript
import { Client } from "discord.js-selfbot-v13";
import { Streamer } from '@dank074/discord-video-stream';
const streamer = new Streamer(new Client());
await streamer.client.login('TOKEN HERE');
```
Make client join a voice channel
```typescript
await streamer.joinVoice("GUILD ID HERE", "CHANNEL ID HERE");
```
Start sending media
```typescript
import { prepareStream, playStream, Utils, Encoders } from "@dank074/discord-video-stream"
try {
// NVENC is also available, change Encoders.software to Encoders.nvenc and
// adapt the settings
let encoder = Encoders.software({
x264: {
preset: "superfast"
},
x265: {
preset: "superfast"
}
});
const { command, output } = prepareStream("DIRECT VIDEO URL OR READABLE STREAM HERE", {
encoder,
// Specify either width or height for aspect ratio aware scaling
// Specify both for stretched output
height: 1080,
// Force frame rate, or leave blank to use source frame rate
frameRate: 30,
bitrateVideo: 5000,
bitrateVideoMax: 7500,
videoCodec: Utils.normalizeVideoCodec("H264" /* or H265 */),
});
command.on("error", (err, stdout, stderr) => {
// Handle ffmpeg errors here
});
await playStream(output, streamer, {
type: "go-live" // use "camera" for camera stream
});
console.log("Finished playing video");
} catch (e) {
console.log(e);
}
```
## Encoder options available
```typescript
/**
* A function returning encoder settings for a specific avg and max bitrate
* You can define your own, or use the pre-made functions in the library
*/
encoder: EncoderSettingsGetter;
/**
* Disable transcoding of the video stream. If specified, all video related
* options have no effects
*
* Only use this if your video stream is Discord streaming friendly, otherwise
* you'll get a glitchy output
*/
noTranscoding?: boolean;
/**
* Video output width
*/
width?: number;
/**
* Video output height
*/
height?: number;
/**
* Video output frames per second
*/
fps?: number;
/**
* 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;
/**
* Enables hardware accelerated video decoding. Enabling this option might result in an exception
* being thrown by Ffmpeg process if your system does not support hardware acceleration
*/
hardwareAcceleratedDecoding?: boolean;
/**
* Output video codec. **Only** supports H264, H265, and VP8 currently
*/
videoCodec?: SupportedVideoCodec;
/**
* Adds ffmpeg params to minimize latency and start outputting video as fast as possible.
* Might create lag in video output in some rare cases
*/
minimizeLatency?: boolean;
/**
* Custom headers for HTTP requests
*/
customHeaders?: Record<string, string>;
/**
* Custom input options to pass directly to ffmpeg
* These will be added to the command *before* other options
*/
customInputOptions?: string[];
/**
* Custom ffmpeg flags/options to pass directly to ffmpeg
* These will be added to the command *after* other options
*/
customFfmpegFlags?: string[];
```
## `playStream` options available
```typescript
/**
* 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,
/**
* Same as ffmpeg's `readrate_initial_burst` command line flag
*
* See https://ffmpeg.org/ffmpeg.html#:~:text=%2Dreadrate_initial_burst
*/
readrateInitialBurst?: number,
```
## Performance tips
See [this page](./PERFORMANCE.md) for some tips on improving performance
## Running example
`examples/basic/src/config.json`:
```json
"token": "SELF TOKEN HERE",
"acceptedAuthors": ["USER_ID_HERE"],
```
1. Configure your `config.json` with your accepted authors ids, and your self token
2. Generate js files with ```npm run build```
3. Start program with: ```npm run start```
4. Join a voice channel
5. Start streaming with commands:
for go-live
```
$play-live <Direct video link>
```
or for cam
```
$play-cam <Direct video link>
```
for example:
```
$play-live http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4
```
## FAQs
- Can I stream on existing voice connection (CAM) and in a go-live connection simultaneously?
Yes, just send the media packets over both connections. The voice gateway expects you to signal when a user turns on their camera, so make sure you signal using `client.signalVideo(guildId, channelId, true)` before you start sending cam media packets.
- Does this library work with bot tokens?
No, Discord blocks video from bots which is why this library uses a selfbot library as peer dependency. You must use a user token
+38
View File
@@ -0,0 +1,38 @@
{
"$schema": "https://biomejs.dev/schemas/2.3.8/schema.json",
"vcs": {
"enabled": true,
"clientKind": "git",
"useIgnoreFile": true
},
"files": {
"ignoreUnknown": false,
"includes": ["./src/**/*"]
},
"formatter": {
"enabled": true,
"indentStyle": "space",
"indentWidth": 2
},
"assist": {
"actions": {
"source": {
"organizeImports": "off"
}
}
},
"linter": {
"enabled": true,
"rules": {
"recommended": true,
"style": {
"noNonNullAssertion": "off"
}
}
},
"javascript": {
"formatter": {
"quoteStyle": "double"
}
}
}
+3
View File
@@ -0,0 +1,3 @@
# basic example
This example shows how to stream a video, both using the existing voice connection or with a Go Live connection, using the new API introduced in v4.1.3
+23
View File
@@ -0,0 +1,23 @@
{
"name": "@dank074/discord-video-stream-example",
"version": "1.0.0",
"description": "",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"type": "module",
"dependencies": {
"@dank074/discord-video-stream": "^5.0.0",
"discord.js-selfbot-v13": "^3.5.1"
},
"devDependencies": {
"@types/node": "^22.10.1",
"typescript": "^5.7.2"
},
"scripts": {
"build": "tsc",
"start": "node ./dist/index.js",
"yeet": "npm run build && npm run start"
},
"author": "",
"license": "ISC"
}
@@ -0,0 +1,13 @@
{
"token": "SELF TOKEN HERE",
"acceptedAuthors": ["USER_ID_HERE"],
"streamOpts": {
"width": 1280,
"height": 720,
"fps": 30,
"bitrateKbps": 1000,
"maxBitrateKbps": 2500,
"hardware_acceleration": false,
"videoCodec": "H264"
}
}
+112
View File
@@ -0,0 +1,112 @@
import { Client, StageChannel } from "discord.js-selfbot-v13";
import { Streamer, Utils, prepareStream, playStream } from "@dank074/discord-video-stream";
import config from "./config.json" with {type: "json"};
const streamer = new Streamer(new Client());
// ready event
streamer.client.on("ready", () => {
console.log(`--- ${streamer.client.user?.tag} is ready ---`);
});
let controller: AbortController;
// message event
streamer.client.on("messageCreate", async (msg) => {
if (msg.author.bot) return;
if (!config.acceptedAuthors.includes(msg.author.id)) return;
if (!msg.content) return;
if (msg.content.startsWith("$play-live")) {
const args = parseArgs(msg.content)
if (!args) return;
const channel = msg.author.voice?.channel;
if(!channel) return;
console.log(`Attempting to join voice channel ${msg.guildId}/${channel.id}`);
await streamer.joinVoice(msg.guildId!, channel.id);
if (channel instanceof StageChannel)
{
await streamer.client.user?.voice?.setSuppressed(false);
}
controller?.abort();
controller = new AbortController();
const { command, output } = prepareStream(args.url, {
width: config.streamOpts.width,
height: config.streamOpts.height,
frameRate: config.streamOpts.fps,
bitrateVideo: config.streamOpts.bitrateKbps,
bitrateVideoMax: config.streamOpts.maxBitrateKbps,
hardwareAcceleratedDecoding: config.streamOpts.hardware_acceleration,
videoCodec: Utils.normalizeVideoCodec(config.streamOpts.videoCodec)
}, controller.signal);
command.on("error", (err) => {
console.log("An error happened with ffmpeg");
console.log(err);
});
await playStream(output, streamer, undefined, controller.signal)
.catch(() => controller.abort());
} else if (msg.content.startsWith("$play-cam")) {
const args = parseArgs(msg.content);
if (!args) return;
const channel = msg.author.voice?.channel;
if (!channel) return;
console.log(`Attempting to join voice channel ${msg.guildId}/${channel.id}`);
const vc = await streamer.joinVoice(msg.guildId!, channel.id);
if (channel instanceof StageChannel)
{
await streamer.client.user?.voice?.setSuppressed(false);
}
controller?.abort();
controller = new AbortController();
const { command, output } = prepareStream(args.url, {
width: config.streamOpts.width,
height: config.streamOpts.height,
frameRate: config.streamOpts.fps,
bitrateVideo: config.streamOpts.bitrateKbps,
bitrateVideoMax: config.streamOpts.maxBitrateKbps,
hardwareAcceleratedDecoding: config.streamOpts.hardware_acceleration,
videoCodec: Utils.normalizeVideoCodec(config.streamOpts.videoCodec)
}, controller.signal)
command.on("error", (err) => {
console.log("An error happened with ffmpeg");
console.log(err);
});
await playStream(output, streamer, undefined, controller.signal)
.catch(() => controller.abort());
} else if (msg.content.startsWith("$disconnect")) {
controller?.abort();
streamer.leaveVoice();
} else if(msg.content.startsWith("$stop-stream")) {
controller?.abort();
}
});
// login
streamer.client.login(config.token);
function parseArgs(message: string): Args | undefined {
const args = message.split(" ");
if (args.length < 2) return;
const url = args[1];
return { url }
}
type Args = {
url: string;
}
+106
View File
@@ -0,0 +1,106 @@
{
"compilerOptions": {
/* Visit https://aka.ms/tsconfig to read more about this file */
/* Projects */
// "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */
// "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */
// "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */
// "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */
// "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */
// "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */
/* Language and Environment */
"target": "ESNext", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */
// "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */
// "jsx": "preserve", /* Specify what JSX code is generated. */
// "experimentalDecorators": true, /* Enable experimental support for TC39 stage 2 draft decorators. */
// "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */
// "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */
// "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */
// "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */
// "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */
// "noLib": true, /* Disable including any library files, including the default lib.d.ts. */
// "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */
// "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */
/* Modules */
"module": "NodeNext", /* Specify what module code is generated. */
// "rootDir": "./", /* Specify the root folder within your source files. */
"moduleResolution": "NodeNext", /* Specify how TypeScript looks up a file from a given module specifier. */
// "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */
// "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */
// "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */
// "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */
// "types": [], /* Specify type package names to be included without being referenced in a source file. */
// "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
// "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */
"resolveJsonModule": true, /* Enable importing .json files. */
// "noResolve": true, /* Disallow 'import's, 'require's or '<reference>'s from expanding the number of files TypeScript should add to a project. */
/* JavaScript Support */
"allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */
// "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */
// "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */
/* Emit */
"declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */
// "declarationMap": true, /* Create sourcemaps for d.ts files. */
// "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */
// "sourceMap": true, /* Create source map files for emitted JavaScript files. */
// "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */
"outDir": "./dist", /* Specify an output folder for all emitted files. */
// "removeComments": true, /* Disable emitting comments. */
// "noEmit": true, /* Disable emitting files from a compilation. */
// "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */
// "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types. */
// "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */
// "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */
// "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
// "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */
// "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */
// "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */
// "newLine": "crlf", /* Set the newline character for emitting files. */
// "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */
// "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */
// "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */
// "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */
// "declarationDir": "./", /* Specify the output directory for generated declaration files. */
// "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */
/* Interop Constraints */
// "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */
// "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */
"esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */
// "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */
"forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */
/* Type Checking */
"strict": true, /* Enable all strict type-checking options. */
"noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */
// "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */
// "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */
// "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */
// "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */
// "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */
// "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */
// "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */
// "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */
// "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */
// "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */
// "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */
// "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */
// "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */
// "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */
// "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */
// "allowUnusedLabels": true, /* Disable error reporting for unused labels. */
// "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */
/* Completeness */
// "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */
"skipLibCheck": true /* Skip type checking all .d.ts files. */
},
"include": [
"src/**/*"
]
}
@@ -0,0 +1,3 @@
# puppeteer stream example
This example shows how to use puppeteer stream to stream a browser window
@@ -0,0 +1,25 @@
{
"name": "@dank074/discord-video-stream-example-puppeteer",
"version": "1.0.0",
"description": "",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"type": "module",
"dependencies": {
"@dank074/discord-video-stream": "^5.0.0",
"discord.js-selfbot-v13": "^3.5.1",
"puppeteer": "^24.1.1",
"puppeteer-stream": "^3.0.19"
},
"devDependencies": {
"@types/node": "^22.13.1",
"typescript": "^5.7.3"
},
"scripts": {
"build": "tsc",
"start": "node ./dist/index.js",
"yeet": "npm run build && npm run start"
},
"author": "",
"license": "ISC"
}
@@ -0,0 +1,13 @@
{
"token": "SELF TOKEN HERE",
"acceptedAuthors": ["USER_ID_HERE"],
"streamOpts": {
"width": 1280,
"height": 720,
"fps": 30,
"bitrateKbps": 1000,
"maxBitrateKbps": 2500,
"hardware_acceleration": false,
"videoCodec": "H264"
}
}
@@ -0,0 +1,104 @@
import { Client, StageChannel } from 'discord.js-selfbot-v13';
import { Streamer, Utils, prepareStream, playStream } from "@dank074/discord-video-stream";
import { executablePath } from 'puppeteer';
import { launch, getStream } from 'puppeteer-stream';
import config from "./config.json" with {type: "json"};
type BrowserOptions = {
width: number,
height: number
}
const streamer = new Streamer(new Client());
let browser: Awaited<ReturnType<typeof launch>>;
// ready event
streamer.client.on("ready", () => {
console.log(`--- ${streamer.client.user?.tag} is ready ---`);
});
let controller: AbortController;
// message event
streamer.client.on("messageCreate", async (msg) => {
if (msg.author.bot) return;
if (!config.acceptedAuthors.includes(msg.author.id)) return;
if (!msg.content) return;
if (msg.content.startsWith("$play-screen")) {
const args = msg.content.split(" ");
if (args.length < 2) return;
const url = args[1];
if (!url) return;
const channel = msg.author.voice?.channel;
if (!channel) return;
console.log(`Attempting to join voice channel ${msg.guildId}/${channel.id}`);
await streamer.joinVoice(msg.guildId!, channel.id);
if (channel instanceof StageChannel)
{
await streamer.client.user?.voice?.setSuppressed(false);
}
controller?.abort();
controller = new AbortController();
await streamPuppeteer(url, streamer, {
width: config.streamOpts.width,
height: config.streamOpts.height
}, controller.signal);
} else if (msg.content.startsWith("$disconnect")) {
controller?.abort();
streamer.leaveVoice();
}
})
// login
streamer.client.login(config.token);
async function streamPuppeteer(url: string, streamer: Streamer, opts: BrowserOptions, cancelSignal?: AbortSignal) {
cancelSignal?.throwIfAborted();
cancelSignal?.addEventListener("abort", () => {
browser.close();
}, { once: true });
browser = await launch({
defaultViewport: {
width: opts.width,
height: opts.height,
},
executablePath: executablePath()
});
const page = await browser.newPage();
await page.goto(url);
const stream = await getStream(page, { audio: true, video: true, mimeType: "video/webm;codecs=vp8,opus" });
try {
const { command, output } = prepareStream(stream, {
frameRate: config.streamOpts.fps,
bitrateVideo: config.streamOpts.bitrateKbps,
bitrateVideoMax: config.streamOpts.maxBitrateKbps,
hardwareAcceleratedDecoding: config.streamOpts.hardware_acceleration,
videoCodec: Utils.normalizeVideoCodec(config.streamOpts.videoCodec)
}, cancelSignal);
command.on("error", (err, stdout, stderr) => {
console.log("An error occurred with ffmpeg");
console.log(err)
});
await playStream(output, streamer, {
// Use this to catch up with ffmpeg
readrateInitialBurst: 10
}, cancelSignal);
console.log("Finished playing video");
} catch (e) {
console.log(e);
}
}
@@ -0,0 +1,106 @@
{
"compilerOptions": {
/* Visit https://aka.ms/tsconfig to read more about this file */
/* Projects */
// "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */
// "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */
// "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */
// "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */
// "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */
// "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */
/* Language and Environment */
"target": "ESNext", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */
// "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */
// "jsx": "preserve", /* Specify what JSX code is generated. */
// "experimentalDecorators": true, /* Enable experimental support for TC39 stage 2 draft decorators. */
// "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */
// "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */
// "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */
// "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */
// "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */
// "noLib": true, /* Disable including any library files, including the default lib.d.ts. */
// "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */
// "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */
/* Modules */
"module": "NodeNext", /* Specify what module code is generated. */
// "rootDir": "./", /* Specify the root folder within your source files. */
"moduleResolution": "NodeNext", /* Specify how TypeScript looks up a file from a given module specifier. */
// "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */
// "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */
// "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */
// "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */
// "types": [], /* Specify type package names to be included without being referenced in a source file. */
// "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
// "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */
"resolveJsonModule": true, /* Enable importing .json files. */
// "noResolve": true, /* Disallow 'import's, 'require's or '<reference>'s from expanding the number of files TypeScript should add to a project. */
/* JavaScript Support */
"allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */
// "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */
// "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */
/* Emit */
"declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */
// "declarationMap": true, /* Create sourcemaps for d.ts files. */
// "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */
// "sourceMap": true, /* Create source map files for emitted JavaScript files. */
// "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */
"outDir": "./dist", /* Specify an output folder for all emitted files. */
// "removeComments": true, /* Disable emitting comments. */
// "noEmit": true, /* Disable emitting files from a compilation. */
// "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */
// "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types. */
// "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */
// "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */
// "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
// "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */
// "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */
// "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */
// "newLine": "crlf", /* Set the newline character for emitting files. */
// "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */
// "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */
// "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */
// "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */
// "declarationDir": "./", /* Specify the output directory for generated declaration files. */
// "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */
/* Interop Constraints */
// "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */
// "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */
"esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */
// "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */
"forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */
/* Type Checking */
"strict": true, /* Enable all strict type-checking options. */
"noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */
// "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */
// "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */
// "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */
// "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */
// "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */
// "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */
// "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */
// "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */
// "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */
// "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */
// "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */
// "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */
// "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */
// "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */
// "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */
// "allowUnusedLabels": true, /* Disable error reporting for unused labels. */
// "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */
/* Completeness */
// "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */
"skipLibCheck": true /* Skip type checking all .d.ts files. */
},
"include": [
"src/**/*"
]
}
+70
View File
@@ -0,0 +1,70 @@
{
"name": "@dank074/discord-video-stream",
"version": "6.0.0",
"description": "Experiment for making video streaming work for discord selfbots",
"exports": "./dist/index.js",
"types": "dist/index.d.ts",
"type": "module",
"files": [
"dist",
"src"
],
"publishConfig": {
"access": "public"
},
"dependencies": {
"@lng2004/node-datachannel": "0.32.0-20260202",
"@snazzah/davey": "^0.1.8",
"debug-level": "^4.1.1",
"fluent-ffmpeg-simplified": "^0.1.0",
"node-av": "^5.2.2",
"p-debounce": "^5.1.0",
"sharp": "^0.34.5",
"zeromq": "^6.5.0"
},
"devDependencies": {
"@biomejs/biome": "2.3.8",
"@types/fluent-ffmpeg": "^2.1.28",
"@types/node": "^25.0.1",
"discord.js-selfbot-v13": "workspace:*",
"pkg-pr-new": "^0.0.62",
"typescript": "^5.9.3"
},
"peerDependencies": {
"discord.js-selfbot-v13": "^3.6.0"
},
"engines": {
"node": ">=22.4.0"
},
"scripts": {
"build": "tsc",
"lint": "biome lint --error-on-warnings .",
"lint:fix": "biome lint --write .",
"check": "biome check .",
"check:fix": "biome check --write ."
},
"keywords": [
"discord",
"video",
"voice",
"stream",
"go-live"
],
"repository": {
"type": "git",
"url": "git+https://github.com/dank074/Discord-video-stream.git"
},
"contributors": [
"Long Nguyen <nguyen.long.908132@gmail.com>",
"s074 <torresefrain10@gmail.com>",
"mrjvs <jellevs@gmail.com>",
"Elysia <71698422+aiko-chan-ai@users.noreply.github.com>",
"Fede14 <fede.ferri2001@gmail.com>",
"Malthe Morsing Larsen <57196060+malthemorsing@users.noreply.github.com>"
],
"license": "ISC",
"bugs": {
"url": "https://github.com/dank074/Discord-video-stream/issues"
},
"homepage": "https://github.com/dank074/Discord-video-stream#readme"
}
+2689
View File
File diff suppressed because it is too large Load Diff
+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";
+3
View File
@@ -0,0 +1,3 @@
export * from "./client/index.js";
export * from "./media/index.js";
export * as Utils from "./utils.js";
+18
View File
@@ -0,0 +1,18 @@
import { BaseMediaStream } from "./BaseMediaStream.js";
import type { WebRtcConnWrapper } from "../client/voice/WebRtcWrapper.js";
export class AudioStream extends BaseMediaStream {
private _conn: WebRtcConnWrapper;
constructor(conn: WebRtcConnWrapper, noSleep = false) {
super("audio", noSleep);
this._conn = conn;
}
protected override async _sendFrame(
frame: Buffer,
frametime: number,
): Promise<void> {
this._conn.sendAudioFrame(frame, frametime);
}
}
+198
View File
@@ -0,0 +1,198 @@
import { Log } from "debug-level";
import { setTimeout } from "node:timers/promises";
import { Writable } from "node:stream";
import type { Packet } from "node-av";
export class BaseMediaStream extends Writable {
private _pts?: number;
private _syncTolerance = 20;
private _loggerSend: Log;
private _loggerSync: Log;
private _loggerSleep: Log;
private _noSleep: boolean;
private _startTime?: number;
private _startPts?: number;
private _sync = true;
private _syncStream?: BaseMediaStream;
constructor(type: string, noSleep = false) {
super({ objectMode: true, highWaterMark: 0 });
this._loggerSend = new Log(`stream:${type}:send`);
this._loggerSync = new Log(`stream:${type}:sync`);
this._loggerSleep = new Log(`stream:${type}:sleep`);
this._noSleep = noSleep;
}
get sync(): boolean {
return this._sync;
}
set sync(val: boolean) {
this._sync = val;
if (val) this._loggerSync.debug("Sync enabled");
else this._loggerSync.debug("Sync disabled");
}
get syncStream() {
return this._syncStream;
}
set syncStream(stream: BaseMediaStream | undefined) {
if (stream !== undefined && this === stream.syncStream)
throw new Error("Cannot sync 2 streams with eachother");
this._syncStream = stream;
}
get noSleep(): boolean {
return this._noSleep;
}
set noSleep(val: boolean) {
this._noSleep = val;
if (!val) this.resetTimingCompensation();
}
get pts(): number | undefined {
return this._pts;
}
get syncTolerance() {
return this._syncTolerance;
}
set syncTolerance(n: number) {
if (n < 0) return;
this._syncTolerance = n;
}
protected async _sendFrame(
_frame: Buffer,
_frametime: number,
): Promise<void> {
throw new Error("Not implemented");
}
private ptsDelta() {
if (this.pts !== undefined && this.syncStream?.pts !== undefined)
return this.pts - this.syncStream.pts;
return undefined;
}
private isAhead() {
const delta = this.ptsDelta();
return (
this.syncStream?.writableEnded === false &&
delta !== undefined &&
delta > this.syncTolerance
);
}
private isBehind() {
const delta = this.ptsDelta();
return (
this.syncStream?.writableEnded === false &&
delta !== undefined &&
delta < -this.syncTolerance
);
}
private resetTimingCompensation() {
this._startTime = this._startPts = undefined;
}
async _write(
frame: Packet,
_: BufferEncoding,
callback: (error?: Error | null) => void,
) {
const { data, pts, duration, timeBase } = frame;
if (!data) {
frame.free();
callback();
return;
}
const frametime = (Number(duration) / timeBase.den) * timeBase.num * 1000;
const start_sendFrame = performance.now();
await this._sendFrame(Buffer.from(data), frametime);
const end_sendFrame = performance.now();
this._pts = (Number(pts) / timeBase.den) * timeBase.num * 1000;
this.emit("pts", this._pts);
const sendTime = end_sendFrame - start_sendFrame;
const ratio = sendTime / frametime;
this._loggerSend.debug(
{
stats: {
pts: this._pts,
frame_size: data.length,
duration: sendTime,
frametime,
},
},
`Frame sent in ${sendTime.toFixed(2)}ms (${(ratio * 100).toFixed(2)}% frametime)`,
);
if (ratio > 1) {
this._loggerSend.warn(
{
frame_size: data.length,
duration: sendTime,
frametime,
},
`Frame takes too long to send (${(ratio * 100).toFixed(2)}% frametime)`,
);
}
this._startTime ??= start_sendFrame;
this._startPts ??= this._pts;
const sleep = Math.max(
0,
this._pts -
this._startPts +
frametime -
(end_sendFrame - this._startTime),
);
if (this._noSleep || sleep === 0) {
callback(null);
} else if (this.sync && this.isBehind()) {
this._loggerSync.debug(
{
stats: {
pts: this.pts,
pts_other: this.syncStream?.pts,
},
},
"Stream is behind. Not sleeping for this frame",
);
this.resetTimingCompensation();
callback(null);
} else if (this.sync && this.isAhead()) {
do {
this._loggerSync.debug(
{
stats: {
pts: this.pts,
pts_other: this.syncStream?.pts,
frametime,
},
},
`Stream is ahead. Waiting for ${frametime}ms`,
);
await setTimeout(frametime);
} while (this.sync && this.isAhead());
this.resetTimingCompensation();
callback(null);
} else {
this._loggerSleep.debug(
{
stats: {
pts: this._pts,
startPts: this._startPts,
time: end_sendFrame,
startTime: this._startTime,
frametime,
},
},
`Sleeping for ${sleep}ms`,
);
setTimeout(sleep).then(() => callback(null));
}
frame.free();
}
_destroy(
error: Error | null,
callback: (error?: Error | null) => void,
): void {
super._destroy(error, callback);
this.syncStream = undefined;
}
}
+564
View File
@@ -0,0 +1,564 @@
// https://ffmpeg.org/doxygen/7.0/codec__id_8h_source.html
export enum AVCodecID {
AV_CODEC_ID_NONE,
/* video codecs */
AV_CODEC_ID_MPEG1VIDEO,
AV_CODEC_ID_MPEG2VIDEO, ///< preferred ID for MPEG-1/2 video decoding
AV_CODEC_ID_H261,
AV_CODEC_ID_H263,
AV_CODEC_ID_RV10,
AV_CODEC_ID_RV20,
AV_CODEC_ID_MJPEG,
AV_CODEC_ID_MJPEGB,
AV_CODEC_ID_LJPEG,
AV_CODEC_ID_SP5X,
AV_CODEC_ID_JPEGLS,
AV_CODEC_ID_MPEG4,
AV_CODEC_ID_RAWVIDEO,
AV_CODEC_ID_MSMPEG4V1,
AV_CODEC_ID_MSMPEG4V2,
AV_CODEC_ID_MSMPEG4V3,
AV_CODEC_ID_WMV1,
AV_CODEC_ID_WMV2,
AV_CODEC_ID_H263P,
AV_CODEC_ID_H263I,
AV_CODEC_ID_FLV1,
AV_CODEC_ID_SVQ1,
AV_CODEC_ID_SVQ3,
AV_CODEC_ID_DVVIDEO,
AV_CODEC_ID_HUFFYUV,
AV_CODEC_ID_CYUV,
AV_CODEC_ID_H264,
AV_CODEC_ID_INDEO3,
AV_CODEC_ID_VP3,
AV_CODEC_ID_THEORA,
AV_CODEC_ID_ASV1,
AV_CODEC_ID_ASV2,
AV_CODEC_ID_FFV1,
AV_CODEC_ID_4XM,
AV_CODEC_ID_VCR1,
AV_CODEC_ID_CLJR,
AV_CODEC_ID_MDEC,
AV_CODEC_ID_ROQ,
AV_CODEC_ID_INTERPLAY_VIDEO,
AV_CODEC_ID_XAN_WC3,
AV_CODEC_ID_XAN_WC4,
AV_CODEC_ID_RPZA,
AV_CODEC_ID_CINEPAK,
AV_CODEC_ID_WS_VQA,
AV_CODEC_ID_MSRLE,
AV_CODEC_ID_MSVIDEO1,
AV_CODEC_ID_IDCIN,
AV_CODEC_ID_8BPS,
AV_CODEC_ID_SMC,
AV_CODEC_ID_FLIC,
AV_CODEC_ID_TRUEMOTION1,
AV_CODEC_ID_VMDVIDEO,
AV_CODEC_ID_MSZH,
AV_CODEC_ID_ZLIB,
AV_CODEC_ID_QTRLE,
AV_CODEC_ID_TSCC,
AV_CODEC_ID_ULTI,
AV_CODEC_ID_QDRAW,
AV_CODEC_ID_VIXL,
AV_CODEC_ID_QPEG,
AV_CODEC_ID_PNG,
AV_CODEC_ID_PPM,
AV_CODEC_ID_PBM,
AV_CODEC_ID_PGM,
AV_CODEC_ID_PGMYUV,
AV_CODEC_ID_PAM,
AV_CODEC_ID_FFVHUFF,
AV_CODEC_ID_RV30,
AV_CODEC_ID_RV40,
AV_CODEC_ID_VC1,
AV_CODEC_ID_WMV3,
AV_CODEC_ID_LOCO,
AV_CODEC_ID_WNV1,
AV_CODEC_ID_AASC,
AV_CODEC_ID_INDEO2,
AV_CODEC_ID_FRAPS,
AV_CODEC_ID_TRUEMOTION2,
AV_CODEC_ID_BMP,
AV_CODEC_ID_CSCD,
AV_CODEC_ID_MMVIDEO,
AV_CODEC_ID_ZMBV,
AV_CODEC_ID_AVS,
AV_CODEC_ID_SMACKVIDEO,
AV_CODEC_ID_NUV,
AV_CODEC_ID_KMVC,
AV_CODEC_ID_FLASHSV,
AV_CODEC_ID_CAVS,
AV_CODEC_ID_JPEG2000,
AV_CODEC_ID_VMNC,
AV_CODEC_ID_VP5,
AV_CODEC_ID_VP6,
AV_CODEC_ID_VP6F,
AV_CODEC_ID_TARGA,
AV_CODEC_ID_DSICINVIDEO,
AV_CODEC_ID_TIERTEXSEQVIDEO,
AV_CODEC_ID_TIFF,
AV_CODEC_ID_GIF,
AV_CODEC_ID_DXA,
AV_CODEC_ID_DNXHD,
AV_CODEC_ID_THP,
AV_CODEC_ID_SGI,
AV_CODEC_ID_C93,
AV_CODEC_ID_BETHSOFTVID,
AV_CODEC_ID_PTX,
AV_CODEC_ID_TXD,
AV_CODEC_ID_VP6A,
AV_CODEC_ID_AMV,
AV_CODEC_ID_VB,
AV_CODEC_ID_PCX,
AV_CODEC_ID_SUNRAST,
AV_CODEC_ID_INDEO4,
AV_CODEC_ID_INDEO5,
AV_CODEC_ID_MIMIC,
AV_CODEC_ID_RL2,
AV_CODEC_ID_ESCAPE124,
AV_CODEC_ID_DIRAC,
AV_CODEC_ID_BFI,
AV_CODEC_ID_CMV,
AV_CODEC_ID_MOTIONPIXELS,
AV_CODEC_ID_TGV,
AV_CODEC_ID_TGQ,
AV_CODEC_ID_TQI,
AV_CODEC_ID_AURA,
AV_CODEC_ID_AURA2,
AV_CODEC_ID_V210X,
AV_CODEC_ID_TMV,
AV_CODEC_ID_V210,
AV_CODEC_ID_DPX,
AV_CODEC_ID_MAD,
AV_CODEC_ID_FRWU,
AV_CODEC_ID_FLASHSV2,
AV_CODEC_ID_CDGRAPHICS,
AV_CODEC_ID_R210,
AV_CODEC_ID_ANM,
AV_CODEC_ID_BINKVIDEO,
AV_CODEC_ID_IFF_ILBM,
AV_CODEC_ID_IFF_BYTERUN1 = AV_CODEC_ID_IFF_ILBM,
AV_CODEC_ID_KGV1,
AV_CODEC_ID_YOP,
AV_CODEC_ID_VP8,
AV_CODEC_ID_PICTOR,
AV_CODEC_ID_ANSI,
AV_CODEC_ID_A64_MULTI,
AV_CODEC_ID_A64_MULTI5,
AV_CODEC_ID_R10K,
AV_CODEC_ID_MXPEG,
AV_CODEC_ID_LAGARITH,
AV_CODEC_ID_PRORES,
AV_CODEC_ID_JV,
AV_CODEC_ID_DFA,
AV_CODEC_ID_WMV3IMAGE,
AV_CODEC_ID_VC1IMAGE,
AV_CODEC_ID_UTVIDEO,
AV_CODEC_ID_BMV_VIDEO,
AV_CODEC_ID_VBLE,
AV_CODEC_ID_DXTORY,
AV_CODEC_ID_V410,
AV_CODEC_ID_XWD,
AV_CODEC_ID_CDXL,
AV_CODEC_ID_XBM,
AV_CODEC_ID_ZEROCODEC,
AV_CODEC_ID_MSS1,
AV_CODEC_ID_MSA1,
AV_CODEC_ID_TSCC2,
AV_CODEC_ID_MTS2,
AV_CODEC_ID_CLLC,
AV_CODEC_ID_MSS2,
AV_CODEC_ID_VP9,
AV_CODEC_ID_AIC,
AV_CODEC_ID_ESCAPE130,
AV_CODEC_ID_G2M,
AV_CODEC_ID_WEBP,
AV_CODEC_ID_HNM4_VIDEO,
AV_CODEC_ID_HEVC,
AV_CODEC_ID_H265 = AV_CODEC_ID_HEVC,
AV_CODEC_ID_FIC,
AV_CODEC_ID_ALIAS_PIX,
AV_CODEC_ID_BRENDER_PIX,
AV_CODEC_ID_PAF_VIDEO,
AV_CODEC_ID_EXR,
AV_CODEC_ID_VP7,
AV_CODEC_ID_SANM,
AV_CODEC_ID_SGIRLE,
AV_CODEC_ID_MVC1,
AV_CODEC_ID_MVC2,
AV_CODEC_ID_HQX,
AV_CODEC_ID_TDSC,
AV_CODEC_ID_HQ_HQA,
AV_CODEC_ID_HAP,
AV_CODEC_ID_DDS,
AV_CODEC_ID_DXV,
AV_CODEC_ID_SCREENPRESSO,
AV_CODEC_ID_RSCC,
AV_CODEC_ID_AVS2,
AV_CODEC_ID_PGX,
AV_CODEC_ID_AVS3,
AV_CODEC_ID_MSP2,
AV_CODEC_ID_VVC,
AV_CODEC_ID_H266 = AV_CODEC_ID_VVC,
AV_CODEC_ID_Y41P,
AV_CODEC_ID_AVRP,
AV_CODEC_ID_012V,
AV_CODEC_ID_AVUI,
AV_CODEC_ID_TARGA_Y216,
AV_CODEC_ID_V308,
AV_CODEC_ID_V408,
AV_CODEC_ID_YUV4,
AV_CODEC_ID_AVRN,
AV_CODEC_ID_CPIA,
AV_CODEC_ID_XFACE,
AV_CODEC_ID_SNOW,
AV_CODEC_ID_SMVJPEG,
AV_CODEC_ID_APNG,
AV_CODEC_ID_DAALA,
AV_CODEC_ID_CFHD,
AV_CODEC_ID_TRUEMOTION2RT,
AV_CODEC_ID_M101,
AV_CODEC_ID_MAGICYUV,
AV_CODEC_ID_SHEERVIDEO,
AV_CODEC_ID_YLC,
AV_CODEC_ID_PSD,
AV_CODEC_ID_PIXLET,
AV_CODEC_ID_SPEEDHQ,
AV_CODEC_ID_FMVC,
AV_CODEC_ID_SCPR,
AV_CODEC_ID_CLEARVIDEO,
AV_CODEC_ID_XPM,
AV_CODEC_ID_AV1,
AV_CODEC_ID_BITPACKED,
AV_CODEC_ID_MSCC,
AV_CODEC_ID_SRGC,
AV_CODEC_ID_SVG,
AV_CODEC_ID_GDV,
AV_CODEC_ID_FITS,
AV_CODEC_ID_IMM4,
AV_CODEC_ID_PROSUMER,
AV_CODEC_ID_MWSC,
AV_CODEC_ID_WCMV,
AV_CODEC_ID_RASC,
AV_CODEC_ID_HYMT,
AV_CODEC_ID_ARBC,
AV_CODEC_ID_AGM,
AV_CODEC_ID_LSCR,
AV_CODEC_ID_VP4,
AV_CODEC_ID_IMM5,
AV_CODEC_ID_MVDV,
AV_CODEC_ID_MVHA,
AV_CODEC_ID_CDTOONS,
AV_CODEC_ID_MV30,
AV_CODEC_ID_NOTCHLC,
AV_CODEC_ID_PFM,
AV_CODEC_ID_MOBICLIP,
AV_CODEC_ID_PHOTOCD,
AV_CODEC_ID_IPU,
AV_CODEC_ID_ARGO,
AV_CODEC_ID_CRI,
AV_CODEC_ID_SIMBIOSIS_IMX,
AV_CODEC_ID_SGA_VIDEO,
AV_CODEC_ID_GEM,
AV_CODEC_ID_VBN,
AV_CODEC_ID_JPEGXL,
AV_CODEC_ID_QOI,
AV_CODEC_ID_PHM,
AV_CODEC_ID_RADIANCE_HDR,
AV_CODEC_ID_WBMP,
AV_CODEC_ID_MEDIA100,
AV_CODEC_ID_VQC,
AV_CODEC_ID_PDV,
AV_CODEC_ID_EVC,
AV_CODEC_ID_RTV1,
AV_CODEC_ID_VMIX,
AV_CODEC_ID_LEAD,
/* various PCM "codecs" */
AV_CODEC_ID_FIRST_AUDIO = 0x10000, ///< A dummy id pointing at the start of audio codecs
AV_CODEC_ID_PCM_S16LE = 0x10000,
AV_CODEC_ID_PCM_S16BE,
AV_CODEC_ID_PCM_U16LE,
AV_CODEC_ID_PCM_U16BE,
AV_CODEC_ID_PCM_S8,
AV_CODEC_ID_PCM_U8,
AV_CODEC_ID_PCM_MULAW,
AV_CODEC_ID_PCM_ALAW,
AV_CODEC_ID_PCM_S32LE,
AV_CODEC_ID_PCM_S32BE,
AV_CODEC_ID_PCM_U32LE,
AV_CODEC_ID_PCM_U32BE,
AV_CODEC_ID_PCM_S24LE,
AV_CODEC_ID_PCM_S24BE,
AV_CODEC_ID_PCM_U24LE,
AV_CODEC_ID_PCM_U24BE,
AV_CODEC_ID_PCM_S24DAUD,
AV_CODEC_ID_PCM_ZORK,
AV_CODEC_ID_PCM_S16LE_PLANAR,
AV_CODEC_ID_PCM_DVD,
AV_CODEC_ID_PCM_F32BE,
AV_CODEC_ID_PCM_F32LE,
AV_CODEC_ID_PCM_F64BE,
AV_CODEC_ID_PCM_F64LE,
AV_CODEC_ID_PCM_BLURAY,
AV_CODEC_ID_PCM_LXF,
AV_CODEC_ID_S302M,
AV_CODEC_ID_PCM_S8_PLANAR,
AV_CODEC_ID_PCM_S24LE_PLANAR,
AV_CODEC_ID_PCM_S32LE_PLANAR,
AV_CODEC_ID_PCM_S16BE_PLANAR,
AV_CODEC_ID_PCM_S64LE,
AV_CODEC_ID_PCM_S64BE,
AV_CODEC_ID_PCM_F16LE,
AV_CODEC_ID_PCM_F24LE,
AV_CODEC_ID_PCM_VIDC,
AV_CODEC_ID_PCM_SGA,
/* various ADPCM codecs */
AV_CODEC_ID_ADPCM_IMA_QT = 0x11000,
AV_CODEC_ID_ADPCM_IMA_WAV,
AV_CODEC_ID_ADPCM_IMA_DK3,
AV_CODEC_ID_ADPCM_IMA_DK4,
AV_CODEC_ID_ADPCM_IMA_WS,
AV_CODEC_ID_ADPCM_IMA_SMJPEG,
AV_CODEC_ID_ADPCM_MS,
AV_CODEC_ID_ADPCM_4XM,
AV_CODEC_ID_ADPCM_XA,
AV_CODEC_ID_ADPCM_ADX,
AV_CODEC_ID_ADPCM_EA,
AV_CODEC_ID_ADPCM_G726,
AV_CODEC_ID_ADPCM_CT,
AV_CODEC_ID_ADPCM_SWF,
AV_CODEC_ID_ADPCM_YAMAHA,
AV_CODEC_ID_ADPCM_SBPRO_4,
AV_CODEC_ID_ADPCM_SBPRO_3,
AV_CODEC_ID_ADPCM_SBPRO_2,
AV_CODEC_ID_ADPCM_THP,
AV_CODEC_ID_ADPCM_IMA_AMV,
AV_CODEC_ID_ADPCM_EA_R1,
AV_CODEC_ID_ADPCM_EA_R3,
AV_CODEC_ID_ADPCM_EA_R2,
AV_CODEC_ID_ADPCM_IMA_EA_SEAD,
AV_CODEC_ID_ADPCM_IMA_EA_EACS,
AV_CODEC_ID_ADPCM_EA_XAS,
AV_CODEC_ID_ADPCM_EA_MAXIS_XA,
AV_CODEC_ID_ADPCM_IMA_ISS,
AV_CODEC_ID_ADPCM_G722,
AV_CODEC_ID_ADPCM_IMA_APC,
AV_CODEC_ID_ADPCM_VIMA,
AV_CODEC_ID_ADPCM_AFC,
AV_CODEC_ID_ADPCM_IMA_OKI,
AV_CODEC_ID_ADPCM_DTK,
AV_CODEC_ID_ADPCM_IMA_RAD,
AV_CODEC_ID_ADPCM_G726LE,
AV_CODEC_ID_ADPCM_THP_LE,
AV_CODEC_ID_ADPCM_PSX,
AV_CODEC_ID_ADPCM_AICA,
AV_CODEC_ID_ADPCM_IMA_DAT4,
AV_CODEC_ID_ADPCM_MTAF,
AV_CODEC_ID_ADPCM_AGM,
AV_CODEC_ID_ADPCM_ARGO,
AV_CODEC_ID_ADPCM_IMA_SSI,
AV_CODEC_ID_ADPCM_ZORK,
AV_CODEC_ID_ADPCM_IMA_APM,
AV_CODEC_ID_ADPCM_IMA_ALP,
AV_CODEC_ID_ADPCM_IMA_MTF,
AV_CODEC_ID_ADPCM_IMA_CUNNING,
AV_CODEC_ID_ADPCM_IMA_MOFLEX,
AV_CODEC_ID_ADPCM_IMA_ACORN,
AV_CODEC_ID_ADPCM_XMD,
/* AMR */
AV_CODEC_ID_AMR_NB = 0x12000,
AV_CODEC_ID_AMR_WB,
/* RealAudio codecs*/
AV_CODEC_ID_RA_144 = 0x13000,
AV_CODEC_ID_RA_288,
/* various DPCM codecs */
AV_CODEC_ID_ROQ_DPCM = 0x14000,
AV_CODEC_ID_INTERPLAY_DPCM,
AV_CODEC_ID_XAN_DPCM,
AV_CODEC_ID_SOL_DPCM,
AV_CODEC_ID_SDX2_DPCM,
AV_CODEC_ID_GREMLIN_DPCM,
AV_CODEC_ID_DERF_DPCM,
AV_CODEC_ID_WADY_DPCM,
AV_CODEC_ID_CBD2_DPCM,
/* audio codecs */
AV_CODEC_ID_MP2 = 0x15000,
AV_CODEC_ID_MP3, ///< preferred ID for decoding MPEG audio layer 1, 2 or 3
AV_CODEC_ID_AAC,
AV_CODEC_ID_AC3,
AV_CODEC_ID_DTS,
AV_CODEC_ID_VORBIS,
AV_CODEC_ID_DVAUDIO,
AV_CODEC_ID_WMAV1,
AV_CODEC_ID_WMAV2,
AV_CODEC_ID_MACE3,
AV_CODEC_ID_MACE6,
AV_CODEC_ID_VMDAUDIO,
AV_CODEC_ID_FLAC,
AV_CODEC_ID_MP3ADU,
AV_CODEC_ID_MP3ON4,
AV_CODEC_ID_SHORTEN,
AV_CODEC_ID_ALAC,
AV_CODEC_ID_WESTWOOD_SND1,
AV_CODEC_ID_GSM, ///< as in Berlin toast format
AV_CODEC_ID_QDM2,
AV_CODEC_ID_COOK,
AV_CODEC_ID_TRUESPEECH,
AV_CODEC_ID_TTA,
AV_CODEC_ID_SMACKAUDIO,
AV_CODEC_ID_QCELP,
AV_CODEC_ID_WAVPACK,
AV_CODEC_ID_DSICINAUDIO,
AV_CODEC_ID_IMC,
AV_CODEC_ID_MUSEPACK7,
AV_CODEC_ID_MLP,
AV_CODEC_ID_GSM_MS /* as found in WAV */,
AV_CODEC_ID_ATRAC3,
AV_CODEC_ID_APE,
AV_CODEC_ID_NELLYMOSER,
AV_CODEC_ID_MUSEPACK8,
AV_CODEC_ID_SPEEX,
AV_CODEC_ID_WMAVOICE,
AV_CODEC_ID_WMAPRO,
AV_CODEC_ID_WMALOSSLESS,
AV_CODEC_ID_ATRAC3P,
AV_CODEC_ID_EAC3,
AV_CODEC_ID_SIPR,
AV_CODEC_ID_MP1,
AV_CODEC_ID_TWINVQ,
AV_CODEC_ID_TRUEHD,
AV_CODEC_ID_MP4ALS,
AV_CODEC_ID_ATRAC1,
AV_CODEC_ID_BINKAUDIO_RDFT,
AV_CODEC_ID_BINKAUDIO_DCT,
AV_CODEC_ID_AAC_LATM,
AV_CODEC_ID_QDMC,
AV_CODEC_ID_CELT,
AV_CODEC_ID_G723_1,
AV_CODEC_ID_G729,
AV_CODEC_ID_8SVX_EXP,
AV_CODEC_ID_8SVX_FIB,
AV_CODEC_ID_BMV_AUDIO,
AV_CODEC_ID_RALF,
AV_CODEC_ID_IAC,
AV_CODEC_ID_ILBC,
AV_CODEC_ID_OPUS,
AV_CODEC_ID_COMFORT_NOISE,
AV_CODEC_ID_TAK,
AV_CODEC_ID_METASOUND,
AV_CODEC_ID_PAF_AUDIO,
AV_CODEC_ID_ON2AVC,
AV_CODEC_ID_DSS_SP,
AV_CODEC_ID_CODEC2,
AV_CODEC_ID_FFWAVESYNTH,
AV_CODEC_ID_SONIC,
AV_CODEC_ID_SONIC_LS,
AV_CODEC_ID_EVRC,
AV_CODEC_ID_SMV,
AV_CODEC_ID_DSD_LSBF,
AV_CODEC_ID_DSD_MSBF,
AV_CODEC_ID_DSD_LSBF_PLANAR,
AV_CODEC_ID_DSD_MSBF_PLANAR,
AV_CODEC_ID_4GV,
AV_CODEC_ID_INTERPLAY_ACM,
AV_CODEC_ID_XMA1,
AV_CODEC_ID_XMA2,
AV_CODEC_ID_DST,
AV_CODEC_ID_ATRAC3AL,
AV_CODEC_ID_ATRAC3PAL,
AV_CODEC_ID_DOLBY_E,
AV_CODEC_ID_APTX,
AV_CODEC_ID_APTX_HD,
AV_CODEC_ID_SBC,
AV_CODEC_ID_ATRAC9,
AV_CODEC_ID_HCOM,
AV_CODEC_ID_ACELP_KELVIN,
AV_CODEC_ID_MPEGH_3D_AUDIO,
AV_CODEC_ID_SIREN,
AV_CODEC_ID_HCA,
AV_CODEC_ID_FASTAUDIO,
AV_CODEC_ID_MSNSIREN,
AV_CODEC_ID_DFPWM,
AV_CODEC_ID_BONK,
AV_CODEC_ID_MISC4,
AV_CODEC_ID_APAC,
AV_CODEC_ID_FTR,
AV_CODEC_ID_WAVARC,
AV_CODEC_ID_RKA,
AV_CODEC_ID_AC4,
AV_CODEC_ID_OSQ,
AV_CODEC_ID_QOA,
/* subtitle codecs */
AV_CODEC_ID_FIRST_SUBTITLE = 0x17000, ///< A dummy ID pointing at the start of subtitle codecs.
AV_CODEC_ID_DVD_SUBTITLE = 0x17000,
AV_CODEC_ID_DVB_SUBTITLE,
AV_CODEC_ID_TEXT, ///< raw UTF-8 text
AV_CODEC_ID_XSUB,
AV_CODEC_ID_SSA,
AV_CODEC_ID_MOV_TEXT,
AV_CODEC_ID_HDMV_PGS_SUBTITLE,
AV_CODEC_ID_DVB_TELETEXT,
AV_CODEC_ID_SRT,
AV_CODEC_ID_MICRODVD,
AV_CODEC_ID_EIA_608,
AV_CODEC_ID_JACOSUB,
AV_CODEC_ID_SAMI,
AV_CODEC_ID_REALTEXT,
AV_CODEC_ID_STL,
AV_CODEC_ID_SUBVIEWER1,
AV_CODEC_ID_SUBVIEWER,
AV_CODEC_ID_SUBRIP,
AV_CODEC_ID_WEBVTT,
AV_CODEC_ID_MPL2,
AV_CODEC_ID_VPLAYER,
AV_CODEC_ID_PJS,
AV_CODEC_ID_ASS,
AV_CODEC_ID_HDMV_TEXT_SUBTITLE,
AV_CODEC_ID_TTML,
AV_CODEC_ID_ARIB_CAPTION,
/* other specific kind of codecs (generally used for attachments) */
AV_CODEC_ID_FIRST_UNKNOWN = 0x18000, ///< A dummy ID pointing at the start of various fake codecs.
AV_CODEC_ID_TTF = 0x18000,
AV_CODEC_ID_SCTE_35, ///< Contain timestamp estimated through PCR of program stream.
AV_CODEC_ID_EPG,
AV_CODEC_ID_BINTEXT,
AV_CODEC_ID_XBIN,
AV_CODEC_ID_IDF,
AV_CODEC_ID_OTF,
AV_CODEC_ID_SMPTE_KLV,
AV_CODEC_ID_DVD_NAV,
AV_CODEC_ID_TIMED_ID3,
AV_CODEC_ID_BIN_DATA,
AV_CODEC_ID_SMPTE_2038,
AV_CODEC_ID_PROBE = 0x19000, ///< codec_id is not known (like AV_CODEC_ID_NONE) but lavf should attempt to identify it
AV_CODEC_ID_MPEG2TS = 0x20000 /**< _FAKE_ codec to indicate a raw MPEG-2 TS
* stream (only used by libavformat) */,
AV_CODEC_ID_MPEG4SYSTEMS = 0x20001 /**< _FAKE_ codec to indicate a MPEG-4 Systems
* stream (only used by libavformat) */,
AV_CODEC_ID_FFMETADATA = 0x21000, ///< Dummy codec for streams containing only metadata information.
AV_CODEC_ID_WRAPPED_AVFRAME = 0x21001, ///< Passthrough codec, AVFrames wrapped in AVPacket
/**
* Dummy null video codec, useful mainly for development and debugging.
* Null encoder/decoder discard all input and never return any output.
*/
AV_CODEC_ID_VNULL,
/**
* Dummy null audio codec, useful mainly for development and debugging.
* Null encoder/decoder discard all input and never return any output.
*/
AV_CODEC_ID_ANULL,
}
+47
View File
@@ -0,0 +1,47 @@
import {
Decoder,
FilterAPI,
type Frame,
type Packet,
type Stream,
} from "node-av";
export async function createDecoder(stream: Stream) {
const decoder = await Decoder.create(stream);
let freed = false;
let serializer: Promise<unknown> | null = null;
const serialize = <T>(f: () => Promise<T>) => {
let p: Promise<T>;
if (serializer) {
p = serializer.catch(() => {}).then(() => f());
} else {
p = f();
}
serializer = p = p.finally(() => {
if (serializer === p) serializer = null;
});
return p;
};
const filter = FilterAPI.create("format=pix_fmts=rgba");
return {
decode: async (packets: Packet) => {
if (freed) return [];
return serialize(async () => {
const frames = await decoder.decodeAll(packets);
let filtered: Frame[] = [];
for (const frame of frames) {
filtered = [...filtered, ...(await filter.processAll(frame))];
}
return filtered;
});
},
free: () => {
freed = true;
return serialize(async () => {
decoder.close();
filter.close();
});
},
};
}
+291
View File
@@ -0,0 +1,291 @@
import pDebounce from "p-debounce";
import {
BitStreamFilterAPI,
Demuxer,
avGetCodecName,
type Stream,
} from "node-av";
import { Log } from "debug-level";
import { randomUUID } from "node:crypto";
import { AVCodecID } from "./LibavCodecId.js";
import { PassThrough } from "node:stream";
import type { CodecParameters, Packet } from "node-av";
import type { Readable } from "node:stream";
type MediaStreamInfoCommon = {
index: number;
codec: AVCodecID;
codecpar: CodecParameters;
avStream: Stream;
};
export type VideoStreamInfo = MediaStreamInfoCommon & {
width: number;
height: number;
framerate_num: number;
framerate_den: number;
};
export type AudioStreamInfo = MediaStreamInfoCommon & {
sample_rate: number;
};
const allowedVideoCodec = new Set([
AVCodecID.AV_CODEC_ID_H264,
AVCodecID.AV_CODEC_ID_H265,
AVCodecID.AV_CODEC_ID_VP8,
AVCodecID.AV_CODEC_ID_VP9,
AVCodecID.AV_CODEC_ID_AV1,
]);
const allowedAudioCodec = new Set([AVCodecID.AV_CODEC_ID_OPUS]);
function parseOpusPacketDuration(frame: Uint8Array) {
// https://datatracker.ietf.org/doc/html/rfc6716#section-3.1
const frameSizes = [
// SILK only, narrow band
10, 20, 40, 60,
// SILK only, medium band
10, 20, 40, 60,
// SILK only, wide band
10, 20, 40, 60,
// Hybrid, super wide band
10, 20,
// Hybrid, full band
10, 20,
// CELT only, narrow band
2.5, 5, 10, 20,
// CELT only, wide band
2.5, 5, 10, 20,
// CELT only, super wide band
2.5, 5, 10, 20,
// CELT only, full band
2.5, 5, 10, 20,
];
const frameSize = (48000 / 1000) * frameSizes[frame[0] >> 3];
let frameCount = 0;
const c = frame[0] & 0b11;
switch (c) {
case 0:
frameCount = 1;
break;
case 1:
case 2:
frameCount = 2;
break;
case 3:
frameCount = frame[1] & 0b111111;
break;
}
return frameSize * frameCount;
}
type DemuxerOptions = {
format: "matroska" | "nut";
};
export async function demux(input: Readable, { format }: DemuxerOptions) {
const loggerFormat = new Log("demux:format");
const loggerFrameCommon = new Log("demux:frame:common");
const loggerFrameVideo = new Log("demux:frame:video");
const loggerFrameAudio = new Log("demux:frame:audio");
const filename = randomUUID();
const demuxer = await Demuxer.open(input, {
options: {
fflags: "nobuffer",
},
format,
bufferSize: 8192,
});
const cleanup = () => {
input.destroy();
demuxer.close();
vPipe.off("drain", readFrame);
aPipe.off("drain", readFrame);
vPipe.end();
aPipe.end();
vbsf.forEach((e) => {
e.close();
});
};
const vStream = demuxer.video();
const aStream = demuxer.audio();
let vInfo: VideoStreamInfo | undefined;
let aInfo: AudioStreamInfo | undefined;
const vPipe = new PassThrough({
objectMode: true,
writableHighWaterMark: 128,
});
const aPipe = new PassThrough({
objectMode: true,
writableHighWaterMark: 128,
});
const vbsf: BitStreamFilterAPI[] = [];
if (vStream) {
const codecId = vStream.codecpar.codecId;
if (!allowedVideoCodec.has(codecId)) {
const codecName = avGetCodecName(codecId);
cleanup();
throw new Error(`Video codec ${codecName} is not allowed`);
}
try {
switch (codecId) {
case AVCodecID.AV_CODEC_ID_H264:
vbsf.push(BitStreamFilterAPI.create("h264_mp4toannexb", vStream));
vbsf.push(
BitStreamFilterAPI.create("h264_metadata", vStream, {
options: {
aud: "remove",
},
}),
);
vbsf.push(BitStreamFilterAPI.create("dump_extra", vStream));
break;
case AVCodecID.AV_CODEC_ID_HEVC:
vbsf.push(BitStreamFilterAPI.create("hevc_mp4toannexb", vStream));
vbsf.push(
BitStreamFilterAPI.create("hevc_metadata", vStream, {
options: {
aud: "remove",
},
}),
);
vbsf.push(BitStreamFilterAPI.create("dump_extra", vStream));
break;
default:
vbsf.push(BitStreamFilterAPI.create("null", vStream));
break;
}
} catch (e) {
cleanup();
throw new Error(`Failed to construct bitstream filterchain`, {
cause: (e as Error).cause,
});
}
const codecpar = vbsf.at(-1)?.outputCodecParameters ?? vStream.codecpar;
vInfo = {
index: vStream.index,
codec: codecId,
codecpar,
width: codecpar.width ?? 0,
height: codecpar.height ?? 0,
framerate_num: codecpar.frameRate.num,
framerate_den: codecpar.frameRate.den,
avStream: vStream,
};
loggerFormat.info(
{
info: vInfo,
},
`Found video stream in input ${filename}`,
);
}
if (aStream) {
const codecId = aStream.codecpar.codecId;
if (!allowedAudioCodec.has(codecId)) {
const codecName = avGetCodecName(codecId);
cleanup();
throw new Error(`Audio codec ${codecName} is not allowed`);
}
aInfo = {
index: aStream.index,
codec: codecId,
codecpar: aStream.codecpar,
sample_rate: aStream.codecpar.sampleRate || 0,
avStream: aStream,
};
loggerFormat.info(
{
info: aInfo,
},
`Found audio stream in input ${filename}`,
);
}
const packetIterator = demuxer.packets();
const applyBitStreamFilters = async (
input: Packet | null,
filters: BitStreamFilterAPI[],
) => {
let packets = [input];
for (const filter of filters) {
let newPackets: (Packet | null)[] = [];
for (const packet of packets) {
newPackets = [...newPackets, ...(await filter.filterAll(packet))];
packet?.free();
}
if (!input) newPackets.push(null);
packets = newPackets;
}
return packets;
};
const readFrame = pDebounce.promise(async () => {
let resume = true;
while (resume) {
try {
const { value: inPacket, done } = await packetIterator.next();
if (done) {
loggerFrameCommon.info("Reached end of stream. Stopping");
const packets = await applyBitStreamFilters(null, vbsf);
for (const packet of packets) {
if (packet) vPipe.write(packet);
}
cleanup();
return;
} else if (inPacket) {
const streamIndex = inPacket.streamIndex;
if (vInfo && vInfo.index === streamIndex) {
loggerFrameVideo.trace("Received a video packet");
const packets = await applyBitStreamFilters(inPacket.clone(), vbsf);
for (const packet of packets) {
if (packet) resume &&= vPipe.write(packet);
}
} else if (aInfo && aInfo.index === streamIndex) {
const packet = inPacket.clone()!;
packet.duration ||= BigInt(parseOpusPacketDuration(packet.data!));
resume &&= aPipe.write(packet);
}
inPacket.free();
}
} catch (e) {
loggerFrameCommon.info(
{ error: e },
"Received an error during frame extraction. Stopping",
);
cleanup();
return;
}
}
});
vPipe.on("drain", () => {
loggerFrameVideo.trace("Video pipe drained");
readFrame();
});
aPipe.on("drain", () => {
loggerFrameAudio.trace("Audio pipe drained");
readFrame();
});
readFrame();
return {
video: vInfo ? { ...vInfo, stream: vPipe as Readable } : undefined,
audio: aInfo ? { ...aInfo, stream: aPipe as Readable } : undefined,
};
}
+17
View File
@@ -0,0 +1,17 @@
import { BaseMediaStream } from "./BaseMediaStream.js";
import type { WebRtcConnWrapper } from "../client/voice/WebRtcWrapper.js";
export class VideoStream extends BaseMediaStream {
private _conn: WebRtcConnWrapper;
constructor(conn: WebRtcConnWrapper, noSleep = false) {
super("video", noSleep);
this._conn = conn;
}
protected override async _sendFrame(
frame: Buffer,
frametime: number,
): Promise<void> {
this._conn.sendVideoFrame(frame, frametime);
}
}
+27
View File
@@ -0,0 +1,27 @@
import type { SupportedVideoCodec } from "../../utils.js";
export type EncoderSettings = {
name: string;
options: string[];
globalOptions?: string[];
outFilters?: string[];
};
export type EncoderSettingsGetter = (
bitrate: number,
bitrateMax: number,
) => Partial<Record<SupportedVideoCodec, EncoderSettings>>;
import { software } from "./software.js";
import { nvenc } from "./nvenc.js";
import { vaapi } from "./vaapi.js";
import { merge } from "./merge.js";
const Encoders = {
software,
nvenc,
vaapi,
merge,
};
export { Encoders };
+14
View File
@@ -0,0 +1,14 @@
import type { EncoderSettingsGetter } from "./index.js";
import type { SupportedVideoCodec } from "../../utils.js";
export const merge = (
encoder: Partial<Record<SupportedVideoCodec, EncoderSettingsGetter>>,
) => {
return ((bitrate, bitrateMax) => ({
H264: encoder.H264?.(bitrate, bitrateMax),
H265: encoder.H265?.(bitrate, bitrateMax),
VP8: encoder.VP8?.(bitrate, bitrateMax),
VP9: encoder.VP9?.(bitrate, bitrateMax),
AV1: encoder.AV1?.(bitrate, bitrateMax),
})) as EncoderSettingsGetter;
};
+38
View File
@@ -0,0 +1,38 @@
import type { EncoderSettingsGetter } from "./index.js";
type NvencPreset = "p1" | "p2" | "p3" | "p4" | "p5" | "p6" | "p7";
type NvencSettings = {
preset: NvencPreset;
spatialAq: boolean;
temporalAq: boolean;
gpu: number;
};
export function nvenc({
preset = "p4",
spatialAq = false,
temporalAq = false,
gpu,
}: Partial<NvencSettings> = {}) {
const options = [
`-preset ${preset}`,
`-spatial-aq ${spatialAq}`,
`-temporal-aq ${temporalAq}`,
...(gpu !== undefined ? [`-gpu ${gpu}`] : []),
];
return (() => ({
H264: {
name: "h264_nvenc",
options,
},
H265: {
name: "hevc_nvenc",
options,
},
AV1: {
name: "av1_nvenc",
options,
},
})) as EncoderSettingsGetter;
}
@@ -0,0 +1,76 @@
import type { EncoderSettingsGetter } from "./index.js";
type DeepPartial<T> = T extends unknown[]
? T
: { [P in keyof T]?: DeepPartial<T[P]> };
type x26xPreset =
| "ultrafast"
| "superfast"
| "veryfast"
| "faster"
| "fast"
| "medium"
| "slow"
| "slower"
| "veryslow"
| "placebo";
export type SoftwareEncoderSettings = {
x264: {
preset: x26xPreset;
tune:
| "film"
| "animation"
| "grain"
| "stillimage"
| "fastdecode"
| "zerolatency"
| "psnr"
| "ssim";
};
x265: {
preset: x26xPreset;
tune:
| "psnr"
| "ssim"
| "grain"
| "fastdecode"
| "zerolatency"
| "animation";
};
};
export const software = ({
x264,
x265,
}: DeepPartial<SoftwareEncoderSettings> = {}) => {
const { preset: x264Preset = "superfast", tune: x264Tune = "film" } =
x264 ?? {};
const { preset: x265Preset = "superfast", tune: x265Tune } = x265 ?? {};
return (() => ({
H264: {
name: "libx264",
options: ["-forced-idr 1", `-tune ${x264Tune}`, `-preset ${x264Preset}`],
},
H265: {
name: "libx265",
options: [
"-forced-idr 1",
...(x265Tune ? [`-tune ${x265Tune}`] : []),
`-preset ${x265Preset}`,
],
},
VP8: {
name: "libvpx",
options: ["-deadline 20000"],
},
VP9: {
name: "libvpx-vp9",
options: ["-deadline 20000"],
},
AV1: {
name: "libsvtav1",
options: [],
},
})) as EncoderSettingsGetter;
};
+29
View File
@@ -0,0 +1,29 @@
import type { EncoderSettingsGetter } from "./index.js";
type VaapiSettings = {
device?: string;
};
export function vaapi({
device = "/dev/dri/renderD128",
}: Partial<VaapiSettings> = {}) {
const props = {
options: [],
globalOptions: ["-vaapi_device", device],
outFilters: ["format=nv12|vaapi", "hwupload"],
};
return (() => ({
H264: {
name: "h264_vaapi",
...props,
},
H265: {
name: "hevc_vaapi",
...props,
},
AV1: {
name: "av1_vaapi",
...props,
},
})) as EncoderSettingsGetter;
}
+4
View File
@@ -0,0 +1,4 @@
export * from "./LibavDemuxer.js";
export * from "./newApi.js";
export * as NewApi from "./newApi.js";
export * from "./encoders/index.js";
+653
View File
@@ -0,0 +1,653 @@
import pDebounce from "p-debounce";
import sharp from "sharp";
import Log from "debug-level";
import { FFmpegCommand } from "fluent-ffmpeg-simplified";
import { type Packet, AV_PKT_FLAG_KEY } from "node-av";
import { PassThrough, type Readable } from "node:stream";
import { demux } from "./LibavDemuxer.js";
import { VideoStream } from "./VideoStream.js";
import { AudioStream } from "./AudioStream.js";
import { isBun, isDeno, isFiniteNonZero } from "../utils.js";
import { AVCodecID } from "./LibavCodecId.js";
import { createDecoder } from "./LibavDecoder.js";
import { Encoders } from "./encoders/index.js";
import type { Request } from "zeromq";
import type { SupportedVideoCodec } from "../utils.js";
import type { Streamer } from "../client/index.js";
import type { EncoderSettingsGetter } from "./encoders/index.js";
import type { VideoStreamInfo } from "./LibavDemuxer.js";
import type { WebRtcConnWrapper } from "../client/voice/WebRtcWrapper.js";
export type PrepareStreamOptions = {
/**
* Disable video transcoding
* If enabled, all video related settings have no effects, and the input
* video stream is used as-is.
*
* You need to ensure that the video stream has the right properties
* (keyframe every 1s, B-frames disabled). Failure to do so will result in
* a glitchy stream, or degraded performance
*/
noTranscoding: boolean;
/**
* 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;
/**
* Functions to get encoder settings
* This function will receive the average and max bitrate as the input, and
* returns an object containing encoder settings for the supported codecs
*/
encoder: EncoderSettingsGetter;
/**
* Enable hardware accelerated decoding
*/
hardwareAcceleratedDecoding: boolean;
/**
* Add some options to minimize latency
*/
minimizeLatency: boolean;
/**
* Custom headers for HTTP requests
*/
customHeaders: Record<string, string>;
/**
* Custom input options to pass directly to ffmpeg
* These will be added to the command before other options
*/
customInputOptions: string[];
/**
* Custom ffmpeg flags/options to pass directly to ffmpeg
* These will be added to the command after other options
*/
customFfmpegFlags: string[];
/**
* FFmpeg log level
*/
logLevel:
| "quiet"
| "panic"
| "fatal"
| "error"
| "warning"
| "info"
| "verbose"
| "debug"
| "trace";
};
export type Controller = {
volume: number;
setVolume(newVolume: number): Promise<boolean>;
};
export function prepareStream(
input: string | Readable,
options: Partial<PrepareStreamOptions> = {},
cancelSignal?: AbortSignal,
) {
cancelSignal?.throwIfAborted();
const logger = new Log("prepareStream");
const loggerFFmpeg = new Log("prepareStream:ffmpeg");
const defaultOptions = {
noTranscoding: false,
// 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,
encoder: Encoders.software(),
hardwareAcceleratedDecoding: false,
minimizeLatency: false,
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",
},
customInputOptions: [],
customFfmpegFlags: [],
logLevel: "verbose",
} satisfies PrepareStreamOptions;
function mergeOptions(opts: Partial<PrepareStreamOptions>) {
return {
noTranscoding: opts.noTranscoding ?? defaultOptions.noTranscoding,
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,
encoder: opts.encoder ?? defaultOptions.encoder,
includeAudio: opts.includeAudio ?? defaultOptions.includeAudio,
hardwareAcceleratedDecoding:
opts.hardwareAcceleratedDecoding ??
defaultOptions.hardwareAcceleratedDecoding,
minimizeLatency: opts.minimizeLatency ?? defaultOptions.minimizeLatency,
customHeaders: {
...defaultOptions.customHeaders,
...opts.customHeaders,
},
customInputOptions:
opts.customInputOptions ?? defaultOptions.customInputOptions,
customFfmpegFlags:
opts.customFfmpegFlags ?? defaultOptions.customFfmpegFlags,
logLevel: opts.logLevel ?? defaultOptions.logLevel,
} satisfies PrepareStreamOptions;
}
const mergedOptions = mergeOptions(options);
let isHttpUrl = false;
let isHls = false;
let isSrt = false;
if (typeof input === "string") {
isHttpUrl = input.startsWith("http") || input.startsWith("https");
isHls = input.includes("m3u");
isSrt = input.startsWith("srt://");
}
const output = new PassThrough();
// command creation
const command = new FFmpegCommand();
command.on("stderr", (line) => {
loggerFFmpeg.debug(line);
});
command.input(input);
command.inputOptions("-y", "-loglevel", mergedOptions.logLevel, "-nostats");
// input options
if (
mergedOptions.customInputOptions &&
mergedOptions.customInputOptions.length > 0
) {
command.inputOptions(mergedOptions.customInputOptions);
}
const { hardwareAcceleratedDecoding, minimizeLatency, customHeaders } =
mergedOptions;
if (hardwareAcceleratedDecoding) command.inputOptions("-hwaccel", "auto");
if (minimizeLatency) {
command.inputOptions(
"-fflags nobuffer",
"-flags lowdelay",
"-flush_packets 1",
"-max_delay 100000",
);
}
if (isHttpUrl) {
const headersString = Object.entries(customHeaders)
.map(([k, v]) => `${k}: ${v}`)
.join("\r\n");
command.inputOptions(`-headers "${headersString}"`);
if (!isHls) {
command.inputOptions([
"-reconnect 1",
"-reconnect_at_eof 1",
"-reconnect_streamed 1",
"-reconnect_delay_max 4294",
]);
}
}
if (isSrt) {
command.inputOptions("-scan_all_pmts 0");
}
// general output options
command.output(output).format("nut");
// video setup
const {
noTranscoding,
width,
height,
frameRate,
bitrateVideo,
bitrateVideoMax,
videoCodec,
encoder,
} = mergedOptions;
command.outputOptions("-map 0:v");
if (noTranscoding) {
command.videoCodec("copy");
} else {
command.videoFilters(`scale=${width}:${height}`);
if (frameRate) command.fps(frameRate);
command.outputOptions([
"-b:v",
`${bitrateVideo}k`,
"-maxrate:v",
`${bitrateVideoMax}k`,
"-bufsize:v",
`${Math.round(bitrateVideo / 2)}k`,
"-bf",
"0",
"-pix_fmt",
"yuv420p",
"-force_key_frames",
"expr:gte(t,n_forced*1)",
]);
const encoderSettings = encoder(bitrateVideo, bitrateVideoMax)[videoCodec];
if (!encoderSettings)
throw new Error(`Encoder settings not specified for ${videoCodec}`);
command
.videoCodec(encoderSettings.name)
.videoFilters(encoderSettings.outFilters ?? [])
.outputOptions(encoderSettings.options)
.outputOptions(encoderSettings.globalOptions ?? []);
}
// audio setup
const { includeAudio, bitrateAudio } = mergedOptions;
if (includeAudio)
command
.outputOptions("-map 0:a:0?")
.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!
*/
.outputOptions("-lfe_mix_level 1")
.audioFrequency(48000)
.audioCodec("libopus")
.audioBitrate(`${bitrateAudio}k`)
.audioFilters("volume@internal_lib=1.0");
// Add custom ffmpeg flags
if (
mergedOptions.customFfmpegFlags &&
mergedOptions.customFfmpegFlags.length > 0
) {
command.outputOptions(mergedOptions.customFfmpegFlags);
}
// realtime control mechanism
let currentVolume = 1;
let zmqClientPromise: Promise<Request> | undefined;
if (includeAudio && !isBun() && !isDeno()) {
function randomInclusive(start: number, end: number) {
return Math.floor(Math.random() * (end - start + 1)) + start;
}
// Last octet is from 2 to 254 to avoid WSL2 shenanigans
const loopbackIp = [
127,
randomInclusive(0, 255),
randomInclusive(0, 255),
randomInclusive(2, 254),
].join(".");
const zmqEndpoint = `tcp://${loopbackIp}:42069`;
command.audioFilters(`azmq=b=${zmqEndpoint.replaceAll(":", "\\\\:")}`);
zmqClientPromise = import("zeromq").then((zmq) => {
const client = new zmq.Request({
sendTimeout: 5000,
receiveTimeout: 5000,
});
client.connect(zmqEndpoint);
promise.catch(() => {}).finally(() => client.disconnect(zmqEndpoint));
return client;
});
}
command.once("start", (cmdline) => {
logger.debug(`Starting ffmpeg: ${cmdline}`);
});
const promise = command.run(cancelSignal);
return {
command,
output,
promise: promise as Promise<unknown>,
controller: {
get volume() {
return currentVolume;
},
async setVolume(newVolume: number) {
if (newVolume < 0) return false;
try {
if (!zmqClientPromise) return false;
const client = await zmqClientPromise;
await client.send(`volume@internal_lib volume ${newVolume}`);
const [res] = await client.receive();
if (res.toString("utf-8").split(" ")[0] !== "0") return false;
currentVolume = newVolume;
return true;
} catch {
return false;
}
},
} satisfies Controller,
};
}
export type PlayStreamOptions = {
/**
* Set stream type as "Go Live" or camera stream
*/
type: "go-live" | "camera";
/**
* Set format of the stream
*/
format: "matroska" | "nut";
/**
* Override video width sent to Discord.
*
* DO NOT SPECIFY UNLESS YOU KNOW WHAT YOU'RE DOING!
*/
width: number | ((v: VideoStreamInfo) => number);
/**
* Override video height sent to Discord.
*
* DO NOT SPECIFY UNLESS YOU KNOW WHAT YOU'RE DOING!
*/
height: number | ((v: VideoStreamInfo) => number);
/**
* Override video frame rate sent to Discord.
*
* DO NOT SPECIFY UNLESS YOU KNOW WHAT YOU'RE DOING!
*/
frameRate: number | ((v: VideoStreamInfo) => number);
/**
* Same as ffmpeg's `readrate_initial_burst` command line flag
*
* See https://ffmpeg.org/ffmpeg.html#:~:text=%2Dreadrate_initial_burst
*/
readrateInitialBurst: number | undefined;
/**
* Enable stream preview from input stream (experimental)
*/
streamPreview: boolean;
};
export async function playStream(
input: Readable,
streamer: Streamer,
options: Partial<PlayStreamOptions> = {},
cancelSignal?: AbortSignal,
) {
const logger = new Log("playStream");
cancelSignal?.throwIfAborted();
if (!streamer.voiceConnection)
throw new Error("Bot is not connected to a voice channel");
const defaultOptions = {
type: "go-live",
format: "nut",
width: (video) => video.width,
height: (video) => video.height,
frameRate: (video) => video.framerate_num / video.framerate_den,
readrateInitialBurst: undefined,
streamPreview: false,
} satisfies PlayStreamOptions;
function mergeOptions(opts: Partial<PlayStreamOptions>) {
return {
type: opts.type ?? defaultOptions.type,
format: opts.format ?? defaultOptions.format,
width:
typeof opts.width === "function" ||
(isFiniteNonZero(opts.width) && opts.width > 0)
? opts.width
: defaultOptions.width,
height:
typeof opts.height === "function" ||
(isFiniteNonZero(opts.height) && opts.height > 0)
? opts.height
: defaultOptions.height,
frameRate:
typeof opts.frameRate === "function" ||
(isFiniteNonZero(opts.frameRate) && opts.frameRate > 0)
? opts.frameRate
: defaultOptions.frameRate,
readrateInitialBurst:
isFiniteNonZero(opts.readrateInitialBurst) &&
opts.readrateInitialBurst > 0
? opts.readrateInitialBurst
: defaultOptions.readrateInitialBurst,
streamPreview: opts.streamPreview ?? defaultOptions.streamPreview,
} satisfies PlayStreamOptions;
}
const mergedOptions = mergeOptions(options);
logger.debug({ options: mergedOptions }, "Merged options");
logger.debug("Initializing demuxer");
const { video, audio } = await demux(input, {
format: mergedOptions.format,
});
cancelSignal?.throwIfAborted();
if (!video) throw new Error("No video stream in media");
const cleanupFuncs: (() => unknown)[] = [];
const videoCodecMap: Record<number, SupportedVideoCodec> = {
[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",
};
let conn: WebRtcConnWrapper;
let stopStream: () => unknown;
if (mergedOptions.type === "go-live") {
conn = await streamer.createStream();
stopStream = () => streamer.stopStream();
} else {
conn = streamer.voiceConnection.webRtcConn;
streamer.signalVideo(true);
stopStream = () => streamer.signalVideo(false);
}
conn.setPacketizer(videoCodecMap[video.codec]);
conn.mediaConnection.setSpeaking(true);
const { width, height, frameRate } = mergedOptions;
conn.mediaConnection.setVideoAttributes(true, {
width: Math.round(typeof width === "function" ? width(video) : width),
height: Math.round(typeof height === "function" ? height(video) : height),
fps: Math.round(
typeof frameRate === "function" ? frameRate(video) : frameRate,
),
});
const vStream = new VideoStream(conn);
video.stream.pipe(vStream);
if (audio) {
const aStream = new AudioStream(conn);
audio.stream.pipe(aStream);
vStream.syncStream = aStream;
const burstTime = mergedOptions.readrateInitialBurst;
if (typeof burstTime === "number") {
vStream.sync = false;
vStream.noSleep = aStream.noSleep = true;
const stopBurst = (pts: number) => {
if (pts < burstTime * 1000) return;
vStream.sync = true;
vStream.noSleep = aStream.noSleep = false;
vStream.off("pts", stopBurst);
};
vStream.on("pts", stopBurst);
}
}
if (mergedOptions.streamPreview && mergedOptions.type === "go-live") {
(async () => {
const logger = new Log("playStream:preview");
logger.debug("Initializing decoder for stream preview");
const decoder = await createDecoder(video.avStream);
if (!decoder) {
logger.warn(
"Failed to initialize decoder. Stream preview will be disabled",
);
return;
}
cleanupFuncs.push(() => {
logger.debug("Freeing decoder");
decoder.free();
});
const updatePreview = pDebounce.promise(async (packet: Packet) => {
if (!(packet.flags !== undefined && packet.flags & AV_PKT_FLAG_KEY))
return;
const decodeStart = performance.now();
const frames = await decoder.decode(packet).catch((e) => {
logger.error(e, "Failed to decode the frame");
return [];
});
if (!frames.length) return;
const decodeEnd = performance.now();
logger.debug(`Decoding a frame took ${decodeEnd - decodeStart}ms`);
const frame = frames[0];
return sharp(frame.toBuffer(), {
raw: {
width: frame.width ?? 0,
height: frame.height ?? 0,
channels: 4,
},
})
.resize(1024, 576, { fit: "inside" })
.jpeg()
.toBuffer()
.then((image) => streamer.setStreamPreview(image))
.catch(() => {})
.finally(() => {
frames.forEach((frame) => {
frame.free();
});
});
});
video.stream.on("data", updatePreview);
cleanupFuncs.push(() => video.stream.off("data", updatePreview));
})();
}
const promise = new Promise<void>((resolve, reject) => {
cleanupFuncs.push(() => {
stopStream();
conn.mediaConnection.setSpeaking(false);
conn.mediaConnection.setVideoAttributes(false);
});
let cleanedUp = false;
const cleanup = () => {
if (cleanedUp) return;
cleanedUp = true;
for (const f of cleanupFuncs) f();
};
cancelSignal?.addEventListener(
"abort",
() => {
cleanup();
reject(cancelSignal.reason);
},
{ once: true },
);
vStream.once("finish", () => {
if (cancelSignal?.aborted) return;
cleanup();
resolve();
});
});
promise.catch(() => {});
return promise;
}
+101
View File
@@ -0,0 +1,101 @@
import type {
AnyChannel,
DMChannel,
GroupDMChannel,
VoiceBasedChannel,
} from "discord.js-selfbot-v13";
export function normalizeVideoCodec(
codec: string,
): "H264" | "H265" | "VP8" | "VP9" | "AV1" {
if (/H\.?264|AVC/i.test(codec)) return "H264";
if (/H\.?265|HEVC/i.test(codec)) return "H265";
if (/VP(8|9)/i.test(codec)) return codec.toUpperCase() as "VP8" | "VP9";
if (/AV1/i.test(codec)) return "AV1";
throw new Error(`Unknown codec: ${codec}`);
}
// The available video streams are sent by client on connection to voice gateway using OpCode Identify (0)
// The server then replies with the ssrc and rtxssrc for each available stream using OpCode Ready (2)
// RID is used specifically to distinguish between different simulcast streams of the same video source,
// but we don't really care about sending multiple quality streams, so we hardcode a single one
export const STREAMS_SIMULCAST = [{ type: "screen", rid: "100", quality: 100 }];
export enum SupportedEncryptionModes {
AES256 = "aead_aes256_gcm_rtpsize",
XCHACHA20 = "aead_xchacha20_poly1305_rtpsize",
}
export type SupportedVideoCodec = "H264" | "H265" | "VP8" | "VP9" | "AV1";
export const max_int16bit = 2 ** 16;
export const max_int32bit = 2 ** 32;
export function isFiniteNonZero(n: unknown): n is number {
return !!n && Number.isFinite(n);
}
export function parseStreamKey(streamKey: string): {
type: "guild" | "call";
channelId: string;
guildId: string | null;
userId: string;
} {
const streamKeyArray = streamKey.split(":");
const type = streamKeyArray.shift();
if (type !== "guild" && type !== "call") {
throw new Error(`Invalid stream key type: ${type}`);
}
if (
(type === "guild" && streamKeyArray.length < 3) ||
(type === "call" && streamKey.length < 2)
)
throw new Error(`Invalid stream key: ${streamKey}`); // invalid stream key
let guildId: string | null = null;
if (type === "guild") {
guildId = streamKeyArray.shift() ?? null;
}
const channelId = streamKeyArray.shift();
const userId = streamKeyArray.shift();
if (!channelId || !userId) {
throw new Error(`Invalid stream key: ${streamKey}`);
}
return { type, channelId, guildId, userId };
}
export function generateStreamKey(
type: "guild" | "call",
guildId: string | null,
channelId: string,
userId: string,
): string {
const streamKey = `${type}${type === "guild" ? `:${guildId}` : ""}:${channelId}:${userId}`;
return streamKey;
}
export function isVoiceChannel(
channel: AnyChannel,
): channel is DMChannel | GroupDMChannel | VoiceBasedChannel {
return (
channel.type === "DM" ||
channel.type === "GROUP_DM" ||
channel.type === "GUILD_STAGE_VOICE" ||
channel.type === "GUILD_VOICE"
);
}
export function isDeno() {
// @ts-expect-error
return typeof Deno !== "undefined";
}
export function isBun() {
// @ts-expect-error
return typeof Bun !== "undefined";
}
+106
View File
@@ -0,0 +1,106 @@
{
"compilerOptions": {
/* Visit https://aka.ms/tsconfig to read more about this file */
/* Projects */
// "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */
// "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */
// "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */
// "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */
// "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */
// "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */
/* Language and Environment */
"target": "es2022", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */
// "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */
// "jsx": "preserve", /* Specify what JSX code is generated. */
// "experimentalDecorators": true, /* Enable experimental support for TC39 stage 2 draft decorators. */
// "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */
// "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */
// "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */
// "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */
// "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */
// "noLib": true, /* Disable including any library files, including the default lib.d.ts. */
// "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */
// "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */
/* Modules */
"module": "Node16", /* Specify what module code is generated. */
// "rootDir": "./", /* Specify the root folder within your source files. */
"moduleResolution": "Node16", /* Specify how TypeScript looks up a file from a given module specifier. */
// "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */
// "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */
// "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */
// "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */
// "types": [], /* Specify type package names to be included without being referenced in a source file. */
// "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
// "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */
"resolveJsonModule": true, /* Enable importing .json files. */
// "noResolve": true, /* Disallow 'import's, 'require's or '<reference>'s from expanding the number of files TypeScript should add to a project. */
/* JavaScript Support */
"allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */
// "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */
// "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */
/* Emit */
"declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */
// "declarationMap": true, /* Create sourcemaps for d.ts files. */
// "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */
"sourceMap": true, /* Create source map files for emitted JavaScript files. */
// "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */
"outDir": "./dist", /* Specify an output folder for all emitted files. */
// "removeComments": true, /* Disable emitting comments. */
// "noEmit": true, /* Disable emitting files from a compilation. */
// "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */
// "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types. */
// "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */
// "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */
// "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
// "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */
// "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */
// "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */
// "newLine": "crlf", /* Set the newline character for emitting files. */
// "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */
// "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */
// "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */
// "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */
// "declarationDir": "./", /* Specify the output directory for generated declaration files. */
// "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */
/* Interop Constraints */
// "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */
// "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */
"esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */
// "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */
"forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */
/* Type Checking */
"strict": true, /* Enable all strict type-checking options. */
"noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */
// "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */
// "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */
// "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */
// "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */
// "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */
// "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */
// "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */
// "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */
// "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */
// "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */
// "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */
// "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */
// "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */
// "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */
// "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */
// "allowUnusedLabels": true, /* Disable error reporting for unused labels. */
// "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */
/* Completeness */
// "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */
"skipLibCheck": true /* Skip type checking all .d.ts files. */
},
"include": [
"src/**/*"
]
}