@m7mdxzx1/discord-video-strem 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +294 -0
- package/dist/client/GatewayEvents.d.ts +27 -0
- package/dist/client/GatewayEvents.js +1 -0
- package/dist/client/GatewayOpCodes.d.ts +40 -0
- package/dist/client/GatewayOpCodes.js +41 -0
- package/dist/client/Streamer.d.ts +41 -0
- package/dist/client/Streamer.js +189 -0
- package/dist/client/encryptor/TransportEncryptor.d.ts +16 -0
- package/dist/client/encryptor/TransportEncryptor.js +38 -0
- package/dist/client/index.d.ts +4 -0
- package/dist/client/index.js +4 -0
- package/dist/client/packet/AudioPacketizer.d.ts +8 -0
- package/dist/client/packet/AudioPacketizer.js +22 -0
- package/dist/client/packet/BaseMediaPacketizer.d.ts +109 -0
- package/dist/client/packet/BaseMediaPacketizer.js +243 -0
- package/dist/client/packet/VideoPacketizerAnnexB.d.ts +132 -0
- package/dist/client/packet/VideoPacketizerAnnexB.js +231 -0
- package/dist/client/packet/VideoPacketizerVP8.d.ts +15 -0
- package/dist/client/packet/VideoPacketizerVP8.js +56 -0
- package/dist/client/packet/index.d.ts +4 -0
- package/dist/client/packet/index.js +4 -0
- package/dist/client/processing/AnnexBHelper.d.ts +93 -0
- package/dist/client/processing/AnnexBHelper.js +132 -0
- package/dist/client/voice/BaseMediaConnection.d.ts +118 -0
- package/dist/client/voice/BaseMediaConnection.js +319 -0
- package/dist/client/voice/MediaUdp.d.ts +26 -0
- package/dist/client/voice/MediaUdp.js +140 -0
- package/dist/client/voice/StreamConnection.d.ts +10 -0
- package/dist/client/voice/StreamConnection.js +30 -0
- package/dist/client/voice/VoiceConnection.d.ts +7 -0
- package/dist/client/voice/VoiceConnection.js +10 -0
- package/dist/client/voice/VoiceMessageTypes.d.ts +136 -0
- package/dist/client/voice/VoiceMessageTypes.js +1 -0
- package/dist/client/voice/VoiceOpCodes.d.ts +21 -0
- package/dist/client/voice/VoiceOpCodes.js +22 -0
- package/dist/client/voice/index.d.ts +5 -0
- package/dist/client/voice/index.js +5 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +5 -0
- package/dist/media/AudioStream.d.ts +25 -0
- package/dist/media/AudioStream.js +63 -0
- package/dist/media/BaseMediaStream.d.ts +31 -0
- package/dist/media/BaseMediaStream.js +145 -0
- package/dist/media/LibavCodecId.d.ts +541 -0
- package/dist/media/LibavCodecId.js +552 -0
- package/dist/media/LibavDecoder.d.ts +5 -0
- package/dist/media/LibavDecoder.js +63 -0
- package/dist/media/LibavDemuxer.d.ts +23 -0
- package/dist/media/LibavDemuxer.js +295 -0
- package/dist/media/VideoStream.d.ts +7 -0
- package/dist/media/VideoStream.js +10 -0
- package/dist/media/index.d.ts +1 -0
- package/dist/media/index.js +1 -0
- package/dist/media/newApi.d.ts +126 -0
- package/dist/media/newApi.js +387 -0
- package/dist/media/utils.d.ts +1 -0
- package/dist/media/utils.js +4 -0
- package/dist/utils.d.ts +28 -0
- package/dist/utils.js +54 -0
- package/package.json +69 -0
- package/src/client/GatewayEvents.ts +41 -0
- package/src/client/GatewayOpCodes.ts +40 -0
- package/src/client/Streamer.ts +279 -0
- package/src/client/encryptor/TransportEncryptor.ts +62 -0
- package/src/client/index.ts +4 -0
- package/src/client/packet/AudioPacketizer.ts +28 -0
- package/src/client/packet/BaseMediaPacketizer.ts +307 -0
- package/src/client/packet/VideoPacketizerAnnexB.ts +263 -0
- package/src/client/packet/VideoPacketizerVP8.ts +73 -0
- package/src/client/packet/index.ts +4 -0
- package/src/client/processing/AnnexBHelper.ts +142 -0
- package/src/client/voice/BaseMediaConnection.ts +407 -0
- package/src/client/voice/MediaUdp.ts +171 -0
- package/src/client/voice/StreamConnection.ts +33 -0
- package/src/client/voice/VoiceConnection.ts +15 -0
- package/src/client/voice/VoiceMessageTypes.ts +164 -0
- package/src/client/voice/VoiceOpCodes.ts +21 -0
- package/src/client/voice/index.ts +5 -0
- package/src/index.ts +5 -0
- package/src/media/AudioStream.ts +81 -0
- package/src/media/BaseMediaStream.ts +173 -0
- package/src/media/LibavCodecId.ts +566 -0
- package/src/media/LibavDecoder.ts +82 -0
- package/src/media/LibavDemuxer.ts +348 -0
- package/src/media/VideoStream.ts +15 -0
- package/src/media/index.ts +1 -0
- package/src/media/newApi.ts +618 -0
- package/src/media/utils.ts +6 -0
- package/src/utils.ts +77 -0
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
import { EventEmitter } from "node:events";
|
|
2
|
+
import { VoiceConnection } from "./voice/VoiceConnection.js";
|
|
3
|
+
import { StreamConnection } from "./voice/StreamConnection.js";
|
|
4
|
+
import { GatewayOpCodes } from "./GatewayOpCodes.js";
|
|
5
|
+
import type TypedEmitter from "typed-emitter";
|
|
6
|
+
import type { Client, DMChannel, GroupDMChannel, VoiceBasedChannel } from 'discord.js-selfbot-v13';
|
|
7
|
+
import type { MediaUdp } from "./voice/MediaUdp.js";
|
|
8
|
+
import type { GatewayEvent } from "./GatewayEvents.js";
|
|
9
|
+
import { generateStreamKey, parseStreamKey } from "../utils.js";
|
|
10
|
+
|
|
11
|
+
type EmitterEvents = {
|
|
12
|
+
[K in GatewayEvent["t"]]: (data: Extract<GatewayEvent, { t: K }>["d"]) => void
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export type StreamerOptions = {
|
|
16
|
+
/**
|
|
17
|
+
* Force the use of ChaCha20 encryption. Faster on CPUs without AES-NI
|
|
18
|
+
*/
|
|
19
|
+
forceChacha20Encryption: boolean;
|
|
20
|
+
/**
|
|
21
|
+
* Enable RTCP Sender Report for synchronization
|
|
22
|
+
*/
|
|
23
|
+
rtcpSenderReportEnabled: boolean
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export class Streamer {
|
|
27
|
+
private _voiceConnection?: VoiceConnection;
|
|
28
|
+
private _client: Client;
|
|
29
|
+
private _opts: StreamerOptions;
|
|
30
|
+
private _gatewayEmitter = new EventEmitter() as TypedEmitter.default<EmitterEvents>
|
|
31
|
+
|
|
32
|
+
constructor(client: Client, opts?: Partial<StreamerOptions>) {
|
|
33
|
+
this._client = client;
|
|
34
|
+
this._opts = {
|
|
35
|
+
forceChacha20Encryption: false,
|
|
36
|
+
rtcpSenderReportEnabled: true,
|
|
37
|
+
...opts
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
//listen for messages
|
|
41
|
+
this.client.on('raw', (packet: GatewayEvent) => {
|
|
42
|
+
// @ts-expect-error I don't know how to make this work with TypeScript, so whatever
|
|
43
|
+
this._gatewayEmitter.emit(packet.t, packet.d);
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
public get client(): Client {
|
|
48
|
+
return this._client;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
public get opts(): StreamerOptions {
|
|
52
|
+
return this._opts;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
public get voiceConnection(): VoiceConnection | undefined {
|
|
56
|
+
return this._voiceConnection;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
public sendOpcode(code: number, data: unknown): void {
|
|
60
|
+
this.client.ws.broadcast({
|
|
61
|
+
op: code,
|
|
62
|
+
d: data,
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
public joinVoiceChannel(channel: DMChannel | GroupDMChannel | VoiceBasedChannel): Promise<MediaUdp> {
|
|
67
|
+
let guildId: string | null = null;
|
|
68
|
+
|
|
69
|
+
if(channel.type === "GUILD_STAGE_VOICE" || channel.type === "GUILD_VOICE") {
|
|
70
|
+
guildId = channel.guildId
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return this.joinVoice(guildId, channel.id);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Joins a voice channel and returns a MediaUdp object.
|
|
78
|
+
* @param guild_id the guild id of the voice channel. If null, it will join a DM voice channel.
|
|
79
|
+
* @param channel_id the channel id of the voice channel
|
|
80
|
+
* @returns the MediaUdp object
|
|
81
|
+
* @throws Error if the client is not logged in
|
|
82
|
+
*/
|
|
83
|
+
public joinVoice(guild_id: string | null, channel_id: string): Promise<MediaUdp> {
|
|
84
|
+
return new Promise<MediaUdp>((resolve, reject) => {
|
|
85
|
+
if (!this.client.user) {
|
|
86
|
+
reject("Client not logged in");
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
const user_id = this.client.user.id;
|
|
90
|
+
const voiceConn = new VoiceConnection(
|
|
91
|
+
this,
|
|
92
|
+
guild_id,
|
|
93
|
+
user_id,
|
|
94
|
+
channel_id,
|
|
95
|
+
(udp) => {
|
|
96
|
+
resolve(udp)
|
|
97
|
+
}
|
|
98
|
+
);
|
|
99
|
+
this._voiceConnection = voiceConn;
|
|
100
|
+
this._gatewayEmitter.on("VOICE_STATE_UPDATE", (d) => {
|
|
101
|
+
if (user_id !== d.user_id) return;
|
|
102
|
+
voiceConn.setSession(d.session_id);
|
|
103
|
+
});
|
|
104
|
+
this._gatewayEmitter.on("VOICE_SERVER_UPDATE", (d) => {
|
|
105
|
+
if (guild_id !== d.guild_id) return;
|
|
106
|
+
|
|
107
|
+
// channel_id is not set for guild voice calls
|
|
108
|
+
if(d.channel_id && (channel_id !== d.channel_id)) return;
|
|
109
|
+
|
|
110
|
+
voiceConn.setTokens(d.endpoint, d.token);
|
|
111
|
+
})
|
|
112
|
+
this.signalVideo(false);
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
public createStream(): Promise<MediaUdp> {
|
|
117
|
+
return new Promise<MediaUdp>((resolve, reject) => {
|
|
118
|
+
if (!this.client.user) {
|
|
119
|
+
reject("Client not logged in");
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
if (!this.voiceConnection) {
|
|
123
|
+
reject("cannot start stream without first joining voice channel");
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
this.signalStream();
|
|
128
|
+
const {
|
|
129
|
+
guildId: clientGuildId,
|
|
130
|
+
channelId: clientChannelId,
|
|
131
|
+
session_id
|
|
132
|
+
} = this.voiceConnection;
|
|
133
|
+
const {
|
|
134
|
+
id: clientUserId
|
|
135
|
+
} = this.client.user;
|
|
136
|
+
|
|
137
|
+
if (!session_id)
|
|
138
|
+
throw new Error("Session doesn't exist yet");
|
|
139
|
+
const streamConn = new StreamConnection(
|
|
140
|
+
this,
|
|
141
|
+
clientGuildId,
|
|
142
|
+
clientUserId,
|
|
143
|
+
clientChannelId,
|
|
144
|
+
(udp) => {
|
|
145
|
+
resolve(udp)
|
|
146
|
+
}
|
|
147
|
+
);
|
|
148
|
+
this.voiceConnection.streamConnection = streamConn;
|
|
149
|
+
this._gatewayEmitter.on("STREAM_CREATE", (d) => {
|
|
150
|
+
const { type, channelId, guildId, userId } = parseStreamKey(d.stream_key);
|
|
151
|
+
|
|
152
|
+
if (
|
|
153
|
+
clientGuildId !== guildId ||
|
|
154
|
+
clientChannelId !== channelId ||
|
|
155
|
+
clientUserId !== userId
|
|
156
|
+
)
|
|
157
|
+
return;
|
|
158
|
+
|
|
159
|
+
streamConn.serverId = d.rtc_server_id;
|
|
160
|
+
streamConn.streamKey = d.stream_key;
|
|
161
|
+
streamConn.setSession(session_id);
|
|
162
|
+
});
|
|
163
|
+
this._gatewayEmitter.on("STREAM_SERVER_UPDATE", (d) => {
|
|
164
|
+
const { type, channelId, guildId, userId } = parseStreamKey(d.stream_key);
|
|
165
|
+
|
|
166
|
+
if (
|
|
167
|
+
clientGuildId !== guildId ||
|
|
168
|
+
clientChannelId !== channelId ||
|
|
169
|
+
clientUserId !== userId
|
|
170
|
+
)
|
|
171
|
+
return;
|
|
172
|
+
|
|
173
|
+
streamConn.setTokens(d.endpoint, d.token);
|
|
174
|
+
})
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
public async setStreamPreview(image: Buffer): Promise<void> {
|
|
179
|
+
if (!this.client.token)
|
|
180
|
+
throw new Error("Please login :)");
|
|
181
|
+
if (!this.voiceConnection?.streamConnection?.guildId)
|
|
182
|
+
return;
|
|
183
|
+
const data = `data:image/jpeg;base64,${image.toString("base64")}`;
|
|
184
|
+
const { guildId } = this.voiceConnection.streamConnection;
|
|
185
|
+
const server = await this.client.guilds.fetch(guildId);
|
|
186
|
+
await server.members.me?.voice.postPreview(data);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
public stopStream(): void {
|
|
190
|
+
const stream = this.voiceConnection?.streamConnection;
|
|
191
|
+
|
|
192
|
+
if (!stream) return;
|
|
193
|
+
|
|
194
|
+
stream.stop();
|
|
195
|
+
|
|
196
|
+
this.signalStopStream();
|
|
197
|
+
|
|
198
|
+
this.voiceConnection.streamConnection = undefined;
|
|
199
|
+
this._gatewayEmitter.removeAllListeners("STREAM_CREATE");
|
|
200
|
+
this._gatewayEmitter.removeAllListeners("STREAM_SERVER_UPDATE");
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
public leaveVoice(): void {
|
|
204
|
+
this.voiceConnection?.stop();
|
|
205
|
+
|
|
206
|
+
this.signalLeaveVoice();
|
|
207
|
+
|
|
208
|
+
this._voiceConnection = undefined;
|
|
209
|
+
this._gatewayEmitter.removeAllListeners("VOICE_STATE_UPDATE");
|
|
210
|
+
this._gatewayEmitter.removeAllListeners("VOICE_SERVER_UPDATE");
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
public signalVideo(video_enabled: boolean): void {
|
|
214
|
+
if (!this.voiceConnection)
|
|
215
|
+
return;
|
|
216
|
+
const {
|
|
217
|
+
guildId: guild_id,
|
|
218
|
+
channelId: channel_id,
|
|
219
|
+
} = this.voiceConnection;
|
|
220
|
+
this.sendOpcode(GatewayOpCodes.VOICE_STATE_UPDATE, {
|
|
221
|
+
guild_id: guild_id,
|
|
222
|
+
channel_id,
|
|
223
|
+
self_mute: false,
|
|
224
|
+
self_deaf: true,
|
|
225
|
+
self_video: video_enabled,
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
public signalStream(): void {
|
|
230
|
+
if (!this.voiceConnection)
|
|
231
|
+
return;
|
|
232
|
+
const {
|
|
233
|
+
type,
|
|
234
|
+
guildId: guild_id,
|
|
235
|
+
channelId: channel_id,
|
|
236
|
+
botId: user_id
|
|
237
|
+
} = this.voiceConnection;
|
|
238
|
+
|
|
239
|
+
const streamKey = generateStreamKey(type, guild_id, channel_id, user_id);
|
|
240
|
+
|
|
241
|
+
this.sendOpcode(GatewayOpCodes.STREAM_CREATE, {
|
|
242
|
+
type,
|
|
243
|
+
guild_id,
|
|
244
|
+
channel_id,
|
|
245
|
+
preferred_region: null,
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
this.sendOpcode(GatewayOpCodes.STREAM_SET_PAUSED, {
|
|
249
|
+
stream_key: streamKey,
|
|
250
|
+
paused: false,
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
public signalStopStream(): void {
|
|
255
|
+
if (!this.voiceConnection)
|
|
256
|
+
return;
|
|
257
|
+
const {
|
|
258
|
+
type,
|
|
259
|
+
guildId: guild_id,
|
|
260
|
+
channelId: channel_id,
|
|
261
|
+
botId: user_id
|
|
262
|
+
} = this.voiceConnection;
|
|
263
|
+
|
|
264
|
+
const streamKey = generateStreamKey(type, guild_id, channel_id, user_id);
|
|
265
|
+
this.sendOpcode(GatewayOpCodes.STREAM_DELETE, {
|
|
266
|
+
stream_key: streamKey
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
public signalLeaveVoice(): void {
|
|
271
|
+
this.sendOpcode(GatewayOpCodes.VOICE_STATE_UPDATE, {
|
|
272
|
+
guild_id: null,
|
|
273
|
+
channel_id: null,
|
|
274
|
+
self_mute: true,
|
|
275
|
+
self_deaf: false,
|
|
276
|
+
self_video: false,
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import sp from "sodium-plus";
|
|
2
|
+
import { max_int32bit } from "../../utils.js";
|
|
3
|
+
const { SodiumPlus } = sp;
|
|
4
|
+
|
|
5
|
+
export interface TransportEncryptor {
|
|
6
|
+
encrypt(plaintext: Buffer, additionalData: Buffer): Promise<[Buffer, Buffer]>
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export class AES256TransportEncryptor implements TransportEncryptor
|
|
10
|
+
{
|
|
11
|
+
private _nonce = 0;
|
|
12
|
+
private _secretKey: Promise<CryptoKey>;
|
|
13
|
+
constructor(secretKey: Buffer)
|
|
14
|
+
{
|
|
15
|
+
this._secretKey = crypto.subtle.importKey("raw",
|
|
16
|
+
secretKey,
|
|
17
|
+
{
|
|
18
|
+
name: "AES-GCM",
|
|
19
|
+
length: 32
|
|
20
|
+
},
|
|
21
|
+
false, ["encrypt"]
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
async encrypt(plaintext: Buffer, additionalData: Buffer): Promise<[Buffer, Buffer]> {
|
|
25
|
+
const nonceBuffer = Buffer.alloc(12);
|
|
26
|
+
nonceBuffer.writeUInt32BE(this._nonce);
|
|
27
|
+
this._nonce = (this._nonce + 1) % max_int32bit;
|
|
28
|
+
|
|
29
|
+
const ciphertext = Buffer.from(await crypto.subtle.encrypt({
|
|
30
|
+
name: "AES-GCM",
|
|
31
|
+
iv: nonceBuffer,
|
|
32
|
+
additionalData,
|
|
33
|
+
}, await this._secretKey, plaintext));
|
|
34
|
+
|
|
35
|
+
return [ciphertext, nonceBuffer]
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export class Chacha20TransportEncryptor implements TransportEncryptor
|
|
40
|
+
{
|
|
41
|
+
private static sodium = SodiumPlus.auto();
|
|
42
|
+
private _nonce = 0;
|
|
43
|
+
private _secretKey: sp.CryptographyKey;
|
|
44
|
+
constructor(secretKey: Buffer)
|
|
45
|
+
{
|
|
46
|
+
this._secretKey = new sp.CryptographyKey(secretKey);
|
|
47
|
+
}
|
|
48
|
+
async encrypt(plaintext: Buffer, additionalData: Buffer): Promise<[Buffer, Buffer]> {
|
|
49
|
+
const nonceBuffer = Buffer.alloc(24);
|
|
50
|
+
nonceBuffer.writeUInt32BE(this._nonce);
|
|
51
|
+
this._nonce = (this._nonce + 1) % max_int32bit;
|
|
52
|
+
|
|
53
|
+
const ciphertext = await Chacha20TransportEncryptor.sodium
|
|
54
|
+
.then(s => s.crypto_aead_xchacha20poly1305_ietf_encrypt(
|
|
55
|
+
plaintext, nonceBuffer,
|
|
56
|
+
this._secretKey,
|
|
57
|
+
additionalData
|
|
58
|
+
));
|
|
59
|
+
|
|
60
|
+
return [ciphertext, nonceBuffer];
|
|
61
|
+
}
|
|
62
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { MediaUdp } from "../voice/MediaUdp.js";
|
|
2
|
+
import { BaseMediaPacketizer } from "./BaseMediaPacketizer.js";
|
|
3
|
+
import { CodecPayloadType } from "../voice/BaseMediaConnection.js";
|
|
4
|
+
|
|
5
|
+
export class AudioPacketizer extends BaseMediaPacketizer {
|
|
6
|
+
constructor(connection: MediaUdp, ssrc: number) {
|
|
7
|
+
super(connection, ssrc, CodecPayloadType.opus.payload_type);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
public override async sendFrame(frame: Buffer, frametime: number): Promise<void> {
|
|
11
|
+
super.sendFrame(frame, frametime);
|
|
12
|
+
const packet = await this.createPacket(frame);
|
|
13
|
+
this.mediaUdp.sendPacket(packet);
|
|
14
|
+
this.onFrameSent(packet.length, frametime);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
public async createPacket(chunk: Buffer): Promise<Buffer> {
|
|
18
|
+
const header = this.makeRtpHeader();
|
|
19
|
+
|
|
20
|
+
const [ciphertext, nonceBuffer] = await this.encryptData(chunk, header);
|
|
21
|
+
return Buffer.concat([header, ciphertext, nonceBuffer.subarray(0, 4)]);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
public override async onFrameSent(bytesSent: number, frametime: number): Promise<void> {
|
|
25
|
+
await super.onFrameSent(1, bytesSent, frametime);
|
|
26
|
+
this.incrementTimestamp(frametime * (48000 / 1000));
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
import sp from "sodium-plus";
|
|
2
|
+
import { Log } from "debug-level";
|
|
3
|
+
import { webcrypto } from 'node:crypto';
|
|
4
|
+
import { max_int16bit, max_int32bit, SupportedEncryptionModes } from "../../utils.js";
|
|
5
|
+
import type { MediaUdp } from "../voice/MediaUdp.js";
|
|
6
|
+
|
|
7
|
+
const { SodiumPlus, CryptographyKey } = sp;
|
|
8
|
+
|
|
9
|
+
const ntpEpoch = new Date("Jan 01 1900 GMT").getTime();
|
|
10
|
+
|
|
11
|
+
let sodium: Promise<sp.SodiumPlus> | undefined;
|
|
12
|
+
|
|
13
|
+
export class BaseMediaPacketizer {
|
|
14
|
+
private _loggerRtcpSr = new Log("packetizer:rtcp-sr");
|
|
15
|
+
|
|
16
|
+
private _ssrc: number;
|
|
17
|
+
private _payloadType: number;
|
|
18
|
+
private _mtu: number;
|
|
19
|
+
private _sequence: number;
|
|
20
|
+
private _timestamp: number;
|
|
21
|
+
|
|
22
|
+
private _totalBytes: number;
|
|
23
|
+
private _totalPackets: number;
|
|
24
|
+
private _lastPacketTime: number;
|
|
25
|
+
private _lastRtcpTime: number;
|
|
26
|
+
private _currentMediaTimestamp: number;
|
|
27
|
+
private _srInterval: number;
|
|
28
|
+
|
|
29
|
+
private _mediaUdp: MediaUdp;
|
|
30
|
+
private _extensionEnabled: boolean;
|
|
31
|
+
|
|
32
|
+
constructor(connection: MediaUdp, ssrc: number, payloadType: number, extensionEnabled = false) {
|
|
33
|
+
this._mediaUdp = connection;
|
|
34
|
+
this._payloadType = payloadType;
|
|
35
|
+
this._ssrc = ssrc;
|
|
36
|
+
this._sequence = 0;
|
|
37
|
+
this._timestamp = 0;
|
|
38
|
+
this._totalBytes = 0;
|
|
39
|
+
this._totalPackets = 0;
|
|
40
|
+
this._lastPacketTime = 0;
|
|
41
|
+
this._lastRtcpTime = 0;
|
|
42
|
+
this._currentMediaTimestamp = 0;
|
|
43
|
+
this._mtu = 1200;
|
|
44
|
+
this._extensionEnabled = extensionEnabled;
|
|
45
|
+
|
|
46
|
+
this._srInterval = 1000;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
public get ssrc(): number | undefined
|
|
50
|
+
{
|
|
51
|
+
return this._ssrc;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
public set ssrc(value: number)
|
|
55
|
+
{
|
|
56
|
+
this._ssrc = value;
|
|
57
|
+
this._totalBytes = this._totalPackets = 0;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The interval between 2 consecutive RTCP Sender Report packets in ms
|
|
62
|
+
*/
|
|
63
|
+
public get srInterval(): number
|
|
64
|
+
{
|
|
65
|
+
return this._srInterval;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
public set srInterval(interval: number)
|
|
69
|
+
{
|
|
70
|
+
this._srInterval = interval;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
public async sendFrame(frame: Buffer, frametime: number): Promise<void> {
|
|
74
|
+
// override this
|
|
75
|
+
this._lastPacketTime = Date.now();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
public async onFrameSent(packetsSent: number, bytesSent: number, frametime: number): Promise<void> {
|
|
79
|
+
|
|
80
|
+
if (this._mediaUdp.mediaConnection.streamer.opts.rtcpSenderReportEnabled)
|
|
81
|
+
{
|
|
82
|
+
this._totalPackets = this._totalPackets + packetsSent;
|
|
83
|
+
this._totalBytes = (this._totalBytes + bytesSent) % max_int32bit;
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Not using modulo here, since the timestamp might not be an exact
|
|
87
|
+
* multiple of the interval
|
|
88
|
+
*/
|
|
89
|
+
if (Math.floor(this._currentMediaTimestamp / this._srInterval) - Math.floor(this._lastRtcpTime / this._srInterval) > 0)
|
|
90
|
+
{
|
|
91
|
+
const senderReport = await this.makeRtcpSenderReport();
|
|
92
|
+
this._mediaUdp.sendPacket(senderReport);
|
|
93
|
+
this._lastRtcpTime = this._currentMediaTimestamp;
|
|
94
|
+
this._loggerRtcpSr.debug({
|
|
95
|
+
stats: {
|
|
96
|
+
ssrc: this._ssrc,
|
|
97
|
+
timestamp: this._timestamp,
|
|
98
|
+
totalPackets: this._totalPackets,
|
|
99
|
+
totalBytes: this._totalBytes
|
|
100
|
+
}
|
|
101
|
+
}, `Sent RTCP sender report for SSRC ${this._ssrc}`);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
this._currentMediaTimestamp += frametime;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Partitions a buffer into chunks of length this.mtu
|
|
109
|
+
* @param data buffer to be partitioned
|
|
110
|
+
* @returns array of chunks
|
|
111
|
+
*/
|
|
112
|
+
public partitionDataMTUSizedChunks(data: Buffer): Buffer[] {
|
|
113
|
+
let i = 0;
|
|
114
|
+
let len = data.length;
|
|
115
|
+
|
|
116
|
+
const out = [];
|
|
117
|
+
|
|
118
|
+
while (len > 0) {
|
|
119
|
+
const size = Math.min(len, this._mtu);
|
|
120
|
+
out.push(data.subarray(i, i + size));
|
|
121
|
+
len -= size;
|
|
122
|
+
i += size;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return out;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
public getNewSequence(): number {
|
|
129
|
+
this._sequence = (this._sequence + 1) % max_int16bit;
|
|
130
|
+
return this._sequence;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
public incrementTimestamp(incrementBy: number): void {
|
|
134
|
+
this._timestamp = (this._timestamp + incrementBy) % max_int32bit;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
public makeRtpHeader(isLastPacket = true): Buffer {
|
|
138
|
+
const packetHeader = Buffer.alloc(12);
|
|
139
|
+
|
|
140
|
+
packetHeader[0] = 2 << 6 | ((this._extensionEnabled ? 1 : 0) << 4); // set version and flags
|
|
141
|
+
packetHeader[1] = this._payloadType; // set packet payload
|
|
142
|
+
if (isLastPacket)
|
|
143
|
+
packetHeader[1] |= 0b10000000; // mark M bit if last frame
|
|
144
|
+
|
|
145
|
+
packetHeader.writeUIntBE(this.getNewSequence(), 2, 2);
|
|
146
|
+
packetHeader.writeUIntBE(this._timestamp, 4, 4);
|
|
147
|
+
packetHeader.writeUIntBE(this._ssrc, 8, 4);
|
|
148
|
+
return packetHeader;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
public async makeRtcpSenderReport(): Promise<Buffer> {
|
|
152
|
+
const packetHeader = Buffer.allocUnsafe(8);
|
|
153
|
+
|
|
154
|
+
packetHeader[0] = 0x80; // RFC1889 v2, no padding, no reception report count
|
|
155
|
+
packetHeader[1] = 0xc8; // Type: Sender Report (200)
|
|
156
|
+
|
|
157
|
+
// Packet length (always 0x06 for some reason)
|
|
158
|
+
packetHeader[2] = 0x00;
|
|
159
|
+
packetHeader[3] = 0x06;
|
|
160
|
+
packetHeader.writeUInt32BE(this._ssrc, 4);
|
|
161
|
+
|
|
162
|
+
const senderReport = Buffer.allocUnsafe(20);
|
|
163
|
+
|
|
164
|
+
// Convert from floating point to 32.32 fixed point
|
|
165
|
+
// Convert each part separately to reduce precision loss
|
|
166
|
+
const ntpTimestamp = (this._lastPacketTime - ntpEpoch) / 1000;
|
|
167
|
+
const ntpTimestampMsw = Math.floor(ntpTimestamp);
|
|
168
|
+
const ntpTimestampLsw = Math.round((ntpTimestamp - ntpTimestampMsw) * max_int32bit);
|
|
169
|
+
|
|
170
|
+
senderReport.writeUInt32BE(ntpTimestampMsw, 0);
|
|
171
|
+
senderReport.writeUInt32BE(ntpTimestampLsw, 4);
|
|
172
|
+
senderReport.writeUInt32BE(this._timestamp, 8);
|
|
173
|
+
senderReport.writeUInt32BE(this._totalPackets % max_int32bit, 12);
|
|
174
|
+
senderReport.writeUInt32BE(this._totalBytes, 16);
|
|
175
|
+
|
|
176
|
+
const [ciphertext, nonceBuffer] = await this.encryptData(senderReport, packetHeader);
|
|
177
|
+
return Buffer.concat([
|
|
178
|
+
packetHeader, ciphertext,
|
|
179
|
+
nonceBuffer.subarray(0, 4)
|
|
180
|
+
]);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Creates a one-byte extension header
|
|
185
|
+
* https://www.rfc-editor.org/rfc/rfc5285#section-4.2
|
|
186
|
+
* @returns extension header
|
|
187
|
+
*/
|
|
188
|
+
public createExtensionHeader(extensions: { id: number, len: number, val: number}[]): Buffer {
|
|
189
|
+
/**
|
|
190
|
+
* 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
|
|
191
|
+
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|
|
192
|
+
| defined by profile | length |
|
|
193
|
+
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|
|
194
|
+
*/
|
|
195
|
+
const profile = Buffer.alloc(4);
|
|
196
|
+
profile[0] = 0xBE;
|
|
197
|
+
profile[1] = 0xDE;
|
|
198
|
+
profile.writeInt16BE(extensions.length, 2); // extension count
|
|
199
|
+
|
|
200
|
+
return profile
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Creates a extension payload in one-byte format according to https://www.rfc-editor.org/rfc/rfc7941.html#section-4.1.1
|
|
205
|
+
* Discord seems to send this extension on every video packet. The extension ids for Discord can be found by connecting
|
|
206
|
+
* to their webrtc gateway using the webclient and the client will send an SDP offer containing it
|
|
207
|
+
* @returns extension payload
|
|
208
|
+
*/
|
|
209
|
+
public createExtensionPayload(extensions: { id: number, len: number, val: number}[]): Buffer {
|
|
210
|
+
const extensionsData = [];
|
|
211
|
+
for(const ext of extensions){
|
|
212
|
+
/**
|
|
213
|
+
* EXTENSION DATA - each extension payload is 32 bits
|
|
214
|
+
*/
|
|
215
|
+
const data = Buffer.alloc(4);
|
|
216
|
+
|
|
217
|
+
// https://webrtc.googlesource.com/src/+/refs/heads/main/docs/native-code/rtp-hdrext/playout-delay
|
|
218
|
+
if(ext.id === 5) {
|
|
219
|
+
/**
|
|
220
|
+
* 0 1 2 3 4 5 6 7
|
|
221
|
+
+-+-+-+-+-+-+-+-+
|
|
222
|
+
| ID | len |
|
|
223
|
+
+-+-+-+-+-+-+-+-+
|
|
224
|
+
|
|
225
|
+
where len = actual length - 1
|
|
226
|
+
*/
|
|
227
|
+
data[0] = (ext.id & 0b00001111) << 4;
|
|
228
|
+
data[0] |= ((ext.len - 1) & 0b00001111);
|
|
229
|
+
|
|
230
|
+
/** Specific to type playout-delay
|
|
231
|
+
* 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4
|
|
232
|
+
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|
|
233
|
+
| MIN delay | MAX delay |
|
|
234
|
+
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|
|
235
|
+
*/
|
|
236
|
+
data.writeUIntBE(ext.val, 1, 2); // not quite but its 0 anyway
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
extensionsData.push(data);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
return Buffer.concat(extensionsData)
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Encrypt packet payload. Encrpyed Payload is determined to be
|
|
247
|
+
* according to https://tools.ietf.org/html/rfc3711#section-3.1
|
|
248
|
+
* and https://datatracker.ietf.org/doc/html/rfc7714#section-8.2
|
|
249
|
+
*
|
|
250
|
+
* Associated Data: The version V (2 bits), padding flag P (1 bit),
|
|
251
|
+
extension flag X (1 bit), Contributing Source
|
|
252
|
+
(CSRC) count CC (4 bits), marker M (1 bit),
|
|
253
|
+
Payload Type PT (7 bits), sequence number
|
|
254
|
+
(16 bits), timestamp (32 bits), SSRC (32 bits),
|
|
255
|
+
optional CSRC identifiers (32 bits each), and
|
|
256
|
+
optional RTP extension (variable length).
|
|
257
|
+
|
|
258
|
+
Plaintext: The RTP payload (variable length), RTP padding
|
|
259
|
+
(if used, variable length), and RTP pad count (if
|
|
260
|
+
used, 1 octet).
|
|
261
|
+
|
|
262
|
+
Raw Data: The optional variable-length SRTP Master Key
|
|
263
|
+
Identifier (MKI) and SRTP authentication tag
|
|
264
|
+
(whose use is NOT RECOMMENDED). These fields are
|
|
265
|
+
appended after encryption has been performed.
|
|
266
|
+
|
|
267
|
+
0 1 2 3
|
|
268
|
+
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
|
|
269
|
+
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|
|
270
|
+
A |V=2|P|X| CC |M| PT | sequence number |
|
|
271
|
+
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|
|
272
|
+
A | timestamp |
|
|
273
|
+
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|
|
274
|
+
A | synchronization source (SSRC) identifier |
|
|
275
|
+
+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+
|
|
276
|
+
A | contributing source (CSRC) identifiers (optional) |
|
|
277
|
+
A | .... |
|
|
278
|
+
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|
|
279
|
+
A | RTP extension header (OPTIONAL) |
|
|
280
|
+
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|
|
281
|
+
P | payload ... |
|
|
282
|
+
P | +-------------------------------+
|
|
283
|
+
P | | RTP padding | RTP pad count |
|
|
284
|
+
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|
|
285
|
+
|
|
286
|
+
P = Plaintext (to be encrypted and authenticated)
|
|
287
|
+
A = Associated Data (to be authenticated only)
|
|
288
|
+
* @param plaintext
|
|
289
|
+
* @param nonceBuffer
|
|
290
|
+
* @param additionalData
|
|
291
|
+
* @returns ciphertext
|
|
292
|
+
*/
|
|
293
|
+
public encryptData(plaintext: Buffer, additionalData: Buffer): Promise<[Buffer, Buffer]> {
|
|
294
|
+
const encryptor = this._mediaUdp.mediaConnection.transportEncryptor;
|
|
295
|
+
if (!encryptor)
|
|
296
|
+
throw new Error("Transport encryptor not defined. Did you forget to select protocol?");
|
|
297
|
+
return encryptor.encrypt(plaintext, additionalData);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
public get mediaUdp(): MediaUdp {
|
|
301
|
+
return this._mediaUdp;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
public get mtu(): number {
|
|
305
|
+
return this._mtu;
|
|
306
|
+
}
|
|
307
|
+
}
|