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 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, H264 camera video, rejoining, and reconnect checks passed. Separate screen-share startup currently fails; recorder output and live VP8/H265 remain unverified. See the [implementation report](https://github.com/rock-forge/rockcord/blob/main/docs/audit/IMPLEMENTATION.md) for coverage and limitations.
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), with original Git history and contributor attribution retained.
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.4",
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": "aiko-chan-ai",
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` (Not supported for encoding & decoding)
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
- return this.channel.client.ws.broadcast({
316
- op: Opcodes.VOICE_STATE_UPDATE,
317
- d: options,
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)) this.ssrcMap.delete(ssrc);
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
- return new Promise((resolve, reject) => {
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
- connection.on('error', reject);
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', reject);
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
- connection.sendSignalScreenshare();
802
- connection.sendScreenshareState(false);
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
- return new Promise((resolve, reject) => {
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
- connection.on('error', reject);
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', reject);
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 secretbox = require('../util/Secretbox');
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._setSpeaking(0);
90
- this._setVideoStatus(false);
91
- this._setStreamStatus(true);
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 { secret_key, mode } = this.player.voiceConnection.authentication;
317
- // A key must never reuse a nonce, including across audio/video dispatchers and playback restarts.
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
- this._nonceBuffer.writeUInt32BE(this._nonce, 0);
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(this._setSpeaking(0));
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 = 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) this.connection.ssrcMap.delete(ssrc);
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) {