rockcord 4.0.0-dev.4 → 4.0.0-dev.5
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 +3 -2
- package/package.json +3 -3
- package/src/client/voice/VoiceConnection.js +96 -22
- package/src/client/voice/dispatcher/BaseDispatcher.js +14 -51
- package/src/client/voice/dispatcher/VideoDispatcher.js +3 -2
- package/src/client/voice/networking/MediaRecovery.js +197 -0
- package/src/client/voice/networking/VoiceUDPClient.js +10 -1
- package/src/client/voice/networking/VoiceWebSocket.js +4 -1
- package/src/client/voice/player/MediaPlayer.js +27 -8
- package/src/client/voice/receiver/AudioJitter.js +98 -0
- package/src/client/voice/receiver/PacketHandler.js +57 -6
- package/src/client/voice/receiver/Receiver.js +5 -2
- package/src/client/voice/receiver/Recorder.js +147 -48
- package/src/client/voice/util/TransportCrypto.js +54 -0
- package/src/client/voice/util/VideoFrames.js +4 -1
- package/typings/index.d.ts +1 -0
package/README.md
CHANGED
|
@@ -30,8 +30,9 @@ Media playback also requires FFmpeg and a supported Opus binding. Keep optional
|
|
|
30
30
|
- Components V2 support and checked runtime exports with TypeScript declarations.
|
|
31
31
|
- Bounded REST retries, request deadlines, credential redaction, and connection cleanup.
|
|
32
32
|
- Native DAVE/MLS encryption for audio and video, complete received video frames, and voice privacy verification codes.
|
|
33
|
+
- Screen-share connections, H264/VP8/H265 playback, H264/Opus recording, video retransmission, and buffered audio reception.
|
|
33
34
|
|
|
34
|
-
This is a **development release**. Live audio,
|
|
35
|
+
This is a **development release**. Live audio, all three video codecs, screen sharing, H264 recording, and packet-loss recovery have passed targeted checks. See the [implementation report](https://github.com/rock-forge/rockcord/blob/main/docs/audit/IMPLEMENTATION.md) for coverage and limitations.
|
|
35
36
|
|
|
36
37
|
## Project links
|
|
37
38
|
|
|
@@ -43,6 +44,6 @@ This is a **development release**. Live audio, H264 camera video, rejoining, and
|
|
|
43
44
|
|
|
44
45
|
## Credits and license
|
|
45
46
|
|
|
46
|
-
Rockcord is maintained independently by [Rock Forge](https://github.com/rock-forge) and [devrock07](https://github.com/devrock07). It builds on [discord.js-selfbot-v13](https://github.com/aiko-chan-ai/discord.js-selfbot-v13) and [discord.js](https://github.com/discordjs/discord.js)
|
|
47
|
+
Rockcord is maintained independently by [Rock Forge](https://github.com/rock-forge) and [devrock07](https://github.com/devrock07). It builds on [discord.js-selfbot-v13](https://github.com/aiko-chan-ai/discord.js-selfbot-v13) and [discord.js](https://github.com/discordjs/discord.js). Original source attribution and licensing are preserved; this repository starts with fresh Rock Forge commit history.
|
|
47
48
|
|
|
48
49
|
Licensed under [GPL v3](https://github.com/rock-forge/rockcord/blob/main/LICENSE). Dependencies retain their own licenses.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "rockcord",
|
|
3
|
-
"version": "4.0.0-dev.
|
|
3
|
+
"version": "4.0.0-dev.5",
|
|
4
4
|
"description": "Rockcord: a maintained Discord client library with tested v13 APIs and DAVE media encryption",
|
|
5
5
|
"main": "./src/index.js",
|
|
6
6
|
"types": "./typings/index.d.ts",
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
"all": "npm run build",
|
|
9
9
|
"test": "npm run test:unit && npm run lint:all && npm run docs:test && npm run test:typescript",
|
|
10
10
|
"test:unit": "node --test test/*.test.cjs",
|
|
11
|
-
"test:media": "node scripts/media-integration.cjs",
|
|
11
|
+
"test:media": "node scripts/media-integration.cjs && node scripts/recording-integration.cjs",
|
|
12
12
|
"test:live": "node --env-file=.env scripts/live-voice-check.cjs",
|
|
13
13
|
"bench": "node --expose-gc scripts/bench.cjs",
|
|
14
14
|
"fix:all": "npm run lint:fix && npm run lint:typings:fix && npm run format",
|
|
@@ -49,7 +49,7 @@
|
|
|
49
49
|
"client",
|
|
50
50
|
"discordapp"
|
|
51
51
|
],
|
|
52
|
-
"author": "
|
|
52
|
+
"author": "Rock Forge (https://github.com/rock-forge)",
|
|
53
53
|
"license": "GNU General Public License v3.0",
|
|
54
54
|
"bugs": {
|
|
55
55
|
"url": "https://github.com/rock-forge/rockcord/issues"
|
|
@@ -31,7 +31,7 @@ if (getCiphers().includes('aes-256-gcm')) {
|
|
|
31
31
|
SUPPORTED_MODES.unshift('aead_aes256_gcm_rtpsize');
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
-
const SUPPORTED_CODECS = ['VP8', 'H264'];
|
|
34
|
+
const SUPPORTED_CODECS = ['VP8', 'H264', 'H265'];
|
|
35
35
|
|
|
36
36
|
/**
|
|
37
37
|
* Represents a connection to a guild's voice server.
|
|
@@ -144,7 +144,7 @@ class VoiceConnection extends EventEmitter {
|
|
|
144
144
|
* * `VP8`
|
|
145
145
|
* * `VP9` (Not supported for encoding & decoding)
|
|
146
146
|
* * `H264`
|
|
147
|
-
* * `H265`
|
|
147
|
+
* * `H265`
|
|
148
148
|
* * `AV1` (Not supported for encoding & decoding)
|
|
149
149
|
* @typedef {string} VideoCodec
|
|
150
150
|
*/
|
|
@@ -312,10 +312,36 @@ class VoiceConnection extends EventEmitter {
|
|
|
312
312
|
|
|
313
313
|
this.emit('debug', `Sending voice state update: ${JSON.stringify(options)}`);
|
|
314
314
|
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
315
|
+
this._pendingVoiceState?.cancel();
|
|
316
|
+
let settle;
|
|
317
|
+
const pending = { promise: new Promise(resolve => (settle = resolve)) };
|
|
318
|
+
const finish = acknowledged => {
|
|
319
|
+
clearTimeout(pending.timer);
|
|
320
|
+
this.client.removeListener('raw', onState);
|
|
321
|
+
if (this._pendingVoiceState === pending) this._pendingVoiceState = null;
|
|
322
|
+
settle(acknowledged);
|
|
323
|
+
};
|
|
324
|
+
const onState = packet => {
|
|
325
|
+
if (packet?.t !== 'VOICE_STATE_UPDATE' || packet.d?.user_id !== this.client.user?.id) return;
|
|
326
|
+
if (!['channel_id', 'self_mute', 'self_deaf', 'self_video'].every(key => packet.d[key] === options[key])) return;
|
|
327
|
+
finish(true);
|
|
328
|
+
};
|
|
329
|
+
pending.cancel = () => finish(false);
|
|
330
|
+
pending.timer = setTimeout(pending.cancel, 15_000).unref();
|
|
331
|
+
this._pendingVoiceState = pending;
|
|
332
|
+
this.client.on('raw', onState);
|
|
333
|
+
|
|
334
|
+
try {
|
|
335
|
+
return Promise.resolve(this.channel.client.ws.broadcast({ op: Opcodes.VOICE_STATE_UPDATE, d: options })).catch(
|
|
336
|
+
error => {
|
|
337
|
+
pending.cancel();
|
|
338
|
+
throw error;
|
|
339
|
+
},
|
|
340
|
+
);
|
|
341
|
+
} catch (error) {
|
|
342
|
+
pending.cancel();
|
|
343
|
+
throw error;
|
|
344
|
+
}
|
|
319
345
|
}
|
|
320
346
|
|
|
321
347
|
/**
|
|
@@ -498,6 +524,11 @@ class VoiceConnection extends EventEmitter {
|
|
|
498
524
|
* @private
|
|
499
525
|
*/
|
|
500
526
|
cleanup() {
|
|
527
|
+
this._pendingVoiceState?.cancel();
|
|
528
|
+
if (!this.voiceConnection) {
|
|
529
|
+
this.streamConnection?.disconnect();
|
|
530
|
+
for (const stream of this.streamWatchConnection.values()) stream.disconnect();
|
|
531
|
+
}
|
|
501
532
|
this.player.destroy();
|
|
502
533
|
this.speaking = new Speaking().freeze();
|
|
503
534
|
const { ws, udp } = this.sockets;
|
|
@@ -576,6 +607,9 @@ class VoiceConnection extends EventEmitter {
|
|
|
576
607
|
* @private
|
|
577
608
|
*/
|
|
578
609
|
onSessionDescription(data) {
|
|
610
|
+
this._mediaRecovery?.reset();
|
|
611
|
+
for (const jitter of this.receiver.packets.audioJitters.values()) jitter.reset();
|
|
612
|
+
this.receiver.packets.audioJitters.clear();
|
|
579
613
|
Object.assign(this.authentication, data);
|
|
580
614
|
this.status = VoiceStatus.CONNECTED;
|
|
581
615
|
const ready = () => {
|
|
@@ -606,17 +640,21 @@ class VoiceConnection extends EventEmitter {
|
|
|
606
640
|
}
|
|
607
641
|
|
|
608
642
|
onStartStreaming({ video_ssrc, user_id, audio_ssrc, rtx_ssrc, streams = [] }) {
|
|
643
|
+
const layers = streams.length
|
|
644
|
+
? streams.filter(stream => stream.active !== false)
|
|
645
|
+
: [{ ssrc: video_ssrc, rtx_ssrc }];
|
|
646
|
+
const retained = new Set(layers.flatMap(layer => [+layer.ssrc, +layer.rtx_ssrc]).filter(Boolean));
|
|
609
647
|
for (const [ssrc, info] of this.ssrcMap) {
|
|
610
|
-
if (info.userId === user_id && ['video', 'video-rtx'].includes(info.kind)
|
|
648
|
+
if (info.userId === user_id && ['video', 'video-rtx'].includes(info.kind) && !retained.has(ssrc)) {
|
|
649
|
+
this.ssrcMap.delete(ssrc);
|
|
650
|
+
this.receiver.packets.forgetSource(ssrc);
|
|
651
|
+
}
|
|
611
652
|
}
|
|
612
653
|
this.ssrcMap.set(+audio_ssrc, {
|
|
613
654
|
...(this.ssrcMap.get(+audio_ssrc) || {}),
|
|
614
655
|
userId: user_id,
|
|
615
656
|
hasVideo: Boolean(video_ssrc), // Maybe ?
|
|
616
657
|
});
|
|
617
|
-
const layers = streams.length
|
|
618
|
-
? streams.filter(stream => stream.active !== false)
|
|
619
|
-
: [{ ssrc: video_ssrc, rtx_ssrc }];
|
|
620
658
|
for (const layer of layers) {
|
|
621
659
|
if (!layer.ssrc) continue;
|
|
622
660
|
this.ssrcMap.set(+layer.ssrc, { userId: user_id, hasVideo: true, kind: 'video' });
|
|
@@ -696,12 +734,15 @@ class VoiceConnection extends EventEmitter {
|
|
|
696
734
|
* @returns {Promise<StreamConnection>}
|
|
697
735
|
*/
|
|
698
736
|
createStreamConnection() {
|
|
737
|
+
if (this.streamConnection?._readyPromise) return this.streamConnection._readyPromise;
|
|
738
|
+
let created;
|
|
699
739
|
// eslint-disable-next-line consistent-return
|
|
700
|
-
|
|
740
|
+
const pending = new Promise((resolve, reject) => {
|
|
701
741
|
if (this.streamConnection) {
|
|
702
742
|
return resolve(this.streamConnection);
|
|
703
743
|
} else {
|
|
704
744
|
const connection = (this.streamConnection = new StreamConnection(this.voiceManager, this.channel, this));
|
|
745
|
+
created = connection;
|
|
705
746
|
connection.setVideoCodec(this.videoCodec); // Sync :?
|
|
706
747
|
// Setup event...
|
|
707
748
|
if (!this.eventHook) {
|
|
@@ -720,8 +761,8 @@ class VoiceConnection extends EventEmitter {
|
|
|
720
761
|
// Current user stream
|
|
721
762
|
switch (event) {
|
|
722
763
|
case 'STREAM_CREATE': {
|
|
723
|
-
this.streamConnection.setSessionId(this.authentication.sessionId);
|
|
724
764
|
this.streamConnection.serverId = data.rtc_server_id;
|
|
765
|
+
this.streamConnection.setSessionId(this.authentication.sessionId);
|
|
725
766
|
break;
|
|
726
767
|
}
|
|
727
768
|
case 'STREAM_SERVER_UPDATE': {
|
|
@@ -743,8 +784,8 @@ class VoiceConnection extends EventEmitter {
|
|
|
743
784
|
// Watch user stream
|
|
744
785
|
switch (event) {
|
|
745
786
|
case 'STREAM_CREATE': {
|
|
746
|
-
streamConnection.setSessionId(this.authentication.sessionId);
|
|
747
787
|
streamConnection.serverId = data.rtc_server_id;
|
|
788
|
+
streamConnection.setSessionId(this.authentication.sessionId);
|
|
748
789
|
break;
|
|
749
790
|
}
|
|
750
791
|
case 'STREAM_SERVER_UPDATE': {
|
|
@@ -782,12 +823,16 @@ class VoiceConnection extends EventEmitter {
|
|
|
782
823
|
connection.disconnect();
|
|
783
824
|
});
|
|
784
825
|
|
|
785
|
-
|
|
826
|
+
const onError = error => {
|
|
827
|
+
reject(error);
|
|
828
|
+
connection.disconnect();
|
|
829
|
+
};
|
|
830
|
+
connection.on('error', onError);
|
|
786
831
|
|
|
787
832
|
connection.once('ready', () => {
|
|
788
833
|
clearTimeout(connection.connectTimeout);
|
|
789
834
|
resolve(connection);
|
|
790
|
-
connection.removeListener('error',
|
|
835
|
+
connection.removeListener('error', onError);
|
|
791
836
|
});
|
|
792
837
|
connection.once('disconnect', () => {
|
|
793
838
|
clearTimeout(connection.connectTimeout);
|
|
@@ -798,10 +843,28 @@ class VoiceConnection extends EventEmitter {
|
|
|
798
843
|
() => connection.authenticateFailed('VOICE_CONNECTION_TIMEOUT'),
|
|
799
844
|
15_000,
|
|
800
845
|
).unref();
|
|
801
|
-
|
|
802
|
-
|
|
846
|
+
const start = () => {
|
|
847
|
+
if (connection.status === VoiceStatus.DISCONNECTED) return;
|
|
848
|
+
connection.sendSignalScreenshare();
|
|
849
|
+
connection.sendScreenshareState(false);
|
|
850
|
+
};
|
|
851
|
+
// Wait for an in-flight camera/voice change before creating a stream: a late
|
|
852
|
+
// camera-off update can otherwise cancel the newly created stream server-side.
|
|
853
|
+
if (this._pendingVoiceState) {
|
|
854
|
+
this._pendingVoiceState.promise.then(acknowledged => {
|
|
855
|
+
if (acknowledged) {
|
|
856
|
+
start();
|
|
857
|
+
} else if (connection.status !== VoiceStatus.DISCONNECTED) {
|
|
858
|
+
connection.authenticateFailed('VOICE_CONNECTION_TIMEOUT');
|
|
859
|
+
}
|
|
860
|
+
});
|
|
861
|
+
} else {
|
|
862
|
+
start();
|
|
863
|
+
}
|
|
803
864
|
}
|
|
804
865
|
});
|
|
866
|
+
if (created) created._readyPromise = pending;
|
|
867
|
+
return pending;
|
|
805
868
|
}
|
|
806
869
|
|
|
807
870
|
/**
|
|
@@ -819,12 +882,17 @@ class VoiceConnection extends EventEmitter {
|
|
|
819
882
|
if (!voiceState || !voiceState.streaming) {
|
|
820
883
|
throw new Error('VOICE_USER_NOT_STREAMING');
|
|
821
884
|
}
|
|
885
|
+
if (this.streamWatchConnection.get(userId)?._readyPromise) {
|
|
886
|
+
return this.streamWatchConnection.get(userId)._readyPromise;
|
|
887
|
+
}
|
|
888
|
+
let created;
|
|
822
889
|
// eslint-disable-next-line consistent-return
|
|
823
|
-
|
|
890
|
+
const pending = new Promise((resolve, reject) => {
|
|
824
891
|
if (this.streamWatchConnection.has(userId)) {
|
|
825
892
|
return resolve(this.streamWatchConnection.get(userId));
|
|
826
893
|
} else {
|
|
827
894
|
const connection = new StreamConnectionReadonly(this.voiceManager, this.channel, this, userId);
|
|
895
|
+
created = connection;
|
|
828
896
|
this.streamWatchConnection.set(userId, connection);
|
|
829
897
|
connection.setVideoCodec(this.videoCodec);
|
|
830
898
|
// Setup event...
|
|
@@ -844,8 +912,8 @@ class VoiceConnection extends EventEmitter {
|
|
|
844
912
|
// Current user stream
|
|
845
913
|
switch (event) {
|
|
846
914
|
case 'STREAM_CREATE': {
|
|
847
|
-
this.streamConnection.setSessionId(this.authentication.sessionId);
|
|
848
915
|
this.streamConnection.serverId = data.rtc_server_id;
|
|
916
|
+
this.streamConnection.setSessionId(this.authentication.sessionId);
|
|
849
917
|
break;
|
|
850
918
|
}
|
|
851
919
|
case 'STREAM_SERVER_UPDATE': {
|
|
@@ -867,8 +935,8 @@ class VoiceConnection extends EventEmitter {
|
|
|
867
935
|
// Watch user stream
|
|
868
936
|
switch (event) {
|
|
869
937
|
case 'STREAM_CREATE': {
|
|
870
|
-
streamConnection.setSessionId(this.authentication.sessionId);
|
|
871
938
|
streamConnection.serverId = data.rtc_server_id;
|
|
939
|
+
streamConnection.setSessionId(this.authentication.sessionId);
|
|
872
940
|
break;
|
|
873
941
|
}
|
|
874
942
|
case 'STREAM_SERVER_UPDATE': {
|
|
@@ -908,12 +976,16 @@ class VoiceConnection extends EventEmitter {
|
|
|
908
976
|
connection.disconnect();
|
|
909
977
|
});
|
|
910
978
|
|
|
911
|
-
|
|
979
|
+
const onError = error => {
|
|
980
|
+
reject(error);
|
|
981
|
+
connection.disconnect();
|
|
982
|
+
};
|
|
983
|
+
connection.on('error', onError);
|
|
912
984
|
|
|
913
985
|
connection.once('ready', () => {
|
|
914
986
|
clearTimeout(connection.connectTimeout);
|
|
915
987
|
resolve(connection);
|
|
916
|
-
connection.removeListener('error',
|
|
988
|
+
connection.removeListener('error', onError);
|
|
917
989
|
});
|
|
918
990
|
connection.once('disconnect', () => {
|
|
919
991
|
clearTimeout(connection.connectTimeout);
|
|
@@ -927,6 +999,8 @@ class VoiceConnection extends EventEmitter {
|
|
|
927
999
|
connection.sendSignalScreenshare();
|
|
928
1000
|
}
|
|
929
1001
|
});
|
|
1002
|
+
if (created) created._readyPromise = pending;
|
|
1003
|
+
return pending;
|
|
930
1004
|
}
|
|
931
1005
|
|
|
932
1006
|
/**
|
|
@@ -1,15 +1,13 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
const { Buffer } = require('node:buffer');
|
|
4
|
-
const crypto = require('node:crypto');
|
|
5
4
|
const { Writable } = require('node:stream');
|
|
6
5
|
const { setTimeout } = require('node:timers');
|
|
7
|
-
const
|
|
6
|
+
const { encrypt } = require('../util/TransportCrypto');
|
|
8
7
|
|
|
9
8
|
const MAX_UINT_16 = 2 ** 16 - 1;
|
|
10
9
|
const MAX_UINT_32 = 2 ** 32 - 1;
|
|
11
10
|
|
|
12
|
-
const nonceStates = new WeakMap();
|
|
13
11
|
const packetStates = new WeakMap();
|
|
14
12
|
|
|
15
13
|
/**
|
|
@@ -86,9 +84,12 @@ class BaseDispatcher extends Writable {
|
|
|
86
84
|
|
|
87
85
|
this.on('finish', () => {
|
|
88
86
|
this._cleanup();
|
|
89
|
-
this.
|
|
90
|
-
|
|
91
|
-
this.
|
|
87
|
+
if (this.getTypeDispatcher() === 'audio') {
|
|
88
|
+
this._setSpeaking(0);
|
|
89
|
+
} else if (this.getTypeDispatcher() === 'video') {
|
|
90
|
+
this._setVideoStatus(false);
|
|
91
|
+
this._setStreamStatus(true);
|
|
92
|
+
}
|
|
92
93
|
});
|
|
93
94
|
}
|
|
94
95
|
|
|
@@ -247,7 +248,7 @@ class BaseDispatcher extends Writable {
|
|
|
247
248
|
const next = (this.count + 1) * this.FRAME_LENGTH - (performance.now() - this.startTime - this._pausedTime);
|
|
248
249
|
this._stepTimer = setTimeout(() => {
|
|
249
250
|
if ((!this.pausedSince || this._silence) && this._writeCallback) this._writeCallback();
|
|
250
|
-
}, next).unref();
|
|
251
|
+
}, Math.max(0, next)).unref();
|
|
251
252
|
this.timestamp = (this.timestamp + this.TIMESTAMP_INC) % (MAX_UINT_32 + 1);
|
|
252
253
|
if (this._packetState) this._packetState.timestamp = this.timestamp;
|
|
253
254
|
this.count++;
|
|
@@ -313,50 +314,11 @@ class BaseDispatcher extends Writable {
|
|
|
313
314
|
}
|
|
314
315
|
|
|
315
316
|
_encrypt(buffer, additionalData) {
|
|
316
|
-
const
|
|
317
|
-
|
|
318
|
-
const connection = this.player.voiceConnection;
|
|
319
|
-
let state = nonceStates.get(connection);
|
|
320
|
-
if (!state || !state.key.equals(Buffer.from(secret_key))) {
|
|
321
|
-
state = { key: Buffer.from(secret_key), value: 0 };
|
|
322
|
-
nonceStates.set(connection, state);
|
|
323
|
-
}
|
|
324
|
-
if (state.value >= MAX_UINT_32) {
|
|
325
|
-
throw new RangeError('Voice encryption nonce exhausted; establish a new session key');
|
|
326
|
-
}
|
|
327
|
-
this._nonce = ++state.value;
|
|
317
|
+
const parts = encrypt(this.player.voiceConnection, buffer, additionalData);
|
|
318
|
+
this._nonce = parts[1].readUInt32BE();
|
|
328
319
|
this.resetNonceBuffer();
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
// 4 extra bytes of padding on the end of the encrypted packet
|
|
332
|
-
const noncePadding = this._nonceBuffer.slice(0, 4);
|
|
333
|
-
|
|
334
|
-
let encrypted;
|
|
335
|
-
|
|
336
|
-
switch (mode) {
|
|
337
|
-
case 'aead_aes256_gcm_rtpsize': {
|
|
338
|
-
const cipher = crypto.createCipheriv('aes-256-gcm', secret_key, this._nonceBuffer);
|
|
339
|
-
cipher.setAAD(additionalData);
|
|
340
|
-
|
|
341
|
-
encrypted = Buffer.concat([cipher.update(buffer), cipher.final(), cipher.getAuthTag()]);
|
|
342
|
-
|
|
343
|
-
return [encrypted, noncePadding];
|
|
344
|
-
}
|
|
345
|
-
case 'aead_xchacha20_poly1305_rtpsize': {
|
|
346
|
-
encrypted = secretbox.methods.crypto_aead_xchacha20poly1305_ietf_encrypt(
|
|
347
|
-
buffer,
|
|
348
|
-
additionalData,
|
|
349
|
-
this._nonceBuffer,
|
|
350
|
-
secret_key,
|
|
351
|
-
);
|
|
352
|
-
|
|
353
|
-
return [encrypted, noncePadding];
|
|
354
|
-
}
|
|
355
|
-
default: {
|
|
356
|
-
// This should never happen. Our encryption mode is chosen from a list given to us by the gateway and checked with the ones we support.
|
|
357
|
-
throw new RangeError(`Unsupported encryption method: ${mode}`);
|
|
358
|
-
}
|
|
359
|
-
}
|
|
320
|
+
parts[1].copy(this._nonceBuffer);
|
|
321
|
+
return parts;
|
|
360
322
|
}
|
|
361
323
|
|
|
362
324
|
_createPacket(buffer, isLastPacket) {
|
|
@@ -403,6 +365,7 @@ class BaseDispatcher extends Writable {
|
|
|
403
365
|
8,
|
|
404
366
|
4,
|
|
405
367
|
);
|
|
368
|
+
this.player.voiceConnection._mediaRecovery?.remember(rtpHeader, buffer);
|
|
406
369
|
return Buffer.concat([rtpHeader, ...this._encrypt(buffer, rtpHeader)]);
|
|
407
370
|
}
|
|
408
371
|
|
|
@@ -424,7 +387,7 @@ class BaseDispatcher extends Writable {
|
|
|
424
387
|
}
|
|
425
388
|
this.player.voiceConnection.sockets.udp.send(packet).catch(e => {
|
|
426
389
|
if (this.getTypeDispatcher() === 'audio') {
|
|
427
|
-
this._setSpeaking(
|
|
390
|
+
this._setSpeaking(0);
|
|
428
391
|
} else if (this.getTypeDispatcher() === 'video') {
|
|
429
392
|
this._setVideoStatus(false);
|
|
430
393
|
this._setStreamStatus(true);
|
|
@@ -14,13 +14,13 @@ const BaseDispatcher = require('./BaseDispatcher');
|
|
|
14
14
|
* @extends {BaseDispatcher}
|
|
15
15
|
*/
|
|
16
16
|
class VideoDispatcher extends BaseDispatcher {
|
|
17
|
-
constructor(player, highWaterMark = 12, streams, fps, payloadType) {
|
|
17
|
+
constructor(player, highWaterMark = 12, streams, fps = 30, payloadType) {
|
|
18
18
|
super(player, highWaterMark, payloadType, true, streams);
|
|
19
19
|
/**
|
|
20
20
|
* Video FPS
|
|
21
21
|
* @type {number}
|
|
22
22
|
*/
|
|
23
|
-
this.fps
|
|
23
|
+
this.setFPSSource(fps);
|
|
24
24
|
|
|
25
25
|
this.mtu = 1200;
|
|
26
26
|
}
|
|
@@ -57,6 +57,7 @@ class VideoDispatcher extends BaseDispatcher {
|
|
|
57
57
|
* @param {number} value fps
|
|
58
58
|
*/
|
|
59
59
|
setFPSSource(value) {
|
|
60
|
+
if (!Number.isFinite(value) || value <= 0) throw new RangeError('Video FPS must be a positive finite number');
|
|
60
61
|
this.fps = value;
|
|
61
62
|
}
|
|
62
63
|
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { Buffer } = require('node:buffer');
|
|
4
|
+
const { setInterval } = require('node:timers');
|
|
5
|
+
const Util = require('../../../util/Util');
|
|
6
|
+
const { encrypt, decrypt } = require('../util/TransportCrypto');
|
|
7
|
+
|
|
8
|
+
/** Bounded RFC 4585 NACK feedback and RFC 4588 video RTX handling. @private */
|
|
9
|
+
class MediaRecovery {
|
|
10
|
+
constructor(connection) {
|
|
11
|
+
this.connection = connection;
|
|
12
|
+
this.cache = new Map();
|
|
13
|
+
this.sources = new Map();
|
|
14
|
+
this.rtxSequence = 0;
|
|
15
|
+
this.stats = { requested: 0, retransmitted: 0, recovered: 0, expired: 0 };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
remember(header, payload) {
|
|
19
|
+
const type = Util.getAllPayloadType().find(
|
|
20
|
+
codec => codec.rtx_payload_type && codec.payload_type === (header[1] & 127),
|
|
21
|
+
);
|
|
22
|
+
if (!type || payload.length > 4096) return;
|
|
23
|
+
const key = Buffer.from(this.connection.authentication.secret_key);
|
|
24
|
+
if (!this.key?.equals(key)) {
|
|
25
|
+
this.reset();
|
|
26
|
+
this.key = key;
|
|
27
|
+
}
|
|
28
|
+
const now = performance.now();
|
|
29
|
+
for (const [id, item] of this.cache) if (now - item.time > 2000) this.cache.delete(id);
|
|
30
|
+
if (this.cache.size >= 512) this.cache.delete(this.cache.keys().next().value);
|
|
31
|
+
const extension = header[0] & 0x10 ? header.readUInt16BE(header.length - 2) * 4 : 0;
|
|
32
|
+
this.cache.set(`${header.readUInt32BE(8)}:${header.readUInt16BE(2)}`, {
|
|
33
|
+
header: Buffer.from(header.subarray(0, 12)),
|
|
34
|
+
payload: Buffer.from(payload.subarray(extension)),
|
|
35
|
+
type: type.rtx_payload_type,
|
|
36
|
+
time: now,
|
|
37
|
+
retries: 0,
|
|
38
|
+
last: -Infinity,
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
receive(packet) {
|
|
43
|
+
const { ssrc, sequenceNumber: seq } = packet.header;
|
|
44
|
+
let source = this.sources.get(ssrc);
|
|
45
|
+
if (!source) {
|
|
46
|
+
if (this.sources.size >= 32) return;
|
|
47
|
+
this.sources.set(ssrc, (source = { highest: seq, missing: new Map() }));
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
if (source.missing.delete(seq)) this.stats.recovered++;
|
|
51
|
+
const distance = (seq - source.highest) & 65535;
|
|
52
|
+
if (!distance || distance >= 32768) return;
|
|
53
|
+
if (distance > 128) {
|
|
54
|
+
this.stats.expired += source.missing.size;
|
|
55
|
+
source.missing.clear();
|
|
56
|
+
} else {
|
|
57
|
+
const time = performance.now();
|
|
58
|
+
for (let i = 1; i < distance; i++) {
|
|
59
|
+
if (source.missing.size >= 128) break;
|
|
60
|
+
source.missing.set((source.highest + i) & 65535, { time, last: -Infinity, retries: 0 });
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
source.highest = seq;
|
|
64
|
+
if (source.missing.size && !this.timer) this.timer = setInterval(() => this._tick(), 20).unref();
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
_tick() {
|
|
68
|
+
const now = performance.now();
|
|
69
|
+
let pending = false;
|
|
70
|
+
for (const [ssrc, source] of this.sources) {
|
|
71
|
+
const sequences = [];
|
|
72
|
+
for (const [seq, item] of source.missing) {
|
|
73
|
+
if (now - item.time >= 500) {
|
|
74
|
+
source.missing.delete(seq);
|
|
75
|
+
this.stats.expired++;
|
|
76
|
+
} else if (now - item.time >= 20 && now - item.last >= 60 && item.retries < 3) {
|
|
77
|
+
item.last = now;
|
|
78
|
+
item.retries++;
|
|
79
|
+
sequences.push(seq);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
if (sequences.length) this._nack(ssrc, sequences);
|
|
83
|
+
pending ||= source.missing.size > 0;
|
|
84
|
+
}
|
|
85
|
+
if (!pending) {
|
|
86
|
+
clearInterval(this.timer);
|
|
87
|
+
this.timer = null;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
_nack(ssrc, sequences) {
|
|
92
|
+
const entries = [];
|
|
93
|
+
for (const seq of sequences) {
|
|
94
|
+
const entry = entries.at(-1);
|
|
95
|
+
const delta = entry ? (seq - entry.seq) & 65535 : 0;
|
|
96
|
+
if (entry && delta > 0 && delta <= 16) entry.mask |= 1 << (delta - 1);
|
|
97
|
+
else entries.push({ seq, mask: 0 });
|
|
98
|
+
}
|
|
99
|
+
const packet = Buffer.alloc(12 + entries.length * 4);
|
|
100
|
+
packet[0] = 0x81;
|
|
101
|
+
packet[1] = 205;
|
|
102
|
+
packet.writeUInt16BE(packet.length / 4 - 1, 2);
|
|
103
|
+
packet.writeUInt32BE(this.connection.authentication.ssrc, 4);
|
|
104
|
+
packet.writeUInt32BE(ssrc, 8);
|
|
105
|
+
entries.forEach((entry, index) => {
|
|
106
|
+
packet.writeUInt16BE(entry.seq, 12 + index * 4);
|
|
107
|
+
packet.writeUInt16BE(entry.mask, 14 + index * 4);
|
|
108
|
+
});
|
|
109
|
+
this.stats.requested += sequences.length;
|
|
110
|
+
try {
|
|
111
|
+
const header = packet.subarray(0, 8);
|
|
112
|
+
this._send(Buffer.concat([header, ...encrypt(this.connection, packet.subarray(8), header)]));
|
|
113
|
+
} catch (error) {
|
|
114
|
+
this.connection.receiver?.emit('debug', error);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
handle(buffer) {
|
|
119
|
+
let packet;
|
|
120
|
+
try {
|
|
121
|
+
packet = decrypt(this.connection, buffer, 8);
|
|
122
|
+
} catch {
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
const messages = [];
|
|
126
|
+
let offset = 0;
|
|
127
|
+
while (offset + 8 <= packet.length) {
|
|
128
|
+
const size = (packet.readUInt16BE(offset + 2) + 1) * 4;
|
|
129
|
+
if (packet[offset] >> 6 !== 2 || size < 8 || offset + size > packet.length) return;
|
|
130
|
+
if (packet[offset + 1] === 205 && (packet[offset] & 31) === 1 && size >= 16 && size % 4 === 0) {
|
|
131
|
+
messages.push(packet.subarray(offset, offset + size));
|
|
132
|
+
}
|
|
133
|
+
offset += size;
|
|
134
|
+
}
|
|
135
|
+
if (offset !== packet.length) return;
|
|
136
|
+
for (const message of messages) this._handleNack(message);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
_handleNack(packet) {
|
|
140
|
+
const now = performance.now();
|
|
141
|
+
const ssrc = packet.readUInt32BE(8);
|
|
142
|
+
for (let offset = 12; offset < packet.length; offset += 4) {
|
|
143
|
+
const seq = packet.readUInt16BE(offset),
|
|
144
|
+
mask = packet.readUInt16BE(offset + 2);
|
|
145
|
+
this._retransmit(ssrc, seq, now);
|
|
146
|
+
for (let bit = 0; bit < 16; bit++) {
|
|
147
|
+
if (mask & (1 << bit)) this._retransmit(ssrc, (seq + bit + 1) & 65535, now);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
_retransmit(ssrc, sequence, now) {
|
|
153
|
+
const item = this.cache.get(`${ssrc}:${sequence}`);
|
|
154
|
+
const auth = this.connection.authentication;
|
|
155
|
+
if (
|
|
156
|
+
!item ||
|
|
157
|
+
!this.key?.equals(Buffer.from(auth.secret_key)) ||
|
|
158
|
+
now - item.time > 2000 ||
|
|
159
|
+
item.retries >= 3 ||
|
|
160
|
+
now - item.last < 20
|
|
161
|
+
) {
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
const stream = auth.streams?.find(value => value.ssrc === ssrc);
|
|
165
|
+
const rtx = stream?.rtx_ssrc ?? (ssrc === auth.ssrc + 1 ? auth.ssrc + 2 : null);
|
|
166
|
+
if (!rtx) return;
|
|
167
|
+
item.retries++;
|
|
168
|
+
item.last = now;
|
|
169
|
+
const header = Buffer.from(item.header);
|
|
170
|
+
header[0] = 0x80;
|
|
171
|
+
header[1] = (header[1] & 0x80) | item.type;
|
|
172
|
+
header.writeUInt16BE(this.rtxSequence++ & 65535, 2);
|
|
173
|
+
header.writeUInt32BE(rtx, 8);
|
|
174
|
+
const original = Buffer.alloc(2);
|
|
175
|
+
original.writeUInt16BE(sequence);
|
|
176
|
+
try {
|
|
177
|
+
this._send(Buffer.concat([header, ...encrypt(this.connection, Buffer.concat([original, item.payload]), header)]));
|
|
178
|
+
this.stats.retransmitted++;
|
|
179
|
+
} catch (error) {
|
|
180
|
+
this.connection.receiver?.emit('debug', error);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
_send(packet) {
|
|
185
|
+
this.connection.sockets?.udp?.send(packet).catch(error => this.connection.receiver?.emit('debug', error));
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
reset() {
|
|
189
|
+
clearInterval(this.timer);
|
|
190
|
+
this.timer = null;
|
|
191
|
+
this.cache.clear();
|
|
192
|
+
this.sources.clear();
|
|
193
|
+
this.rtxSequence = 0;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
module.exports = MediaRecovery;
|
|
@@ -137,7 +137,16 @@ class VoiceConnectionUDPClient extends EventEmitter {
|
|
|
137
137
|
op: VoiceOpcodes.SELECT_PROTOCOL,
|
|
138
138
|
d: {
|
|
139
139
|
protocol: 'udp',
|
|
140
|
-
codecs: Util.getAllPayloadType()
|
|
140
|
+
codecs: Util.getAllPayloadType().map(codec =>
|
|
141
|
+
codec.type === 'video'
|
|
142
|
+
? {
|
|
143
|
+
...codec,
|
|
144
|
+
priority: codec.name === this.voiceConnection.videoCodec ? 1 : codec.priority,
|
|
145
|
+
encode: codec.name === this.voiceConnection.videoCodec,
|
|
146
|
+
decode: ['H264', 'H265', 'VP8'].includes(codec.name),
|
|
147
|
+
}
|
|
148
|
+
: codec,
|
|
149
|
+
),
|
|
141
150
|
data: {
|
|
142
151
|
address: packet.address,
|
|
143
152
|
port: packet.port,
|
|
@@ -263,7 +263,10 @@ class VoiceWebSocket extends EventEmitter {
|
|
|
263
263
|
case VoiceOpcodes.CLIENT_DISCONNECT:
|
|
264
264
|
this.dave.clients.delete(packet.d.user_id);
|
|
265
265
|
for (const [ssrc, info] of this.connection.ssrcMap) {
|
|
266
|
-
if (info.userId === packet.d.user_id)
|
|
266
|
+
if (info.userId === packet.d.user_id) {
|
|
267
|
+
this.connection.ssrcMap.delete(ssrc);
|
|
268
|
+
this.connection.receiver?.packets.forgetSource(ssrc);
|
|
269
|
+
}
|
|
267
270
|
}
|
|
268
271
|
const streamInfo = this.connection.receiver && this.connection.receiver.packets.streams.get(packet.d.user_id);
|
|
269
272
|
if (streamInfo) {
|