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.
@@ -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
- // '-g',
56
- // `${options?.fps}`,
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?.presetH265 || 'faster',
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 (!options?.fps) options.fps = 30;
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
- const frame = this.videoFrames.push(packet, codec);
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.audioReceiver(ssrc, userStat, packet);
302
- this.audioReceiverForStream(ssrc, userStat, packet);
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
- const wants = { any: 0 };
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
- * <info>Proof of concept - Requires a very good internet connection</info>
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
- const sdpData = Util.getSDPCodecName(this.portUdpH264, this.portUdpH265, this.portUdpOpus);
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('ffmpeg', [
87
- '-reorder_queue_size',
88
- '500',
89
- '-thread_queue_size',
90
- '500',
91
- '-err_detect',
92
- 'ignore_err',
93
- '-flags2',
94
- '+export_mvs',
95
- '-fflags',
96
- '+genpts+discardcorrupt',
97
- '-use_wallclock_as_timestamps',
98
- '1',
99
- '-f',
100
- 'sdp',
101
- '-analyzeduration',
102
- '1M',
103
- '-probesize',
104
- '1M',
105
- '-protocol_whitelist',
106
- 'file,udp,rtp,pipe,fd',
107
- '-i',
108
- '-', // Read from stdin
109
- '-buffer_size',
110
- '4M',
111
- '-max_delay',
112
- '500000', // 500ms
113
- '-rtbufsize',
114
- '4M',
115
- '-c',
116
- 'copy',
117
- '-y',
118
- '-f',
119
- 'matroska',
120
- isStream ? this.outputStream.url : output,
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('exit', () => this.destroy());
131
- this.stream.stdin.write(sdpData);
132
- this.stream.stdin.end();
133
- this.stream.stderr.once('data', data => {
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
- } else if (payload.header.payloadType === Util.getPayloadType('H265')) {
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
- if (this.stream?.pid) kill(this.stream.pid, () => {});
182
- if (this.receiver?.videoStreams?.get(this.userId) === this) this.receiver.videoStreams.delete(this.userId);
183
- this.emit('closed');
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);
@@ -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;