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
package/src/room/RTCEngine.ts
CHANGED
|
@@ -227,6 +227,8 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
|
|
|
227
227
|
|
|
228
228
|
private attemptingReconnect: boolean = false;
|
|
229
229
|
|
|
230
|
+
private reconnectAbortController?: AbortController;
|
|
231
|
+
|
|
230
232
|
private reconnectPolicy: ReconnectPolicy;
|
|
231
233
|
|
|
232
234
|
private reconnectTimeout?: ReturnType<typeof setTimeout>;
|
|
@@ -236,6 +238,14 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
|
|
|
236
238
|
/** keeps track of how often an initial join connection has been tried */
|
|
237
239
|
private joinAttempts: number = 0;
|
|
238
240
|
|
|
241
|
+
/**
|
|
242
|
+
* When the current join attempt began (`performance.now()`), taken at the top of `join` before
|
|
243
|
+
* the signal connection or the peer connections exist. Every attempt (a join retry, or the join
|
|
244
|
+
* inside a full restart) times itself from scratch. Consumed when the primary transport first
|
|
245
|
+
* connects, to hand the publisher its connection setup time; resumes never set it.
|
|
246
|
+
*/
|
|
247
|
+
private joinStartedAt?: number;
|
|
248
|
+
|
|
239
249
|
/** specifies how often an initial join connection is allowed to retry */
|
|
240
250
|
private maxJoinAttempts: number = 1;
|
|
241
251
|
|
|
@@ -352,6 +362,7 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
|
|
|
352
362
|
this.maxJoinAttempts = opts.maxRetries;
|
|
353
363
|
try {
|
|
354
364
|
this.joinAttempts += 1;
|
|
365
|
+
this.joinStartedAt = performance.now();
|
|
355
366
|
|
|
356
367
|
this.setupSignalClientCallbacks();
|
|
357
368
|
// Whether the initial publisher offer is bundled with the join request. Computed once and
|
|
@@ -392,6 +403,10 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
|
|
|
392
403
|
useV0Path,
|
|
393
404
|
offerProto,
|
|
394
405
|
);
|
|
406
|
+
if (abortSignal?.aborted) {
|
|
407
|
+
this.log.info('join cancelled after the signal join response');
|
|
408
|
+
throw ConnectionError.cancelled('Connection aborted');
|
|
409
|
+
}
|
|
395
410
|
this._isClosed = false;
|
|
396
411
|
this.latestJoinResponse = joinResponse;
|
|
397
412
|
this.participantSid = joinResponse.participant?.sid;
|
|
@@ -405,6 +420,10 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
|
|
|
405
420
|
// the server's ICE servers and topology, then negotiate separately rather than bundling
|
|
406
421
|
// the offer with the join.
|
|
407
422
|
await this.configure(joinResponse, !useV0Path);
|
|
423
|
+
if (abortSignal?.aborted) {
|
|
424
|
+
this.log.info('join cancelled after configuring peer connections');
|
|
425
|
+
throw ConnectionError.cancelled('Connection aborted');
|
|
426
|
+
}
|
|
408
427
|
if (!useV0Path) {
|
|
409
428
|
// The V1 first offer must carry the media layout so Firefox binds receive decoders for
|
|
410
429
|
// subscribed tracks — without it, subscribed audio/video arrive as RTP but
|
|
@@ -420,6 +439,12 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
|
|
|
420
439
|
}
|
|
421
440
|
}
|
|
422
441
|
|
|
442
|
+
if (this.joinStartedAt !== undefined) {
|
|
443
|
+
// Lets a video published before the transports connect be seeded from the time elapsed
|
|
444
|
+
// so far, rather than delaying its offer until the setup time is measured.
|
|
445
|
+
this.pcManager?.publisher.setConnectionStartedAt(this.joinStartedAt);
|
|
446
|
+
}
|
|
447
|
+
|
|
423
448
|
this.registerOnLineListener();
|
|
424
449
|
this.clientConfiguration = joinResponse.clientConfiguration;
|
|
425
450
|
this.emit(EngineEvent.SignalConnected, joinResponse);
|
|
@@ -463,6 +488,8 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
|
|
|
463
488
|
* having given up on reconnecting.
|
|
464
489
|
*/
|
|
465
490
|
async close(reason?: string) {
|
|
491
|
+
this.reconnectAbortController?.abort();
|
|
492
|
+
this.reconnectAbortController = undefined;
|
|
466
493
|
const unlock = await this.closingLock.lock();
|
|
467
494
|
if (this.isClosed) {
|
|
468
495
|
unlock();
|
|
@@ -471,6 +498,7 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
|
|
|
471
498
|
try {
|
|
472
499
|
this._isClosed = true;
|
|
473
500
|
this.joinAttempts = 0;
|
|
501
|
+
this.joinStartedAt = undefined;
|
|
474
502
|
this.emit(EngineEvent.Closing);
|
|
475
503
|
this.removeAllListeners();
|
|
476
504
|
this.deregisterOnLineListener();
|
|
@@ -629,6 +657,16 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
|
|
|
629
657
|
this.publisherConnectionPromise = undefined;
|
|
630
658
|
}
|
|
631
659
|
if (connectionState === PCTransportState.CONNECTED) {
|
|
660
|
+
if (this.joinStartedAt !== undefined) {
|
|
661
|
+
// Connection setup time is the only network signal there is before the first video
|
|
662
|
+
// offer. Set synchronously, so it is in place before `room.connect()` resolves and the
|
|
663
|
+
// app can publish. A full restart joins again and builds new transports, so it is
|
|
664
|
+
// measured again; a resume keeps its estimator and never restarts the timer.
|
|
665
|
+
const setupTimeMs = Math.round(performance.now() - this.joinStartedAt);
|
|
666
|
+
this.joinStartedAt = undefined;
|
|
667
|
+
this.log.info(`connection setup took ${setupTimeMs} ms`);
|
|
668
|
+
this.pcManager?.publisher.setConnectionSetupTime(setupTimeMs);
|
|
669
|
+
}
|
|
632
670
|
const shouldEmit = this.pcState === PCState.New;
|
|
633
671
|
this.pcState = PCState.Connected;
|
|
634
672
|
if (shouldEmit) {
|
|
@@ -899,6 +937,12 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
|
|
|
899
937
|
return;
|
|
900
938
|
}
|
|
901
939
|
|
|
940
|
+
if (this.dataChannels.hasPublisherChannels) {
|
|
941
|
+
// Replacing live channels (the Safari null-id path in resumeConnection): the new ones start
|
|
942
|
+
// out connecting, so drop the memoized readiness check and make the next sender wait for
|
|
943
|
+
// them to open.
|
|
944
|
+
this.publisherConnectionPromise = undefined;
|
|
945
|
+
}
|
|
902
946
|
this.dataChannels.createPublisherChannels(this.pcManager);
|
|
903
947
|
}
|
|
904
948
|
|
|
@@ -1221,7 +1265,14 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
|
|
|
1221
1265
|
return;
|
|
1222
1266
|
}
|
|
1223
1267
|
if (connection === leaveReconnect) {
|
|
1224
|
-
|
|
1268
|
+
// The server asked for a reconnect, so skip any remaining backoff. Use the policy's first
|
|
1269
|
+
// retry delay instead of 0 so that clients told to reconnect together don't all reconnect
|
|
1270
|
+
// at the same instant.
|
|
1271
|
+
delay = this.getNextRetryDelay({ elapsedMs: duration, retryCount: 0 });
|
|
1272
|
+
if (delay === null) {
|
|
1273
|
+
disconnect(duration);
|
|
1274
|
+
return;
|
|
1275
|
+
}
|
|
1225
1276
|
}
|
|
1226
1277
|
|
|
1227
1278
|
this.log.debug(`reconnecting in ${delay}ms`);
|
|
@@ -1338,16 +1389,26 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
|
|
|
1338
1389
|
this.fullReconnectOnNext = false;
|
|
1339
1390
|
|
|
1340
1391
|
let succeeded = false;
|
|
1392
|
+
const abortController = new AbortController();
|
|
1393
|
+
this.reconnectAbortController = abortController;
|
|
1341
1394
|
try {
|
|
1342
1395
|
this.attemptingReconnect = true;
|
|
1343
1396
|
if (fullReconnect) {
|
|
1344
|
-
await this.restartConnection();
|
|
1397
|
+
await this.restartConnection(abortController.signal);
|
|
1345
1398
|
} else {
|
|
1346
1399
|
await this.resumeConnection(reason);
|
|
1347
1400
|
}
|
|
1401
|
+
if (abortController.signal.aborted) {
|
|
1402
|
+
this.log.info('reconnect attempt cancelled, not marking it as succeeded');
|
|
1403
|
+
return;
|
|
1404
|
+
}
|
|
1348
1405
|
this.clearPendingReconnect();
|
|
1349
1406
|
succeeded = true;
|
|
1350
1407
|
} catch (e) {
|
|
1408
|
+
if (abortController.signal.aborted) {
|
|
1409
|
+
this.log.info('reconnect attempt cancelled, not retrying', { error: e });
|
|
1410
|
+
return;
|
|
1411
|
+
}
|
|
1351
1412
|
this.reconnectAttempts += 1;
|
|
1352
1413
|
let recoverable = true;
|
|
1353
1414
|
if (e instanceof UnexpectedConnectionState) {
|
|
@@ -1376,6 +1437,10 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
|
|
|
1376
1437
|
);
|
|
1377
1438
|
}
|
|
1378
1439
|
} finally {
|
|
1440
|
+
// close() may already have cleared this (or a later attempt replaced it); only release our own
|
|
1441
|
+
if (this.reconnectAbortController === abortController) {
|
|
1442
|
+
this.reconnectAbortController = undefined;
|
|
1443
|
+
}
|
|
1379
1444
|
this.attemptingReconnect = false;
|
|
1380
1445
|
|
|
1381
1446
|
// A full reconnect requested while this attempt was running (e.g. a `RECONNECT` leave
|
|
@@ -1399,8 +1464,12 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
|
|
|
1399
1464
|
return null;
|
|
1400
1465
|
}
|
|
1401
1466
|
|
|
1402
|
-
private async restartConnection(regionUrl?: string) {
|
|
1467
|
+
private async restartConnection(abortSignal: AbortSignal, regionUrl?: string) {
|
|
1403
1468
|
try {
|
|
1469
|
+
if (abortSignal.aborted) {
|
|
1470
|
+
this.log.info('full reconnect cancelled before starting', { regionUrl });
|
|
1471
|
+
return;
|
|
1472
|
+
}
|
|
1404
1473
|
if (!this.url || !this.token) {
|
|
1405
1474
|
// permanent failure, don't attempt reconnection
|
|
1406
1475
|
throw new UnexpectedConnectionState('could not reconnect, url or token not saved');
|
|
@@ -1414,6 +1483,12 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
|
|
|
1414
1483
|
}
|
|
1415
1484
|
await this.cleanupPeerConnections();
|
|
1416
1485
|
await this.cleanupClient();
|
|
1486
|
+
if (abortSignal.aborted) {
|
|
1487
|
+
this.log.info('full reconnect cancelled after cleaning up the previous session', {
|
|
1488
|
+
regionUrl,
|
|
1489
|
+
});
|
|
1490
|
+
return;
|
|
1491
|
+
}
|
|
1417
1492
|
|
|
1418
1493
|
let joinResponse: JoinResponse;
|
|
1419
1494
|
try {
|
|
@@ -1427,7 +1502,7 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
|
|
|
1427
1502
|
regionUrl ?? this.url,
|
|
1428
1503
|
this.token,
|
|
1429
1504
|
this.signalOpts,
|
|
1430
|
-
|
|
1505
|
+
abortSignal,
|
|
1431
1506
|
!this.options.singlePeerConnection,
|
|
1432
1507
|
)
|
|
1433
1508
|
).joinResponse;
|
|
@@ -1437,6 +1512,10 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
|
|
|
1437
1512
|
}
|
|
1438
1513
|
throw new SignalReconnectError();
|
|
1439
1514
|
}
|
|
1515
|
+
if (abortSignal.aborted) {
|
|
1516
|
+
this.log.info('full reconnect cancelled after the signal join response', { regionUrl });
|
|
1517
|
+
return;
|
|
1518
|
+
}
|
|
1440
1519
|
|
|
1441
1520
|
if (this.shouldFailNext) {
|
|
1442
1521
|
this.shouldFailNext = false;
|
|
@@ -1447,6 +1526,10 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
|
|
|
1447
1526
|
this.emit(EngineEvent.SignalRestarted, joinResponse);
|
|
1448
1527
|
|
|
1449
1528
|
await this.waitForPCReconnected();
|
|
1529
|
+
if (abortSignal.aborted) {
|
|
1530
|
+
this.log.info('full reconnect cancelled after peer connections reconnected', { regionUrl });
|
|
1531
|
+
return;
|
|
1532
|
+
}
|
|
1450
1533
|
|
|
1451
1534
|
// re-check signal connection state before setting engine as resumed
|
|
1452
1535
|
if (this.client.currentState !== SignalConnectionState.CONNECTED) {
|
|
@@ -1457,9 +1540,17 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
|
|
|
1457
1540
|
// reconnect success
|
|
1458
1541
|
this.emit(EngineEvent.Restarted);
|
|
1459
1542
|
} catch (error) {
|
|
1460
|
-
|
|
1543
|
+
if (abortSignal.aborted) {
|
|
1544
|
+
this.log.info('full reconnect cancelled after a failed attempt', { regionUrl });
|
|
1545
|
+
return;
|
|
1546
|
+
}
|
|
1547
|
+
const nextRegionUrl = await this.regionStrategy?.getNextUrl(abortSignal);
|
|
1548
|
+
if (abortSignal.aborted) {
|
|
1549
|
+
this.log.info('full reconnect cancelled during region selection', { regionUrl });
|
|
1550
|
+
return;
|
|
1551
|
+
}
|
|
1461
1552
|
if (nextRegionUrl) {
|
|
1462
|
-
await this.restartConnection(nextRegionUrl);
|
|
1553
|
+
await this.restartConnection(abortSignal, nextRegionUrl);
|
|
1463
1554
|
return;
|
|
1464
1555
|
} else {
|
|
1465
1556
|
// no more regions to try (or we're not on cloud)
|
|
@@ -1693,7 +1784,11 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
|
|
|
1693
1784
|
* {@link FlowControlledDataChannel.waitForHeadroomWithLock}.
|
|
1694
1785
|
*/
|
|
1695
1786
|
async waitForBufferHeadroom(kind: DataChannelKind) {
|
|
1696
|
-
|
|
1787
|
+
const dc = this.dataChannelForKind(kind);
|
|
1788
|
+
if (!dc) {
|
|
1789
|
+
throw new UnexpectedConnectionState(`DataChannel not found, kind: ${kind}`);
|
|
1790
|
+
}
|
|
1791
|
+
return this.flowControlFor(kind).waitForHeadroomWithLock(dc);
|
|
1697
1792
|
}
|
|
1698
1793
|
|
|
1699
1794
|
/**
|
package/src/room/Room.test.ts
CHANGED
|
@@ -347,3 +347,119 @@ describe('stream state updates', () => {
|
|
|
347
347
|
expect(participantEvents).not.toHaveBeenCalled();
|
|
348
348
|
});
|
|
349
349
|
});
|
|
350
|
+
|
|
351
|
+
describe('audio context ownership', () => {
|
|
352
|
+
class StubAudioContext {
|
|
353
|
+
state: AudioContextState = 'running';
|
|
354
|
+
|
|
355
|
+
closed = false;
|
|
356
|
+
|
|
357
|
+
async close() {
|
|
358
|
+
this.closed = true;
|
|
359
|
+
this.state = 'closed';
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
async resume() {
|
|
363
|
+
this.state = 'running';
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
const created: StubAudioContext[] = [];
|
|
368
|
+
|
|
369
|
+
function stubAudioContext() {
|
|
370
|
+
created.length = 0;
|
|
371
|
+
vi.stubGlobal(
|
|
372
|
+
'AudioContext',
|
|
373
|
+
class extends StubAudioContext {
|
|
374
|
+
constructor() {
|
|
375
|
+
super();
|
|
376
|
+
created.push(this);
|
|
377
|
+
}
|
|
378
|
+
},
|
|
379
|
+
);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/** `acquireAudioContext` and `releaseAudioContext` are private */
|
|
383
|
+
function internals(room: Room) {
|
|
384
|
+
return room as unknown as {
|
|
385
|
+
acquireAudioContext: () => Promise<void>;
|
|
386
|
+
releaseAudioContext: (retained?: unknown[]) => Promise<void>;
|
|
387
|
+
audioContext?: StubAudioContext;
|
|
388
|
+
ownsAudioContext: boolean;
|
|
389
|
+
localParticipant: { setAudioContext: (ctx: AudioContext | undefined) => Promise<void> };
|
|
390
|
+
};
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
afterEach(() => {
|
|
394
|
+
vi.unstubAllGlobals();
|
|
395
|
+
vi.restoreAllMocks();
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
it('closes a context it created for a webAudioMix object that supplies none', async () => {
|
|
399
|
+
stubAudioContext();
|
|
400
|
+
const room = internals(new Room({ webAudioMix: {} }));
|
|
401
|
+
|
|
402
|
+
await room.acquireAudioContext();
|
|
403
|
+
expect(created).toHaveLength(1);
|
|
404
|
+
expect(room.ownsAudioContext).toBe(true);
|
|
405
|
+
expect(room.audioContext).toBe(created[0]);
|
|
406
|
+
|
|
407
|
+
await room.releaseAudioContext();
|
|
408
|
+
expect(created[0].closed).toBe(true);
|
|
409
|
+
expect(room.audioContext).toBeUndefined();
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
it('leaves a supplied context open', async () => {
|
|
413
|
+
stubAudioContext();
|
|
414
|
+
const provided = new StubAudioContext();
|
|
415
|
+
const room = internals(
|
|
416
|
+
new Room({ webAudioMix: { audioContext: provided as unknown as AudioContext } }),
|
|
417
|
+
);
|
|
418
|
+
|
|
419
|
+
await room.acquireAudioContext();
|
|
420
|
+
expect(room.ownsAudioContext).toBe(false);
|
|
421
|
+
expect(created).toHaveLength(0);
|
|
422
|
+
|
|
423
|
+
await room.releaseAudioContext();
|
|
424
|
+
expect(provided.closed).toBe(false);
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
it('closes the context even when a retained track fails to detach', async () => {
|
|
428
|
+
stubAudioContext();
|
|
429
|
+
const room = internals(new Room({ webAudioMix: {} }));
|
|
430
|
+
await room.acquireAudioContext();
|
|
431
|
+
|
|
432
|
+
const retained = { setAudioContext: () => Promise.reject(new Error('detach failed')) };
|
|
433
|
+
await room.releaseAudioContext([retained]);
|
|
434
|
+
|
|
435
|
+
expect(created[0].closed).toBe(true);
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
it('does not build a context for an acquire queued behind a release', async () => {
|
|
439
|
+
stubAudioContext();
|
|
440
|
+
const room = internals(new Room({ webAudioMix: {} }));
|
|
441
|
+
await room.acquireAudioContext();
|
|
442
|
+
expect(created).toHaveLength(1);
|
|
443
|
+
|
|
444
|
+
// `startAudio` reads `audioContextReleased` before it waits on the mutex, so its acquire can
|
|
445
|
+
// already be queued behind a disconnect by the time the flag is set
|
|
446
|
+
const released = room.releaseAudioContext();
|
|
447
|
+
const acquired = room.acquireAudioContext();
|
|
448
|
+
await Promise.all([released, acquired]);
|
|
449
|
+
|
|
450
|
+
expect(created).toHaveLength(1);
|
|
451
|
+
expect(created[0].closed).toBe(true);
|
|
452
|
+
expect(room.audioContext).toBeUndefined();
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
it('closes the outgoing context even when a participant fails to move off it', async () => {
|
|
456
|
+
stubAudioContext();
|
|
457
|
+
const room = internals(new Room({ webAudioMix: {} }));
|
|
458
|
+
await room.acquireAudioContext();
|
|
459
|
+
|
|
460
|
+
vi.spyOn(room.localParticipant, 'setAudioContext').mockRejectedValue(new Error('move failed'));
|
|
461
|
+
await room.releaseAudioContext();
|
|
462
|
+
|
|
463
|
+
expect(created[0].closed).toBe(true);
|
|
464
|
+
});
|
|
465
|
+
});
|