rockcord 4.0.0-dev.4 → 4.0.0

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.
Files changed (69) hide show
  1. package/README.md +5 -3
  2. package/package.json +17 -14
  3. package/src/client/Client.js +3 -3
  4. package/src/client/WebhookClient.js +1 -1
  5. package/src/client/actions/VoiceStateUpdate.js +2 -2
  6. package/src/client/voice/VoiceConnection.js +98 -24
  7. package/src/client/voice/dispatcher/AudioDispatcher.js +1 -1
  8. package/src/client/voice/dispatcher/BaseDispatcher.js +49 -61
  9. package/src/client/voice/dispatcher/VideoDispatcher.js +86 -2
  10. package/src/client/voice/networking/CongestionControl.js +84 -0
  11. package/src/client/voice/networking/DAVE.js +26 -1
  12. package/src/client/voice/networking/MediaRecovery.js +300 -0
  13. package/src/client/voice/networking/VoiceUDPClient.js +10 -1
  14. package/src/client/voice/networking/VoiceWebSocket.js +4 -1
  15. package/src/client/voice/player/MediaPlayer.js +45 -11
  16. package/src/client/voice/player/processing/AnnexBNalSplitter.js +0 -1
  17. package/src/client/voice/receiver/AudioJitter.js +101 -0
  18. package/src/client/voice/receiver/PacketHandler.js +66 -9
  19. package/src/client/voice/receiver/Receiver.js +9 -4
  20. package/src/client/voice/receiver/Recorder.js +186 -57
  21. package/src/client/voice/util/PlayInterface.js +2 -0
  22. package/src/client/voice/util/Secretbox.js +1 -1
  23. package/src/client/voice/util/TransportCrypto.js +54 -0
  24. package/src/client/voice/util/VideoFrames.js +4 -1
  25. package/src/client/websocket/WebSocketShard.js +0 -1
  26. package/src/managers/ApplicationCommandManager.js +0 -2
  27. package/src/managers/ApplicationCommandPermissionsManager.js +0 -1
  28. package/src/managers/GuildChannelManager.js +2 -3
  29. package/src/managers/GuildMemberManager.js +0 -1
  30. package/src/managers/GuildTextThreadManager.js +1 -1
  31. package/src/managers/MessageManager.js +5 -5
  32. package/src/managers/RoleManager.js +1 -1
  33. package/src/rest/APIRequest.js +0 -1
  34. package/src/rest/APIRouter.js +2 -2
  35. package/src/rest/DiscordAPIError.js +1 -1
  36. package/src/rest/RequestHandler.js +3 -3
  37. package/src/sharding/ShardingManager.js +2 -2
  38. package/src/structures/ApplicationCommand.js +2 -7
  39. package/src/structures/BaseCommandInteraction.js +1 -2
  40. package/src/structures/BaseGuildTextChannel.js +1 -1
  41. package/src/structures/BaseGuildVoiceChannel.js +1 -1
  42. package/src/structures/ClientPresence.js +0 -1
  43. package/src/structures/ClientUser.js +13 -5
  44. package/src/structures/ContainerComponent.js +1 -1
  45. package/src/structures/DMChannel.js +1 -1
  46. package/src/structures/GroupDMChannel.js +4 -4
  47. package/src/structures/Guild.js +0 -2
  48. package/src/structures/GuildAuditLogs.js +4 -4
  49. package/src/structures/GuildScheduledEvent.js +0 -2
  50. package/src/structures/Interaction.js +1 -1
  51. package/src/structures/InteractionCollector.js +2 -2
  52. package/src/structures/InteractionWebhook.js +0 -1
  53. package/src/structures/Message.js +16 -17
  54. package/src/structures/MessageComponentInteraction.js +1 -1
  55. package/src/structures/MessageEmbed.js +0 -2
  56. package/src/structures/MessagePayload.js +2 -2
  57. package/src/structures/ModalSubmitInteraction.js +2 -2
  58. package/src/structures/ThreadChannel.js +3 -3
  59. package/src/structures/ThreadOnlyChannel.js +1 -1
  60. package/src/structures/WebEmbed.js +0 -1
  61. package/src/structures/interfaces/Application.js +2 -2
  62. package/src/structures/interfaces/Collector.js +1 -4
  63. package/src/structures/interfaces/TextBasedChannel.js +6 -6
  64. package/src/util/AttachmentFlags.js +0 -1
  65. package/src/util/Constants.js +0 -2
  66. package/src/util/Options.js +3 -3
  67. package/src/util/Util.js +1 -2
  68. package/typings/index.d.ts +109 -115
  69. package/typings/rawDataTypes.d.ts +5 -15
package/README.md CHANGED
@@ -19,7 +19,7 @@
19
19
  npm i rockcord
20
20
  ```
21
21
 
22
- Requires **Node.js 22 or newer**; tested on Node.js 22 and 24. Import with `require('rockcord')` or TypeScript imports from `'rockcord'`.
22
+ Requires **Node.js 22.19.0 or newer**; tested on Node.js 22 and 24. Import with `require('rockcord')` or TypeScript imports from `'rockcord'`.
23
23
 
24
24
  Media playback also requires FFmpeg and a supported Opus binding. Keep optional dependencies enabled so npm can install DAVE's native binary for your platform.
25
25
 
@@ -30,8 +30,10 @@ 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 and H264, VP8, and H265 playback and recording with Opus audio.
34
+ - Adaptive video packet pacing using authenticated receiver loss reports, bounded retransmission, and buffered audio reception.
33
35
 
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.
36
+ The **4.0.0 stable release** includes the media repairs and reviewed dependency updates. See the [media options](https://github.com/rock-forge/rockcord/blob/main/docs/MEDIA.md), [dependency review](https://github.com/rock-forge/rockcord/blob/main/docs/audit/DEPENDENCY_REVIEW.md), and [implementation report](https://github.com/rock-forge/rockcord/blob/main/docs/audit/IMPLEMENTATION.md) for configuration and verification coverage.
35
37
 
36
38
  ## Project links
37
39
 
@@ -43,6 +45,6 @@ This is a **development release**. Live audio, H264 camera video, rejoining, and
43
45
 
44
46
  ## Credits and license
45
47
 
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.
48
+ 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
49
 
48
50
  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",
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"
@@ -58,6 +58,7 @@
58
58
  "dependencies": {
59
59
  "@discordjs/builders": "^1.6.3",
60
60
  "@discordjs/collection": "^2.1.1",
61
+ "@otplib/v12-adapter": "^13.5.0",
61
62
  "@sapphire/async-queue": "^1.5.5",
62
63
  "@sapphire/shapeshift": "^4.0.0",
63
64
  "@snazzah/davey": "0.1.12",
@@ -66,29 +67,31 @@
66
67
  "discord-api-types": "^0.38.15",
67
68
  "fetch-cookie": "^3.1.0",
68
69
  "find-process": "^2.0.0",
69
- "otplib": "^12.0.1",
70
70
  "prism-media": "^1.3.5",
71
71
  "qrcode": "^1.5.4",
72
- "tough-cookie": "^5.1.2",
72
+ "tough-cookie": "^6.0.2",
73
73
  "tree-kill": "^1.2.2",
74
- "undici": "^7.11.0",
74
+ "undici": "^8.11.2",
75
75
  "werift-rtp": "^0.8.4",
76
76
  "ws": "^8.16.0"
77
77
  },
78
78
  "engines": {
79
- "node": ">=22.0.0"
79
+ "node": ">=22.19.0"
80
80
  },
81
81
  "devDependencies": {
82
82
  "@discordjs/docgen": "^0.12.1",
83
+ "@eslint/js": "^10.0.1",
83
84
  "@types/debug": "^4.1.12",
84
- "eslint": "^8.39.0",
85
- "eslint-config-prettier": "^8.8.0",
86
- "eslint-plugin-import": "^2.27.5",
87
- "eslint-plugin-prettier": "^4.2.1",
88
- "jsdoc-to-markdown": "^7.1.1",
85
+ "eslint": "^10.12.0",
86
+ "eslint-config-prettier": "^10.1.8",
87
+ "eslint-import-resolver-node": "^0.3.10",
88
+ "eslint-plugin-import-x": "^4.17.1",
89
+ "eslint-plugin-prettier": "^5.5.6",
90
+ "globals": "^16.5.0",
91
+ "jsdoc-to-markdown": "^8.0.3",
89
92
  "opusscript": "0.0.8",
90
- "prettier": "^2.8.8",
91
- "typescript": "^5.5.4"
93
+ "prettier": "^3.9.9",
94
+ "typescript": "^5.9.3"
92
95
  },
93
96
  "overrides": {
94
97
  "jsdoc": "^4.0.5"
@@ -1,11 +1,10 @@
1
- /* eslint-disable no-unreachable */
2
1
  'use strict';
3
2
 
4
3
  const process = require('node:process');
5
4
  const { setInterval } = require('node:timers');
6
5
  const { setTimeout } = require('node:timers');
7
6
  const { Collection } = require('@discordjs/collection');
8
- const { authenticator } = require('otplib');
7
+ const { authenticator } = require('@otplib/v12-adapter');
9
8
  const BaseClient = require('./BaseClient');
10
9
  const ActionsManager = require('./actions/ActionsManager');
11
10
  const ClientVoiceManager = require('./voice/ClientVoiceManager');
@@ -205,7 +204,7 @@ class Client extends BaseClient {
205
204
  * The authenticator used for TOTP
206
205
  * @type {Object}
207
206
  */
208
- this.authenticator = authenticator;
207
+ this.authenticator = authenticator.create({ guardrails: { MIN_SECRET_BYTES: 1 } });
209
208
 
210
209
  this.authenticator.options = {
211
210
  step: 30,
@@ -352,6 +351,7 @@ class Client extends BaseClient {
352
351
  * @returns {void}
353
352
  */
354
353
  destroy() {
354
+ this.user?._stopSamsungPresence?.();
355
355
  super.destroy();
356
356
 
357
357
  for (const fn of this._cleanups) fn();
@@ -43,7 +43,7 @@ class WebhookClient extends BaseClient {
43
43
  }
44
44
 
45
45
  // These are here only for documentation purposes - they are implemented by Webhook
46
- /* eslint-disable no-empty-function */
46
+
47
47
  send() {}
48
48
  sendSlackMessage() {}
49
49
  fetchMessage() {}
@@ -16,11 +16,11 @@ class VoiceStateUpdate extends Action {
16
16
  const newState = guild.voiceStates._add(data);
17
17
 
18
18
  // Get the member
19
- let member = guild.members.cache.get(data.user_id);
19
+ const member = guild.members.cache.get(data.user_id);
20
20
  if (member && data.member) {
21
21
  member._patch(data.member);
22
22
  } else if (data.member?.user && data.member.joined_at) {
23
- member = guild.members._add(data.member);
23
+ guild.members._add(data.member);
24
24
  }
25
25
 
26
26
  /**
@@ -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' });
@@ -688,20 +726,23 @@ class VoiceConnection extends EventEmitter {
688
726
  }
689
727
  }
690
728
 
691
- playAudio() {} // eslint-disable-line no-empty-function
692
- playVideo() {} // eslint-disable-line no-empty-function
729
+ playAudio() {}
730
+ playVideo() {}
693
731
 
694
732
  /**
695
733
  * Create new connection to screenshare stream
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
  /**
@@ -133,7 +133,7 @@ class AudioDispatcher extends BaseDispatcher {
133
133
  }
134
134
 
135
135
  // Volume stubs for docs
136
- /* eslint-disable no-empty-function*/
136
+
137
137
  get volumeDecibels() {}
138
138
  get volumeLogarithmic() {}
139
139
  setVolumeDecibels() {}
@@ -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
 
@@ -132,6 +133,19 @@ class BaseDispatcher extends Writable {
132
133
  this._syncDispatcher.once('start', cb);
133
134
  this._syncStartTimer = setTimeout(cb, 10_000).unref();
134
135
  }
136
+ // Announce both dispatchers before arbitrating the initial encrypted frame,
137
+ // so audio/video synchronization cannot deadlock against startup ownership.
138
+ const dave = this.player.voiceConnection?.dave;
139
+ if (dave?.beginMedia?.(this) === false) {
140
+ this._bootstrapWaitAt ??= performance.now();
141
+ if (performance.now() - this._bootstrapWaitAt > 10000) {
142
+ done(new Error('DAVE media bootstrap timed out'));
143
+ return;
144
+ }
145
+ this._bootstrapTimer = setTimeout(() => this._write(chunk, enc, done), 5).unref();
146
+ return;
147
+ }
148
+ this._bootstrapWaitAt = null;
135
149
  try {
136
150
  if (this.getTypeDispatcher() === 'video') {
137
151
  this._codecCallback(chunk);
@@ -151,6 +165,8 @@ class BaseDispatcher extends Writable {
151
165
  }
152
166
 
153
167
  _cleanup() {
168
+ clearTimeout(this._bootstrapTimer);
169
+ this.player.voiceConnection?.dave?.releaseMedia?.(this);
154
170
  clearTimeout(this._stepTimer);
155
171
  clearTimeout(this._syncStartTimer);
156
172
  if (this._syncStartListener) this._syncDispatcher?.removeListener('start', this._syncStartListener);
@@ -245,9 +261,12 @@ class BaseDispatcher extends Writable {
245
261
  done();
246
262
  };
247
263
  const next = (this.count + 1) * this.FRAME_LENGTH - (performance.now() - this.startTime - this._pausedTime);
248
- this._stepTimer = setTimeout(() => {
249
- if ((!this.pausedSince || this._silence) && this._writeCallback) this._writeCallback();
250
- }, next).unref();
264
+ this._stepTimer = setTimeout(
265
+ () => {
266
+ if ((!this.pausedSince || this._silence) && this._writeCallback) this._writeCallback();
267
+ },
268
+ Math.max(0, next),
269
+ ).unref();
251
270
  this.timestamp = (this.timestamp + this.TIMESTAMP_INC) % (MAX_UINT_32 + 1);
252
271
  if (this._packetState) this._packetState.timestamp = this.timestamp;
253
272
  this.count++;
@@ -313,50 +332,11 @@ class BaseDispatcher extends Writable {
313
332
  }
314
333
 
315
334
  _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;
335
+ const parts = encrypt(this.player.voiceConnection, buffer, additionalData);
336
+ this._nonce = parts[1].readUInt32BE();
328
337
  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
- }
338
+ parts[1].copy(this._nonceBuffer);
339
+ return parts;
360
340
  }
361
341
 
362
342
  _createPacket(buffer, isLastPacket) {
@@ -403,6 +383,7 @@ class BaseDispatcher extends Writable {
403
383
  8,
404
384
  4,
405
385
  );
386
+ this.player.voiceConnection._mediaRecovery?.remember(rtpHeader, buffer);
406
387
  return Buffer.concat([rtpHeader, ...this._encrypt(buffer, rtpHeader)]);
407
388
  }
408
389
 
@@ -422,15 +403,22 @@ class BaseDispatcher extends Writable {
422
403
  this.emit('debug', 'Failed to send a packet - no UDP socket');
423
404
  return;
424
405
  }
425
- this.player.voiceConnection.sockets.udp.send(packet).catch(e => {
426
- if (this.getTypeDispatcher() === 'audio') {
427
- this._setSpeaking(this._setSpeaking(0));
428
- } else if (this.getTypeDispatcher() === 'video') {
429
- this._setVideoStatus(false);
430
- this._setStreamStatus(true);
431
- }
432
- this.emit('debug', `Failed to send a packet - ${e}`);
433
- });
406
+ this.player.voiceConnection.sockets.udp
407
+ .send(packet)
408
+ .then(() => {
409
+ if (this.getTypeDispatcher() === 'audio' || packet[1] & 0x80) {
410
+ this.player.voiceConnection.dave?.sentMedia?.(this);
411
+ }
412
+ })
413
+ .catch(e => {
414
+ if (this.getTypeDispatcher() === 'audio') {
415
+ this._setSpeaking(0);
416
+ } else if (this.getTypeDispatcher() === 'video') {
417
+ this._setVideoStatus(false);
418
+ this._setStreamStatus(true);
419
+ }
420
+ this.emit('debug', `Failed to send a packet - ${e}`);
421
+ });
434
422
  }
435
423
 
436
424
  _setSpeaking(value) {