livekit-client 2.22.4 → 2.23.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/dist/livekit-client.e2ee.worker.js +1 -1
- package/dist/livekit-client.e2ee.worker.js.map +1 -1
- package/dist/livekit-client.e2ee.worker.mjs +69 -7
- package/dist/livekit-client.e2ee.worker.mjs.map +1 -1
- package/dist/livekit-client.esm.mjs +1133 -289
- package/dist/livekit-client.esm.mjs.map +1 -1
- package/dist/livekit-client.fm.worker.js.map +1 -1
- package/dist/livekit-client.fm.worker.mjs.map +1 -1
- package/dist/livekit-client.umd.js +1 -1
- package/dist/livekit-client.umd.js.map +1 -1
- package/dist/src/api/SignalClient.d.ts.map +1 -1
- package/dist/src/api/WebSocketStream.d.ts +1 -0
- package/dist/src/api/WebSocketStream.d.ts.map +1 -1
- package/dist/src/api/utils.d.ts +1 -1
- package/dist/src/api/utils.d.ts.map +1 -1
- package/dist/src/e2ee/E2eeManager.d.ts.map +1 -1
- package/dist/src/e2ee/worker/FrameCryptor.d.ts.map +1 -1
- package/dist/src/e2ee/worker/naluUtils.d.ts +12 -1
- package/dist/src/e2ee/worker/naluUtils.d.ts.map +1 -1
- package/dist/src/index.d.ts +2 -2
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/room/DefaultReconnectPolicy.d.ts.map +1 -1
- package/dist/src/room/PCTransport.d.ts +26 -4
- package/dist/src/room/PCTransport.d.ts.map +1 -1
- package/dist/src/room/RTCEngine.d.ts +8 -0
- package/dist/src/room/RTCEngine.d.ts.map +1 -1
- package/dist/src/room/Room.d.ts +53 -0
- package/dist/src/room/Room.d.ts.map +1 -1
- package/dist/src/room/data-channel/DataChannelManager.d.ts +9 -8
- package/dist/src/room/data-channel/DataChannelManager.d.ts.map +1 -1
- package/dist/src/room/data-channel/FlowControlledDataChannel.d.ts +25 -12
- package/dist/src/room/data-channel/FlowControlledDataChannel.d.ts.map +1 -1
- package/dist/src/room/data-channel/LossyDataChannel.d.ts.map +1 -1
- package/dist/src/room/data-channel/ReliableDataChannel.d.ts.map +1 -1
- package/dist/src/room/data-stream/incoming/IncomingDataStreamManager.d.ts.map +1 -1
- package/dist/src/room/participant/LocalParticipant.d.ts +4 -0
- package/dist/src/room/participant/LocalParticipant.d.ts.map +1 -1
- package/dist/src/room/participant/Participant.d.ts +1 -1
- package/dist/src/room/participant/Participant.d.ts.map +1 -1
- package/dist/src/room/rpc/utils.d.ts.map +1 -1
- package/dist/src/room/track/LocalAudioTrack.d.ts +24 -1
- package/dist/src/room/track/LocalAudioTrack.d.ts.map +1 -1
- package/dist/src/room/track/LocalTrack.d.ts +18 -0
- package/dist/src/room/track/LocalTrack.d.ts.map +1 -1
- package/dist/src/room/track/LocalVideoTrack.d.ts +7 -0
- package/dist/src/room/track/LocalVideoTrack.d.ts.map +1 -1
- package/dist/src/room/track/RemoteAudioTrack.d.ts +6 -0
- package/dist/src/room/track/RemoteAudioTrack.d.ts.map +1 -1
- package/dist/src/room/track/processor/types.d.ts +11 -0
- package/dist/src/room/track/processor/types.d.ts.map +1 -1
- package/dist/src/room/track/utils.d.ts.map +1 -1
- package/dist/src/room/utils.d.ts +28 -1
- package/dist/src/room/utils.d.ts.map +1 -1
- package/dist/ts4.2/api/WebSocketStream.d.ts +1 -0
- package/dist/ts4.2/api/utils.d.ts +1 -1
- package/dist/ts4.2/e2ee/worker/naluUtils.d.ts +12 -1
- package/dist/ts4.2/index.d.ts +2 -2
- package/dist/ts4.2/room/PCTransport.d.ts +26 -4
- package/dist/ts4.2/room/RTCEngine.d.ts +8 -0
- package/dist/ts4.2/room/Room.d.ts +53 -0
- package/dist/ts4.2/room/data-channel/DataChannelManager.d.ts +9 -8
- package/dist/ts4.2/room/data-channel/FlowControlledDataChannel.d.ts +25 -12
- package/dist/ts4.2/room/participant/LocalParticipant.d.ts +4 -0
- package/dist/ts4.2/room/participant/Participant.d.ts +1 -1
- package/dist/ts4.2/room/track/LocalAudioTrack.d.ts +24 -1
- package/dist/ts4.2/room/track/LocalTrack.d.ts +18 -0
- package/dist/ts4.2/room/track/LocalVideoTrack.d.ts +7 -0
- package/dist/ts4.2/room/track/RemoteAudioTrack.d.ts +6 -0
- package/dist/ts4.2/room/track/processor/types.d.ts +11 -0
- package/dist/ts4.2/room/utils.d.ts +28 -1
- package/package.json +1 -1
- package/src/api/SignalClient.test.ts +234 -0
- package/src/api/SignalClient.ts +58 -23
- package/src/api/WebSocketStream.test.ts +11 -0
- package/src/api/WebSocketStream.ts +22 -1
- package/src/api/utils.ts +1 -1
- package/src/e2ee/E2eeManager.test.ts +43 -0
- package/src/e2ee/E2eeManager.ts +6 -2
- package/src/e2ee/worker/FrameCryptor.test.ts +212 -90
- package/src/e2ee/worker/FrameCryptor.ts +21 -4
- package/src/e2ee/worker/naluUtils.test.ts +123 -0
- package/src/e2ee/worker/naluUtils.ts +57 -2
- package/src/index.ts +2 -0
- package/src/room/DefaultReconnectPolicy.test.ts +58 -0
- package/src/room/DefaultReconnectPolicy.ts +14 -2
- package/src/room/PCTransport.test.ts +144 -2
- package/src/room/PCTransport.ts +151 -9
- package/src/room/RTCEngine.test.ts +234 -2
- package/src/room/RTCEngine.ts +102 -7
- package/src/room/Room.test.ts +116 -0
- package/src/room/Room.ts +286 -74
- package/src/room/data-channel/DataChannelManager.test.ts +24 -7
- package/src/room/data-channel/DataChannelManager.ts +43 -38
- package/src/room/data-channel/FlowControlledDataChannel.test.ts +60 -18
- package/src/room/data-channel/FlowControlledDataChannel.ts +40 -22
- package/src/room/data-channel/LossyDataChannel.test.ts +38 -0
- package/src/room/data-channel/LossyDataChannel.ts +10 -1
- package/src/room/data-channel/ReliableDataChannel.test.ts +72 -0
- package/src/room/data-channel/ReliableDataChannel.ts +16 -5
- package/src/room/data-stream/incoming/IncomingDataStreamManager.test.ts +31 -0
- package/src/room/data-stream/incoming/IncomingDataStreamManager.ts +11 -0
- package/src/room/participant/LocalParticipant.test.ts +136 -1
- package/src/room/participant/LocalParticipant.ts +66 -17
- package/src/room/participant/Participant.ts +6 -3
- package/src/room/participant/restartOnDefaultAudioDevice.test.ts +177 -0
- package/src/room/rpc/utils.test.ts +32 -0
- package/src/room/rpc/utils.ts +7 -0
- package/src/room/track/LocalAudioTrack.ts +101 -10
- package/src/room/track/LocalTrack.ts +70 -13
- package/src/room/track/LocalVideoTrack.ts +7 -0
- package/src/room/track/RemoteAudioTrack.ts +56 -11
- package/src/room/track/processor/types.ts +11 -0
- package/src/room/track/utils.test.ts +5 -0
- package/src/room/track/utils.ts +29 -5
- package/src/room/utils.test.ts +126 -0
- package/src/room/utils.ts +84 -21
|
@@ -25,7 +25,7 @@ import type {
|
|
|
25
25
|
import { deriveKeys, isVideoFrame, needsRbspUnescaping, parseRbsp, writeRbsp } from '../utils';
|
|
26
26
|
import { ErrorRateLimiter } from './ErrorRateLimiter';
|
|
27
27
|
import type { ParticipantKeyHandler } from './ParticipantKeyHandler';
|
|
28
|
-
import { processNALUsForEncryption } from './naluUtils';
|
|
28
|
+
import { normalizeAnnexBStartCodes, processNALUsForEncryption } from './naluUtils';
|
|
29
29
|
import { identifySifPayload } from './sifPayload';
|
|
30
30
|
|
|
31
31
|
export const encryptionEnabledMap: Map<string, boolean> = new Map();
|
|
@@ -490,7 +490,16 @@ export class FrameCryptor extends BaseFrameCryptor {
|
|
|
490
490
|
let frameInfo = this.getUnencryptedBytes(encodedFrame);
|
|
491
491
|
|
|
492
492
|
// Thіs is not encrypted and contains the VP8 payload descriptor or the Opus TOC byte.
|
|
493
|
-
|
|
493
|
+
let frameHeader: NonSharedUint8Array = new Uint8Array(
|
|
494
|
+
encodedFrame.data,
|
|
495
|
+
0,
|
|
496
|
+
frameInfo.unencryptedBytes,
|
|
497
|
+
);
|
|
498
|
+
if (frameInfo.requiresNALUProcessing) {
|
|
499
|
+
// The receiver's depacketizer rebuilds NALUs with 4-byte start codes, so authenticate (and
|
|
500
|
+
// send) the header in that form or decryption fails for encoders emitting 3-byte ones.
|
|
501
|
+
frameHeader = normalizeAnnexBStartCodes(frameHeader);
|
|
502
|
+
}
|
|
494
503
|
|
|
495
504
|
// Frame trailer contains the R|IV_LENGTH and key index
|
|
496
505
|
const frameTrailer = new Uint8Array(2);
|
|
@@ -510,7 +519,7 @@ export class FrameCryptor extends BaseFrameCryptor {
|
|
|
510
519
|
{
|
|
511
520
|
name: ENCRYPTION_ALGORITHM,
|
|
512
521
|
iv,
|
|
513
|
-
additionalData:
|
|
522
|
+
additionalData: frameHeader,
|
|
514
523
|
},
|
|
515
524
|
encryptionKey,
|
|
516
525
|
new Uint8Array(encodedFrame.data, frameInfo.unencryptedBytes),
|
|
@@ -906,7 +915,7 @@ export class FrameCryptor extends BaseFrameCryptor {
|
|
|
906
915
|
};
|
|
907
916
|
}
|
|
908
917
|
} catch (e) {
|
|
909
|
-
this.logNALUFallbackOnce(fallbackKey, payloadType, e);
|
|
918
|
+
this.logNALUFallbackOnce(fallbackKey, frame, payloadType, detectedCodec, e);
|
|
910
919
|
}
|
|
911
920
|
|
|
912
921
|
// Fallback to VP8 handling
|
|
@@ -919,7 +928,9 @@ export class FrameCryptor extends BaseFrameCryptor {
|
|
|
919
928
|
*/
|
|
920
929
|
private logNALUFallbackOnce(
|
|
921
930
|
fallbackKey: string,
|
|
931
|
+
frame: RTCEncodedVideoFrame,
|
|
922
932
|
payloadType: number | undefined,
|
|
933
|
+
detectedCodec: VideoCodec | undefined,
|
|
923
934
|
error: unknown,
|
|
924
935
|
) {
|
|
925
936
|
if (this.loggedNALUFallbacks.has(fallbackKey)) {
|
|
@@ -929,6 +940,12 @@ export class FrameCryptor extends BaseFrameCryptor {
|
|
|
929
940
|
workerLogger.warn('NALU processing failed, falling back to VP8 handling', {
|
|
930
941
|
error,
|
|
931
942
|
payloadType,
|
|
943
|
+
detectedCodec,
|
|
944
|
+
frameType: frame.type,
|
|
945
|
+
byteLength: frame.data.byteLength,
|
|
946
|
+
firstBytes: Array.from(new Uint8Array(frame.data, 0, Math.min(16, frame.data.byteLength)))
|
|
947
|
+
.map((b) => b.toString(16).padStart(2, '0'))
|
|
948
|
+
.join(' '),
|
|
932
949
|
...this.logContext,
|
|
933
950
|
});
|
|
934
951
|
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { normalizeAnnexBStartCodes, processNALUsForEncryption } from './naluUtils';
|
|
3
|
+
|
|
4
|
+
/** Annex-B frame: a 4-byte start code before every NALU. */
|
|
5
|
+
function annexB(...nalus: number[][]) {
|
|
6
|
+
return Uint8Array.from(nalus.flatMap((nalu) => [0x00, 0x00, 0x00, 0x01, ...nalu]));
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
// h264: 0x06 SEI, 0x65 IDR slice, 0x67 SPS. h265: 0x26 IDR_W_RADL slice.
|
|
10
|
+
// An h264 SEI byte (0x06) reads as h265 TSA_R, which is why the SEI leads here.
|
|
11
|
+
// findNALUIndices stops at stream.length - 3. A trailing NALU with fewer than 4 payload
|
|
12
|
+
// bytes is never indexed, so its fixture tests nothing. Interior NALUs are fine at any
|
|
13
|
+
// length, because the next start code ends them.
|
|
14
|
+
const h264KeyFrame = annexB([0x06, 0x11, 0x22], [0x65, 0x33, 0x44, 0x55, 0x66]);
|
|
15
|
+
const h265KeyFrame = annexB([0x26, 0x11, 0x22], [0x02, 0x33, 0x44, 0x55, 0x66]);
|
|
16
|
+
const noSliceFrame = annexB([0x67, 0x11, 0x22, 0x33, 0x44, 0x55]);
|
|
17
|
+
// 0x25 is a slice under both: h264 &0x1f = 5 IDR, h265 (b >> 1) & 0x3f = 18 BLA_W_RADL.
|
|
18
|
+
const ambiguousFrame = annexB([0x25, 0x11, 0x22, 0x33, 0x44, 0x55]);
|
|
19
|
+
|
|
20
|
+
describe('normalizeAnnexBStartCodes', () => {
|
|
21
|
+
it('rewrites 3-byte start codes to 4-byte start codes', () => {
|
|
22
|
+
// prettier-ignore
|
|
23
|
+
const input = new Uint8Array([
|
|
24
|
+
0x00, 0x00, 0x01, 0x67, 0x64, // SPS
|
|
25
|
+
0x00, 0x00, 0x01, 0x68, 0xee, // PPS
|
|
26
|
+
0x00, 0x00, 0x01, 0x65, 0x88, // IDR slice
|
|
27
|
+
]);
|
|
28
|
+
// prettier-ignore
|
|
29
|
+
const expected = new Uint8Array([
|
|
30
|
+
0x00, 0x00, 0x00, 0x01, 0x67, 0x64,
|
|
31
|
+
0x00, 0x00, 0x00, 0x01, 0x68, 0xee,
|
|
32
|
+
0x00, 0x00, 0x00, 0x01, 0x65, 0x88,
|
|
33
|
+
]);
|
|
34
|
+
|
|
35
|
+
expect(normalizeAnnexBStartCodes(input)).toEqual(expected);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('rewrites only the 3-byte start codes when mixed with 4-byte ones', () => {
|
|
39
|
+
// prettier-ignore
|
|
40
|
+
const input = new Uint8Array([
|
|
41
|
+
0x00, 0x00, 0x00, 0x01, 0x67, 0x64,
|
|
42
|
+
0x00, 0x00, 0x01, 0x68, 0xee,
|
|
43
|
+
0x00, 0x00, 0x00, 0x01, 0x06, 0x05,
|
|
44
|
+
0x00, 0x00, 0x01, 0x65, 0x88,
|
|
45
|
+
]);
|
|
46
|
+
// prettier-ignore
|
|
47
|
+
const expected = new Uint8Array([
|
|
48
|
+
0x00, 0x00, 0x00, 0x01, 0x67, 0x64,
|
|
49
|
+
0x00, 0x00, 0x00, 0x01, 0x68, 0xee,
|
|
50
|
+
0x00, 0x00, 0x00, 0x01, 0x06, 0x05,
|
|
51
|
+
0x00, 0x00, 0x00, 0x01, 0x65, 0x88,
|
|
52
|
+
]);
|
|
53
|
+
|
|
54
|
+
expect(normalizeAnnexBStartCodes(input)).toEqual(expected);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('returns the same instance when all start codes are already 4-byte', () => {
|
|
58
|
+
// prettier-ignore
|
|
59
|
+
const input = new Uint8Array([
|
|
60
|
+
0x00, 0x00, 0x00, 0x01, 0x67, 0x64,
|
|
61
|
+
0x00, 0x00, 0x00, 0x01, 0x65, 0x88,
|
|
62
|
+
]);
|
|
63
|
+
|
|
64
|
+
expect(normalizeAnnexBStartCodes(input)).toBe(input);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('does not add a zero when a start code already has extra leading zeros', () => {
|
|
68
|
+
const input = new Uint8Array([0x00, 0x00, 0x00, 0x00, 0x01, 0x65, 0x88]);
|
|
69
|
+
|
|
70
|
+
expect(normalizeAnnexBStartCodes(input)).toBe(input);
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
describe('processNALUsForEncryption', () => {
|
|
75
|
+
it('slices at the h264 slice NALU even when seeded with h265', () => {
|
|
76
|
+
// The whole point: the h265 seed must not win, or the offset lands at 6 (inside the
|
|
77
|
+
// SEI) and the slice header gets encrypted with no error raised.
|
|
78
|
+
expect(processNALUsForEncryption(h264KeyFrame, 'h265')).toEqual({
|
|
79
|
+
unencryptedBytes: 13,
|
|
80
|
+
detectedCodec: 'h264',
|
|
81
|
+
requiresNALUProcessing: true,
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('reads an h264 frame whose SEI precedes the slice as h264', () => {
|
|
86
|
+
expect(processNALUsForEncryption(h264KeyFrame)).toEqual({
|
|
87
|
+
unencryptedBytes: 13,
|
|
88
|
+
detectedCodec: 'h264',
|
|
89
|
+
requiresNALUProcessing: true,
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('still reads an h265 frame as h265', () => {
|
|
94
|
+
expect(processNALUsForEncryption(h265KeyFrame)).toEqual({
|
|
95
|
+
unencryptedBytes: 6,
|
|
96
|
+
detectedCodec: 'h265',
|
|
97
|
+
requiresNALUProcessing: true,
|
|
98
|
+
});
|
|
99
|
+
expect(processNALUsForEncryption(h265KeyFrame, 'h264')).toEqual({
|
|
100
|
+
unencryptedBytes: 6,
|
|
101
|
+
detectedCodec: 'h265',
|
|
102
|
+
requiresNALUProcessing: true,
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('resolves a byte that is a slice under both codecs as h264', () => {
|
|
107
|
+
// Only the h264-first ordering decides this one. Swap the two loops back and it reads h265.
|
|
108
|
+
expect(processNALUsForEncryption(ambiguousFrame)).toEqual({
|
|
109
|
+
unencryptedBytes: 6,
|
|
110
|
+
detectedCodec: 'h264',
|
|
111
|
+
requiresNALUProcessing: true,
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('throws with a seed, and returns quietly without one, when no slice NALU is present', () => {
|
|
116
|
+
expect(() => processNALUsForEncryption(noSliceFrame, 'h264')).toThrow('Could not find NALU');
|
|
117
|
+
expect(processNALUsForEncryption(noSliceFrame)).toEqual({
|
|
118
|
+
unencryptedBytes: 0,
|
|
119
|
+
detectedCodec: 'unknown',
|
|
120
|
+
requiresNALUProcessing: false,
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
});
|
|
@@ -202,8 +202,14 @@ export interface NALUProcessingResult {
|
|
|
202
202
|
* @returns Detected codec type
|
|
203
203
|
*/
|
|
204
204
|
function detectCodecFromNALUs(data: NonSharedUint8Array, naluIndices: number[]): DetectedCodec {
|
|
205
|
+
// h264 runs to completion first. Several non-slice h264 NALUs read as h265 slice types
|
|
206
|
+
// (AUD 0x09 -> STSA_N, SEI 0x06 -> TSA_R), so interleaving the two checks lets one of
|
|
207
|
+
// those outrank the real h264 slice that follows it. The reverse cannot happen: h265
|
|
208
|
+
// header bytes are even at nuh_layer_id 0, so they never mask to an h264 slice type.
|
|
205
209
|
for (const naluIndex of naluIndices) {
|
|
206
210
|
if (isH264SliceNALU(parseH264NALUType(data[naluIndex]))) return 'h264';
|
|
211
|
+
}
|
|
212
|
+
for (const naluIndex of naluIndices) {
|
|
207
213
|
if (isH265SliceNALU(parseH265NALUType(data[naluIndex]))) return 'h265';
|
|
208
214
|
}
|
|
209
215
|
return 'unknown';
|
|
@@ -306,7 +312,7 @@ function findNALUIndices(stream: NonSharedUint8Array): number[] {
|
|
|
306
312
|
/**
|
|
307
313
|
* Process NALU data for frame encryption, detecting codec and finding unencrypted bytes
|
|
308
314
|
* @param data Frame data
|
|
309
|
-
* @param knownCodec
|
|
315
|
+
* @param knownCodec Codec from other sources, used only when the bytes are inconclusive (optional)
|
|
310
316
|
* @returns NALU processing result
|
|
311
317
|
*/
|
|
312
318
|
export function processNALUsForEncryption(
|
|
@@ -314,7 +320,16 @@ export function processNALUsForEncryption(
|
|
|
314
320
|
knownCodec?: 'h264' | 'h265',
|
|
315
321
|
): NALUProcessingResult {
|
|
316
322
|
const naluIndices = findNALUIndices(data);
|
|
317
|
-
|
|
323
|
+
// The bytes outrank knownCodec, which can be the codec the client asked for rather than the
|
|
324
|
+
// one that was negotiated. Parsing the wrong one mis-slices without raising anything: the
|
|
325
|
+
// offset lands before the real slice header, so the header itself gets encrypted.
|
|
326
|
+
// That holds in one direction only. An h264 access unit carrying no slice NALU but carrying
|
|
327
|
+
// an AUD or SEI that reads as an h265 slice now slices there instead of throwing. No real
|
|
328
|
+
// WebRTC access unit can reach it, because every one of them carries a type 1 or 5 slice.
|
|
329
|
+
let detectedCodec = detectCodecFromNALUs(data, naluIndices);
|
|
330
|
+
if (detectedCodec === 'unknown') {
|
|
331
|
+
detectedCodec = knownCodec ?? 'unknown';
|
|
332
|
+
}
|
|
318
333
|
|
|
319
334
|
if (detectedCodec === 'unknown') {
|
|
320
335
|
return { unencryptedBytes: 0, detectedCodec, requiresNALUProcessing: false };
|
|
@@ -327,3 +342,43 @@ export function processNALUsForEncryption(
|
|
|
327
342
|
|
|
328
343
|
return { unencryptedBytes, detectedCodec, requiresNALUProcessing: true };
|
|
329
344
|
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Rewrite every 3-byte Annex B start code (00 00 01) in `data` to the 4-byte form (00 00 00 01).
|
|
348
|
+
*
|
|
349
|
+
* WebRTC's H.264/H.265 depacketizer always reconstructs NALUs with 4-byte start codes, regardless
|
|
350
|
+
* of what the encoder emitted. Some encoders (e.g. Intel Quick Sync) emit 3-byte start codes, so
|
|
351
|
+
* the unencrypted header a receiver sees would differ from what the sender authenticated as
|
|
352
|
+
* AES-GCM additionalData. Normalizing on the sender makes both sides agree.
|
|
353
|
+
*
|
|
354
|
+
* Returns `data` unchanged (same instance) when it contains no 3-byte start codes.
|
|
355
|
+
*/
|
|
356
|
+
export function normalizeAnnexBStartCodes(data: NonSharedUint8Array): NonSharedUint8Array {
|
|
357
|
+
const shortStartCodes: number[] = [];
|
|
358
|
+
for (let i = 0; i + 2 < data.length;) {
|
|
359
|
+
if (data[i + 2] === 0) {
|
|
360
|
+
i += 1;
|
|
361
|
+
continue;
|
|
362
|
+
}
|
|
363
|
+
if (data[i + 2] === 1 && data[i] === 0 && data[i + 1] === 0 && (i === 0 || data[i - 1] !== 0)) {
|
|
364
|
+
shortStartCodes.push(i);
|
|
365
|
+
}
|
|
366
|
+
// data[i + 2] is non-zero, so no start code can begin at i + 1 or i + 2 either.
|
|
367
|
+
i += 3;
|
|
368
|
+
}
|
|
369
|
+
if (shortStartCodes.length === 0) {
|
|
370
|
+
return data;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// The new array is zero-filled, so the one-byte gap left before each short start code becomes
|
|
374
|
+
// the inserted leading 00.
|
|
375
|
+
const result = new Uint8Array(data.length + shortStartCodes.length);
|
|
376
|
+
let from = 0;
|
|
377
|
+
for (let inserted = 0; inserted < shortStartCodes.length; inserted++) {
|
|
378
|
+
const to = shortStartCodes[inserted];
|
|
379
|
+
result.set(data.subarray(from, to), from + inserted);
|
|
380
|
+
from = to;
|
|
381
|
+
}
|
|
382
|
+
result.set(data.subarray(from), from + shortStartCodes.length);
|
|
383
|
+
return result;
|
|
384
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -56,6 +56,7 @@ import {
|
|
|
56
56
|
isSVCCodec,
|
|
57
57
|
isVideoCodec,
|
|
58
58
|
isVideoTrack,
|
|
59
|
+
releaseEmptyAudioStreamTrack,
|
|
59
60
|
supportsAV1,
|
|
60
61
|
supportsAdaptiveStream,
|
|
61
62
|
supportsAudioOutputSelection,
|
|
@@ -145,6 +146,7 @@ export {
|
|
|
145
146
|
getEmptyVideoStreamTrack,
|
|
146
147
|
getLogger,
|
|
147
148
|
isBrowserSupported,
|
|
149
|
+
releaseEmptyAudioStreamTrack,
|
|
148
150
|
setLogExtension,
|
|
149
151
|
setLogLevel,
|
|
150
152
|
supportsAV1,
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import DefaultReconnectPolicy from './DefaultReconnectPolicy';
|
|
3
|
+
|
|
4
|
+
const delayFor = (policy: DefaultReconnectPolicy, retryCount: number, random: number) => {
|
|
5
|
+
vi.spyOn(Math, 'random').mockReturnValue(random);
|
|
6
|
+
return policy.nextRetryDelayInMs({ retryCount, elapsedMs: 0 });
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
describe('DefaultReconnectPolicy', () => {
|
|
10
|
+
afterEach(() => {
|
|
11
|
+
vi.restoreAllMocks();
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it('spreads the first retry over a window instead of retrying immediately', () => {
|
|
15
|
+
const policy = new DefaultReconnectPolicy();
|
|
16
|
+
expect(delayFor(policy, 0, 0)).toBe(0);
|
|
17
|
+
expect(delayFor(policy, 0, 0.5)).toBe(250);
|
|
18
|
+
expect(delayFor(policy, 0, 0.999)).toBeGreaterThan(450);
|
|
19
|
+
expect(delayFor(policy, 0, 0.999)).toBeLessThan(500);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('scales the jitter of later retries with the delay', () => {
|
|
23
|
+
const policy = new DefaultReconnectPolicy();
|
|
24
|
+
expect(delayFor(policy, 1, 0)).toBe(150);
|
|
25
|
+
expect(delayFor(policy, 1, 0.5)).toBe(300);
|
|
26
|
+
expect(delayFor(policy, 2, 0)).toBe(600);
|
|
27
|
+
expect(delayFor(policy, 2, 0.5)).toBe(1200);
|
|
28
|
+
expect(delayFor(policy, 9, 0)).toBe(3500);
|
|
29
|
+
expect(delayFor(policy, 9, 0.999)).toBeLessThan(10500);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('returns a different delay for each client on every retry', () => {
|
|
33
|
+
const policy = new DefaultReconnectPolicy();
|
|
34
|
+
for (let retryCount = 0; retryCount < 10; retryCount++) {
|
|
35
|
+
expect(delayFor(policy, retryCount, 0.1)).not.toBe(delayFor(policy, retryCount, 0.9));
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('returns null once the retries are exhausted', () => {
|
|
40
|
+
const policy = new DefaultReconnectPolicy([0, 100]);
|
|
41
|
+
expect(policy.nextRetryDelayInMs({ retryCount: 2, elapsedMs: 0 })).toBeNull();
|
|
42
|
+
expect(
|
|
43
|
+
new DefaultReconnectPolicy().nextRetryDelayInMs({ retryCount: 10, elapsedMs: 0 }),
|
|
44
|
+
).toBeNull();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('applies jitter to custom delays', () => {
|
|
48
|
+
const policy = new DefaultReconnectPolicy([1000]);
|
|
49
|
+
expect(delayFor(policy, 0, 0.5)).toBe(1000);
|
|
50
|
+
expect(delayFor(policy, 0, 0)).toBe(500);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('keeps a zero delay at a later index immediate', () => {
|
|
54
|
+
const policy = new DefaultReconnectPolicy([100, 0]);
|
|
55
|
+
expect(delayFor(policy, 1, 0.5)).toBe(0);
|
|
56
|
+
expect(delayFor(policy, 1, 0.999)).toBe(0);
|
|
57
|
+
});
|
|
58
|
+
});
|
|
@@ -2,6 +2,12 @@ import type { ReconnectContext, ReconnectPolicy } from './ReconnectPolicy';
|
|
|
2
2
|
|
|
3
3
|
const maxRetryDelay = 7000;
|
|
4
4
|
|
|
5
|
+
/**
|
|
6
|
+
* Upper bound of the random delay added to the first retry, whose base delay is 0. It keeps a
|
|
7
|
+
* brief network blip recovering quickly while spreading out clients that disconnect together.
|
|
8
|
+
*/
|
|
9
|
+
const maxFirstRetryJitterInMs = 500;
|
|
10
|
+
|
|
5
11
|
const DEFAULT_RETRY_DELAYS_IN_MS = [
|
|
6
12
|
0,
|
|
7
13
|
300,
|
|
@@ -26,9 +32,15 @@ class DefaultReconnectPolicy implements ReconnectPolicy {
|
|
|
26
32
|
if (context.retryCount >= this._retryDelays.length) return null;
|
|
27
33
|
|
|
28
34
|
const retryDelay = this._retryDelays[context.retryCount];
|
|
29
|
-
if (context.retryCount <= 1) return retryDelay;
|
|
30
35
|
|
|
31
|
-
|
|
36
|
+
// The first retry has no base delay to scale, so it gets a fixed window instead. A zero at a
|
|
37
|
+
// later index is an explicit request for an immediate retry and passes through the formula as zero.
|
|
38
|
+
if (context.retryCount === 0 && retryDelay === 0) {
|
|
39
|
+
return Math.random() * maxFirstRetryJitterInMs;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Jitter scales with the delay (+/-50%) so clients spread out further on later retries.
|
|
43
|
+
return Math.round(retryDelay * (0.5 + Math.random()));
|
|
32
44
|
}
|
|
33
45
|
}
|
|
34
46
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { type MediaDescription, parse } from 'sdp-transform';
|
|
2
|
-
import { describe, expect, it } from 'vitest';
|
|
3
|
-
import {
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
3
|
+
import PCTransport, {
|
|
4
4
|
applyVideoStartBitrate,
|
|
5
5
|
computeConnectionStartBitrate,
|
|
6
6
|
computeTrackStartBitrate,
|
|
@@ -156,6 +156,148 @@ describe('video start bitrate', () => {
|
|
|
156
156
|
expect(computeTrackStartBitrate({ cid: 'c', codec: 'VP8', maxbr: 300 })).toBe(270);
|
|
157
157
|
});
|
|
158
158
|
|
|
159
|
+
it('ramps the cap down with connection setup time', () => {
|
|
160
|
+
// A 3 Mbps camera target, so only the cap moves.
|
|
161
|
+
const camera = (ms: number) =>
|
|
162
|
+
computeTrackStartBitrate({ cid: 'c', codec: 'VP8', maxbr: 3_000 }, ms);
|
|
163
|
+
|
|
164
|
+
expect(camera(0)).toBe(1_000);
|
|
165
|
+
expect(camera(471)).toBe(1_000); // unshaped baseline
|
|
166
|
+
expect(camera(1_273)).toBe(1_000); // 1 Mbps link median
|
|
167
|
+
expect(camera(1_500)).toBe(1_000); // the fast anchor
|
|
168
|
+
expect(camera(1_724)).toBe(922); // 1 Mbps link slow attempt
|
|
169
|
+
expect(camera(2_334)).toBe(708); // 500 kbps link median
|
|
170
|
+
expect(camera(2_500)).toBe(650); // midpoint of the ramp
|
|
171
|
+
expect(camera(3_093)).toBe(442); // 300 kbps link fastest attempt
|
|
172
|
+
expect(camera(3_500)).toBe(300); // the slow anchor
|
|
173
|
+
expect(camera(18_131)).toBe(300); // anything slower stays at the floor
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it('keeps the 1 Mbps ceiling without a setup time', () => {
|
|
177
|
+
expect(computeTrackStartBitrate({ cid: 'c', codec: 'VP8', maxbr: 3_000 }, undefined)).toBe(
|
|
178
|
+
1_000,
|
|
179
|
+
);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it('still applies 90% of the target under the ramp, and hints at the floor', () => {
|
|
183
|
+
expect(computeTrackStartBitrate({ cid: 'c', codec: 'VP8', maxbr: 500 }, 2_500)).toBe(450);
|
|
184
|
+
expect(computeTrackStartBitrate({ cid: 'c', codec: 'VP8', maxbr: 300 }, 3_500)).toBe(270);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it('leaves screen share uncapped however slow the setup', () => {
|
|
188
|
+
const screenShare = (ms: number) =>
|
|
189
|
+
computeTrackStartBitrate({ cid: 's', codec: 'VP8', maxbr: 3_000, isScreenShare: true }, ms);
|
|
190
|
+
|
|
191
|
+
expect(screenShare(1_273)).toBe(2_700);
|
|
192
|
+
expect(screenShare(2_500)).toBe(2_700);
|
|
193
|
+
expect(screenShare(4_061)).toBe(2_700);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it('applies the setup time to the connection-level value', () => {
|
|
197
|
+
const { media } = parse(TWO_VIDEO_SECTIONS);
|
|
198
|
+
const camera = { cid: 'camera-cid', codec: 'VP8', maxbr: 3_000 };
|
|
199
|
+
const screenShare = { cid: 'other-track', codec: 'VP8', maxbr: 3_000, isScreenShare: true };
|
|
200
|
+
|
|
201
|
+
expect(computeConnectionStartBitrate(media, [camera], 1_000)).toBe(1_000);
|
|
202
|
+
expect(computeConnectionStartBitrate(media, [camera], 2_500)).toBe(650);
|
|
203
|
+
// The uncapped screen share still wins the connection-level max on a slow setup.
|
|
204
|
+
expect(computeConnectionStartBitrate(media, [camera, screenShare], 2_500)).toBe(2_700);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
describe('browser effective connection type', () => {
|
|
208
|
+
const withEffectiveType = (effectiveType: string | undefined) =>
|
|
209
|
+
vi.stubGlobal('navigator', { connection: effectiveType ? { effectiveType } : undefined });
|
|
210
|
+
|
|
211
|
+
afterEach(() => {
|
|
212
|
+
vi.unstubAllGlobals();
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
// A 3 Mbps camera target, so only the cap moves.
|
|
216
|
+
const camera = (setupTimeMs?: number) =>
|
|
217
|
+
computeTrackStartBitrate({ cid: 'c', codec: 'VP8', maxbr: 3_000 }, setupTimeMs);
|
|
218
|
+
|
|
219
|
+
it('caps 2g and slow-2g at 300 kbps and 3g at 500 kbps', () => {
|
|
220
|
+
withEffectiveType('slow-2g');
|
|
221
|
+
expect(camera(500)).toBe(300);
|
|
222
|
+
withEffectiveType('2g');
|
|
223
|
+
expect(camera(500)).toBe(300);
|
|
224
|
+
withEffectiveType('3g');
|
|
225
|
+
expect(camera(500)).toBe(500);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it('gives no cap for 4g or without the API', () => {
|
|
229
|
+
withEffectiveType('4g');
|
|
230
|
+
expect(camera(500)).toBe(1_000);
|
|
231
|
+
withEffectiveType(undefined);
|
|
232
|
+
expect(camera(500)).toBe(1_000);
|
|
233
|
+
vi.stubGlobal('navigator', undefined);
|
|
234
|
+
expect(camera(500)).toBe(1_000);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it('takes the lower of the setup time cap and the effective type cap', () => {
|
|
238
|
+
withEffectiveType('3g');
|
|
239
|
+
expect(camera(2_500)).toBe(500); // setup time alone allows 650
|
|
240
|
+
expect(camera(3_093)).toBe(442); // setup time is lower
|
|
241
|
+
expect(camera()).toBe(500);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it('still applies 90% of a target below the cap', () => {
|
|
245
|
+
withEffectiveType('3g');
|
|
246
|
+
expect(computeTrackStartBitrate({ cid: 'c', codec: 'VP8', maxbr: 450 }, 500)).toBe(405);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it('leaves screen share uncapped', () => {
|
|
250
|
+
withEffectiveType('slow-2g');
|
|
251
|
+
expect(
|
|
252
|
+
computeTrackStartBitrate(
|
|
253
|
+
{ cid: 's', codec: 'VP8', maxbr: 3_000, isScreenShare: true },
|
|
254
|
+
500,
|
|
255
|
+
),
|
|
256
|
+
).toBe(2_700);
|
|
257
|
+
});
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
describe('connection setup time while still connecting', () => {
|
|
261
|
+
beforeEach(() => {
|
|
262
|
+
// The constructor creates its peer connection eagerly; only its event handlers are set.
|
|
263
|
+
vi.stubGlobal('RTCPeerConnection', class {});
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
afterEach(() => {
|
|
267
|
+
vi.restoreAllMocks();
|
|
268
|
+
vi.unstubAllGlobals();
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
const estimate = (transport: PCTransport) =>
|
|
272
|
+
(
|
|
273
|
+
transport as unknown as { connectionSetupTimeEstimateMs(): number | undefined }
|
|
274
|
+
).connectionSetupTimeEstimateMs();
|
|
275
|
+
|
|
276
|
+
it('uses the time elapsed since the join started before the setup time is measured', () => {
|
|
277
|
+
const transport = new PCTransport();
|
|
278
|
+
vi.spyOn(performance, 'now').mockReturnValue(10_000);
|
|
279
|
+
transport.setConnectionStartedAt(7_500);
|
|
280
|
+
|
|
281
|
+
expect(estimate(transport)).toBe(2_500);
|
|
282
|
+
expect(
|
|
283
|
+
computeTrackStartBitrate({ cid: 'c', codec: 'VP8', maxbr: 3_000 }, estimate(transport)),
|
|
284
|
+
).toBe(650);
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
it('prefers the measured setup time once known', () => {
|
|
288
|
+
const transport = new PCTransport();
|
|
289
|
+
vi.spyOn(performance, 'now').mockReturnValue(10_000);
|
|
290
|
+
transport.setConnectionStartedAt(7_500);
|
|
291
|
+
transport.setConnectionSetupTime(3_600);
|
|
292
|
+
|
|
293
|
+
expect(estimate(transport)).toBe(3_600);
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
it('has no estimate without any timing', () => {
|
|
297
|
+
expect(estimate(new PCTransport())).toBeUndefined();
|
|
298
|
+
});
|
|
299
|
+
});
|
|
300
|
+
|
|
159
301
|
it('uses one connection-level value: the largest hint across video sections', () => {
|
|
160
302
|
const { media } = parse(TWO_VIDEO_SECTIONS);
|
|
161
303
|
|