rockcord 4.0.0-dev.5 → 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 (65) hide show
  1. package/README.md +4 -3
  2. package/package.json +15 -12
  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 +2 -2
  7. package/src/client/voice/dispatcher/AudioDispatcher.js +1 -1
  8. package/src/client/voice/dispatcher/BaseDispatcher.js +37 -12
  9. package/src/client/voice/dispatcher/VideoDispatcher.js +83 -0
  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 +112 -9
  13. package/src/client/voice/player/MediaPlayer.js +18 -3
  14. package/src/client/voice/player/processing/AnnexBNalSplitter.js +0 -1
  15. package/src/client/voice/receiver/AudioJitter.js +7 -4
  16. package/src/client/voice/receiver/PacketHandler.js +10 -4
  17. package/src/client/voice/receiver/Receiver.js +5 -3
  18. package/src/client/voice/receiver/Recorder.js +44 -14
  19. package/src/client/voice/util/PlayInterface.js +2 -0
  20. package/src/client/voice/util/Secretbox.js +1 -1
  21. package/src/client/websocket/WebSocketShard.js +0 -1
  22. package/src/managers/ApplicationCommandManager.js +0 -2
  23. package/src/managers/ApplicationCommandPermissionsManager.js +0 -1
  24. package/src/managers/GuildChannelManager.js +2 -3
  25. package/src/managers/GuildMemberManager.js +0 -1
  26. package/src/managers/GuildTextThreadManager.js +1 -1
  27. package/src/managers/MessageManager.js +5 -5
  28. package/src/managers/RoleManager.js +1 -1
  29. package/src/rest/APIRequest.js +0 -1
  30. package/src/rest/APIRouter.js +2 -2
  31. package/src/rest/DiscordAPIError.js +1 -1
  32. package/src/rest/RequestHandler.js +3 -3
  33. package/src/sharding/ShardingManager.js +2 -2
  34. package/src/structures/ApplicationCommand.js +2 -7
  35. package/src/structures/BaseCommandInteraction.js +1 -2
  36. package/src/structures/BaseGuildTextChannel.js +1 -1
  37. package/src/structures/BaseGuildVoiceChannel.js +1 -1
  38. package/src/structures/ClientPresence.js +0 -1
  39. package/src/structures/ClientUser.js +13 -5
  40. package/src/structures/ContainerComponent.js +1 -1
  41. package/src/structures/DMChannel.js +1 -1
  42. package/src/structures/GroupDMChannel.js +4 -4
  43. package/src/structures/Guild.js +0 -2
  44. package/src/structures/GuildAuditLogs.js +4 -4
  45. package/src/structures/GuildScheduledEvent.js +0 -2
  46. package/src/structures/Interaction.js +1 -1
  47. package/src/structures/InteractionCollector.js +2 -2
  48. package/src/structures/InteractionWebhook.js +0 -1
  49. package/src/structures/Message.js +16 -17
  50. package/src/structures/MessageComponentInteraction.js +1 -1
  51. package/src/structures/MessageEmbed.js +0 -2
  52. package/src/structures/MessagePayload.js +2 -2
  53. package/src/structures/ModalSubmitInteraction.js +2 -2
  54. package/src/structures/ThreadChannel.js +3 -3
  55. package/src/structures/ThreadOnlyChannel.js +1 -1
  56. package/src/structures/WebEmbed.js +0 -1
  57. package/src/structures/interfaces/Application.js +2 -2
  58. package/src/structures/interfaces/Collector.js +1 -4
  59. package/src/structures/interfaces/TextBasedChannel.js +6 -6
  60. package/src/util/AttachmentFlags.js +0 -1
  61. package/src/util/Constants.js +0 -2
  62. package/src/util/Options.js +3 -3
  63. package/src/util/Util.js +1 -2
  64. package/typings/index.d.ts +108 -115
  65. 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,9 +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, H264/VP8/H265 playback, H264/Opus recording, video retransmission, and buffered audio reception.
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.
34
35
 
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.
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.
36
37
 
37
38
  ## Project links
38
39
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rockcord",
3
- "version": "4.0.0-dev.5",
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",
@@ -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
  /**
@@ -726,8 +726,8 @@ class VoiceConnection extends EventEmitter {
726
726
  }
727
727
  }
728
728
 
729
- playAudio() {} // eslint-disable-line no-empty-function
730
- playVideo() {} // eslint-disable-line no-empty-function
729
+ playAudio() {}
730
+ playVideo() {}
731
731
 
732
732
  /**
733
733
  * Create new connection to screenshare stream
@@ -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() {}
@@ -133,6 +133,19 @@ class BaseDispatcher extends Writable {
133
133
  this._syncDispatcher.once('start', cb);
134
134
  this._syncStartTimer = setTimeout(cb, 10_000).unref();
135
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;
136
149
  try {
137
150
  if (this.getTypeDispatcher() === 'video') {
138
151
  this._codecCallback(chunk);
@@ -152,6 +165,8 @@ class BaseDispatcher extends Writable {
152
165
  }
153
166
 
154
167
  _cleanup() {
168
+ clearTimeout(this._bootstrapTimer);
169
+ this.player.voiceConnection?.dave?.releaseMedia?.(this);
155
170
  clearTimeout(this._stepTimer);
156
171
  clearTimeout(this._syncStartTimer);
157
172
  if (this._syncStartListener) this._syncDispatcher?.removeListener('start', this._syncStartListener);
@@ -246,9 +261,12 @@ class BaseDispatcher extends Writable {
246
261
  done();
247
262
  };
248
263
  const next = (this.count + 1) * this.FRAME_LENGTH - (performance.now() - this.startTime - this._pausedTime);
249
- this._stepTimer = setTimeout(() => {
250
- if ((!this.pausedSince || this._silence) && this._writeCallback) this._writeCallback();
251
- }, Math.max(0, 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();
252
270
  this.timestamp = (this.timestamp + this.TIMESTAMP_INC) % (MAX_UINT_32 + 1);
253
271
  if (this._packetState) this._packetState.timestamp = this.timestamp;
254
272
  this.count++;
@@ -385,15 +403,22 @@ class BaseDispatcher extends Writable {
385
403
  this.emit('debug', 'Failed to send a packet - no UDP socket');
386
404
  return;
387
405
  }
388
- this.player.voiceConnection.sockets.udp.send(packet).catch(e => {
389
- if (this.getTypeDispatcher() === 'audio') {
390
- this._setSpeaking(0);
391
- } else if (this.getTypeDispatcher() === 'video') {
392
- this._setVideoStatus(false);
393
- this._setStreamStatus(true);
394
- }
395
- this.emit('debug', `Failed to send a packet - ${e}`);
396
- });
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
+ });
397
422
  }
398
423
 
399
424
  _setSpeaking(value) {
@@ -1,6 +1,8 @@
1
1
  'use strict';
2
2
 
3
+ const { setTimeout } = require('node:timers');
3
4
  const BaseDispatcher = require('./BaseDispatcher');
5
+ const CongestionControl = require('../networking/CongestionControl');
4
6
 
5
7
  /**
6
8
  * The class that sends video packet data to the voice connection.
@@ -64,6 +66,87 @@ class VideoDispatcher extends BaseDispatcher {
64
66
  _codecCallback() {
65
67
  throw new Error('The _codecCallback method must be implemented');
66
68
  }
69
+
70
+ configureCongestion(options = {}) {
71
+ if (options.congestionControl === false) return;
72
+ const config = typeof options.congestionControl === 'object' ? options.congestionControl : {};
73
+ const bitrate = config.bitrate ?? (typeof options.bitrate === 'number' ? options.bitrate : 2000);
74
+ this.congestionControl = new CongestionControl({
75
+ bitrate,
76
+ minBitrate: Math.min(128, bitrate),
77
+ ...config,
78
+ onChange: state => this.emit('congestion', state),
79
+ });
80
+ this.player.voiceConnection._congestionControl = this.congestionControl;
81
+ }
82
+
83
+ _write(chunk, encoding, done) {
84
+ if (this.congestionControl) {
85
+ this._pacedPackets = [];
86
+ this._pacedBytes = 0;
87
+ this._pacingKey = this.player.voiceConnection.authentication.secret_key;
88
+ }
89
+ super._write(chunk, encoding, done);
90
+ }
91
+
92
+ _sendPacket(packet) {
93
+ if (!this._pacedPackets) return super._sendPacket(packet);
94
+ this._pacedBytes += packet.length;
95
+ if (this._pacedBytes > 4 * 1024 * 1024 || this._pacedPackets.length >= 4096) {
96
+ throw new RangeError('Video frame exceeds the pacing queue limit');
97
+ }
98
+ this._pacedPackets.push(packet);
99
+ return undefined;
100
+ }
101
+
102
+ _step(done) {
103
+ if (!this._pacedPackets) return super._step(done);
104
+ const packets = this._pacedPackets;
105
+ this._pacedPackets = null;
106
+ let index = 0;
107
+ const sendNext = () => {
108
+ if (this.destroyed) return;
109
+ if (this.paused) {
110
+ this._pacingTimer = setTimeout(sendNext, 20).unref();
111
+ return;
112
+ }
113
+ if (index === packets.length) {
114
+ super._step(done);
115
+ return;
116
+ }
117
+ const packet = packets[index++];
118
+ const delay = this.congestionControl.delay(packet.length);
119
+ this._pacingTimer = setTimeout(
120
+ () => {
121
+ if (this.destroyed) return;
122
+ if (this._pacingKey !== this.player.voiceConnection.authentication.secret_key) {
123
+ this.destroy(new Error('Video transport key changed during paced playback'));
124
+ return;
125
+ }
126
+ if (this.paused) {
127
+ index--;
128
+ sendNext();
129
+ return;
130
+ }
131
+ this.congestionControl.packetSent();
132
+ super._sendPacket(packet);
133
+ sendNext();
134
+ },
135
+ Math.max(0, delay),
136
+ ).unref();
137
+ };
138
+ sendNext();
139
+ return undefined;
140
+ }
141
+
142
+ _cleanup() {
143
+ clearTimeout(this._pacingTimer);
144
+ this._pacedPackets = null;
145
+ if (this.player.voiceConnection._congestionControl === this.congestionControl) {
146
+ this.player.voiceConnection._congestionControl = null;
147
+ }
148
+ super._cleanup();
149
+ }
67
150
  }
68
151
 
69
152
  module.exports = VideoDispatcher;
@@ -0,0 +1,84 @@
1
+ 'use strict';
2
+
3
+ /** Loss-based AIMD rate control and bounded video packet pacing. @private */
4
+ class CongestionControl {
5
+ constructor({ bitrate = 2000, minBitrate = 128, onChange = () => {} } = {}) {
6
+ if (!Number.isFinite(bitrate) || !Number.isFinite(minBitrate) || minBitrate <= 0 || bitrate < minBitrate) {
7
+ throw new RangeError('Congestion bitrates must be finite, positive and ordered');
8
+ }
9
+ this.maximum = bitrate * 1000;
10
+ this.minimum = minBitrate * 1000;
11
+ this.targetBitrate = this.maximum;
12
+ this.onChange = onChange;
13
+ this.loss = 0;
14
+ this.reason = 'initial';
15
+ this.nextSendAt = 0;
16
+ this.windowStart = performance.now();
17
+ this.lastUpdate = -Infinity;
18
+ this.lastReport = -Infinity;
19
+ this.sent = 0;
20
+ this.totalSent = 0;
21
+ this.missing = new Set();
22
+ this.reports = new Map();
23
+ }
24
+
25
+ get state() {
26
+ return { targetBitrate: this.targetBitrate, loss: this.loss, packetsSent: this.totalSent, reason: this.reason };
27
+ }
28
+
29
+ report(loss, highest, reporter, now = performance.now()) {
30
+ if (!Number.isFinite(loss) || loss < 0 || loss > 1) return;
31
+ const previous = this.reports.get(reporter);
32
+ const distance = previous ? (highest - previous.highest) >>> 0 : 1;
33
+ if (distance >= 0x80000000 || !distance) return;
34
+ for (const [id, report] of this.reports) if (now - report.time > 3000) this.reports.delete(id);
35
+ if (!this.reports.has(reporter) && this.reports.size >= 32) return;
36
+ this.reports.set(reporter, { highest, loss, time: now });
37
+ this.lastReport = now;
38
+ this._update(Math.max(...[...this.reports.values()].map(report => report.loss)), now, true);
39
+ }
40
+
41
+ nack(sequence) {
42
+ if (this.missing.size < 512) this.missing.add(sequence);
43
+ }
44
+
45
+ packetSent(now = performance.now()) {
46
+ if (now - this.windowStart >= 1000) {
47
+ if (now - this.lastReport > 2000 && this.sent >= 20 && this.missing.size) {
48
+ this._update(Math.min(1, this.missing.size / this.sent), now, false);
49
+ }
50
+ this.windowStart = now;
51
+ this.sent = 0;
52
+ this.missing.clear();
53
+ }
54
+ this.sent++;
55
+ this.totalSent++;
56
+ }
57
+
58
+ _update(loss, now, canIncrease) {
59
+ if (now - this.lastUpdate < 1000) return;
60
+ this.lastUpdate = now;
61
+ this.loss = loss;
62
+ const before = this.targetBitrate;
63
+ if (loss >= 0.1) {
64
+ this.targetBitrate *= 0.75;
65
+ this.reason = 'high-loss';
66
+ } else if (loss >= 0.02) {
67
+ this.targetBitrate *= 0.9;
68
+ this.reason = 'loss';
69
+ } else if (canIncrease && loss < 0.02) {
70
+ this.targetBitrate += Math.max(8000, this.targetBitrate * 0.05);
71
+ this.reason = 'recovery';
72
+ }
73
+ this.targetBitrate = Math.round(Math.max(this.minimum, Math.min(this.maximum, this.targetBitrate)));
74
+ if (before !== this.targetBitrate) this.onChange(this.state);
75
+ }
76
+
77
+ delay(bytes, now = performance.now()) {
78
+ const due = Math.max(now, this.nextSendAt);
79
+ this.nextSendAt = due + (bytes * 8000) / this.targetBitrate;
80
+ return due - now;
81
+ }
82
+ }
83
+
84
+ module.exports = CongestionControl;
@@ -51,6 +51,8 @@ class DAVE {
51
51
  }
52
52
 
53
53
  reinit(version = this.targetVersion) {
54
+ this._mediaStarted = false;
55
+ this._bootstrapSender = null;
54
56
  this.validateVersion(version);
55
57
  this.blocked = true;
56
58
  if (!version) return;
@@ -81,6 +83,8 @@ class DAVE {
81
83
  this.version = this.targetVersion = this.pending.get(id);
82
84
  this.pending.delete(id);
83
85
  this.lastTransition = id;
86
+ this._mediaStarted = false;
87
+ this._bootstrapSender = null;
84
88
  this.recovering = false;
85
89
  this.blocked = this.version > 0 && !this.session?.ready;
86
90
  // When upgrading, expire the explicitly permitted plaintext grace period after ten seconds.
@@ -137,7 +141,11 @@ class DAVE {
137
141
  encrypt(frame, codec = 'OPUS') {
138
142
  if (this.closed || !this.negotiated) return null;
139
143
  // The protocol's Opus silence marker is exempt from frame encryption, including while joining.
140
- if (codec === 'OPUS' && SILENCE_FRAME.equals(frame)) return frame;
144
+ if (codec === 'OPUS' && SILENCE_FRAME.equals(frame)) {
145
+ // Exempt silence must not unlock nonce-zero startup ordering.
146
+ this._bootstrapSender = null;
147
+ return frame;
148
+ }
141
149
  if (this.blocked) return null;
142
150
  if (this.version === 0) return frame;
143
151
  if (!this.session?.ready) return null;
@@ -146,6 +154,23 @@ class DAVE {
146
154
  return this.session.encrypt(codec === 'OPUS' ? api.MediaType.AUDIO : api.MediaType.VIDEO, api.Codec[codec], frame);
147
155
  }
148
156
 
157
+ beginMedia(sender) {
158
+ if (this._mediaStarted || !this.version || this.blocked || !this.negotiated || !this.session?.ready) return true;
159
+ if (this._bootstrapSender && this._bootstrapSender !== sender) return false;
160
+ this._bootstrapSender = sender;
161
+ return true;
162
+ }
163
+
164
+ sentMedia(sender) {
165
+ if (this._bootstrapSender !== sender) return;
166
+ this._mediaStarted = true;
167
+ this._bootstrapSender = null;
168
+ }
169
+
170
+ releaseMedia(sender) {
171
+ if (this._bootstrapSender === sender) this._bootstrapSender = null;
172
+ }
173
+
149
174
  decrypt(frame, userId, video = false) {
150
175
  if (this.closed || !this.negotiated) return null;
151
176
  if (!video && SILENCE_FRAME.equals(frame)) return frame;
@@ -1,7 +1,7 @@
1
1
  'use strict';
2
2
 
3
3
  const { Buffer } = require('node:buffer');
4
- const { setInterval } = require('node:timers');
4
+ const { setInterval, setTimeout } = require('node:timers');
5
5
  const Util = require('../../../util/Util');
6
6
  const { encrypt, decrypt } = require('../util/TransportCrypto');
7
7
 
@@ -11,6 +11,7 @@ class MediaRecovery {
11
11
  this.connection = connection;
12
12
  this.cache = new Map();
13
13
  this.sources = new Map();
14
+ this.pendingRtx = new Set();
14
15
  this.rtxSequence = 0;
15
16
  this.stats = { requested: 0, retransmitted: 0, recovered: 0, expired: 0 };
16
17
  }
@@ -39,14 +40,34 @@ class MediaRecovery {
39
40
  });
40
41
  }
41
42
 
42
- receive(packet) {
43
+ receive(packet, retransmission = false) {
43
44
  const { ssrc, sequenceNumber: seq } = packet.header;
44
45
  let source = this.sources.get(ssrc);
46
+ if (retransmission) {
47
+ if (source?.missing.delete(seq)) this.stats.recovered++;
48
+ return;
49
+ }
45
50
  if (!source) {
46
51
  if (this.sources.size >= 32) return;
47
- this.sources.set(ssrc, (source = { highest: seq, missing: new Map() }));
52
+ this.sources.set(ssrc, {
53
+ highest: seq,
54
+ highestExtended: seq,
55
+ first: seq,
56
+ received: 1,
57
+ seen: new Set([seq]),
58
+ missing: new Map(),
59
+ reportedExpected: 0,
60
+ reportedReceived: 0,
61
+ });
62
+ if (!this.reportTimer) this.reportTimer = setInterval(() => this._report(), 1000).unref();
48
63
  return;
49
64
  }
65
+ if (!source.seen.has(seq)) {
66
+ const age = (source.highest - seq) & 65535;
67
+ if (age < 128 || age >= 32768) source.received++;
68
+ source.seen.add(seq);
69
+ if (source.seen.size > 512) source.seen.delete(source.seen.values().next().value);
70
+ }
50
71
  if (source.missing.delete(seq)) this.stats.recovered++;
51
72
  const distance = (seq - source.highest) & 65535;
52
73
  if (!distance || distance >= 32768) return;
@@ -61,6 +82,7 @@ class MediaRecovery {
61
82
  }
62
83
  }
63
84
  source.highest = seq;
85
+ source.highestExtended += distance;
64
86
  if (source.missing.size && !this.timer) this.timer = setInterval(() => this._tick(), 20).unref();
65
87
  }
66
88
 
@@ -129,11 +151,61 @@ class MediaRecovery {
129
151
  if (packet[offset] >> 6 !== 2 || size < 8 || offset + size > packet.length) return;
130
152
  if (packet[offset + 1] === 205 && (packet[offset] & 31) === 1 && size >= 16 && size % 4 === 0) {
131
153
  messages.push(packet.subarray(offset, offset + size));
154
+ } else if (packet[offset + 1] === 201 || packet[offset + 1] === 200) {
155
+ const base = packet[offset + 1] === 201 ? 8 : 28;
156
+ if (size < base + (packet[offset] & 31) * 24) return;
157
+ messages.push(packet.subarray(offset, offset + size));
132
158
  }
133
159
  offset += size;
134
160
  }
135
161
  if (offset !== packet.length) return;
136
- for (const message of messages) this._handleNack(message);
162
+ for (const message of messages) {
163
+ if (message[1] === 205) this._handleNack(message);
164
+ else this._handleReport(message);
165
+ }
166
+ }
167
+
168
+ _handleReport(packet) {
169
+ const control = this.connection._congestionControl;
170
+ if (!control) return;
171
+ const reporter = packet.readUInt32BE(4);
172
+ const videoSsrc = this.connection.authentication.ssrc + 1;
173
+ const base = packet[1] === 201 ? 8 : 28;
174
+ for (let index = 0; index < (packet[0] & 31); index++) {
175
+ const offset = base + index * 24;
176
+ if (packet.readUInt32BE(offset) === videoSsrc) {
177
+ control.report(packet[offset + 4] / 256, packet.readUInt32BE(offset + 8), reporter);
178
+ }
179
+ }
180
+ }
181
+
182
+ _report() {
183
+ for (const [ssrc, source] of this.sources) {
184
+ const expected = source.highestExtended - source.first + 1;
185
+ const intervalExpected = expected - source.reportedExpected;
186
+ const intervalReceived = source.received - source.reportedReceived;
187
+ source.reportedExpected = expected;
188
+ source.reportedReceived = source.received;
189
+ if (!intervalExpected) continue;
190
+ const packet = Buffer.alloc(32);
191
+ packet[0] = 0x81;
192
+ packet[1] = 201;
193
+ packet.writeUInt16BE(7, 2);
194
+ packet.writeUInt32BE(this.connection.authentication.ssrc, 4);
195
+ packet.writeUInt32BE(ssrc, 8);
196
+ packet[12] = Math.max(
197
+ 0,
198
+ Math.min(255, Math.floor(((intervalExpected - intervalReceived) * 256) / intervalExpected)),
199
+ );
200
+ packet.writeUIntBE(Math.max(0, Math.min(0x7fffff, expected - source.received)), 13, 3);
201
+ packet.writeUInt32BE(source.highestExtended >>> 0, 16);
202
+ try {
203
+ const header = packet.subarray(0, 8);
204
+ this._send(Buffer.concat([header, ...encrypt(this.connection, packet.subarray(8), header)]));
205
+ } catch (error) {
206
+ this.connection.receiver?.emit('debug', error);
207
+ }
208
+ }
137
209
  }
138
210
 
139
211
  _handleNack(packet) {
@@ -154,6 +226,7 @@ class MediaRecovery {
154
226
  const auth = this.connection.authentication;
155
227
  if (
156
228
  !item ||
229
+ !auth.secret_key ||
157
230
  !this.key?.equals(Buffer.from(auth.secret_key)) ||
158
231
  now - item.time > 2000 ||
159
232
  item.retries >= 3 ||
@@ -166,6 +239,7 @@ class MediaRecovery {
166
239
  if (!rtx) return;
167
240
  item.retries++;
168
241
  item.last = now;
242
+ if (ssrc === auth.ssrc + 1) this.connection._congestionControl?.nack(sequence);
169
243
  const header = Buffer.from(item.header);
170
244
  header[0] = 0x80;
171
245
  header[1] = (header[1] & 0x80) | item.type;
@@ -173,12 +247,37 @@ class MediaRecovery {
173
247
  header.writeUInt32BE(rtx, 8);
174
248
  const original = Buffer.alloc(2);
175
249
  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);
250
+ const send = () => {
251
+ const current = this.connection.authentication;
252
+ if (
253
+ !current.secret_key ||
254
+ !this.key?.equals(Buffer.from(current.secret_key)) ||
255
+ performance.now() - item.time > 2000
256
+ )
257
+ return;
258
+ try {
259
+ this._send(
260
+ Buffer.concat([header, ...encrypt(this.connection, Buffer.concat([original, item.payload]), header)]),
261
+ );
262
+ this.stats.retransmitted++;
263
+ } catch (error) {
264
+ this.connection.receiver?.emit('debug', error);
265
+ }
266
+ };
267
+ const control = this.connection._congestionControl;
268
+ if (!control) {
269
+ send();
270
+ return;
181
271
  }
272
+ if (this.pendingRtx.size >= 128) return;
273
+ if (control.nextSendAt - performance.now() > 500) return;
274
+ const delay = control.delay(header.length + original.length + item.payload.length + 20);
275
+ if (delay > 500) return;
276
+ const timer = setTimeout(() => {
277
+ this.pendingRtx.delete(timer);
278
+ send();
279
+ }, delay).unref();
280
+ this.pendingRtx.add(timer);
182
281
  }
183
282
 
184
283
  _send(packet) {
@@ -187,9 +286,13 @@ class MediaRecovery {
187
286
 
188
287
  reset() {
189
288
  clearInterval(this.timer);
289
+ clearInterval(this.reportTimer);
290
+ this.reportTimer = null;
190
291
  this.timer = null;
191
292
  this.cache.clear();
192
293
  this.sources.clear();
294
+ for (const timer of this.pendingRtx) clearTimeout(timer);
295
+ this.pendingRtx.clear();
193
296
  this.rtxSequence = 0;
194
297
  }
195
298
  }