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.
- package/README.md +4 -3
- package/package.json +15 -12
- package/src/client/Client.js +3 -3
- package/src/client/WebhookClient.js +1 -1
- package/src/client/actions/VoiceStateUpdate.js +2 -2
- package/src/client/voice/VoiceConnection.js +2 -2
- package/src/client/voice/dispatcher/AudioDispatcher.js +1 -1
- package/src/client/voice/dispatcher/BaseDispatcher.js +37 -12
- package/src/client/voice/dispatcher/VideoDispatcher.js +83 -0
- package/src/client/voice/networking/CongestionControl.js +84 -0
- package/src/client/voice/networking/DAVE.js +26 -1
- package/src/client/voice/networking/MediaRecovery.js +112 -9
- package/src/client/voice/player/MediaPlayer.js +18 -3
- package/src/client/voice/player/processing/AnnexBNalSplitter.js +0 -1
- package/src/client/voice/receiver/AudioJitter.js +7 -4
- package/src/client/voice/receiver/PacketHandler.js +10 -4
- package/src/client/voice/receiver/Receiver.js +5 -3
- package/src/client/voice/receiver/Recorder.js +44 -14
- package/src/client/voice/util/PlayInterface.js +2 -0
- package/src/client/voice/util/Secretbox.js +1 -1
- package/src/client/websocket/WebSocketShard.js +0 -1
- package/src/managers/ApplicationCommandManager.js +0 -2
- package/src/managers/ApplicationCommandPermissionsManager.js +0 -1
- package/src/managers/GuildChannelManager.js +2 -3
- package/src/managers/GuildMemberManager.js +0 -1
- package/src/managers/GuildTextThreadManager.js +1 -1
- package/src/managers/MessageManager.js +5 -5
- package/src/managers/RoleManager.js +1 -1
- package/src/rest/APIRequest.js +0 -1
- package/src/rest/APIRouter.js +2 -2
- package/src/rest/DiscordAPIError.js +1 -1
- package/src/rest/RequestHandler.js +3 -3
- package/src/sharding/ShardingManager.js +2 -2
- package/src/structures/ApplicationCommand.js +2 -7
- package/src/structures/BaseCommandInteraction.js +1 -2
- package/src/structures/BaseGuildTextChannel.js +1 -1
- package/src/structures/BaseGuildVoiceChannel.js +1 -1
- package/src/structures/ClientPresence.js +0 -1
- package/src/structures/ClientUser.js +13 -5
- package/src/structures/ContainerComponent.js +1 -1
- package/src/structures/DMChannel.js +1 -1
- package/src/structures/GroupDMChannel.js +4 -4
- package/src/structures/Guild.js +0 -2
- package/src/structures/GuildAuditLogs.js +4 -4
- package/src/structures/GuildScheduledEvent.js +0 -2
- package/src/structures/Interaction.js +1 -1
- package/src/structures/InteractionCollector.js +2 -2
- package/src/structures/InteractionWebhook.js +0 -1
- package/src/structures/Message.js +16 -17
- package/src/structures/MessageComponentInteraction.js +1 -1
- package/src/structures/MessageEmbed.js +0 -2
- package/src/structures/MessagePayload.js +2 -2
- package/src/structures/ModalSubmitInteraction.js +2 -2
- package/src/structures/ThreadChannel.js +3 -3
- package/src/structures/ThreadOnlyChannel.js +1 -1
- package/src/structures/WebEmbed.js +0 -1
- package/src/structures/interfaces/Application.js +2 -2
- package/src/structures/interfaces/Collector.js +1 -4
- package/src/structures/interfaces/TextBasedChannel.js +6 -6
- package/src/util/AttachmentFlags.js +0 -1
- package/src/util/Constants.js +0 -2
- package/src/util/Options.js +3 -3
- package/src/util/Util.js +1 -2
- package/typings/index.d.ts +108 -115
- 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,
|
|
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
|
-
|
|
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
|
|
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": "^
|
|
72
|
+
"tough-cookie": "^6.0.2",
|
|
73
73
|
"tree-kill": "^1.2.2",
|
|
74
|
-
"undici": "^
|
|
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.
|
|
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": "^
|
|
85
|
-
"eslint-config-prettier": "^
|
|
86
|
-
"eslint-
|
|
87
|
-
"eslint-plugin-
|
|
88
|
-
"
|
|
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": "^
|
|
91
|
-
"typescript": "^5.
|
|
93
|
+
"prettier": "^3.9.9",
|
|
94
|
+
"typescript": "^5.9.3"
|
|
92
95
|
},
|
|
93
96
|
"overrides": {
|
|
94
97
|
"jsdoc": "^4.0.5"
|
package/src/client/Client.js
CHANGED
|
@@ -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();
|
|
@@ -16,11 +16,11 @@ class VoiceStateUpdate extends Action {
|
|
|
16
16
|
const newState = guild.voiceStates._add(data);
|
|
17
17
|
|
|
18
18
|
// Get the member
|
|
19
|
-
|
|
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
|
-
|
|
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() {}
|
|
730
|
-
playVideo() {}
|
|
729
|
+
playAudio() {}
|
|
730
|
+
playVideo() {}
|
|
731
731
|
|
|
732
732
|
/**
|
|
733
733
|
* Create new connection to screenshare stream
|
|
@@ -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
|
-
|
|
251
|
-
|
|
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
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
}
|
|
395
|
-
|
|
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))
|
|
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,
|
|
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)
|
|
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
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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
|
}
|