rockcord 4.0.0-dev.4 → 4.0.0-dev.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -2
- package/package.json +3 -3
- package/src/client/voice/VoiceConnection.js +96 -22
- package/src/client/voice/dispatcher/BaseDispatcher.js +14 -51
- package/src/client/voice/dispatcher/VideoDispatcher.js +3 -2
- package/src/client/voice/networking/MediaRecovery.js +197 -0
- package/src/client/voice/networking/VoiceUDPClient.js +10 -1
- package/src/client/voice/networking/VoiceWebSocket.js +4 -1
- package/src/client/voice/player/MediaPlayer.js +27 -8
- package/src/client/voice/receiver/AudioJitter.js +98 -0
- package/src/client/voice/receiver/PacketHandler.js +57 -6
- package/src/client/voice/receiver/Receiver.js +5 -2
- package/src/client/voice/receiver/Recorder.js +147 -48
- package/src/client/voice/util/TransportCrypto.js +54 -0
- package/src/client/voice/util/VideoFrames.js +4 -1
- package/typings/index.d.ts +1 -0
|
@@ -22,7 +22,7 @@ const { Readable: ReadableStream } = require('stream');
|
|
|
22
22
|
const prism = require('prism-media');
|
|
23
23
|
const { H264NalSplitter, H265NalSplitter } = require('./processing/AnnexBNalSplitter');
|
|
24
24
|
const { IvfTransformer } = require('./processing/IvfSplitter');
|
|
25
|
-
const { H264Dispatcher } = require('../dispatcher/AnnexBDispatcher');
|
|
25
|
+
const { H264Dispatcher, H265Dispatcher } = require('../dispatcher/AnnexBDispatcher');
|
|
26
26
|
const AudioDispatcher = require('../dispatcher/AudioDispatcher');
|
|
27
27
|
const { VP8Dispatcher } = require('../dispatcher/VPxDispatcher');
|
|
28
28
|
|
|
@@ -52,10 +52,8 @@ const FFMPEG_H264_ARGUMENTS = options => [
|
|
|
52
52
|
options?.presetH26x || 'faster',
|
|
53
53
|
'-profile:v',
|
|
54
54
|
'baseline',
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
// '-x264-params',
|
|
58
|
-
// `keyint=${options?.fps}:min-keyint=${options?.fps}`,
|
|
55
|
+
'-g',
|
|
56
|
+
String(Math.max(1, Math.round(options?.fps || 30))),
|
|
59
57
|
'-bf',
|
|
60
58
|
'0',
|
|
61
59
|
'-bsf:v',
|
|
@@ -68,9 +66,15 @@ const FFMPEG_H265_ARGUMENTS = options => [
|
|
|
68
66
|
'-f',
|
|
69
67
|
'hevc',
|
|
70
68
|
'-preset',
|
|
71
|
-
options?.
|
|
69
|
+
options?.presetH26x || 'faster',
|
|
70
|
+
'-tune',
|
|
71
|
+
'zerolatency',
|
|
72
72
|
'-profile:v',
|
|
73
73
|
'main',
|
|
74
|
+
'-g',
|
|
75
|
+
String(Math.max(1, Math.round(options?.fps || 30))),
|
|
76
|
+
'-x265-params',
|
|
77
|
+
'repeat-headers=1:aud=1',
|
|
74
78
|
'-bf',
|
|
75
79
|
'0',
|
|
76
80
|
];
|
|
@@ -173,7 +177,10 @@ class MediaPlayer extends EventEmitter {
|
|
|
173
177
|
playUnknownVideo(input, options = {}) {
|
|
174
178
|
this.destroyVideoDispatcher();
|
|
175
179
|
const isStream = input instanceof ReadableStream;
|
|
176
|
-
if (
|
|
180
|
+
if (options.fps == null) options.fps = 30;
|
|
181
|
+
if (!Number.isFinite(options.fps) || options.fps <= 0) {
|
|
182
|
+
throw new RangeError('Video FPS must be a positive finite number');
|
|
183
|
+
}
|
|
177
184
|
|
|
178
185
|
const args = [
|
|
179
186
|
'-i',
|
|
@@ -245,8 +252,11 @@ class MediaPlayer extends EventEmitter {
|
|
|
245
252
|
case 'H264': {
|
|
246
253
|
return this.playAnnexBVideo(ffmpeg, options, streams, 'H264');
|
|
247
254
|
}
|
|
255
|
+
case 'H265': {
|
|
256
|
+
return this.playAnnexBVideo(ffmpeg, options, streams, 'H265');
|
|
257
|
+
}
|
|
248
258
|
default: {
|
|
249
|
-
throw new Error('Invalid codec (Supported: VP8, H264)');
|
|
259
|
+
throw new Error('Invalid codec (Supported: VP8, H264, H265)');
|
|
250
260
|
}
|
|
251
261
|
}
|
|
252
262
|
}
|
|
@@ -311,6 +321,15 @@ class MediaPlayer extends EventEmitter {
|
|
|
311
321
|
));
|
|
312
322
|
return dispatcher;
|
|
313
323
|
}
|
|
324
|
+
case 'H265': {
|
|
325
|
+
const dispatcher = (this.videoDispatcher = new H265Dispatcher(
|
|
326
|
+
this,
|
|
327
|
+
options?.highWaterMark,
|
|
328
|
+
streams,
|
|
329
|
+
options?.fps,
|
|
330
|
+
));
|
|
331
|
+
return dispatcher;
|
|
332
|
+
}
|
|
314
333
|
default: {
|
|
315
334
|
throw new Error('Invalid codec');
|
|
316
335
|
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { Buffer } = require('node:buffer');
|
|
4
|
+
const { setTimeout } = require('node:timers');
|
|
5
|
+
const { RtpHeader, RtpPacket } = require('werift-rtp');
|
|
6
|
+
const { SILENCE_FRAME } = require('../util/Silence');
|
|
7
|
+
|
|
8
|
+
// Opus TOC frame durations from RFC 6716, section 3.1.
|
|
9
|
+
function duration(payload) {
|
|
10
|
+
if (!payload.length) return 20;
|
|
11
|
+
const config = payload[0] >> 3;
|
|
12
|
+
const frame =
|
|
13
|
+
config >= 16 ? 2.5 * 2 ** (config & 3) : config >= 12 ? 10 * 2 ** (config & 1) : [10, 20, 40, 60][config & 3];
|
|
14
|
+
const code = payload[0] & 3;
|
|
15
|
+
const frames = code === 0 ? 1 : code === 3 ? (payload[1] || 0) & 63 : 2;
|
|
16
|
+
const milliseconds = frame * frames;
|
|
17
|
+
return milliseconds > 0 && milliseconds <= 120 ? milliseconds : 20;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Forty-millisecond playout window with bounded reordering and loss padding. @private */
|
|
21
|
+
class AudioJitter {
|
|
22
|
+
constructor(deliver) {
|
|
23
|
+
this.deliver = deliver;
|
|
24
|
+
this.packets = new Map();
|
|
25
|
+
this.expected = null;
|
|
26
|
+
this.lastArrival = -Infinity;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
push(packet) {
|
|
30
|
+
const now = performance.now();
|
|
31
|
+
const seq = packet.header.sequenceNumber;
|
|
32
|
+
if (packet.payload.length > 4096) return;
|
|
33
|
+
if (
|
|
34
|
+
this.expected === null ||
|
|
35
|
+
now - this.lastArrival > 500 ||
|
|
36
|
+
(((seq - this.expected) & 65535) > 1024 && ((seq - this.expected) & 65535) < 32768)
|
|
37
|
+
) {
|
|
38
|
+
this.reset();
|
|
39
|
+
this.expected = seq;
|
|
40
|
+
this.next = now + 40;
|
|
41
|
+
}
|
|
42
|
+
const ahead = (seq - this.expected) & 65535;
|
|
43
|
+
if (ahead >= 32768 || this.packets.has(seq)) return;
|
|
44
|
+
if (this.packets.size >= 64) return;
|
|
45
|
+
this.lastArrival = now;
|
|
46
|
+
this.packets.set(seq, packet);
|
|
47
|
+
this._schedule();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
_schedule() {
|
|
51
|
+
if (this.timer || !this.packets.size) return;
|
|
52
|
+
this.timer = setTimeout(() => {
|
|
53
|
+
this.timer = null;
|
|
54
|
+
this._play();
|
|
55
|
+
}, Math.max(0, this.next - performance.now())).unref();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
_play() {
|
|
59
|
+
let packet = this.packets.get(this.expected);
|
|
60
|
+
if (!packet) {
|
|
61
|
+
const sequence = [...this.packets.keys()].sort(
|
|
62
|
+
(a, b) => ((a - this.expected) & 65535) - ((b - this.expected) & 65535),
|
|
63
|
+
)[0];
|
|
64
|
+
if (sequence === undefined) return;
|
|
65
|
+
const gap = (sequence - this.expected) & 65535;
|
|
66
|
+
if (gap > 10 || !this.last) {
|
|
67
|
+
this.expected = sequence;
|
|
68
|
+
packet = this.packets.get(sequence);
|
|
69
|
+
} else {
|
|
70
|
+
packet = new RtpPacket(
|
|
71
|
+
new RtpHeader({
|
|
72
|
+
...this.last.header,
|
|
73
|
+
sequenceNumber: this.expected,
|
|
74
|
+
timestamp: (this.last.header.timestamp + duration(this.last.payload) * 48) >>> 0,
|
|
75
|
+
}),
|
|
76
|
+
Buffer.from(SILENCE_FRAME),
|
|
77
|
+
);
|
|
78
|
+
packet.concealed = true;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
this.packets.delete(this.expected);
|
|
82
|
+
this.expected = (this.expected + 1) & 65535;
|
|
83
|
+
this.last = packet;
|
|
84
|
+
this.next += duration(packet.payload);
|
|
85
|
+
this.deliver(packet);
|
|
86
|
+
this._schedule();
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
reset() {
|
|
90
|
+
clearTimeout(this.timer);
|
|
91
|
+
this.timer = null;
|
|
92
|
+
this.packets.clear();
|
|
93
|
+
this.expected = null;
|
|
94
|
+
this.last = null;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
module.exports = { AudioJitter, duration };
|
|
@@ -5,9 +5,11 @@ const { Buffer } = require('node:buffer');
|
|
|
5
5
|
const crypto = require('node:crypto');
|
|
6
6
|
const { setTimeout } = require('node:timers');
|
|
7
7
|
const { RtpPacket, RtpHeader } = require('werift-rtp');
|
|
8
|
+
const { AudioJitter } = require('./AudioJitter');
|
|
8
9
|
const Recorder = require('./Recorder');
|
|
9
10
|
const Speaking = require('../../../util/Speaking');
|
|
10
11
|
const Util = require('../../../util/Util');
|
|
12
|
+
const MediaRecovery = require('../networking/MediaRecovery');
|
|
11
13
|
const secretbox = require('../util/Secretbox');
|
|
12
14
|
const { SILENCE_FRAME } = require('../util/Silence');
|
|
13
15
|
const { VideoFrames, packetize } = require('../util/VideoFrames');
|
|
@@ -38,6 +40,8 @@ class PacketHandler extends EventEmitter {
|
|
|
38
40
|
this.speakingTimeouts = new Map();
|
|
39
41
|
this.videoFrames = new VideoFrames();
|
|
40
42
|
this.videoSequences = new Map();
|
|
43
|
+
this.audioJitters = new Map();
|
|
44
|
+
this.recovery = this.connection._mediaRecovery = new MediaRecovery(this.connection);
|
|
41
45
|
}
|
|
42
46
|
|
|
43
47
|
getNonceBuffer() {
|
|
@@ -164,7 +168,7 @@ class PacketHandler extends EventEmitter {
|
|
|
164
168
|
if (!opusPacket.payload) {
|
|
165
169
|
return;
|
|
166
170
|
}
|
|
167
|
-
if (SILENCE_FRAME.equals(opusPacket.payload)) {
|
|
171
|
+
if (SILENCE_FRAME.equals(opusPacket.payload) && !opusPacket.concealed) {
|
|
168
172
|
// If this is a silence frame, pretend we never received it
|
|
169
173
|
return;
|
|
170
174
|
}
|
|
@@ -239,10 +243,12 @@ class PacketHandler extends EventEmitter {
|
|
|
239
243
|
if (this.connection.dave) {
|
|
240
244
|
const codec = ['H264', 'H265', 'VP8'].find(name => Util.getPayloadType(name) === packet.header.payloadType);
|
|
241
245
|
if (!codec) return;
|
|
242
|
-
|
|
246
|
+
let plain;
|
|
247
|
+
const frame = this.videoFrames.push(packet, codec, assembled => {
|
|
248
|
+
plain = this.connection.dave.decrypt(assembled, userStat.userId, true);
|
|
249
|
+
return Boolean(plain);
|
|
250
|
+
});
|
|
243
251
|
if (!frame) return;
|
|
244
|
-
const plain = this.connection.dave.decrypt(frame, userStat.userId, true);
|
|
245
|
-
if (!plain) return;
|
|
246
252
|
this.receiver.emit('videoFrame', userStat, plain, codec);
|
|
247
253
|
if (streamInfo) {
|
|
248
254
|
const payloads = packetize(plain, codec);
|
|
@@ -270,6 +276,16 @@ class PacketHandler extends EventEmitter {
|
|
|
270
276
|
}
|
|
271
277
|
|
|
272
278
|
push(buffer) {
|
|
279
|
+
if (
|
|
280
|
+
Buffer.isBuffer(buffer) &&
|
|
281
|
+
buffer.length >= 28 &&
|
|
282
|
+
buffer[0] >> 6 === 2 &&
|
|
283
|
+
buffer[1] >= 192 &&
|
|
284
|
+
buffer[1] <= 223
|
|
285
|
+
) {
|
|
286
|
+
this.recovery.handle(buffer);
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
273
289
|
if (!Buffer.isBuffer(buffer) || buffer.length < 32 || buffer[0] >> 6 !== 2) return;
|
|
274
290
|
const ssrc = buffer.readUInt32BE(8);
|
|
275
291
|
let userStat, packet;
|
|
@@ -280,6 +296,8 @@ class PacketHandler extends EventEmitter {
|
|
|
280
296
|
return;
|
|
281
297
|
}
|
|
282
298
|
if (packet instanceof Error) return;
|
|
299
|
+
const type = packet.header.payloadType;
|
|
300
|
+
if (!Util.getAllPayloadType().some(codec => codec.payload_type === type || codec.rtx_payload_type === type)) return;
|
|
283
301
|
if (this.connection.ssrcMap.has(ssrc)) {
|
|
284
302
|
userStat = this.connection.ssrcMap.get(ssrc); // Audio_ssrc
|
|
285
303
|
if (userStat.kind === 'video-rtx') {
|
|
@@ -289,17 +307,35 @@ class PacketHandler extends EventEmitter {
|
|
|
289
307
|
packet.header.sequenceNumber = packet.payload.readUInt16BE();
|
|
290
308
|
packet.header.payloadType = codec.payload_type;
|
|
291
309
|
packet.payload = packet.payload.subarray(2);
|
|
310
|
+
this.recovery.receive(packet);
|
|
292
311
|
this.videoReceiver(userStat.primarySsrc, userStat, packet);
|
|
293
312
|
} else if (userStat.kind === 'video') {
|
|
313
|
+
this.recovery.receive(packet);
|
|
294
314
|
this.videoReceiver(ssrc, userStat, packet);
|
|
295
315
|
} else {
|
|
316
|
+
if (type !== Util.getPayloadType('opus')) return;
|
|
296
317
|
if (this.connection.dave) {
|
|
297
318
|
const plain = this.connection.dave.decrypt(packet.payload, userStat.userId);
|
|
298
319
|
if (!plain) return;
|
|
299
320
|
packet.payload = plain;
|
|
300
321
|
}
|
|
301
|
-
this.
|
|
302
|
-
|
|
322
|
+
let jitter = this.audioJitters.get(ssrc);
|
|
323
|
+
if (jitter && jitter.userId !== userStat.userId) {
|
|
324
|
+
jitter.reset();
|
|
325
|
+
this.audioJitters.delete(ssrc);
|
|
326
|
+
jitter = null;
|
|
327
|
+
}
|
|
328
|
+
if (!jitter) {
|
|
329
|
+
if (this.audioJitters.size >= 32) return;
|
|
330
|
+
jitter = new AudioJitter(ordered => {
|
|
331
|
+
this.audioReceiver(ssrc, jitter.userStat, ordered);
|
|
332
|
+
this.audioReceiverForStream(ssrc, jitter.userStat, ordered);
|
|
333
|
+
});
|
|
334
|
+
jitter.userId = userStat.userId;
|
|
335
|
+
this.audioJitters.set(ssrc, jitter);
|
|
336
|
+
}
|
|
337
|
+
jitter.userStat = userStat;
|
|
338
|
+
jitter.push(packet);
|
|
303
339
|
}
|
|
304
340
|
} else if (this.connection.ssrcMap.has(ssrc - 1)) {
|
|
305
341
|
userStat = this.connection.ssrcMap.get(ssrc - 1); // Video_ssrc
|
|
@@ -309,7 +345,22 @@ class PacketHandler extends EventEmitter {
|
|
|
309
345
|
}
|
|
310
346
|
|
|
311
347
|
// When udp connection is closed (STREAM_DELETE), destroy all streams (Memory leak)
|
|
348
|
+
forgetSource(ssrc) {
|
|
349
|
+
this.audioJitters.get(ssrc)?.reset();
|
|
350
|
+
this.audioJitters.delete(ssrc);
|
|
351
|
+
this.recovery.sources.delete(ssrc);
|
|
352
|
+
this.videoSequences.delete(ssrc);
|
|
353
|
+
clearTimeout(this.speakingTimeouts.get(ssrc));
|
|
354
|
+
this.speakingTimeouts.delete(ssrc);
|
|
355
|
+
for (const key of this.videoFrames.frames.keys()) {
|
|
356
|
+
if (key.startsWith(`${ssrc}:`)) this.videoFrames.frames.delete(key);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
312
360
|
destroyAllStream() {
|
|
361
|
+
this.recovery.reset();
|
|
362
|
+
for (const jitter of this.audioJitters.values()) jitter.reset();
|
|
363
|
+
this.audioJitters.clear();
|
|
313
364
|
for (const timeout of this.speakingTimeouts.values()) clearTimeout(timeout);
|
|
314
365
|
this.speakingTimeouts.clear();
|
|
315
366
|
this.videoFrames.frames.clear();
|
|
@@ -34,7 +34,9 @@ class VoiceReceiver extends EventEmitter {
|
|
|
34
34
|
}
|
|
35
35
|
|
|
36
36
|
_updateVideoSubscriptions(frames = this.listenerCount('videoFrame') > 0) {
|
|
37
|
-
|
|
37
|
+
// Keep default media delivery enabled: any: 0 also suppresses audio when
|
|
38
|
+
// switching on explicit video subscriptions.
|
|
39
|
+
const wants = { any: 100 };
|
|
38
40
|
for (const [ssrc, info] of this.connection.ssrcMap) {
|
|
39
41
|
if (info.kind === 'video') wants[ssrc] = frames || this.packets.videoStreams.has(info.userId) ? 100 : 0;
|
|
40
42
|
}
|
|
@@ -90,7 +92,8 @@ class VoiceReceiver extends EventEmitter {
|
|
|
90
92
|
/**
|
|
91
93
|
* Creates a new video receiving stream. If a stream already exists for a user, then that stream will be returned
|
|
92
94
|
* rather than generating a new one.
|
|
93
|
-
*
|
|
95
|
+
* Records received H264 video and Opus audio using FFmpeg.
|
|
96
|
+
* Wait for `ready` before sending media and await `stop()` to finalize the output.
|
|
94
97
|
* @param {UserResolvable} user The user to start listening to.
|
|
95
98
|
* @param {WritableStream|string} output Output stream or file path to write the video stream to.
|
|
96
99
|
* @returns {Recorder} The video stream for the specified user.
|
|
@@ -4,6 +4,11 @@ const { spawn } = require('child_process');
|
|
|
4
4
|
const { createSocket } = require('dgram');
|
|
5
5
|
const { EventEmitter } = require('events');
|
|
6
6
|
const { Buffer } = require('node:buffer');
|
|
7
|
+
const fs = require('node:fs');
|
|
8
|
+
const os = require('node:os');
|
|
9
|
+
const path = require('node:path');
|
|
10
|
+
const process = require('node:process');
|
|
11
|
+
const { setTimeout } = require('node:timers');
|
|
7
12
|
const { Writable } = require('stream');
|
|
8
13
|
const kill = require('tree-kill');
|
|
9
14
|
const { RtpPacket } = require('werift-rtp');
|
|
@@ -63,6 +68,8 @@ class Recorder extends EventEmitter {
|
|
|
63
68
|
*/
|
|
64
69
|
this.ready = false;
|
|
65
70
|
this.destroyed = false;
|
|
71
|
+
this.closed = false;
|
|
72
|
+
this.completion = new Promise(resolve => (this._resolveCompletion = resolve));
|
|
66
73
|
|
|
67
74
|
this.socket = createSocket('udp4');
|
|
68
75
|
|
|
@@ -71,6 +78,7 @@ class Recorder extends EventEmitter {
|
|
|
71
78
|
}
|
|
72
79
|
|
|
73
80
|
fail(error) {
|
|
81
|
+
this._error = error;
|
|
74
82
|
if (this.listenerCount('error')) this.emit('error', error);
|
|
75
83
|
else this.emit('debug', error.message);
|
|
76
84
|
this.destroy();
|
|
@@ -78,47 +86,60 @@ class Recorder extends EventEmitter {
|
|
|
78
86
|
async init(output) {
|
|
79
87
|
await this.promise;
|
|
80
88
|
if (this.destroyed) return;
|
|
81
|
-
|
|
89
|
+
// Keep stdin available for FFmpeg's graceful quit command. Reading SDP from
|
|
90
|
+
// stdin and closing it leaves no way to write the Matroska trailer on Windows.
|
|
91
|
+
const sdpData = Util.getSDPCodecName(this.portUdpH264, this.portUdpH265, this.portUdpOpus).replace(
|
|
92
|
+
/;sprop-parameter-sets=[^;\r\n]+/,
|
|
93
|
+
'',
|
|
94
|
+
);
|
|
95
|
+
this._directory = fs.mkdtempSync(path.join(os.tmpdir(), 'rockcord-recorder-'));
|
|
96
|
+
this._sdpPath = path.join(this._directory, 'input.sdp');
|
|
97
|
+
fs.writeFileSync(this._sdpPath, sdpData);
|
|
82
98
|
const isStream = output instanceof Writable;
|
|
83
99
|
if (isStream) {
|
|
84
100
|
this.outputStream = StreamOutput(output);
|
|
85
101
|
}
|
|
86
|
-
const stream = spawn(
|
|
87
|
-
'
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
102
|
+
const stream = spawn(
|
|
103
|
+
process.env.FFMPEG_PATH || 'ffmpeg',
|
|
104
|
+
[
|
|
105
|
+
'-hide_banner',
|
|
106
|
+
'-reorder_queue_size',
|
|
107
|
+
'0',
|
|
108
|
+
'-thread_queue_size',
|
|
109
|
+
'500',
|
|
110
|
+
'-err_detect',
|
|
111
|
+
'ignore_err',
|
|
112
|
+
'-flags2',
|
|
113
|
+
'+export_mvs',
|
|
114
|
+
'-fflags',
|
|
115
|
+
'+genpts+discardcorrupt',
|
|
116
|
+
'-f',
|
|
117
|
+
'sdp',
|
|
118
|
+
'-analyzeduration',
|
|
119
|
+
'5000000',
|
|
120
|
+
'-probesize',
|
|
121
|
+
'1M',
|
|
122
|
+
'-listen_timeout',
|
|
123
|
+
'0',
|
|
124
|
+
'-protocol_whitelist',
|
|
125
|
+
'file,udp,rtp,pipe,fd',
|
|
126
|
+
'-i',
|
|
127
|
+
this._sdpPath,
|
|
128
|
+
'-buffer_size',
|
|
129
|
+
'4M',
|
|
130
|
+
'-max_delay',
|
|
131
|
+
'0',
|
|
132
|
+
'-rtbufsize',
|
|
133
|
+
'4M',
|
|
134
|
+
'-c',
|
|
135
|
+
'copy',
|
|
136
|
+
'-y',
|
|
137
|
+
'-f',
|
|
138
|
+
'matroska',
|
|
139
|
+
isStream ? this.outputStream.url : output,
|
|
140
|
+
],
|
|
141
|
+
{ windowsHide: true },
|
|
142
|
+
);
|
|
122
143
|
|
|
123
144
|
/**
|
|
124
145
|
* The FFmpeg process
|
|
@@ -127,15 +148,44 @@ class Recorder extends EventEmitter {
|
|
|
127
148
|
this.stream = stream;
|
|
128
149
|
stream.on('error', error => this.fail(error));
|
|
129
150
|
stream.stdin.on('error', error => this.fail(error));
|
|
130
|
-
stream.once('
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
151
|
+
stream.once('close', code => {
|
|
152
|
+
this.exitCode = code;
|
|
153
|
+
if (code && !this._error) this._error = new Error(`FFmpeg recording exited with code ${code}`);
|
|
154
|
+
this.destroy();
|
|
155
|
+
this._finalize();
|
|
156
|
+
});
|
|
157
|
+
this.stream.stdout?.resume();
|
|
158
|
+
stream.once('spawn', () => this._waitForPorts().catch(error => this.fail(error)));
|
|
159
|
+
this.stream.stderr.on('data', data => {
|
|
134
160
|
this.emit('debug', `stderr: ${data}`);
|
|
135
|
-
this.ready = true;
|
|
136
|
-
this.emit('ready');
|
|
137
161
|
});
|
|
138
162
|
}
|
|
163
|
+
|
|
164
|
+
async _waitForPorts() {
|
|
165
|
+
const bound = port =>
|
|
166
|
+
new Promise(resolve => {
|
|
167
|
+
const probe = createSocket('udp4');
|
|
168
|
+
probe.once('error', error => {
|
|
169
|
+
try {
|
|
170
|
+
probe.close();
|
|
171
|
+
} catch {
|
|
172
|
+
/* An unsuccessful bind may close the probe. */
|
|
173
|
+
}
|
|
174
|
+
resolve(error.code === 'EADDRINUSE');
|
|
175
|
+
});
|
|
176
|
+
probe.bind(port, '127.0.0.1', () => probe.close(() => resolve(false)));
|
|
177
|
+
});
|
|
178
|
+
for (let attempt = 0; attempt < 120 && !this.destroyed; attempt++) {
|
|
179
|
+
const ports = await Promise.all([bound(this.portUdpH264), bound(this.portUdpOpus)]);
|
|
180
|
+
if (ports.every(Boolean)) {
|
|
181
|
+
this.ready = true;
|
|
182
|
+
this.emit('ready', this);
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
await new Promise(resolve => setTimeout(resolve, 25));
|
|
186
|
+
}
|
|
187
|
+
if (!this.destroyed) throw new Error('FFmpeg did not open the recording RTP ports');
|
|
188
|
+
}
|
|
139
189
|
/**
|
|
140
190
|
* Send a payload to FFmpeg via UDP
|
|
141
191
|
* @param {RtpPacket|string|Buffer} payload The payload
|
|
@@ -158,10 +208,10 @@ class Recorder extends EventEmitter {
|
|
|
158
208
|
let port;
|
|
159
209
|
if (payload.header.payloadType === Util.getPayloadType('opus')) {
|
|
160
210
|
port = this.portUdpOpus;
|
|
211
|
+
this._lastAudioHeader = payload.header;
|
|
161
212
|
} else if (payload.header.payloadType === Util.getPayloadType('H264')) {
|
|
162
213
|
port = this.portUdpH264;
|
|
163
|
-
|
|
164
|
-
port = this.portUdpH265;
|
|
214
|
+
this._lastVideoHeader = payload.header;
|
|
165
215
|
} else {
|
|
166
216
|
return;
|
|
167
217
|
}
|
|
@@ -172,15 +222,64 @@ class Recorder extends EventEmitter {
|
|
|
172
222
|
if (this.destroyed) return;
|
|
173
223
|
this.destroyed = true;
|
|
174
224
|
this.ready = false;
|
|
225
|
+
if (this.receiver?.videoStreams?.get(this.userId) === this) this.receiver.videoStreams.delete(this.userId);
|
|
226
|
+
if (this.stream?.pid && this.stream.exitCode === null && !this._error) {
|
|
227
|
+
// RTCP BYE ends both local RTP inputs. Unlike an immediate quit, EOF lets
|
|
228
|
+
// FFmpeg drain its parser and muxer, preserving the final buffered frame.
|
|
229
|
+
for (const [port, ssrc] of [
|
|
230
|
+
[this.portUdpH264, this._lastVideoHeader?.ssrc ?? 1],
|
|
231
|
+
[this.portUdpOpus, this._lastAudioHeader?.ssrc ?? 2],
|
|
232
|
+
]) {
|
|
233
|
+
// Include an empty, padded reason: FFmpeg's RTP demuxer requires at
|
|
234
|
+
// least 12 bytes even though a reason-less BYE may be eight bytes.
|
|
235
|
+
const bye = Buffer.from([0x81, 203, 0, 2, 0, 0, 0, 0, 0, 0, 0, 0]);
|
|
236
|
+
bye.writeUInt32BE(ssrc, 4);
|
|
237
|
+
this.socket.send(bye, port + 1, '127.0.0.1', () => {});
|
|
238
|
+
}
|
|
239
|
+
this._quitTimer = setTimeout(() => this.stream.stdin.end('q\n'), 2000).unref();
|
|
240
|
+
this._killTimer = setTimeout(() => {
|
|
241
|
+
this._error = new Error('FFmpeg recording did not stop within 5 seconds');
|
|
242
|
+
kill(this.stream.pid, () => {});
|
|
243
|
+
}, 5000).unref();
|
|
244
|
+
} else if (this.stream?.pid && this.stream.exitCode === null) {
|
|
245
|
+
// Wait for close before removing the SDP: Windows keeps the input file
|
|
246
|
+
// locked until the failed child has actually exited.
|
|
247
|
+
kill(this.stream.pid, () => {});
|
|
248
|
+
} else {
|
|
249
|
+
this._finalize();
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Stop receiving and wait for FFmpeg to finalize the recording.
|
|
255
|
+
* @returns {Promise<void>}
|
|
256
|
+
*/
|
|
257
|
+
async stop() {
|
|
258
|
+
this.destroy();
|
|
259
|
+
await this.completion;
|
|
260
|
+
if (this._error) throw this._error;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
_finalize() {
|
|
264
|
+
if (this.closed) return;
|
|
265
|
+
this.closed = true;
|
|
266
|
+
clearTimeout(this._killTimer);
|
|
267
|
+
clearTimeout(this._quitTimer);
|
|
175
268
|
try {
|
|
176
269
|
this.socket.close();
|
|
177
270
|
} catch {
|
|
178
271
|
/* Socket may not have bound yet. */
|
|
179
272
|
}
|
|
180
273
|
this.outputStream?.destroy();
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
274
|
+
try {
|
|
275
|
+
if (this._sdpPath) fs.rmSync(this._sdpPath, { force: true });
|
|
276
|
+
if (this._directory) fs.rmdirSync(this._directory);
|
|
277
|
+
} catch (error) {
|
|
278
|
+
this._error ??= error;
|
|
279
|
+
this.emit('debug', `Recorder temporary-file cleanup failed: ${error.message}`);
|
|
280
|
+
}
|
|
281
|
+
this._resolveCompletion();
|
|
282
|
+
this.emit('closed', this);
|
|
184
283
|
}
|
|
185
284
|
|
|
186
285
|
/**
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { Buffer } = require('node:buffer');
|
|
4
|
+
const crypto = require('node:crypto');
|
|
5
|
+
const secretbox = require('./Secretbox');
|
|
6
|
+
|
|
7
|
+
const states = new WeakMap();
|
|
8
|
+
|
|
9
|
+
// RTP, RTX and RTCP all share a nonce space for a negotiated session key.
|
|
10
|
+
function encrypt(connection, payload, header) {
|
|
11
|
+
const { secret_key: key, mode } = connection.authentication;
|
|
12
|
+
let state = states.get(connection);
|
|
13
|
+
if (!state || !state.key.equals(Buffer.from(key))) {
|
|
14
|
+
state = { key: Buffer.from(key), value: 0 };
|
|
15
|
+
states.set(connection, state);
|
|
16
|
+
}
|
|
17
|
+
if (state.value >= 0xffffffff) throw new RangeError('Voice encryption nonce exhausted; establish a new session key');
|
|
18
|
+
const nonce = Buffer.alloc(mode === 'aead_aes256_gcm_rtpsize' ? 12 : 24);
|
|
19
|
+
nonce.writeUInt32BE(++state.value);
|
|
20
|
+
let ciphertext;
|
|
21
|
+
if (mode === 'aead_aes256_gcm_rtpsize') {
|
|
22
|
+
const cipher = crypto.createCipheriv('aes-256-gcm', key, nonce);
|
|
23
|
+
cipher.setAAD(header);
|
|
24
|
+
ciphertext = Buffer.concat([cipher.update(payload), cipher.final(), cipher.getAuthTag()]);
|
|
25
|
+
} else if (mode === 'aead_xchacha20_poly1305_rtpsize') {
|
|
26
|
+
ciphertext = Buffer.from(secretbox.methods.crypto_aead_xchacha20poly1305_ietf_encrypt(payload, header, nonce, key));
|
|
27
|
+
} else {
|
|
28
|
+
throw new RangeError(`Unsupported encryption method: ${mode}`);
|
|
29
|
+
}
|
|
30
|
+
return [ciphertext, nonce.subarray(0, 4)];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function decrypt(connection, packet, headerSize) {
|
|
34
|
+
const { secret_key: key, mode } = connection.authentication;
|
|
35
|
+
if (!key || packet.length < headerSize + 20) throw new RangeError('Truncated encrypted media packet');
|
|
36
|
+
const header = packet.subarray(0, headerSize);
|
|
37
|
+
const nonce = Buffer.alloc(mode === 'aead_aes256_gcm_rtpsize' ? 12 : 24);
|
|
38
|
+
packet.copy(nonce, 0, packet.length - 4);
|
|
39
|
+
const ciphertext = packet.subarray(headerSize, packet.length - 4);
|
|
40
|
+
let plain;
|
|
41
|
+
if (mode === 'aead_aes256_gcm_rtpsize') {
|
|
42
|
+
const cipher = crypto.createDecipheriv('aes-256-gcm', key, nonce);
|
|
43
|
+
cipher.setAAD(header);
|
|
44
|
+
cipher.setAuthTag(ciphertext.subarray(-16));
|
|
45
|
+
plain = Buffer.concat([cipher.update(ciphertext.subarray(0, -16)), cipher.final()]);
|
|
46
|
+
} else if (mode === 'aead_xchacha20_poly1305_rtpsize') {
|
|
47
|
+
plain = Buffer.from(secretbox.methods.crypto_aead_xchacha20poly1305_ietf_decrypt(ciphertext, header, nonce, key));
|
|
48
|
+
} else {
|
|
49
|
+
throw new RangeError(`Unsupported decryption method: ${mode}`);
|
|
50
|
+
}
|
|
51
|
+
return Buffer.concat([header, plain]);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
module.exports = { encrypt, decrypt };
|
|
@@ -132,7 +132,7 @@ class VideoFrames {
|
|
|
132
132
|
this.completed = new Map();
|
|
133
133
|
}
|
|
134
134
|
|
|
135
|
-
push(packet, codec) {
|
|
135
|
+
push(packet, codec, validate) {
|
|
136
136
|
const { ssrc, timestamp, sequenceNumber: seq, marker } = packet.header;
|
|
137
137
|
const now = Date.now();
|
|
138
138
|
const key = `${ssrc}:${timestamp}:${codec}`;
|
|
@@ -169,6 +169,9 @@ class VideoFrames {
|
|
|
169
169
|
}
|
|
170
170
|
try {
|
|
171
171
|
const complete = depacketize(payloads, codec);
|
|
172
|
+
// A marker and contiguous tail do not prove the beginning arrived. DAVE
|
|
173
|
+
// authentication must succeed before suppressing subsequent RTX fragments.
|
|
174
|
+
if (validate && !validate(complete)) return null;
|
|
172
175
|
this.frames.delete(key);
|
|
173
176
|
if (this.completed.size >= 128) this.completed.delete(this.completed.keys().next().value);
|
|
174
177
|
this.completed.set(key, now);
|
package/typings/index.d.ts
CHANGED
|
@@ -1255,6 +1255,7 @@ export class Recorder<Ready extends boolean = boolean, T = any> extends EventEmi
|
|
|
1255
1255
|
public output: Writable | string;
|
|
1256
1256
|
public userId: Snowflake;
|
|
1257
1257
|
public feed(payload: RtpPacket | BufferResolvable): void;
|
|
1258
|
+
public stop(): Promise<void>;
|
|
1258
1259
|
public on(event: 'ready' | 'closed', listener: (recorder: Recorder<true, T>) => void): this;
|
|
1259
1260
|
public once(event: 'ready' | 'closed', listener: (recorder: Recorder<true, T>) => void): this;
|
|
1260
1261
|
public destroy(): void;
|