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.
Files changed (116) hide show
  1. package/dist/livekit-client.e2ee.worker.js +1 -1
  2. package/dist/livekit-client.e2ee.worker.js.map +1 -1
  3. package/dist/livekit-client.e2ee.worker.mjs +69 -7
  4. package/dist/livekit-client.e2ee.worker.mjs.map +1 -1
  5. package/dist/livekit-client.esm.mjs +1133 -289
  6. package/dist/livekit-client.esm.mjs.map +1 -1
  7. package/dist/livekit-client.fm.worker.js.map +1 -1
  8. package/dist/livekit-client.fm.worker.mjs.map +1 -1
  9. package/dist/livekit-client.umd.js +1 -1
  10. package/dist/livekit-client.umd.js.map +1 -1
  11. package/dist/src/api/SignalClient.d.ts.map +1 -1
  12. package/dist/src/api/WebSocketStream.d.ts +1 -0
  13. package/dist/src/api/WebSocketStream.d.ts.map +1 -1
  14. package/dist/src/api/utils.d.ts +1 -1
  15. package/dist/src/api/utils.d.ts.map +1 -1
  16. package/dist/src/e2ee/E2eeManager.d.ts.map +1 -1
  17. package/dist/src/e2ee/worker/FrameCryptor.d.ts.map +1 -1
  18. package/dist/src/e2ee/worker/naluUtils.d.ts +12 -1
  19. package/dist/src/e2ee/worker/naluUtils.d.ts.map +1 -1
  20. package/dist/src/index.d.ts +2 -2
  21. package/dist/src/index.d.ts.map +1 -1
  22. package/dist/src/room/DefaultReconnectPolicy.d.ts.map +1 -1
  23. package/dist/src/room/PCTransport.d.ts +26 -4
  24. package/dist/src/room/PCTransport.d.ts.map +1 -1
  25. package/dist/src/room/RTCEngine.d.ts +8 -0
  26. package/dist/src/room/RTCEngine.d.ts.map +1 -1
  27. package/dist/src/room/Room.d.ts +53 -0
  28. package/dist/src/room/Room.d.ts.map +1 -1
  29. package/dist/src/room/data-channel/DataChannelManager.d.ts +9 -8
  30. package/dist/src/room/data-channel/DataChannelManager.d.ts.map +1 -1
  31. package/dist/src/room/data-channel/FlowControlledDataChannel.d.ts +25 -12
  32. package/dist/src/room/data-channel/FlowControlledDataChannel.d.ts.map +1 -1
  33. package/dist/src/room/data-channel/LossyDataChannel.d.ts.map +1 -1
  34. package/dist/src/room/data-channel/ReliableDataChannel.d.ts.map +1 -1
  35. package/dist/src/room/data-stream/incoming/IncomingDataStreamManager.d.ts.map +1 -1
  36. package/dist/src/room/participant/LocalParticipant.d.ts +4 -0
  37. package/dist/src/room/participant/LocalParticipant.d.ts.map +1 -1
  38. package/dist/src/room/participant/Participant.d.ts +1 -1
  39. package/dist/src/room/participant/Participant.d.ts.map +1 -1
  40. package/dist/src/room/rpc/utils.d.ts.map +1 -1
  41. package/dist/src/room/track/LocalAudioTrack.d.ts +24 -1
  42. package/dist/src/room/track/LocalAudioTrack.d.ts.map +1 -1
  43. package/dist/src/room/track/LocalTrack.d.ts +18 -0
  44. package/dist/src/room/track/LocalTrack.d.ts.map +1 -1
  45. package/dist/src/room/track/LocalVideoTrack.d.ts +7 -0
  46. package/dist/src/room/track/LocalVideoTrack.d.ts.map +1 -1
  47. package/dist/src/room/track/RemoteAudioTrack.d.ts +6 -0
  48. package/dist/src/room/track/RemoteAudioTrack.d.ts.map +1 -1
  49. package/dist/src/room/track/processor/types.d.ts +11 -0
  50. package/dist/src/room/track/processor/types.d.ts.map +1 -1
  51. package/dist/src/room/track/utils.d.ts.map +1 -1
  52. package/dist/src/room/utils.d.ts +28 -1
  53. package/dist/src/room/utils.d.ts.map +1 -1
  54. package/dist/ts4.2/api/WebSocketStream.d.ts +1 -0
  55. package/dist/ts4.2/api/utils.d.ts +1 -1
  56. package/dist/ts4.2/e2ee/worker/naluUtils.d.ts +12 -1
  57. package/dist/ts4.2/index.d.ts +2 -2
  58. package/dist/ts4.2/room/PCTransport.d.ts +26 -4
  59. package/dist/ts4.2/room/RTCEngine.d.ts +8 -0
  60. package/dist/ts4.2/room/Room.d.ts +53 -0
  61. package/dist/ts4.2/room/data-channel/DataChannelManager.d.ts +9 -8
  62. package/dist/ts4.2/room/data-channel/FlowControlledDataChannel.d.ts +25 -12
  63. package/dist/ts4.2/room/participant/LocalParticipant.d.ts +4 -0
  64. package/dist/ts4.2/room/participant/Participant.d.ts +1 -1
  65. package/dist/ts4.2/room/track/LocalAudioTrack.d.ts +24 -1
  66. package/dist/ts4.2/room/track/LocalTrack.d.ts +18 -0
  67. package/dist/ts4.2/room/track/LocalVideoTrack.d.ts +7 -0
  68. package/dist/ts4.2/room/track/RemoteAudioTrack.d.ts +6 -0
  69. package/dist/ts4.2/room/track/processor/types.d.ts +11 -0
  70. package/dist/ts4.2/room/utils.d.ts +28 -1
  71. package/package.json +1 -1
  72. package/src/api/SignalClient.test.ts +234 -0
  73. package/src/api/SignalClient.ts +58 -23
  74. package/src/api/WebSocketStream.test.ts +11 -0
  75. package/src/api/WebSocketStream.ts +22 -1
  76. package/src/api/utils.ts +1 -1
  77. package/src/e2ee/E2eeManager.test.ts +43 -0
  78. package/src/e2ee/E2eeManager.ts +6 -2
  79. package/src/e2ee/worker/FrameCryptor.test.ts +212 -90
  80. package/src/e2ee/worker/FrameCryptor.ts +21 -4
  81. package/src/e2ee/worker/naluUtils.test.ts +123 -0
  82. package/src/e2ee/worker/naluUtils.ts +57 -2
  83. package/src/index.ts +2 -0
  84. package/src/room/DefaultReconnectPolicy.test.ts +58 -0
  85. package/src/room/DefaultReconnectPolicy.ts +14 -2
  86. package/src/room/PCTransport.test.ts +144 -2
  87. package/src/room/PCTransport.ts +151 -9
  88. package/src/room/RTCEngine.test.ts +234 -2
  89. package/src/room/RTCEngine.ts +102 -7
  90. package/src/room/Room.test.ts +116 -0
  91. package/src/room/Room.ts +286 -74
  92. package/src/room/data-channel/DataChannelManager.test.ts +24 -7
  93. package/src/room/data-channel/DataChannelManager.ts +43 -38
  94. package/src/room/data-channel/FlowControlledDataChannel.test.ts +60 -18
  95. package/src/room/data-channel/FlowControlledDataChannel.ts +40 -22
  96. package/src/room/data-channel/LossyDataChannel.test.ts +38 -0
  97. package/src/room/data-channel/LossyDataChannel.ts +10 -1
  98. package/src/room/data-channel/ReliableDataChannel.test.ts +72 -0
  99. package/src/room/data-channel/ReliableDataChannel.ts +16 -5
  100. package/src/room/data-stream/incoming/IncomingDataStreamManager.test.ts +31 -0
  101. package/src/room/data-stream/incoming/IncomingDataStreamManager.ts +11 -0
  102. package/src/room/participant/LocalParticipant.test.ts +136 -1
  103. package/src/room/participant/LocalParticipant.ts +66 -17
  104. package/src/room/participant/Participant.ts +6 -3
  105. package/src/room/participant/restartOnDefaultAudioDevice.test.ts +177 -0
  106. package/src/room/rpc/utils.test.ts +32 -0
  107. package/src/room/rpc/utils.ts +7 -0
  108. package/src/room/track/LocalAudioTrack.ts +101 -10
  109. package/src/room/track/LocalTrack.ts +70 -13
  110. package/src/room/track/LocalVideoTrack.ts +7 -0
  111. package/src/room/track/RemoteAudioTrack.ts +56 -11
  112. package/src/room/track/processor/types.ts +11 -0
  113. package/src/room/track/utils.test.ts +5 -0
  114. package/src/room/track/utils.ts +29 -5
  115. package/src/room/utils.test.ts +126 -0
  116. package/src/room/utils.ts +84 -21
package/src/room/Room.ts CHANGED
@@ -130,6 +130,7 @@ import {
130
130
  isSafariBased,
131
131
  isWeb,
132
132
  numberToBigInt,
133
+ releaseEmptyAudioStreamTrack,
133
134
  sleep,
134
135
  supportsSetSinkId,
135
136
  toHttpUrl,
@@ -148,6 +149,30 @@ export enum ConnectionState {
148
149
  const CONNECTION_RECONCILE_FREQUENCY_MS = 4 * 1000;
149
150
  const STATS_LOG_FREQUENCY_MS = 30 * 1000;
150
151
 
152
+ const DUMMY_AUDIO_ELEMENT_ID = 'livekit-dummy-audio-el';
153
+
154
+ /**
155
+ * One silent audio element serves every room on the page, so it is reference counted: a room
156
+ * disconnecting must not pull it out from under the rooms still playing through it. The element
157
+ * and its track go away once the last room hands its reference back.
158
+ */
159
+ let sharedDummyAudioElement:
160
+ | { element: HTMLAudioElement; stream: MediaStream; track: MediaStreamTrack; refCount: number }
161
+ | undefined;
162
+
163
+ function createSharedDummyAudioElement() {
164
+ const element = document.createElement('audio');
165
+ element.id = DUMMY_AUDIO_ELEMENT_ID;
166
+ element.autoplay = true;
167
+ element.hidden = true;
168
+ const track = getEmptyAudioStreamTrack();
169
+ track.enabled = true;
170
+ const stream = new MediaStream([track]);
171
+ element.srcObject = stream;
172
+ document.body.append(element);
173
+ return { element, stream, track, refCount: 0 };
174
+ }
175
+
151
176
  /**
152
177
  * In LiveKit, a room is the logical grouping for a list of participants.
153
178
  * Participants in a room can publish tracks, and subscribe to others' tracks.
@@ -195,6 +220,31 @@ class Room extends (EventEmitter as new () => TypedEmitter<RoomEventCallbacks>)
195
220
 
196
221
  private audioContext?: AudioContext;
197
222
 
223
+ /**
224
+ * whether `audioContext` was created by the SDK and therefore has to be closed by it.
225
+ * A context handed in through `webAudioMix` is owned by the application instead.
226
+ */
227
+ private ownsAudioContext = false;
228
+
229
+ /**
230
+ * set once the audio context has been released as part of a disconnect. Guards against
231
+ * creating a new context outside of a connection lifecycle, where nothing would close it again.
232
+ */
233
+ private audioContextReleased = false;
234
+
235
+ /**
236
+ * serialises `acquireAudioContext` against `releaseAudioContext`. Both await per-track graph
237
+ * rebuilds, so without it a reconnect landing inside a disconnect's teardown leaves the two
238
+ * racing to set the participants' context, and the loser's value is the one that sticks.
239
+ */
240
+ private audioContextMutex: Mutex = new Mutex();
241
+
242
+ /** tears down the iOS dummy audio element, set while one is in place. See `startAudio` */
243
+ private releaseDummyAudioElement?: () => void;
244
+
245
+ /** silent audio tracks this room acquired, handed back to the shared source on disconnect */
246
+ private acquiredEmptyAudioTracks = new Set<MediaStreamTrack>();
247
+
198
248
  /** used for aborting pending connections to a LiveKit server */
199
249
  private abortController?: AbortController;
200
250
 
@@ -1073,7 +1123,9 @@ class Room extends (EventEmitter as new () => TypedEmitter<RoomEventCallbacks>)
1073
1123
  this.engine.setRegionStrategy(this.createRegionStrategy());
1074
1124
  }
1075
1125
 
1076
- this.acquireAudioContext();
1126
+ this.acquireAudioContext(true).catch((error) =>
1127
+ this.log.warn('Could not acquire audio context', { ...this.logContext, error }),
1128
+ );
1077
1129
 
1078
1130
  this.connOptions = { ...roomConnectOptionDefaults, ...opts } as InternalRoomConnectOptions;
1079
1131
 
@@ -1345,6 +1397,93 @@ class Room extends (EventEmitter as new () => TypedEmitter<RoomEventCallbacks>)
1345
1397
  await this.disconnect();
1346
1398
  };
1347
1399
 
1400
+ /**
1401
+ * iOS blocks audio element playback if
1402
+ * - user is not publishing audio themselves and
1403
+ * - no other audio source is playing
1404
+ *
1405
+ * as a workaround, we create an audio element with an empty track, so that
1406
+ * silent audio is always playing
1407
+ *
1408
+ * The element is shared with any other room on the page and handed back on disconnect, so it
1409
+ * outlives whichever room happened to create it.
1410
+ */
1411
+ private acquireDummyAudioElement(): HTMLAudioElement {
1412
+ if (!sharedDummyAudioElement) {
1413
+ sharedDummyAudioElement = createSharedDummyAudioElement();
1414
+ }
1415
+ const shared = sharedDummyAudioElement;
1416
+ if (this.releaseDummyAudioElement) {
1417
+ // this room already holds a reference, a second one would never be handed back
1418
+ return shared.element;
1419
+ }
1420
+ shared.refCount += 1;
1421
+
1422
+ // the listener is per room rather than shared, so that the room it resumes playback for is
1423
+ // still connected, and so that it goes away with the room that installed it
1424
+ const handleVisibilityChange = () => {
1425
+ // set the srcObject to null on page hide in order to prevent lock screen controls to show up for it
1426
+ shared.element.srcObject = document.hidden ? null : shared.stream;
1427
+ if (!document.hidden) {
1428
+ this.log.debug(
1429
+ 'page visible again, triggering startAudio to resume playback and update playback status',
1430
+ );
1431
+ this.startAudio().catch((error) =>
1432
+ this.log.warn('Could not restart audio on page becoming visible', {
1433
+ ...this.logContext,
1434
+ error,
1435
+ }),
1436
+ );
1437
+ }
1438
+ };
1439
+ document.addEventListener('visibilitychange', handleVisibilityChange);
1440
+
1441
+ this.releaseDummyAudioElement = () => {
1442
+ this.releaseDummyAudioElement = undefined;
1443
+ document.removeEventListener('visibilitychange', handleVisibilityChange);
1444
+ shared.refCount -= 1;
1445
+ if (shared.refCount > 0) {
1446
+ return;
1447
+ }
1448
+ shared.element.srcObject = null;
1449
+ shared.element.remove();
1450
+ sharedDummyAudioElement = undefined;
1451
+ releaseEmptyAudioStreamTrack(shared.track).catch((error) =>
1452
+ this.log.warn('Could not release the dummy audio element track', {
1453
+ ...this.logContext,
1454
+ error,
1455
+ }),
1456
+ );
1457
+ };
1458
+
1459
+ return shared.element;
1460
+ }
1461
+
1462
+ /**
1463
+ * Obtains a silent audio track from the shared source and keeps track of it, so that it can be
1464
+ * handed back on disconnect and the shared `AudioContext` behind it can be closed again.
1465
+ */
1466
+ private acquireEmptyAudioStreamTrack(): MediaStreamTrack {
1467
+ const track = getEmptyAudioStreamTrack();
1468
+ this.acquiredEmptyAudioTracks.add(track);
1469
+ return track;
1470
+ }
1471
+
1472
+ private releaseAcquiredEmptyAudioTrack(track: MediaStreamTrack) {
1473
+ if (!this.acquiredEmptyAudioTracks.delete(track)) {
1474
+ return;
1475
+ }
1476
+ releaseEmptyAudioStreamTrack(track).catch((error) =>
1477
+ this.log.warn('Could not release empty audio stream track', { ...this.logContext, error }),
1478
+ );
1479
+ }
1480
+
1481
+ private releaseAcquiredEmptyAudioTracks() {
1482
+ for (const track of this.acquiredEmptyAudioTracks) {
1483
+ this.releaseAcquiredEmptyAudioTrack(track);
1484
+ }
1485
+ }
1486
+
1348
1487
  /**
1349
1488
  * Browsers have different policies regarding audio playback. Most requiring
1350
1489
  * some form of user interaction (click/tap/etc).
@@ -1355,46 +1494,8 @@ class Room extends (EventEmitter as new () => TypedEmitter<RoomEventCallbacks>)
1355
1494
  startAudio = async () => {
1356
1495
  const elements: Array<HTMLMediaElement> = [];
1357
1496
  const browser = getBrowser();
1358
- if (browser && browser.os === 'iOS') {
1359
- /**
1360
- * iOS blocks audio element playback if
1361
- * - user is not publishing audio themselves and
1362
- * - no other audio source is playing
1363
- *
1364
- * as a workaround, we create an audio element with an empty track, so that
1365
- * silent audio is always playing
1366
- */
1367
- const audioId = 'livekit-dummy-audio-el';
1368
- let dummyAudioEl = document.getElementById(audioId) as HTMLAudioElement | null;
1369
- if (!dummyAudioEl) {
1370
- dummyAudioEl = document.createElement('audio');
1371
- dummyAudioEl.id = audioId;
1372
- dummyAudioEl.autoplay = true;
1373
- dummyAudioEl.hidden = true;
1374
- const track = getEmptyAudioStreamTrack();
1375
- track.enabled = true;
1376
- const stream = new MediaStream([track]);
1377
- dummyAudioEl.srcObject = stream;
1378
- document.addEventListener('visibilitychange', () => {
1379
- if (!dummyAudioEl) {
1380
- return;
1381
- }
1382
- // set the srcObject to null on page hide in order to prevent lock screen controls to show up for it
1383
- dummyAudioEl.srcObject = document.hidden ? null : stream;
1384
- if (!document.hidden) {
1385
- this.log.debug(
1386
- 'page visible again, triggering startAudio to resume playback and update playback status',
1387
- );
1388
- this.startAudio();
1389
- }
1390
- });
1391
- document.body.append(dummyAudioEl);
1392
- this.once(RoomEvent.Disconnected, () => {
1393
- dummyAudioEl?.remove();
1394
- dummyAudioEl = null;
1395
- });
1396
- }
1397
- elements.push(dummyAudioEl);
1497
+ if (browser && browser.os === 'iOS' && !this.audioContextReleased) {
1498
+ elements.push(this.acquireDummyAudioElement());
1398
1499
  }
1399
1500
 
1400
1501
  this.remoteParticipants.forEach((p) => {
@@ -1409,7 +1510,9 @@ class Room extends (EventEmitter as new () => TypedEmitter<RoomEventCallbacks>)
1409
1510
 
1410
1511
  try {
1411
1512
  await Promise.all([
1412
- this.acquireAudioContext(),
1513
+ // once the audio context has been released as part of a disconnect there is nothing left
1514
+ // to unlock, and creating a new context here would leak it as no disconnect will follow
1515
+ ...(this.audioContextReleased ? [] : [this.acquireAudioContext()]),
1413
1516
  ...elements.map((e) => {
1414
1517
  // when webAudioMix is enabled, attached elements are deliberately kept muted by
1415
1518
  // RemoteAudioTrack.attach() and audio is routed through the web audio graph instead.
@@ -1859,6 +1962,14 @@ class Room extends (EventEmitter as new () => TypedEmitter<RoomEventCallbacks>)
1859
1962
  }
1860
1963
 
1861
1964
  try {
1965
+ // audio tracks that outlive this room are about to become unreachable through the local
1966
+ // participant, collect them while we can so they can be detached from the context below
1967
+ const retainedAudioTracks = shouldStopTracks
1968
+ ? []
1969
+ : Array.from(this.localParticipant.audioTrackPublications.values())
1970
+ .map((pub) => pub.track)
1971
+ .filter(isLocalAudioTrack);
1972
+
1862
1973
  this.remoteParticipants.forEach((p) => {
1863
1974
  p.trackPublications.forEach((pub) => {
1864
1975
  p.unpublishTrack(pub.trackSid);
@@ -1901,10 +2012,13 @@ class Room extends (EventEmitter as new () => TypedEmitter<RoomEventCallbacks>)
1901
2012
  this.remoteParticipants.clear();
1902
2013
  this.sidToIdentity.clear();
1903
2014
  this.activeSpeakers = [];
1904
- if (this.audioContext && typeof this.options.webAudioMix === 'boolean') {
1905
- this.audioContext.close();
1906
- this.audioContext = undefined;
1907
- }
2015
+ this.releaseDummyAudioElement?.();
2016
+ this.releaseAcquiredEmptyAudioTracks();
2017
+ // teardown is asynchronous (processors need to release their web audio nodes), but
2018
+ // `handleDisconnect` is not, so let it finish in the background
2019
+ this.releaseAudioContext(retainedAudioTracks).catch((error) =>
2020
+ this.log.warn('Could not release audio context', { ...this.logContext, error }),
2021
+ );
1908
2022
  if (isWeb()) {
1909
2023
  window.removeEventListener('beforeunload', this.onPageLeave);
1910
2024
  window.removeEventListener('pagehide', this.onPageLeave);
@@ -2359,38 +2473,132 @@ class Room extends (EventEmitter as new () => TypedEmitter<RoomEventCallbacks>)
2359
2473
  });
2360
2474
  };
2361
2475
 
2362
- private async acquireAudioContext() {
2363
- if (typeof this.options.webAudioMix !== 'boolean' && this.options.webAudioMix.audioContext) {
2364
- // override audio context with custom audio context if supplied by user
2365
- this.audioContext = this.options.webAudioMix.audioContext;
2366
- } else if (!this.audioContext || this.audioContext.state === 'closed') {
2367
- // by using an AudioContext, it reduces lag on audio elements
2368
- // https://stackoverflow.com/questions/9811429/html5-audio-tag-on-safari-has-a-delay/54119854#54119854
2369
- this.audioContext = getNewAudioContext() ?? undefined;
2370
- }
2476
+ /**
2477
+ * @param forConnect whether this acquisition belongs to a connect, which is the only caller
2478
+ * allowed to put a released room back on an audio context.
2479
+ */
2480
+ private async acquireAudioContext(forConnect = false) {
2481
+ const unlock = await this.audioContextMutex.lock();
2482
+ try {
2483
+ if (this.audioContextReleased && !forConnect) {
2484
+ // a disconnect set the flag and took the lock while this call was queued behind it.
2485
+ // Creating a context now would leave one open on a room nothing tears down again
2486
+ return;
2487
+ }
2488
+ this.audioContextReleased = false;
2489
+ const providedAudioContext =
2490
+ typeof this.options.webAudioMix !== 'boolean'
2491
+ ? this.options.webAudioMix.audioContext
2492
+ : undefined;
2493
+ if (providedAudioContext) {
2494
+ // override audio context with custom audio context if supplied by user
2495
+ await this.setRoomAudioContext(providedAudioContext, false);
2496
+ } else if (!this.audioContext || this.audioContext.state === 'closed') {
2497
+ // by using an AudioContext, it reduces lag on audio elements
2498
+ // https://stackoverflow.com/questions/9811429/html5-audio-tag-on-safari-has-a-delay/54119854#54119854
2499
+ await this.setRoomAudioContext(getNewAudioContext() ?? undefined, true);
2500
+ }
2371
2501
 
2372
- if (this.options.webAudioMix) {
2373
- this.remoteParticipants.forEach((participant) =>
2374
- participant.setAudioContext(this.audioContext),
2375
- );
2502
+ if (this.audioContext && this.audioContext.state === 'suspended') {
2503
+ // for iOS a newly created AudioContext is always in `suspended` state.
2504
+ // we try our best to resume the context here, if that doesn't work, we just continue with regular processing
2505
+ try {
2506
+ await Promise.race([this.audioContext.resume(), sleep(200)]);
2507
+ } catch (e: any) {
2508
+ this.log.warn('Could not resume audio context', { error: e });
2509
+ }
2510
+ }
2511
+
2512
+ const newContextIsRunning = this.audioContext?.state === 'running';
2513
+ if (newContextIsRunning !== this.canPlaybackAudio) {
2514
+ this.audioEnabled = newContextIsRunning;
2515
+ this.emit(RoomEvent.AudioPlaybackStatusChanged, newContextIsRunning);
2516
+ }
2517
+ } finally {
2518
+ unlock();
2376
2519
  }
2520
+ }
2377
2521
 
2378
- this.localParticipant.setAudioContext(this.audioContext);
2522
+ /**
2523
+ * Points the room and its participants at `audioContext`, rebuilding the web audio graph of
2524
+ * every audio track in it. Closes the previous context if the SDK `owned` it.
2525
+ */
2526
+ private async setRoomAudioContext(audioContext: AudioContext | undefined, owned: boolean) {
2527
+ const ownsAudioContext = !!audioContext && owned;
2528
+ if (this.audioContext === audioContext) {
2529
+ this.ownsAudioContext = ownsAudioContext;
2530
+ return;
2531
+ }
2532
+ const contextToClose = this.ownsAudioContext ? this.audioContext : undefined;
2533
+ this.audioContext = audioContext;
2534
+ this.ownsAudioContext = ownsAudioContext;
2379
2535
 
2380
- if (this.audioContext && this.audioContext.state === 'suspended') {
2381
- // for iOS a newly created AudioContext is always in `suspended` state.
2382
- // we try our best to resume the context here, if that doesn't work, we just continue with regular processing
2383
- try {
2384
- await Promise.race([this.audioContext.resume(), sleep(200)]);
2385
- } catch (e: any) {
2386
- this.log.warn('Could not resume audio context', { error: e });
2536
+ try {
2537
+ // every audio track rebuilds its nodes in the new context, which has to happen before the
2538
+ // previous one is closed so that processors can move off it while it is still alive
2539
+ if (this.options.webAudioMix) {
2540
+ await Promise.all(
2541
+ Array.from(this.remoteParticipants.values()).map((participant) =>
2542
+ participant.setAudioContext(audioContext),
2543
+ ),
2544
+ );
2545
+ }
2546
+ await this.localParticipant.setAudioContext(audioContext);
2547
+ } catch (error) {
2548
+ // the context is being abandoned either way, so a track that failed to move off it must
2549
+ // not stop us closing it — that would leak the context this whole path exists to release
2550
+ this.log.warn('Could not move audio tracks to the new audio context', {
2551
+ ...this.logContext,
2552
+ error,
2553
+ });
2554
+ } finally {
2555
+ if (contextToClose && contextToClose.state !== 'closed') {
2556
+ try {
2557
+ await contextToClose.close();
2558
+ } catch (error) {
2559
+ this.log.warn('Could not close audio context', { ...this.logContext, error });
2560
+ }
2387
2561
  }
2388
2562
  }
2563
+ }
2389
2564
 
2390
- const newContextIsRunning = this.audioContext?.state === 'running';
2391
- if (newContextIsRunning !== this.canPlaybackAudio) {
2392
- this.audioEnabled = newContextIsRunning;
2393
- this.emit(RoomEvent.AudioPlaybackStatusChanged, newContextIsRunning);
2565
+ /**
2566
+ * Detaches the web audio graph the SDK built from the current audio context and closes that
2567
+ * context if the SDK created it. A context provided through `webAudioMix` is left untouched.
2568
+ */
2569
+ private async releaseAudioContext(retainedAudioTracks: LocalAudioTrack[] = []) {
2570
+ // both reads happen before the first await, so they describe the room as it was when the
2571
+ // disconnect started rather than after a reconnect has had a chance to run
2572
+ this.audioContextReleased = true;
2573
+ const contextToRelease = this.audioContext;
2574
+ const unlock = await this.audioContextMutex.lock();
2575
+ try {
2576
+ if (!this.audioContextReleased || this.audioContext !== contextToRelease) {
2577
+ // a reconnect acquired a new context while we were waiting, closing it would leave the
2578
+ // reconnected room without one
2579
+ return;
2580
+ }
2581
+ if (!this.ownsAudioContext) {
2582
+ // a provided context stays open, so tracks that outlive the room stay functional on it.
2583
+ // The tracks this room detached have released their nodes during track teardown already.
2584
+ // read under the lock: an acquire queued behind an in-flight release flips this flag
2585
+ return;
2586
+ }
2587
+ try {
2588
+ // tracks that outlive the room aren't reachable through the local participant anymore,
2589
+ // but still hold web audio nodes in the context we're about to close
2590
+ await Promise.all(retainedAudioTracks.map((track) => track.setAudioContext(undefined)));
2591
+ } catch (error) {
2592
+ this.log.warn('Could not detach retained audio tracks from the audio context', {
2593
+ ...this.logContext,
2594
+ error,
2595
+ });
2596
+ }
2597
+ // the participant maps have already been cleared by `handleDisconnect`, so this reaches no
2598
+ // tracks of its own — `retainedAudioTracks` above is what detaches them
2599
+ await this.setRoomAudioContext(undefined, false);
2600
+ } finally {
2601
+ unlock();
2394
2602
  }
2395
2603
  }
2396
2604
 
@@ -2421,7 +2629,11 @@ class Room extends (EventEmitter as new () => TypedEmitter<RoomEventCallbacks>)
2421
2629
  );
2422
2630
  }
2423
2631
  if (this.options.webAudioMix) {
2424
- participant.setAudioContext(this.audioContext);
2632
+ participant
2633
+ .setAudioContext(this.audioContext)
2634
+ .catch((error) =>
2635
+ this.log.warn('Could not set audio context', { ...this.logContext, error }),
2636
+ );
2425
2637
  }
2426
2638
  if (this.options.audioOutput?.deviceId) {
2427
2639
  participant
@@ -2922,7 +3134,7 @@ class Room extends (EventEmitter as new () => TypedEmitter<RoomEventCallbacks>)
2922
3134
  new LocalAudioTrack(
2923
3135
  publishOptions.useRealTracks && navigator.mediaDevices?.getUserMedia
2924
3136
  ? (await navigator.mediaDevices.getUserMedia({ audio: true })).getAudioTracks()[0]
2925
- : getEmptyAudioStreamTrack(),
3137
+ : this.acquireEmptyAudioStreamTrack(),
2926
3138
  undefined,
2927
3139
  false,
2928
3140
  this.audioContext,
@@ -2965,7 +3177,7 @@ class Room extends (EventEmitter as new () => TypedEmitter<RoomEventCallbacks>)
2965
3177
  info.tracks = [...info.tracks, videoTrack];
2966
3178
  }
2967
3179
  if (participantOptions.audio) {
2968
- const dummyTrack = getEmptyAudioStreamTrack();
3180
+ const dummyTrack = this.acquireEmptyAudioStreamTrack();
2969
3181
  const audioTrack = new TrackInfo({
2970
3182
  source: TrackSource.MICROPHONE,
2971
3183
  sid: Math.floor(Math.random() * 10_000).toString(),
@@ -17,15 +17,22 @@ class FakeDataChannel extends EventTarget {
17
17
 
18
18
  onbufferedamountlow: (() => void) | null = null;
19
19
 
20
+ readyState: RTCDataChannelState = 'open';
21
+
20
22
  send = vi.fn();
21
23
 
22
- close = vi.fn();
24
+ close = vi.fn(() => {
25
+ this.readyState = 'closing';
26
+ });
23
27
 
24
28
  constructor(public label: string) {
25
29
  super();
26
30
  }
27
31
  }
28
32
 
33
+ const reliableHandle = (manager: DataChannelManager) =>
34
+ manager.getHandle(DataChannelKind.RELIABLE) as RTCDataChannel;
35
+
29
36
  const tick = () => new Promise<void>((resolve) => setTimeout(resolve, 0));
30
37
 
31
38
  function makeManager(overrides?: Partial<DataChannelManagerOptions>) {
@@ -89,22 +96,32 @@ describe('DataChannelManager', () => {
89
96
  manager.lossy.stopThresholdTuning();
90
97
  });
91
98
 
92
- it('recreating channels rejects waiters parked on the replaced handles', async () => {
93
- const { manager, pcManager, created } = makeManager();
99
+ it('recreating channels closes the replaced handles and rejects waiters parked on them', async () => {
100
+ const { manager, opts, pcManager, created } = makeManager();
94
101
  manager.createPublisherChannels(pcManager);
102
+ const firstGeneration = { ...created };
95
103
 
96
104
  // Park a reliable sender on the first-generation channel.
97
105
  created._reliable.bufferedAmount = 2 * 1024 * 1024;
98
- const parked = manager.reliable.waitForHeadroomWithLock();
106
+ const parked = manager.reliable.waitForHeadroomWithLock(reliableHandle(manager));
99
107
  parked.catch(() => {});
100
108
  await tick();
101
109
 
102
- // Safari null-id path: channels recreated without closing the old ones.
110
+ // Safari null-id path: channels recreated while the old ones are still open.
103
111
  manager.createPublisherChannels(pcManager);
104
112
 
105
113
  await expect(parked).rejects.toBeInstanceOf(UnexpectedConnectionState);
114
+ for (const dc of Object.values(firstGeneration)) {
115
+ expect(dc.close).toHaveBeenCalled();
116
+ // Unhooked before closing, so the deliberate close isn't reported as an unexpected one.
117
+ expect(dc.onclose).toBeNull();
118
+ expect(dc.onbufferedamountlow).toBeNull();
119
+ }
120
+ expect(opts.onChannelClose).not.toHaveBeenCalled();
106
121
  // The gate recovers against the fresh (empty) channel.
107
- await expect(manager.reliable.waitForHeadroomWithLock()).resolves.toBeUndefined();
122
+ await expect(
123
+ manager.reliable.waitForHeadroomWithLock(reliableHandle(manager)),
124
+ ).resolves.toBeUndefined();
108
125
 
109
126
  manager.lossy.stopThresholdTuning();
110
127
  });
@@ -133,7 +150,7 @@ describe('DataChannelManager', () => {
133
150
  manager.adoptSubscriberChannel(sub as unknown as RTCDataChannel);
134
151
 
135
152
  created._reliable.bufferedAmount = 2 * 1024 * 1024;
136
- const parked = manager.reliable.waitForHeadroomWithLock();
153
+ const parked = manager.reliable.waitForHeadroomWithLock(reliableHandle(manager));
137
154
  parked.catch(() => {});
138
155
  await tick();
139
156
 
@@ -24,13 +24,24 @@ export interface DataChannelManagerOptions {
24
24
  onBufferStatusChanged: (kind: DataChannelKind, isLow: boolean) => void;
25
25
  }
26
26
 
27
+ /** Unhooks every handler the manager may have wired onto `dc`, ahead of closing it. */
28
+ function clearHandlers(dc: RTCDataChannel) {
29
+ dc.onbufferedamountlow = null;
30
+ dc.onclose = null;
31
+ dc.onclosing = null;
32
+ dc.onerror = null;
33
+ dc.onmessage = null;
34
+ dc.onopen = null;
35
+ }
36
+
27
37
  /**
28
38
  * Owns the engine's data channels: the three flow-controlled publisher channel wrappers (which
29
39
  * live for the engine's lifetime and have RTCDataChannel handles attached/detached as peer
30
40
  * connections come and go) plus the subscriber-side receive handles adopted by label.
31
41
  *
32
- * Handle turnover goes through {@link FlowControlledDataChannel.attach}/`detach`, which reject
33
- * parked headroom waiters as a built-in — there is no separate invalidation step to forget.
42
+ * Handle turnover goes through {@link FlowControlledDataChannel.attach}/`detach`, which close the
43
+ * released handle and reject parked headroom waiters as a built-in — there is no separate
44
+ * invalidation step to forget.
34
45
  */
35
46
  export class DataChannelManager {
36
47
  readonly reliable: ReliableDataChannel;
@@ -110,17 +121,16 @@ export class DataChannelManager {
110
121
 
111
122
  /**
112
123
  * Creates the three publisher data channels on the given transport, wires their handlers, and
113
- * attaches them to the wrappers — attaching rejects any waiters still parked on replaced
114
- * channel objects.
124
+ * attaches them to the wrappers — attaching closes any replaced channel objects and rejects the
125
+ * waiters still parked on them.
115
126
  */
116
127
  createPublisherChannels(pcManager: PCTransportManager) {
117
- // clear old data channel callbacks if recreate
128
+ // Recreating (the Safari null-id path): attaching the new handles below closes the old ones,
129
+ // so unhook them first — the old channel's close must not report an unexpected close.
118
130
  for (const channel of [this.lossy, this.reliable, this.dataTrack]) {
119
131
  const old = channel.channelHandle;
120
132
  if (old) {
121
- old.onmessage = null;
122
- old.onerror = null;
123
- old.onclose = null;
133
+ clearHandlers(old);
124
134
  }
125
135
  }
126
136
 
@@ -192,41 +202,36 @@ export class DataChannelManager {
192
202
  }
193
203
 
194
204
  /**
195
- * Tears down all channels for a peer-connection cleanup: rejects parked waiters (detach — the
196
- * spec allows `pc.close()` to transition channels to 'closed' without firing events, so waiting
197
- * for browser close events is not an option), strips handlers, closes the handles, and resets
198
- * the reliable session state.
205
+ * Tears down all channels for a peer-connection cleanup: strips handlers, closes the handles,
206
+ * and resets the reliable session state. Detaching the publisher handles closes them and
207
+ * rejects parked waiters (the spec allows `pc.close()` to transition channels to 'closed'
208
+ * without firing events, so waiting for browser close events is not an option).
199
209
  */
200
210
  teardown() {
201
- const dcCleanup = (dc: RTCDataChannel | undefined) => {
202
- if (!dc) {
203
- return;
204
- }
205
-
206
- // Detach the data channel handlers before closing anything. Closing a peer connection tears
207
- // down the SCTP transport, which can dispatch `error`/`close` events on the still-open data
208
- // channels; if our handlers are still attached at that point, handleDataError logs a spurious
209
- // "Unknown DataChannel error" during an otherwise graceful disconnect. Removing the handlers
210
- // before dc.close()/pcManager.close() makes this deterministic regardless of how/when the
211
- // browser dispatches those teardown events. See livekit/client-sdk-js#1953.
212
- dc.onbufferedamountlow = null;
213
- dc.onclose = null;
214
- dc.onclosing = null;
215
- dc.onerror = null;
216
- dc.onmessage = null;
217
- dc.onopen = null;
218
-
219
- dc.close();
220
- };
221
-
222
- for (const channel of [this.lossy, this.reliable, this.dataTrack]) {
211
+ // Detach the data channel handlers before closing anything. Closing a peer connection tears
212
+ // down the SCTP transport, which can dispatch `error`/`close` events on the still-open data
213
+ // channels; if our handlers are still attached at that point, handleDataError logs a spurious
214
+ // "Unknown DataChannel error" during an otherwise graceful disconnect. Removing the handlers
215
+ // before dc.close()/pcManager.close() makes this deterministic regardless of how/when the
216
+ // browser dispatches those teardown events. See livekit/client-sdk-js#1953.
217
+ const publisherDataChannels = [this.lossy, this.reliable, this.dataTrack];
218
+ const subscriberDataChannels = [this.lossySub, this.reliableSub, this.dataTrackSub];
219
+
220
+ // Publisher handles are closed by detach, which also rejects parked waiters.
221
+ for (const channel of publisherDataChannels) {
223
222
  const dc = channel.channelHandle;
223
+ if (dc) {
224
+ clearHandlers(dc);
225
+ }
224
226
  channel.detach('peer connections cleaned up');
225
- dcCleanup(dc);
226
227
  }
227
- dcCleanup(this.lossySub);
228
- dcCleanup(this.reliableSub);
229
- dcCleanup(this.dataTrackSub);
228
+ // Subscriber handles have no wrapper, so close them directly.
229
+ for (const dc of subscriberDataChannels) {
230
+ if (dc) {
231
+ clearHandlers(dc);
232
+ dc.close();
233
+ }
234
+ }
230
235
  this.lossySub = undefined;
231
236
  this.reliableSub = undefined;
232
237
  this.dataTrackSub = undefined;