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
@@ -8708,6 +8708,11 @@ function onWorkerLogLevelChanged(cb) {
8708
8708
  workerLogLevelListeners.delete(cb);
8709
8709
  };
8710
8710
  }const maxRetryDelay = 7000;
8711
+ /**
8712
+ * Upper bound of the random delay added to the first retry, whose base delay is 0. It keeps a
8713
+ * brief network blip recovering quickly while spreading out clients that disconnect together.
8714
+ */
8715
+ const maxFirstRetryJitterInMs = 500;
8711
8716
  const DEFAULT_RETRY_DELAYS_IN_MS = [0, 300, 2 * 2 * 300, 3 * 3 * 300, 4 * 4 * 300, maxRetryDelay, maxRetryDelay, maxRetryDelay, maxRetryDelay, maxRetryDelay];
8712
8717
  class DefaultReconnectPolicy {
8713
8718
  constructor(retryDelays) {
@@ -8716,8 +8721,13 @@ class DefaultReconnectPolicy {
8716
8721
  nextRetryDelayInMs(context) {
8717
8722
  if (context.retryCount >= this._retryDelays.length) return null;
8718
8723
  const retryDelay = this._retryDelays[context.retryCount];
8719
- if (context.retryCount <= 1) return retryDelay;
8720
- return retryDelay + Math.random() * 1000;
8724
+ // The first retry has no base delay to scale, so it gets a fixed window instead. A zero at a
8725
+ // later index is an explicit request for an immediate retry and passes through the formula as zero.
8726
+ if (context.retryCount === 0 && retryDelay === 0) {
8727
+ return Math.random() * maxFirstRetryJitterInMs;
8728
+ }
8729
+ // Jitter scales with the delay (+/-50%) so clients spread out further on later retries.
8730
+ return Math.round(retryDelay * (0.5 + Math.random()));
8721
8731
  }
8722
8732
  }/******************************************************************************
8723
8733
  Copyright (c) Microsoft Corporation.
@@ -12290,7 +12300,7 @@ function getMatch(exp, ua) {
12290
12300
  }
12291
12301
  function getOSVersion(ua) {
12292
12302
  return ua.includes('mac os') ? getMatch(/\(.+?(\d+_\d+(:?_\d+)?)/, ua, 1).replace(/_/g, '.') : undefined;
12293
- }var version$1 = "2.22.4";const version = version$1;
12303
+ }var version$1 = "2.23.0";const version = version$1;
12294
12304
  const protocolVersion = 17;
12295
12305
  /** Initial client protocol. */
12296
12306
  const CLIENT_PROTOCOL_DEFAULT = 0;
@@ -13558,20 +13568,44 @@ function detectSilence(track_1) {
13558
13568
  let timeOffset = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : 200;
13559
13569
  return function* () {
13560
13570
  const ctx = getNewAudioContext();
13561
- if (ctx) {
13562
- const analyser = ctx.createAnalyser();
13571
+ if (!ctx) {
13572
+ return false;
13573
+ }
13574
+ let source;
13575
+ let analyser;
13576
+ try {
13577
+ analyser = ctx.createAnalyser();
13563
13578
  analyser.fftSize = 2048;
13564
13579
  const bufferLength = analyser.frequencyBinCount;
13565
13580
  const dataArray = new Uint8Array(bufferLength);
13566
- const source = ctx.createMediaStreamSource(new MediaStream([track.mediaStreamTrack]));
13581
+ source = ctx.createMediaStreamSource(new MediaStream([track.mediaStreamTrack]));
13567
13582
  source.connect(analyser);
13583
+ // read through a function so that `state` isn't narrowed to its value from before `resume`
13584
+ const isRunning = () => ctx.state === 'running';
13585
+ if (!isRunning()) {
13586
+ try {
13587
+ // `resume` can stay pending indefinitely on a blocked page rather than rejecting, which
13588
+ // would keep the context and its nodes alive past the `finally` below
13589
+ yield Promise.race([ctx.resume(), sleep(200)]);
13590
+ } catch (_a) {
13591
+ // autoplay policies can keep us from resuming the context
13592
+ }
13593
+ if (!isRunning()) {
13594
+ // a context that isn't running doesn't process any audio, so the analyser would only ever
13595
+ // read silence. Report the track as not silent rather than raising a false positive.
13596
+ return false;
13597
+ }
13598
+ }
13568
13599
  yield sleep(timeOffset);
13569
13600
  analyser.getByteTimeDomainData(dataArray);
13570
13601
  const someNoise = dataArray.some(sample => sample !== 128 && sample !== 0);
13571
- ctx.close();
13572
13602
  return !someNoise;
13603
+ } finally {
13604
+ source === null || source === void 0 ? void 0 : source.disconnect();
13605
+ analyser === null || analyser === void 0 ? void 0 : analyser.disconnect();
13606
+ // don't let a failing teardown mask the detection result
13607
+ yield ctx.close().catch(() => {});
13573
13608
  }
13574
- return false;
13575
13609
  }();
13576
13610
  });
13577
13611
  }
@@ -14710,6 +14744,15 @@ function createDummyVideoStreamTrack() {
14710
14744
  return dummyTrack;
14711
14745
  }
14712
14746
  let emptyAudioStreamTrack;
14747
+ let emptyAudioStreamContext;
14748
+ /** tracks handed out by `getEmptyAudioStreamTrack` that haven't been released yet */
14749
+ const outstandingEmptyAudioStreamTracks = new Set();
14750
+ /**
14751
+ * Returns a silent audio track, cloned off a shared `AudioContext` created on first use. Hand the
14752
+ * clone back to `releaseEmptyAudioStreamTrack` rather than stopping it, so the context can close.
14753
+ * A clone that is only stopped stays counted as outstanding, which holds the shared context open
14754
+ * for the lifetime of the page.
14755
+ */
14713
14756
  function getEmptyAudioStreamTrack() {
14714
14757
  if (!emptyAudioStreamTrack) {
14715
14758
  // implementation adapted from https://blog.mozilla.org/webrtc/warm-up-with-replacetrack/
@@ -14725,11 +14768,38 @@ function getEmptyAudioStreamTrack() {
14725
14768
  var _dst$stream$getAudioT2 = _slicedToArray(_dst$stream$getAudioT, 1);
14726
14769
  emptyAudioStreamTrack = _dst$stream$getAudioT2[0];
14727
14770
  if (!emptyAudioStreamTrack) {
14771
+ // don't hold on to a context we can't get a track out of
14772
+ ctx.close().catch(() => {});
14728
14773
  throw Error('Could not get empty media stream audio track');
14729
14774
  }
14730
14775
  emptyAudioStreamTrack.enabled = false;
14776
+ emptyAudioStreamContext = ctx;
14731
14777
  }
14732
- return emptyAudioStreamTrack.clone();
14778
+ const track = emptyAudioStreamTrack.clone();
14779
+ outstandingEmptyAudioStreamTracks.add(track);
14780
+ return track;
14781
+ }
14782
+ /**
14783
+ * Stops a track obtained from `getEmptyAudioStreamTrack` and closes the shared `AudioContext`
14784
+ * backing it once every track it handed out has been released. Does nothing for a track this
14785
+ * module never handed out, or one that has been released already.
14786
+ */
14787
+ function releaseEmptyAudioStreamTrack(track) {
14788
+ return __awaiter(this, void 0, void 0, function* () {
14789
+ if (!outstandingEmptyAudioStreamTracks.delete(track)) {
14790
+ // the track wasn't obtained from `getEmptyAudioStreamTrack`, or has been released before
14791
+ return;
14792
+ }
14793
+ track.stop();
14794
+ if (outstandingEmptyAudioStreamTracks.size > 0) {
14795
+ return;
14796
+ }
14797
+ const context = emptyAudioStreamContext;
14798
+ emptyAudioStreamTrack === null || emptyAudioStreamTrack === void 0 ? void 0 : emptyAudioStreamTrack.stop();
14799
+ emptyAudioStreamTrack = undefined;
14800
+ emptyAudioStreamContext = undefined;
14801
+ yield context === null || context === void 0 ? void 0 : context.close();
14802
+ });
14733
14803
  }
14734
14804
  /** An object that represents a serialized version of a `new Promise((resolve, reject) => {})`
14735
14805
  * constructor. Wait for a promise resolution with `await future.promise` and explicitly resolve or
@@ -14793,11 +14863,19 @@ function createAudioAnalyser(track, options) {
14793
14863
  const volume = Math.sqrt(sum / dataArray.length);
14794
14864
  return volume;
14795
14865
  };
14866
+ let isCleanedUp = false;
14796
14867
  const cleanup = () => __awaiter(this, void 0, void 0, function* () {
14797
- yield audioContext.close();
14868
+ if (isCleanedUp) {
14869
+ // closing an already closed context would reject, make repeated cleanups a no-op instead
14870
+ return;
14871
+ }
14872
+ isCleanedUp = true;
14873
+ mediaStreamSource.disconnect();
14874
+ analyser.disconnect();
14798
14875
  if (opts.cloneTrack) {
14799
14876
  streamTrack.stop();
14800
14877
  }
14878
+ yield audioContext.close();
14801
14879
  });
14802
14880
  return {
14803
14881
  calculateVolume,
@@ -14832,6 +14910,31 @@ function unwrapConstraint(constraint) {
14832
14910
  }
14833
14911
  throw Error('could not unwrap constraint');
14834
14912
  }
14913
+ /**
14914
+ * The getUserMedia rejections that no softer constraint can recover from, because the user or the
14915
+ * document refused capture outright. Retrying one of these would put a second prompt in front of
14916
+ * someone who already said no. The last two names are legacy spellings still reported by older
14917
+ * engines. Matched by `name` rather than with `instanceof`, because these are not reliably distinct
14918
+ * constructors across environments. happy-dom, which the tests run under, aliases them to `Event`.
14919
+ */
14920
+ function isPermissionDeniedError(e) {
14921
+ return typeof e === 'object' && e !== null && 'name' in e && (e.name === 'NotAllowedError' || e.name === 'SecurityError' || e.name === 'PermissionDeniedError' || e.name === 'PermissionDismissedError');
14922
+ }
14923
+ const deviceAcquisitionFailures = new WeakSet();
14924
+ /** Records that `e` is the rejection getUserMedia itself produced. */
14925
+ function markDeviceAcquisitionFailure(e) {
14926
+ if (typeof e === 'object' && e !== null) {
14927
+ deviceAcquisitionFailures.add(e);
14928
+ }
14929
+ }
14930
+ /**
14931
+ * Whether a track restart failed at getUserMedia rather than in the work that adopts the new track.
14932
+ * Recorded on the side rather than by wrapping, because callers of `restartTrack` match on the
14933
+ * rejection's own name.
14934
+ */
14935
+ function isDeviceAcquisitionFailure(e) {
14936
+ return typeof e === 'object' && e !== null && deviceAcquisitionFailures.has(e);
14937
+ }
14835
14938
  function toWebsocketUrl(url) {
14836
14939
  if (url.startsWith('http')) {
14837
14940
  return url.replace(/^(http)/, 'ws');
@@ -16545,12 +16648,16 @@ class E2EEManager extends eventsExports.EventEmitter {
16545
16648
  this.handleReceiver(track.receiver, track.mediaStreamID, remoteId, track.kind === 'video' ? mimeTypeToVideoCodecString(trackInfo.mimeType) : undefined, hasPacketTrailer);
16546
16649
  }
16547
16650
  setupE2EESender(track, sender) {
16548
- var _a, _b, _c;
16651
+ var _a, _b, _c, _d;
16549
16652
  if (!isLocalTrack(track) || !sender) {
16550
16653
  if (!sender) this.log.warn('early return because sender is not ready');
16551
16654
  return;
16552
16655
  }
16553
- this.handleSender(sender, track.mediaStreamID, undefined, isVideoTrack(track) ? (_b = (_a = track.publishOptions) === null || _a === void 0 ? void 0 : _a.frameMetadata) !== null && _b !== void 0 ? _b : (_c = track.publishOptions) === null || _c === void 0 ? void 0 : _c.packetTrailer : undefined);
16656
+ const isVideo = isVideoTrack(track);
16657
+ // Safari exposes no per-frame codec info until trackInfo arrives. Seed only h264/h265:
16658
+ // a wrong vp8 seed would skip the NALU path, a wrong NALU seed only costs a logged fallback.
16659
+ const publishedCodec = isVideo ? (_a = track.publishOptions) === null || _a === void 0 ? void 0 : _a.videoCodec : undefined;
16660
+ this.handleSender(sender, track.mediaStreamID, publishedCodec === 'h264' || publishedCodec === 'h265' ? publishedCodec : undefined, isVideo ? (_c = (_b = track.publishOptions) === null || _b === void 0 ? void 0 : _b.frameMetadata) !== null && _c !== void 0 ? _c : (_d = track.publishOptions) === null || _d === void 0 ? void 0 : _d.packetTrailer : undefined);
16554
16661
  }
16555
16662
  /**
16556
16663
  * Handles the given {@code RTCRtpReceiver} by creating a {@code TransformStream} which will inject
@@ -17320,6 +17427,58 @@ class AsyncQueue {
17320
17427
  snapshot() {
17321
17428
  return Array.from(this.pendingTasks.values());
17322
17429
  }
17430
+ }/**
17431
+ * Implementation of AbortSignal.any
17432
+ * Creates a signal that will be aborted when any of the given signals is aborted.
17433
+ * @link https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal/any
17434
+ */
17435
+ function abortSignalAny(signals) {
17436
+ // Handle empty signals array
17437
+ if (signals.length === 0) {
17438
+ const controller = new AbortController();
17439
+ return controller.signal;
17440
+ }
17441
+ // Fast path for single signal
17442
+ if (signals.length === 1) {
17443
+ return signals[0];
17444
+ }
17445
+ // Check if any signal is already aborted
17446
+ for (const signal of signals) {
17447
+ if (signal.aborted) {
17448
+ return signal;
17449
+ }
17450
+ }
17451
+ // Create a new controller for the combined signal
17452
+ const controller = new AbortController();
17453
+ const unlisteners = Array(signals.length);
17454
+ // Function to clean up all event listeners
17455
+ const cleanup = () => {
17456
+ for (const unsubscribe of unlisteners) {
17457
+ unsubscribe();
17458
+ }
17459
+ };
17460
+ // Add event listeners to each signal
17461
+ signals.forEach((signal, index) => {
17462
+ const handler = () => {
17463
+ controller.abort(signal.reason);
17464
+ cleanup();
17465
+ };
17466
+ signal.addEventListener('abort', handler);
17467
+ unlisteners[index] = () => signal.removeEventListener('abort', handler);
17468
+ });
17469
+ return controller.signal;
17470
+ }
17471
+ /**
17472
+ * Implementation of AbortSignal.timeout
17473
+ * Creates a signal that will be aborted after the specified timeout.
17474
+ * @link https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal/timeout
17475
+ */
17476
+ function abortSignalTimeout(ms) {
17477
+ const controller = new AbortController();
17478
+ setTimeout(() => {
17479
+ controller.abort(new DOMException("signal timed out after ".concat(ms, " ms"), 'TimeoutError'));
17480
+ }, ms);
17481
+ return controller.signal;
17323
17482
  }/**
17324
17483
  * Opt-in registry that lets a development tool observe the state machines driving the connection
17325
17484
  * layer.
@@ -18477,7 +18636,7 @@ class WebSocketStream {
18477
18636
  let _ref = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {},
18478
18637
  code = _ref.closeCode,
18479
18638
  reason = _ref.reason;
18480
- return ws.close(code, reason);
18639
+ return ws.close(code, reason === undefined ? undefined : truncateCloseReason(reason));
18481
18640
  };
18482
18641
  this.opened = new TypedPromise((resolve, reject) => {
18483
18642
  const rejectHandler = () => {
@@ -18553,6 +18712,25 @@ class WebSocketStream {
18553
18712
  }
18554
18713
  this.close = closeWithInfo;
18555
18714
  }
18715
+ }
18716
+ /** `WebSocket.close` throws a `SyntaxError` for a reason longer than this many UTF-8 bytes. */
18717
+ const MAX_CLOSE_REASON_BYTES = 123;
18718
+ function truncateCloseReason(reason) {
18719
+ const encoder = new TextEncoder();
18720
+ if (encoder.encode(reason).length <= MAX_CLOSE_REASON_BYTES) {
18721
+ return reason;
18722
+ }
18723
+ // cut on code point boundaries, so a multi-byte character is never split
18724
+ let truncated = '';
18725
+ let bytes = 0;
18726
+ for (const char of reason) {
18727
+ bytes += encoder.encode(char).length;
18728
+ if (bytes > MAX_CLOSE_REASON_BYTES) {
18729
+ break;
18730
+ }
18731
+ truncated += char;
18732
+ }
18733
+ return truncated;
18556
18734
  }const passThroughQueueSignals = ['syncState', 'trickle', 'offer', 'answer', 'simulate', 'leave'];
18557
18735
  /**
18558
18736
  * Whether a failed resume may be followed by another one. Mirrors how `RTCEngine` classifies these
@@ -18768,12 +18946,25 @@ class SignalClient {
18768
18946
  let publisherOffer = arguments.length > 5 ? arguments[5] : undefined;
18769
18947
  return function* () {
18770
18948
  const unlock = yield _this2.connectionLock.lock();
18771
- _this2.connectOptions = opts;
18772
- _this2.useV0SignalPath = useV0Path;
18773
- const clientInfo = getClientInfo(opts.clientInfoCapabilities);
18774
- const params = useV0Path ? createConnectionParams(token, clientInfo, opts) : yield createJoinRequestConnectionParams(token, clientInfo, opts, publisherOffer);
18775
- const rtcUrl = createRtcUrl(url, params, useV0Path).toString();
18776
- const validateUrl = createValidateUrl(rtcUrl).toString();
18949
+ let rtcUrl;
18950
+ let validateUrl;
18951
+ try {
18952
+ _this2.connectOptions = opts;
18953
+ _this2.useV0SignalPath = useV0Path;
18954
+ const clientInfo = getClientInfo(opts.clientInfoCapabilities);
18955
+ const params = useV0Path ? createConnectionParams(token, clientInfo, opts) : yield createJoinRequestConnectionParams(token, clientInfo, opts, publisherOffer);
18956
+ rtcUrl = createRtcUrl(url, params, useV0Path).toString();
18957
+ validateUrl = createValidateUrl(rtcUrl).toString();
18958
+ } catch (e) {
18959
+ // the executor below owns the lock from here on
18960
+ unlock();
18961
+ throw e;
18962
+ }
18963
+ if (abortSignal === null || abortSignal === void 0 ? void 0 : abortSignal.aborted) {
18964
+ unlock();
18965
+ _this2.log.debug('signal connect abandoned before opening a transport');
18966
+ throw ConnectionError.cancelled('Connection aborted');
18967
+ }
18777
18968
  return new Promise((resolve, reject) => __awaiter(_this2, void 0, void 0, function* () {
18778
18969
  var _a, _b, _c, _d, _e;
18779
18970
  try {
@@ -18787,12 +18978,12 @@ class SignalClient {
18787
18978
  const reason = getAbortReasonAsString(target, 'Abort handler called');
18788
18979
  // send leave if we have an active stream writer (connection is open)
18789
18980
  if (this.streamWriter && !this.isDisconnected) {
18790
- this.sendLeave().then(() => this.close(reason)).catch(e => {
18981
+ this.sendLeave().then(() => this.close(true, reason)).catch(e => {
18791
18982
  this.log.error(e);
18792
- this.close();
18983
+ this.close(true, reason);
18793
18984
  });
18794
18985
  } else {
18795
- this.close();
18986
+ this.close(true, reason);
18796
18987
  }
18797
18988
  cleanupAbortHandlers();
18798
18989
  // The caller may already have classified this failure: the connect timeout hands us a
@@ -18818,6 +19009,10 @@ class SignalClient {
18818
19009
  yield this.teardownTransport('replaced by a new connection attempt');
18819
19010
  this.log.debug("closed previous ws connection in ".concat(performance.now() - startClose, "ms"));
18820
19011
  }
19012
+ if (alreadyAborted) {
19013
+ this.log.debug('signal connect abandoned after tearing down the previous transport');
19014
+ return;
19015
+ }
18821
19016
  // the transport created below belongs to this attempt; events arriving from it after a
18822
19017
  // newer attempt has started are dropped by the machine
18823
19018
  const attemptId = this.attemptId;
@@ -18862,6 +19057,12 @@ class SignalClient {
18862
19057
  }
18863
19058
  });
18864
19059
  const connection = yield this.ws.opened.catch(reason => __awaiter(this, void 0, void 0, function* () {
19060
+ if (alreadyAborted) {
19061
+ // the abort handler has already rejected this attempt; don't probe the validate
19062
+ // endpoint for a failure nobody is waiting on
19063
+ this.log.debug('signal connect abandoned while the websocket was opening');
19064
+ return;
19065
+ }
18865
19066
  if (this.lifecycleState !== 'connected') {
18866
19067
  // claimed synchronously, before the await below: the socket's close event is already
18867
19068
  // on its way and would otherwise reject first with a less useful error
@@ -18924,6 +19125,13 @@ class SignalClient {
18924
19125
  return;
18925
19126
  }
18926
19127
  signalReader.releaseLock();
19128
+ if (alreadyAborted) {
19129
+ // The abort handler has already rejected this attempt and is closing the transport.
19130
+ // Don't act on a join that raced it: `onJoined` would publish an abandoned session's
19131
+ // state to the room.
19132
+ this.log.debug('signal connect abandoned while waiting for the first message');
19133
+ return;
19134
+ }
18927
19135
  // Validate the first message
18928
19136
  const validation = this.validateFirstMessage(firstSignalResponse, (_d = opts.reconnect) !== null && _d !== void 0 ? _d : false);
18929
19137
  if (!validation.isValid) {
@@ -19020,16 +19228,17 @@ class SignalClient {
19020
19228
  return __awaiter(this, void 0, void 0, function* () {
19021
19229
  try {
19022
19230
  this.clearPingInterval();
19023
- if (this.ws) {
19024
- this.ws.close({
19231
+ const ws = this.ws;
19232
+ if (ws) {
19233
+ // released before closing, so a close that throws cannot leave the transport attached
19234
+ this.ws = undefined;
19235
+ this.streamWriter = undefined;
19236
+ ws.close({
19025
19237
  closeCode: 1000,
19026
19238
  reason
19027
19239
  });
19028
19240
  // calling `ws.close()` only starts the closing handshake (CLOSING state), prefer to wait until state is actually CLOSED
19029
- const closePromise = this.ws.closed;
19030
- this.ws = undefined;
19031
- this.streamWriter = undefined;
19032
- yield Promise.race([closePromise, sleep(MAX_WS_CLOSE_TIME)]);
19241
+ yield Promise.race([ws.closed, sleep(MAX_WS_CLOSE_TIME)]);
19033
19242
  }
19034
19243
  } catch (e) {
19035
19244
  this.log.debug('websocket error while closing', {
@@ -19552,7 +19761,9 @@ class SignalClient {
19552
19761
  handleConnectionError(reason, validateUrl) {
19553
19762
  return __awaiter(this, void 0, void 0, function* () {
19554
19763
  try {
19555
- const resp = yield fetch(validateUrl);
19764
+ const resp = yield fetch(validateUrl, {
19765
+ signal: abortSignalTimeout(3000)
19766
+ });
19556
19767
  switch (resp.status) {
19557
19768
  case 404:
19558
19769
  const errorMsg = yield resp.text();
@@ -19576,7 +19787,11 @@ class SignalClient {
19576
19787
  });
19577
19788
  }
19578
19789
  } catch (e) {
19579
- return e instanceof ConnectionError ? e : ConnectionError.serverUnreachable(e instanceof Error ? e.message : 'server was not reachable');
19790
+ if (e instanceof ConnectionError) {
19791
+ return e;
19792
+ } else {
19793
+ return ConnectionError.serverUnreachable(e instanceof Error ? e.message : 'server was not reachable');
19794
+ }
19580
19795
  }
19581
19796
  });
19582
19797
  }
@@ -20502,15 +20717,32 @@ function requireLib() {
20502
20717
  * Why same for all codecs: Target bitrate already accounts for codec efficiency
20503
20718
  * (e.g., users set lower targets for VP9/AV1 knowing they're more efficient).
20504
20719
  * Why cap at 1 Mbps: Prevents BWE from starting too aggressively on high bitrate tracks.
20720
+ * The cap is lowered further for connections that were slow to set up, see
20721
+ * `computeStartBitrateCap`, or that the browser reports as slow, see
20722
+ * `connectionEffectiveTypeStartBitrateCap`.
20505
20723
  */
20506
20724
  const startBitrateMultiplier = 0.9;
20507
20725
  /** Maximum x-google-start-bitrate in kbps. 1 Mbps prevents BWE from starting too aggressively. */
20508
20726
  const maxStartBitrateKbps = 1000;
20509
20727
  /**
20510
- * Minimum target bitrate in kbps for the start bitrate hint. Below this, seeding above the
20511
- * real capacity costs more than the ramp it saves, so libwebrtc's default is left in place.
20728
+ * Minimum x-google-start-bitrate in kbps: libwebrtc's own default starting estimate. A target
20729
+ * below this gets no hint, since seeding above the real capacity costs more than the ramp it
20730
+ * saves, and a slow connection is seeded no higher than this.
20512
20731
  */
20513
20732
  const minTargetBitrateKbps = 300;
20733
+ /**
20734
+ * Connection setup times (ms) bounding the ramp in `computeStartBitrateCap`: at or below the
20735
+ * first the hint is capped at `maxStartBitrateKbps`, at or above the second at
20736
+ * `minTargetBitrateKbps`. Measured on shaped links (CLT-3380): healthy links set up in under
20737
+ * 860 ms, a 1 Mbps link with 150 ms RTT took 1.1–1.7 s, a 500 kbps link ~2.3 s at the median,
20738
+ * and a 300 kbps link never under 3 s. The first sits above the 1 Mbps link, which a lower seed
20739
+ * only slows down, and the second above the 500 kbps link's median, so only links that cannot
20740
+ * carry more land at the floor.
20741
+ */
20742
+ const setupTimeForMaxStartBitrateMs = 1500;
20743
+ const setupTimeForMinStartBitrateMs = 3500;
20744
+ /** Cap on the start bitrate hint, in kbps, when the browser reports a `3g` connection. */
20745
+ const effectiveType3gStartBitrateKbps = 500;
20514
20746
  const debounceInterval = 20;
20515
20747
  /**
20516
20748
  * Codec payload for `codec` in this media section, when the section carries `cid`.
@@ -20537,17 +20769,66 @@ function findTrackCodecPayload(media, cid, codec) {
20537
20769
  * high-bitrate track; screen share is exempt, because its content needs the bitrate
20538
20770
  * immediately to stay legible.
20539
20771
  *
20540
- * TODO: adjust dynamically from network conditions (e.g. a previous BWE estimate) rather
20541
- * than a fixed cap.
20772
+ * The cap is lowered for a connection that was slow to set up (`computeStartBitrateCap`), and
20773
+ * for one the browser reports as slow (`connectionEffectiveTypeStartBitrateCap`), whichever is
20774
+ * lower. Screen share is exempt from both, however slow the connection.
20542
20775
  *
20543
20776
  * @internal
20544
20777
  */
20545
- function computeTrackStartBitrate(trackbr) {
20778
+ function computeTrackStartBitrate(trackbr, connectionSetupTimeMs) {
20546
20779
  if (trackbr.maxbr < minTargetBitrateKbps) {
20547
20780
  return undefined;
20548
20781
  }
20549
- const calculated = Math.round(trackbr.maxbr * startBitrateMultiplier);
20550
- return trackbr.isScreenShare ? calculated : Math.min(calculated, maxStartBitrateKbps);
20782
+ const calculated = Math.min(Math.round(trackbr.maxbr * startBitrateMultiplier), trackbr.maxbr);
20783
+ if (trackbr.isScreenShare) {
20784
+ return calculated;
20785
+ }
20786
+ const cap = Math.min(computeStartBitrateCap(connectionSetupTimeMs), connectionEffectiveTypeStartBitrateCap());
20787
+ return Math.min(calculated, cap);
20788
+ }
20789
+ /**
20790
+ * Cap on the start bitrate hint, in kbps, from the browser's effective connection type
20791
+ * (`navigator.connection.effectiveType`, Chromium only): `slow-2g` and `2g` cap at
20792
+ * `minTargetBitrateKbps`, `3g` at `effectiveType3gStartBitrateKbps`.
20793
+ *
20794
+ * It catches links that have low bandwidth but low latency, which set up quickly and so slip
20795
+ * past the setup time ramp. `4g`, and browsers without the API, give no guidance and stay at the
20796
+ * `maxStartBitrateKbps` ceiling, as `computeStartBitrateCap` does without a setup time. The
20797
+ * connection RTT the API also reports is not used: it lags behind network changes, and the setup
20798
+ * time already reflects latency.
20799
+ */
20800
+ function connectionEffectiveTypeStartBitrateCap() {
20801
+ // A `typeof` guard, since `globalThis` is missing from some supported browsers (Chrome < 71,
20802
+ // Firefox < 65, Safari < 12.1) and the build does not polyfill it.
20803
+ const connection = typeof navigator !== 'undefined' ? navigator.connection : undefined;
20804
+ switch (connection === null || connection === void 0 ? void 0 : connection.effectiveType) {
20805
+ case 'slow-2g':
20806
+ case '2g':
20807
+ return minTargetBitrateKbps;
20808
+ case '3g':
20809
+ return effectiveType3gStartBitrateKbps;
20810
+ default:
20811
+ return maxStartBitrateKbps;
20812
+ }
20813
+ }
20814
+ /**
20815
+ * Cap on the start bitrate hint, in kbps, from how long the connection took to set up.
20816
+ *
20817
+ * Connection setup time (signaling join plus ICE/DTLS) is the network signal every platform has
20818
+ * before the first video offer, since libwebrtc cannot probe the path until a video sender
20819
+ * exists. It grows with round-trip time and loss, which also mark the links where a 1 Mbps
20820
+ * seed overshoots, so the cap ramps linearly from `maxStartBitrateKbps` at
20821
+ * `setupTimeForMaxStartBitrateMs` down to `minTargetBitrateKbps` at
20822
+ * `setupTimeForMinStartBitrateMs`. A video offer built while the connection is still being set
20823
+ * up (publishing during connect) passes the time elapsed so far, a lower bound on the setup time.
20824
+ * Without any timing the cap stays at the 1 Mbps ceiling.
20825
+ */
20826
+ function computeStartBitrateCap(connectionSetupTimeMs) {
20827
+ if (connectionSetupTimeMs === undefined) {
20828
+ return maxStartBitrateKbps;
20829
+ }
20830
+ const ramp = Math.min(Math.max((connectionSetupTimeMs - setupTimeForMaxStartBitrateMs) / (setupTimeForMinStartBitrateMs - setupTimeForMaxStartBitrateMs), 0), 1);
20831
+ return Math.round(maxStartBitrateKbps - ramp * (maxStartBitrateKbps - minTargetBitrateKbps));
20551
20832
  }
20552
20833
  /**
20553
20834
  * The single start bitrate for this peer connection: the largest hint among the video
@@ -20576,7 +20857,7 @@ function computeTrackStartBitrate(trackbr) {
20576
20857
  *
20577
20858
  * @internal
20578
20859
  */
20579
- function computeConnectionStartBitrate(media, trackBitrates) {
20860
+ function computeConnectionStartBitrate(media, trackBitrates, connectionSetupTimeMs) {
20580
20861
  let connectionStartBitrate;
20581
20862
  for (const m of media) {
20582
20863
  if (m.type !== 'video' || m.direction === 'recvonly' || m.direction === 'inactive') {
@@ -20590,7 +20871,7 @@ function computeConnectionStartBitrate(media, trackBitrates) {
20590
20871
  if (codecPayload === undefined) {
20591
20872
  continue;
20592
20873
  }
20593
- const startBitrate = codecPayload > 0 ? computeTrackStartBitrate(trackbr) : undefined;
20874
+ const startBitrate = codecPayload > 0 ? computeTrackStartBitrate(trackbr, connectionSetupTimeMs) : undefined;
20594
20875
  if (startBitrate !== undefined && (connectionStartBitrate === undefined || startBitrate > connectionStartBitrate)) {
20595
20876
  connectionStartBitrate = startBitrate;
20596
20877
  }
@@ -20989,7 +21270,7 @@ class PCTransport extends eventsExports.EventEmitter {
20989
21270
  // local video: the hint is connection-level in libwebrtc, so differing per-section
20990
21271
  // values would be last-writer-wins on m-section order. Offers before any video is
20991
21272
  // published (data channel or audio only) find no target and leave the latch unset.
20992
- const connectionStartBitrate = this.hasAppliedVideoStartBitrate ? undefined : computeConnectionStartBitrate(sdpParsed.media, this.trackBitrates);
21273
+ const connectionStartBitrate = this.hasAppliedVideoStartBitrate ? undefined : computeConnectionStartBitrate(sdpParsed.media, this.trackBitrates, this.connectionSetupTimeEstimateMs());
20993
21274
  let appliedVideoStartBitrate = false;
20994
21275
  sdpParsed.media.forEach(media => {
20995
21276
  ensureIPAddrMatchVersion(media);
@@ -21038,6 +21319,10 @@ class PCTransport extends eventsExports.EventEmitter {
21038
21319
  // one-shot hint once the SDP carrying it has been accepted locally.
21039
21320
  if (appliedVideoStartBitrate && offer.sdp === mungedSdp) {
21040
21321
  this.hasAppliedVideoStartBitrate = true;
21322
+ this.log.info('applied x-google-start-bitrate', {
21323
+ startBitrateKbps: connectionStartBitrate,
21324
+ connectionSetupTimeMs: this.connectionSetupTimeEstimateMs()
21325
+ });
21041
21326
  }
21042
21327
  this.onOffer(offer, this.latestOfferId);
21043
21328
  } finally {
@@ -21089,6 +21374,27 @@ class PCTransport extends eventsExports.EventEmitter {
21089
21374
  setTrackCodecBitrate(info) {
21090
21375
  this.trackBitrates.push(info);
21091
21376
  }
21377
+ /**
21378
+ * Record how long this peer connection took to set up. Called once, when the initial connect
21379
+ * succeeds; resumes and ICE restarts keep the estimator they have and never call this.
21380
+ */
21381
+ setConnectionSetupTime(ms) {
21382
+ this.connectionSetupTimeMs = ms;
21383
+ }
21384
+ /** Record when the connection attempt that built this peer connection began. */
21385
+ setConnectionStartedAt(startedAt) {
21386
+ this.connectionStartedAt = startedAt;
21387
+ }
21388
+ /** The measured setup time, or while still connecting, the time elapsed so far. */
21389
+ connectionSetupTimeEstimateMs() {
21390
+ if (this.connectionSetupTimeMs !== undefined) {
21391
+ return this.connectionSetupTimeMs;
21392
+ }
21393
+ if (this.connectionStartedAt === undefined) {
21394
+ return undefined;
21395
+ }
21396
+ return Math.round(performance.now() - this.connectionStartedAt);
21397
+ }
21092
21398
  setConfiguration(rtcConfig) {
21093
21399
  var _a;
21094
21400
  if (!this._pc) {
@@ -21905,7 +22211,8 @@ class PCTransportManager {
21905
22211
  * Waiters are parked on the channel object captured at wait entry. If that object stops being
21906
22212
  * current — replaced or torn down — its events may never fire again, so the owner must call
21907
22213
  * {@link invalidateWaiters}, which aborts parked waiters (releasing the gate); the next waiter
21908
- * gets a fresh controller.
22214
+ * gets a fresh controller. {@link attach} and {@link detach} do this, and close the released
22215
+ * handle, as part of handle turnover.
21909
22216
  */
21910
22217
  class FlowControlledDataChannel {
21911
22218
  constructor(opts) {
@@ -21925,25 +22232,38 @@ class FlowControlledDataChannel {
21925
22232
  return this.handle;
21926
22233
  }
21927
22234
  /**
21928
- * Attaches the channel handle this wrapper controls. Replacing an existing handle rejects
21929
- * parked waiters — their events would never fire again on the abandoned object — and installs a
21930
- * fresh controller, so queued senders re-check against the new channel. Wrappers outlive their
21931
- * handles: this is the one place handle turnover happens, which is what makes stranding a
21932
- * waiter structurally impossible.
22235
+ * Attaches the channel handle this wrapper controls. Replacing an existing handle closes it and
22236
+ * rejects parked waiters — their events would never fire again on the abandoned object — and
22237
+ * installs a fresh controller. Wrappers outlive their handles: this is the one place handle
22238
+ * turnover happens, so a handle the wrapper lets go of is never left open, and stranding a
22239
+ * waiter is structurally impossible.
21933
22240
  */
21934
22241
  attach(dc) {
21935
- if (this.handle && this.handle !== dc) {
21936
- this.invalidateWaiters('data channel replaced');
21937
- }
22242
+ const old = this.handle;
21938
22243
  this.handle = dc;
22244
+ if (old && old !== dc) {
22245
+ this.release(old, 'data channel replaced');
22246
+ }
21939
22247
  }
21940
- /** Detaches the handle on teardown, rejecting parked waiters. */
22248
+ /** Detaches and closes the handle on teardown, rejecting parked waiters. */
21941
22249
  detach() {
21942
22250
  let reason = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : 'data channel torn down';
21943
- if (this.handle) {
21944
- this.invalidateWaiters(reason);
21945
- }
22251
+ const old = this.handle;
21946
22252
  this.handle = undefined;
22253
+ if (old) {
22254
+ this.release(old, reason);
22255
+ }
22256
+ }
22257
+ /**
22258
+ * Closes a handle the wrapper no longer controls. `close()` moves it to `closing` synchronously,
22259
+ * so senders still holding it (queued on the headroom lock) fail the open check once they get
22260
+ * the lock. Parked waiters are aborted directly instead of relying on the `close` event, which
22261
+ * fires asynchronously, if at all (the spec lets `pc.close()` skip it). Strip any handlers that
22262
+ * must not observe the close before calling this.
22263
+ */
22264
+ release(dc, reason) {
22265
+ this.invalidateWaiters(reason);
22266
+ dc.close();
21947
22267
  }
21948
22268
  getChannel() {
21949
22269
  return this.handle;
@@ -21974,32 +22294,35 @@ class FlowControlledDataChannel {
21974
22294
  return this.headroomLock.lock();
21975
22295
  }
21976
22296
  /**
21977
- * Resolves once the caller may send on this channel: immediately while the send buffer is at or
21978
- * below its high-water mark, otherwise once the buffer has drained to the low-water mark (the
22297
+ * Resolves once the caller may send on `dc`: immediately while its send buffer is at or below
22298
+ * the high-water mark, otherwise once the buffer has drained to the low-water mark (the
21979
22299
  * `bufferedamountlow` event). Callers are serialized through the headroom lock so that, when
21980
22300
  * the buffer drains, they refill it one at a time (up to the high-water mark) rather than all
21981
22301
  * sending at once and overflowing the SCTP send buffer (see livekit/client-sdk-js#1995). The
21982
- * closed/buffer checks run inside the lock so queued callers proceed in FIFO order.
22302
+ * checks run inside the lock so queued callers proceed in FIFO order.
22303
+ *
22304
+ * The caller passes the handle it resolved before queueing, and the wait is against that one
22305
+ * handle throughout: if it was replaced meanwhile, it has been closed, so the wait rejects
22306
+ * rather than letting the caller send on the replacement out of turn.
21983
22307
  */
21984
- waitForHeadroomWithLock() {
22308
+ waitForHeadroomWithLock(dc) {
21985
22309
  return __awaiter(this, void 0, void 0, function* () {
21986
22310
  const unlock = yield this.lockHeadroom();
21987
22311
  try {
21988
- yield this.waitForHeadroomWithoutLock();
22312
+ yield this.waitForHeadroomWithoutLock(dc);
21989
22313
  } finally {
21990
22314
  unlock();
21991
22315
  }
21992
22316
  });
21993
22317
  }
21994
22318
  /** Core wait of {@link waitForHeadroomWithLock}. The caller must hold the headroom lock. */
21995
- waitForHeadroomWithoutLock() {
22319
+ waitForHeadroomWithoutLock(dc) {
21996
22320
  return __awaiter(this, void 0, void 0, function* () {
21997
22321
  if (this.isEngineClosed()) {
21998
22322
  throw new UnexpectedConnectionState('engine closed');
21999
22323
  }
22000
- const dc = this.getChannel();
22001
- if (!dc) {
22002
- throw new UnexpectedConnectionState("DataChannel not found, kind: ".concat(this.kind));
22324
+ if (dc.readyState !== 'open') {
22325
+ throw new UnexpectedConnectionState("DataChannel ".concat(this.kind, " is ").concat(dc.readyState));
22003
22326
  }
22004
22327
  if (this.isBelowHighWaterMark(dc)) {
22005
22328
  return;
@@ -22089,7 +22412,16 @@ class LossyDataChannel extends FlowControlledDataChannel {
22089
22412
  switch (this.bufferFullBehavior) {
22090
22413
  case 'wait':
22091
22414
  if (!this.isBelowHighWaterMark(dc)) {
22092
- yield this.waitForHeadroomWithLock();
22415
+ try {
22416
+ yield this.waitForHeadroomWithLock(dc);
22417
+ } catch (error) {
22418
+ if (this.isEngineClosed()) {
22419
+ throw error;
22420
+ }
22421
+ // The channel closed or was replaced while this send waited. The replacement may
22422
+ // still be connecting, so drop the packet, as sends during a reconnect are.
22423
+ return;
22424
+ }
22093
22425
  }
22094
22426
  break;
22095
22427
  case 'drop':
@@ -22271,7 +22603,7 @@ class ReliableDataChannel extends FlowControlledDataChannel {
22271
22603
  return;
22272
22604
  }
22273
22605
  try {
22274
- yield this.waitForHeadroomWithLock();
22606
+ yield this.waitForHeadroomWithLock(dc);
22275
22607
  } catch (error) {
22276
22608
  if (this.isEngineClosed()) {
22277
22609
  // No replay is coming after an engine close — surface the failure.
@@ -22279,7 +22611,9 @@ class ReliableDataChannel extends FlowControlledDataChannel {
22279
22611
  }
22280
22612
  // Transient teardown (the channel closed or was replaced while we waited): the reliable
22281
22613
  // channel promises delivery across resume, so queue the packet for the replay instead of
22282
- // rejecting a send the app can't meaningfully retry.
22614
+ // rejecting a send the app can't meaningfully retry. This also keeps the order: a send
22615
+ // queued behind one aborted by a replacement fails the same way (the old handle is closed),
22616
+ // so it can't reach the replacement ahead of the lower sequence the replay will deliver.
22283
22617
  this.messageBuffer.push({
22284
22618
  data: msg,
22285
22619
  sequence,
@@ -22314,13 +22648,18 @@ class ReliableDataChannel extends FlowControlledDataChannel {
22314
22648
  */
22315
22649
  replay(lastMessageSeq) {
22316
22650
  return __awaiter(this, void 0, void 0, function* () {
22317
- const dc = this.getChannel();
22318
- if (!dc) {
22651
+ if (!this.getChannel()) {
22319
22652
  return;
22320
22653
  }
22321
22654
  this.messageBuffer.popToSequence(lastMessageSeq);
22322
22655
  const unlock = yield this.lockHeadroom();
22323
22656
  try {
22657
+ // Resolved only once the lock is held: the handle can be replaced while the replay waits
22658
+ // for it.
22659
+ const dc = this.getChannel();
22660
+ if (!dc) {
22661
+ throw new UnexpectedConnectionState("DataChannel not found, kind: ".concat(this.kind));
22662
+ }
22324
22663
  // Everything left after the ack cutoff must be re-handed to the current channel.
22325
22664
  this.messageBuffer.markAllUnsent();
22326
22665
  // Drain in passes, re-scanning the live buffer each time: a send that arrives (deferred,
@@ -22332,7 +22671,10 @@ class ReliableDataChannel extends FlowControlledDataChannel {
22332
22671
  for (let batch = this.messageBuffer.getUnsent(); batch.length > 0; batch = this.messageBuffer.getUnsent()) {
22333
22672
  for (const item of batch) {
22334
22673
  // Respect flow control on resume too, so a large resend doesn't overflow the buffer.
22335
- yield this.waitForHeadroomWithoutLock();
22674
+ // Against the handle resolved under the lock: if it is replaced mid-replay (another
22675
+ // resume), it has been closed, so this throws and that resume's replay picks up the
22676
+ // entries still flagged unsent.
22677
+ yield this.waitForHeadroomWithoutLock(dc);
22336
22678
  dc.send(item.data);
22337
22679
  this.messageBuffer.markSent(item);
22338
22680
  }
@@ -22387,13 +22729,23 @@ function dataChannelHighWaterMark(kind) {
22387
22729
  }const lossyDataChannelLabel = '_lossy';
22388
22730
  const reliableDataChannelLabel = '_reliable';
22389
22731
  const dataTrackDataChannelLabel = '_data_track';
22732
+ /** Unhooks every handler the manager may have wired onto `dc`, ahead of closing it. */
22733
+ function clearHandlers(dc) {
22734
+ dc.onbufferedamountlow = null;
22735
+ dc.onclose = null;
22736
+ dc.onclosing = null;
22737
+ dc.onerror = null;
22738
+ dc.onmessage = null;
22739
+ dc.onopen = null;
22740
+ }
22390
22741
  /**
22391
22742
  * Owns the engine's data channels: the three flow-controlled publisher channel wrappers (which
22392
22743
  * live for the engine's lifetime and have RTCDataChannel handles attached/detached as peer
22393
22744
  * connections come and go) plus the subscriber-side receive handles adopted by label.
22394
22745
  *
22395
- * Handle turnover goes through {@link FlowControlledDataChannel.attach}/`detach`, which reject
22396
- * parked headroom waiters as a built-in — there is no separate invalidation step to forget.
22746
+ * Handle turnover goes through {@link FlowControlledDataChannel.attach}/`detach`, which close the
22747
+ * released handle and reject parked headroom waiters as a built-in — there is no separate
22748
+ * invalidation step to forget.
22397
22749
  */
22398
22750
  class DataChannelManager {
22399
22751
  constructor(opts) {
@@ -22451,17 +22803,16 @@ class DataChannelManager {
22451
22803
  }
22452
22804
  /**
22453
22805
  * Creates the three publisher data channels on the given transport, wires their handlers, and
22454
- * attaches them to the wrappers — attaching rejects any waiters still parked on replaced
22455
- * channel objects.
22806
+ * attaches them to the wrappers — attaching closes any replaced channel objects and rejects the
22807
+ * waiters still parked on them.
22456
22808
  */
22457
22809
  createPublisherChannels(pcManager) {
22458
- // clear old data channel callbacks if recreate
22810
+ // Recreating (the Safari null-id path): attaching the new handles below closes the old ones,
22811
+ // so unhook them first — the old channel's close must not report an unexpected close.
22459
22812
  for (const channel of [this.lossy, this.reliable, this.dataTrack]) {
22460
22813
  const old = channel.channelHandle;
22461
22814
  if (old) {
22462
- old.onmessage = null;
22463
- old.onerror = null;
22464
- old.onclose = null;
22815
+ clearHandlers(old);
22465
22816
  }
22466
22817
  }
22467
22818
  const wire = (channel, dc, onMessage) => {
@@ -22512,38 +22863,35 @@ class DataChannelManager {
22512
22863
  return true;
22513
22864
  }
22514
22865
  /**
22515
- * Tears down all channels for a peer-connection cleanup: rejects parked waiters (detach — the
22516
- * spec allows `pc.close()` to transition channels to 'closed' without firing events, so waiting
22517
- * for browser close events is not an option), strips handlers, closes the handles, and resets
22518
- * the reliable session state.
22866
+ * Tears down all channels for a peer-connection cleanup: strips handlers, closes the handles,
22867
+ * and resets the reliable session state. Detaching the publisher handles closes them and
22868
+ * rejects parked waiters (the spec allows `pc.close()` to transition channels to 'closed'
22869
+ * without firing events, so waiting for browser close events is not an option).
22519
22870
  */
22520
22871
  teardown() {
22521
- const dcCleanup = dc => {
22522
- if (!dc) {
22523
- return;
22524
- }
22525
- // Detach the data channel handlers before closing anything. Closing a peer connection tears
22526
- // down the SCTP transport, which can dispatch `error`/`close` events on the still-open data
22527
- // channels; if our handlers are still attached at that point, handleDataError logs a spurious
22528
- // "Unknown DataChannel error" during an otherwise graceful disconnect. Removing the handlers
22529
- // before dc.close()/pcManager.close() makes this deterministic regardless of how/when the
22530
- // browser dispatches those teardown events. See livekit/client-sdk-js#1953.
22531
- dc.onbufferedamountlow = null;
22532
- dc.onclose = null;
22533
- dc.onclosing = null;
22534
- dc.onerror = null;
22535
- dc.onmessage = null;
22536
- dc.onopen = null;
22537
- dc.close();
22538
- };
22539
- for (const channel of [this.lossy, this.reliable, this.dataTrack]) {
22872
+ // Detach the data channel handlers before closing anything. Closing a peer connection tears
22873
+ // down the SCTP transport, which can dispatch `error`/`close` events on the still-open data
22874
+ // channels; if our handlers are still attached at that point, handleDataError logs a spurious
22875
+ // "Unknown DataChannel error" during an otherwise graceful disconnect. Removing the handlers
22876
+ // before dc.close()/pcManager.close() makes this deterministic regardless of how/when the
22877
+ // browser dispatches those teardown events. See livekit/client-sdk-js#1953.
22878
+ const publisherDataChannels = [this.lossy, this.reliable, this.dataTrack];
22879
+ const subscriberDataChannels = [this.lossySub, this.reliableSub, this.dataTrackSub];
22880
+ // Publisher handles are closed by detach, which also rejects parked waiters.
22881
+ for (const channel of publisherDataChannels) {
22540
22882
  const dc = channel.channelHandle;
22883
+ if (dc) {
22884
+ clearHandlers(dc);
22885
+ }
22541
22886
  channel.detach('peer connections cleaned up');
22542
- dcCleanup(dc);
22543
22887
  }
22544
- dcCleanup(this.lossySub);
22545
- dcCleanup(this.reliableSub);
22546
- dcCleanup(this.dataTrackSub);
22888
+ // Subscriber handles have no wrapper, so close them directly.
22889
+ for (const dc of subscriberDataChannels) {
22890
+ if (dc) {
22891
+ clearHandlers(dc);
22892
+ dc.close();
22893
+ }
22894
+ }
22547
22895
  this.lossySub = undefined;
22548
22896
  this.reliableSub = undefined;
22549
22897
  this.dataTrackSub = undefined;
@@ -22705,6 +23053,9 @@ class LocalTrack extends Track {
22705
23053
  get isUserProvided() {
22706
23054
  return this.providedByUser;
22707
23055
  }
23056
+ get isStopped() {
23057
+ return this.manuallyStopped;
23058
+ }
22708
23059
  get mediaStreamTrack() {
22709
23060
  var _a, _b;
22710
23061
  return (_b = (_a = this.processor) === null || _a === void 0 ? void 0 : _a.processedTrack) !== null && _b !== void 0 ? _b : this._mediaStreamTrack;
@@ -22762,6 +23113,9 @@ class LocalTrack extends Track {
22762
23113
  track: newTrack,
22763
23114
  kind: this.kind,
22764
23115
  element: this.processorElement,
23116
+ // audio processors need the context they were initialised with in order to rebuild
23117
+ // their graph, omitting it here would leave them without one
23118
+ audioContext: this.audioContext,
22765
23119
  localTrack: this
22766
23120
  });
22767
23121
  processedTrack = this.processor.processedTrack;
@@ -22785,6 +23139,18 @@ class LocalTrack extends Track {
22785
23139
  attachToElement(processedTrack !== null && processedTrack !== void 0 ? processedTrack : newTrack, el);
22786
23140
  });
22787
23141
  }
23142
+ yield this.onMediaStreamTrackChanged();
23143
+ });
23144
+ }
23145
+ /**
23146
+ * Hook invoked after the underlying MediaStreamTrack has been swapped and the processor has
23147
+ * been restarted around it, so `processor.processedTrack` already holds the new track.
23148
+ * Unlike `onSenderTrackSwapped` this also fires on the restart and unmute paths.
23149
+ */
23150
+ onMediaStreamTrackChanged() {
23151
+ return __awaiter(this, void 0, void 0, function* () {
23152
+ // base implementation is a no-op; LocalAudioTrack overrides this to move the krisp
23153
+ // feature listeners onto the new processed track.
22788
23154
  });
22789
23155
  }
22790
23156
  waitForDimensions() {
@@ -22823,7 +23189,7 @@ class LocalTrack extends Track {
22823
23189
  }
22824
23190
  setDeviceId(deviceId) {
22825
23191
  return __awaiter(this, void 0, void 0, function* () {
22826
- if (this._constraints.deviceId === deviceId && this._mediaStreamTrack.getSettings().deviceId === unwrapConstraint(deviceId)) {
23192
+ if (this._constraints.deviceId === deviceId && this._mediaStreamTrack.getSettings().deviceId === unwrapConstraint(deviceId) && this._mediaStreamTrack.readyState === 'live') {
22827
23193
  return true;
22828
23194
  }
22829
23195
  this._constraints.deviceId = deviceId;
@@ -22951,24 +23317,40 @@ class LocalTrack extends Track {
22951
23317
  // 'A MediaStreamTrack ended due to a capture failure`
22952
23318
  this._mediaStreamTrack.stop();
22953
23319
  // create new track and attach
22954
- const mediaStream = yield navigator.mediaDevices.getUserMedia(streamConstraints);
23320
+ let mediaStream;
23321
+ try {
23322
+ mediaStream = yield navigator.mediaDevices.getUserMedia(streamConstraints);
23323
+ } catch (e) {
23324
+ markDeviceAcquisitionFailure(e);
23325
+ throw e;
23326
+ }
22955
23327
  const newTrack = mediaStream.getTracks()[0];
22956
- if (this.kind === Track.Kind.Video) {
22957
- // we already captured the audio track with the constraints, so we only need to apply the video constraints
22958
- yield newTrack.applyConstraints(otherConstraints);
23328
+ try {
23329
+ if (this.kind === Track.Kind.Video) {
23330
+ // we already captured the audio track with the constraints, so we only need to apply the video constraints
23331
+ yield newTrack.applyConstraints(otherConstraints);
23332
+ }
23333
+ newTrack.addEventListener('ended', this.handleEnded);
23334
+ this.log.debug('re-acquired MediaStreamTrack', this.logContext);
23335
+ yield this.setMediaStreamTrack(newTrack, false, isUnmuting);
23336
+ } catch (e) {
23337
+ // setMediaStreamTrack can throw after acquisition but before it adopts the new track,
23338
+ // which would leave the capture device live with nothing referencing it
23339
+ if (this._mediaStreamTrack !== newTrack) {
23340
+ newTrack.stop();
23341
+ }
23342
+ throw e;
22959
23343
  }
22960
- newTrack.addEventListener('ended', this.handleEnded);
22961
- this.log.debug('re-acquired MediaStreamTrack', this.logContext);
22962
- yield this.setMediaStreamTrack(newTrack, false, isUnmuting);
22963
23344
  this._constraints = constraints;
22964
23345
  this.pendingDeviceChange = false;
22965
23346
  this.emit(TrackEvent.Restarted, this);
23347
+ return this;
23348
+ } finally {
23349
+ // in the finally because a throw can also leave the new track adopted, and so still live
22966
23350
  if (this.manuallyStopped) {
22967
23351
  this.log.warn('track was stopped during a restart, stopping restarted track', this.logContext);
22968
23352
  this.stop();
22969
23353
  }
22970
- return this;
22971
- } finally {
22972
23354
  unlock();
22973
23355
  }
22974
23356
  });
@@ -23009,7 +23391,13 @@ class LocalTrack extends Track {
23009
23391
  this._mediaStreamTrack.removeEventListener('ended', this.handleEnded);
23010
23392
  this._mediaStreamTrack.removeEventListener('mute', this.handleTrackMuteEvent);
23011
23393
  this._mediaStreamTrack.removeEventListener('unmute', this.handleTrackUnmuteEvent);
23012
- (_a = this.processor) === null || _a === void 0 ? void 0 : _a.destroy();
23394
+ // `stop` is synchronous, so we can't await the teardown of the processor here.
23395
+ // make sure a failing teardown doesn't surface as an unhandled rejection.
23396
+ (_a = this.processor) === null || _a === void 0 ? void 0 : _a.destroy().catch(error => {
23397
+ this.log.error('failed to destroy processor', Object.assign(Object.assign({}, this.logContext), {
23398
+ error
23399
+ }));
23400
+ });
23013
23401
  this.processor = undefined;
23014
23402
  }
23015
23403
  /**
@@ -23088,6 +23476,11 @@ class LocalTrack extends Track {
23088
23476
  * Sets a processor on this track.
23089
23477
  * See https://github.com/livekit/track-processors-js for example usage
23090
23478
  *
23479
+ * The processor lives on the track, so stopping the track destroys it. Unpublishing and
23480
+ * disconnecting both stop the track by default, which means a processor has to be set again on
23481
+ * the track published after a reconnect. See {@link TrackProcessor} for the full lifecycle,
23482
+ * including what `stopLocalTrackOnUnpublish: false` changes.
23483
+ *
23091
23484
  * @param processor
23092
23485
  * @param showProcessedStreamLocally
23093
23486
  * @returns
@@ -23157,6 +23550,10 @@ class LocalTrack extends Track {
23157
23550
  }();
23158
23551
  });
23159
23552
  }
23553
+ /**
23554
+ * Returns the processor currently set on this track, or `undefined` if there is none. A track
23555
+ * that was stopped by an unpublish or a disconnect has none. See {@link TrackProcessor}.
23556
+ */
23160
23557
  getProcessor() {
23161
23558
  return this.processor;
23162
23559
  }
@@ -23164,6 +23561,8 @@ class LocalTrack extends Track {
23164
23561
  * Stops the track processor
23165
23562
  * See https://github.com/livekit/track-processors-js for example usage
23166
23563
  *
23564
+ * Only needed to remove a processor from a track that stays published. Unpublishing or
23565
+ * disconnecting already destroys it. See {@link TrackProcessor}.
23167
23566
  */
23168
23567
  stopProcessor() {
23169
23568
  return __awaiter(this, arguments, void 0, function () {
@@ -23404,6 +23803,38 @@ class LocalTrack extends Track {
23404
23803
  this.monitorSender();
23405
23804
  }, monitorFrequency);
23406
23805
  }
23806
+ /**
23807
+ * registers the krisp feature listeners on the given processed track, moving them over from
23808
+ * a previously observed one. Passing `undefined` just removes the existing listeners.
23809
+ */
23810
+ observeProcessedTrack(processedTrack) {
23811
+ if (this.observedProcessedTrack === processedTrack) {
23812
+ return;
23813
+ }
23814
+ if (this.observedProcessedTrack) {
23815
+ this.observedProcessedTrack.removeEventListener('enable-lk-krisp-noise-filter', this.handleKrispNoiseFilterEnable);
23816
+ this.observedProcessedTrack.removeEventListener('disable-lk-krisp-noise-filter', this.handleKrispNoiseFilterDisable);
23817
+ this.observedProcessedTrack = undefined;
23818
+ if (this.isKrispNoiseFilterEnabled) {
23819
+ // we're no longer observing the track that had the filter enabled, so the feature
23820
+ // can't be active anymore. Make sure the state doesn't get stuck on `true`.
23821
+ this.handleKrispNoiseFilterDisable();
23822
+ }
23823
+ }
23824
+ if (!processedTrack) {
23825
+ return;
23826
+ }
23827
+ processedTrack.addEventListener('enable-lk-krisp-noise-filter', this.handleKrispNoiseFilterEnable);
23828
+ processedTrack.addEventListener('disable-lk-krisp-noise-filter', this.handleKrispNoiseFilterDisable);
23829
+ this.observedProcessedTrack = processedTrack;
23830
+ }
23831
+ /**
23832
+ * Sets a processor on this track.
23833
+ *
23834
+ * The processor is destroyed when the track is stopped, and also when the audio context goes
23835
+ * away, so a disconnect ends it on both counts and it has to be set again after reconnecting.
23836
+ * See {@link TrackProcessor} for the full lifecycle.
23837
+ */
23407
23838
  setProcessor(processor) {
23408
23839
  return __awaiter(this, void 0, void 0, function* () {
23409
23840
  var _a;
@@ -23427,8 +23858,7 @@ class LocalTrack extends Track {
23427
23858
  this.processor = processor;
23428
23859
  if (this.processor.processedTrack) {
23429
23860
  yield (_a = this.sender) === null || _a === void 0 ? void 0 : _a.replaceTrack(this.processor.processedTrack);
23430
- this.processor.processedTrack.addEventListener('enable-lk-krisp-noise-filter', this.handleKrispNoiseFilterEnable);
23431
- this.processor.processedTrack.addEventListener('disable-lk-krisp-noise-filter', this.handleKrispNoiseFilterDisable);
23861
+ this.observeProcessedTrack(this.processor.processedTrack);
23432
23862
  }
23433
23863
  this.emit(TrackEvent.TrackProcessorUpdate, this.processor);
23434
23864
  } finally {
@@ -23436,12 +23866,73 @@ class LocalTrack extends Track {
23436
23866
  }
23437
23867
  });
23438
23868
  }
23869
+ onMediaStreamTrackChanged() {
23870
+ return __awaiter(this, void 0, void 0, function* () {
23871
+ var _a;
23872
+ // the processor rebuilds its graph around the new track, so the krisp listeners have to
23873
+ // follow it onto whatever it hands back
23874
+ this.observeProcessedTrack((_a = this.processor) === null || _a === void 0 ? void 0 : _a.processedTrack);
23875
+ });
23876
+ }
23877
+ internalStopProcessor() {
23878
+ const _super = Object.create(null, {
23879
+ internalStopProcessor: {
23880
+ get: () => super.internalStopProcessor
23881
+ }
23882
+ });
23883
+ return __awaiter(this, arguments, void 0, function () {
23884
+ var _this = this;
23885
+ let keepElement = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : true;
23886
+ return function* () {
23887
+ _this.observeProcessedTrack(undefined);
23888
+ yield _super.internalStopProcessor.call(_this, keepElement);
23889
+ }();
23890
+ });
23891
+ }
23892
+ stop() {
23893
+ this.observeProcessedTrack(undefined);
23894
+ super.stop();
23895
+ }
23439
23896
  /**
23440
23897
  * @internal
23441
23898
  * @experimental
23899
+ *
23900
+ * A processor's nodes (`AudioWorkletNode`, ...) can't move between contexts, so the processor
23901
+ * is rebuilt whenever the context changes and torn down when there is none left to run in.
23442
23902
  */
23443
23903
  setAudioContext(audioContext) {
23444
- this.audioContext = audioContext;
23904
+ return __awaiter(this, void 0, void 0, function* () {
23905
+ var _a;
23906
+ if (this.audioContext === audioContext) {
23907
+ return;
23908
+ }
23909
+ const unlock = yield this.trackChangeLock.lock();
23910
+ try {
23911
+ this.audioContext = audioContext;
23912
+ if (!this.processor || isReactNative()) {
23913
+ return;
23914
+ }
23915
+ if (!audioContext) {
23916
+ this.log.debug('stopping audio processor, audio context has been removed', this.logContext);
23917
+ yield this.internalStopProcessor();
23918
+ return;
23919
+ }
23920
+ this.log.debug('restarting audio processor on new audio context', this.logContext);
23921
+ yield this.processor.restart({
23922
+ kind: this.kind,
23923
+ track: this._mediaStreamTrack,
23924
+ audioContext,
23925
+ localTrack: this
23926
+ });
23927
+ if (this.processor.processedTrack) {
23928
+ yield (_a = this.sender) === null || _a === void 0 ? void 0 : _a.replaceTrack(this.processor.processedTrack);
23929
+ this.observeProcessedTrack(this.processor.processedTrack);
23930
+ }
23931
+ this.emit(TrackEvent.TrackProcessorUpdate, this.processor);
23932
+ } finally {
23933
+ unlock();
23934
+ }
23935
+ });
23445
23936
  }
23446
23937
  getSenderStats() {
23447
23938
  return __awaiter(this, void 0, void 0, function* () {
@@ -24271,7 +24762,14 @@ class LocalVideoTrack extends LocalTrack {
24271
24762
  yield sender.setParameters(params);
24272
24763
  });
24273
24764
  }
24274
- setProcessor(processor_1) {
24765
+ /**
24766
+ * Sets a processor on this track, and wires its processed track into the simulcast codecs.
24767
+ *
24768
+ * The processor is destroyed when the track is stopped, which unpublishing and disconnecting
24769
+ * both do by default, so it has to be set again after reconnecting. See {@link TrackProcessor}
24770
+ * for the full lifecycle.
24771
+ */
24772
+ setProcessor(processor_1) {
24275
24773
  const _super = Object.create(null, {
24276
24774
  setProcessor: {
24277
24775
  get: () => super.setProcessor
@@ -24872,7 +25370,17 @@ class RTCEngine extends eventsExports.EventEmitter {
24872
25370
  return;
24873
25371
  }
24874
25372
  if (connection === leaveReconnect) {
24875
- delay = 0;
25373
+ // The server asked for a reconnect, so skip any remaining backoff. Use the policy's first
25374
+ // retry delay instead of 0 so that clients told to reconnect together don't all reconnect
25375
+ // at the same instant.
25376
+ delay = this.getNextRetryDelay({
25377
+ elapsedMs: duration,
25378
+ retryCount: 0
25379
+ });
25380
+ if (delay === null) {
25381
+ disconnect(duration);
25382
+ return;
25383
+ }
24876
25384
  }
24877
25385
  this.log.debug("reconnecting in ".concat(delay, "ms"));
24878
25386
  this.clearReconnectTimeout();
@@ -25005,7 +25513,7 @@ class RTCEngine extends eventsExports.EventEmitter {
25005
25513
  var _this2 = this;
25006
25514
  let useV0Path = arguments.length > 4 && arguments[4] !== undefined ? arguments[4] : false;
25007
25515
  return function* () {
25008
- var _a, _b, _c;
25516
+ var _a, _b, _c, _d;
25009
25517
  _this2._isNewlyCreated = false;
25010
25518
  _this2.url = url;
25011
25519
  _this2.token = token;
@@ -25013,6 +25521,7 @@ class RTCEngine extends eventsExports.EventEmitter {
25013
25521
  _this2.maxJoinAttempts = opts.maxRetries;
25014
25522
  try {
25015
25523
  _this2.joinAttempts += 1;
25524
+ _this2.joinStartedAt = performance.now();
25016
25525
  _this2.setupSignalClientCallbacks();
25017
25526
  // Whether the initial publisher offer is bundled with the join request. Computed once and
25018
25527
  // reused after the join below. Only the (non-Firefox) offer-with-join path does this.
@@ -25042,6 +25551,10 @@ class RTCEngine extends eventsExports.EventEmitter {
25042
25551
  throw ConnectionError.serviceNotFound('Simulated v1 path failure', 'v0-rtc');
25043
25552
  }
25044
25553
  const joinResponse = yield _this2.client.join(url, token, opts, abortSignal, useV0Path, offerProto);
25554
+ if (abortSignal === null || abortSignal === void 0 ? void 0 : abortSignal.aborted) {
25555
+ _this2.log.info('join cancelled after the signal join response');
25556
+ throw ConnectionError.cancelled('Connection aborted');
25557
+ }
25045
25558
  _this2._isClosed = false;
25046
25559
  _this2.latestJoinResponse = joinResponse;
25047
25560
  _this2.participantSid = (_b = joinResponse.participant) === null || _b === void 0 ? void 0 : _b.sid;
@@ -25054,6 +25567,10 @@ class RTCEngine extends eventsExports.EventEmitter {
25054
25567
  // the server's ICE servers and topology, then negotiate separately rather than bundling
25055
25568
  // the offer with the join.
25056
25569
  yield _this2.configure(joinResponse, !useV0Path);
25570
+ if (abortSignal === null || abortSignal === void 0 ? void 0 : abortSignal.aborted) {
25571
+ _this2.log.info('join cancelled after configuring peer connections');
25572
+ throw ConnectionError.cancelled('Connection aborted');
25573
+ }
25057
25574
  if (!useV0Path) {
25058
25575
  // The V1 first offer must carry the media layout so Firefox binds receive decoders for
25059
25576
  // subscribed tracks — without it, subscribed audio/video arrive as RTP but
@@ -25068,6 +25585,11 @@ class RTCEngine extends eventsExports.EventEmitter {
25068
25585
  });
25069
25586
  }
25070
25587
  }
25588
+ if (_this2.joinStartedAt !== undefined) {
25589
+ // Lets a video published before the transports connect be seeded from the time elapsed
25590
+ // so far, rather than delaying its offer until the setup time is measured.
25591
+ (_d = _this2.pcManager) === null || _d === void 0 ? void 0 : _d.publisher.setConnectionStartedAt(_this2.joinStartedAt);
25592
+ }
25071
25593
  _this2.registerOnLineListener();
25072
25594
  _this2.clientConfiguration = joinResponse.clientConfiguration;
25073
25595
  _this2.emit(EngineEvent.SignalConnected, joinResponse);
@@ -25116,6 +25638,9 @@ class RTCEngine extends eventsExports.EventEmitter {
25116
25638
  */
25117
25639
  close(reason) {
25118
25640
  return __awaiter(this, void 0, void 0, function* () {
25641
+ var _a;
25642
+ (_a = this.reconnectAbortController) === null || _a === void 0 ? void 0 : _a.abort();
25643
+ this.reconnectAbortController = undefined;
25119
25644
  const unlock = yield this.closingLock.lock();
25120
25645
  if (this.isClosed) {
25121
25646
  unlock();
@@ -25124,6 +25649,7 @@ class RTCEngine extends eventsExports.EventEmitter {
25124
25649
  try {
25125
25650
  this._isClosed = true;
25126
25651
  this.joinAttempts = 0;
25652
+ this.joinStartedAt = undefined;
25127
25653
  this.emit(EngineEvent.Closing);
25128
25654
  this.removeAllListeners();
25129
25655
  this.deregisterOnLineListener();
@@ -25252,6 +25778,7 @@ class RTCEngine extends eventsExports.EventEmitter {
25252
25778
  };
25253
25779
  this.pcManager.onDataChannel = this.handleDataChannel;
25254
25780
  this.pcManager.onStateChange = (connectionState, publisherState, subscriberState) => __awaiter(this, void 0, void 0, function* () {
25781
+ var _a;
25255
25782
  this.log.debug("primary PC state changed ".concat(connectionState));
25256
25783
  // Record when the primary transport actually entered CONNECTING so
25257
25784
  // verifyTransport() can bound how long we tolerate it. Deriving it from the
@@ -25267,6 +25794,16 @@ class RTCEngine extends eventsExports.EventEmitter {
25267
25794
  this.publisherConnectionPromise = undefined;
25268
25795
  }
25269
25796
  if (connectionState === PCTransportState.CONNECTED) {
25797
+ if (this.joinStartedAt !== undefined) {
25798
+ // Connection setup time is the only network signal there is before the first video
25799
+ // offer. Set synchronously, so it is in place before `room.connect()` resolves and the
25800
+ // app can publish. A full restart joins again and builds new transports, so it is
25801
+ // measured again; a resume keeps its estimator and never restarts the timer.
25802
+ const setupTimeMs = Math.round(performance.now() - this.joinStartedAt);
25803
+ this.joinStartedAt = undefined;
25804
+ this.log.info("connection setup took ".concat(setupTimeMs, " ms"));
25805
+ (_a = this.pcManager) === null || _a === void 0 ? void 0 : _a.publisher.setConnectionSetupTime(setupTimeMs);
25806
+ }
25270
25807
  const shouldEmit = this.pcState === PCState.New;
25271
25808
  this.pcState = PCState.Connected;
25272
25809
  if (shouldEmit) {
@@ -25501,6 +26038,12 @@ class RTCEngine extends eventsExports.EventEmitter {
25501
26038
  if (!this.pcManager) {
25502
26039
  return;
25503
26040
  }
26041
+ if (this.dataChannels.hasPublisherChannels) {
26042
+ // Replacing live channels (the Safari null-id path in resumeConnection): the new ones start
26043
+ // out connecting, so drop the memoized readiness check and make the next sender wait for
26044
+ // them to open.
26045
+ this.publisherConnectionPromise = undefined;
26046
+ }
25504
26047
  this.dataChannels.createPublisherChannels(this.pcManager);
25505
26048
  }
25506
26049
  /** Normalizes an incoming data-channel message into bytes, or logs and returns undefined. */
@@ -25733,16 +26276,28 @@ class RTCEngine extends eventsExports.EventEmitter {
25733
26276
  const fullReconnect = this.fullReconnectOnNext;
25734
26277
  this.fullReconnectOnNext = false;
25735
26278
  let succeeded = false;
26279
+ const abortController = new AbortController();
26280
+ this.reconnectAbortController = abortController;
25736
26281
  try {
25737
26282
  this.attemptingReconnect = true;
25738
26283
  if (fullReconnect) {
25739
- yield this.restartConnection();
26284
+ yield this.restartConnection(abortController.signal);
25740
26285
  } else {
25741
26286
  yield this.resumeConnection(reason);
25742
26287
  }
26288
+ if (abortController.signal.aborted) {
26289
+ this.log.info('reconnect attempt cancelled, not marking it as succeeded');
26290
+ return;
26291
+ }
25743
26292
  this.clearPendingReconnect();
25744
26293
  succeeded = true;
25745
26294
  } catch (e) {
26295
+ if (abortController.signal.aborted) {
26296
+ this.log.info('reconnect attempt cancelled, not retrying', {
26297
+ error: e
26298
+ });
26299
+ return;
26300
+ }
25746
26301
  this.reconnectAttempts += 1;
25747
26302
  let recoverable = true;
25748
26303
  if (e instanceof UnexpectedConnectionState) {
@@ -25764,6 +26319,10 @@ class RTCEngine extends eventsExports.EventEmitter {
25764
26319
  yield this.close("gave up reconnecting after ".concat(this.reconnectAttempts, " attempts, ").concat(Date.now() - this.reconnectStart, "ms"));
25765
26320
  }
25766
26321
  } finally {
26322
+ // close() may already have cleared this (or a later attempt replaced it); only release our own
26323
+ if (this.reconnectAbortController === abortController) {
26324
+ this.reconnectAbortController = undefined;
26325
+ }
25767
26326
  this.attemptingReconnect = false;
25768
26327
  // A full reconnect requested while this attempt was running (e.g. a `RECONNECT` leave
25769
26328
  // during a resume or a restart) that a successful attempt didn't act on; dispatch it now
@@ -25786,10 +26345,16 @@ class RTCEngine extends eventsExports.EventEmitter {
25786
26345
  // error in user code with provided reconnect policy, stop reconnecting
25787
26346
  return null;
25788
26347
  }
25789
- restartConnection(regionUrl) {
26348
+ restartConnection(abortSignal, regionUrl) {
25790
26349
  return __awaiter(this, void 0, void 0, function* () {
25791
26350
  var _a, _b, _c;
25792
26351
  try {
26352
+ if (abortSignal.aborted) {
26353
+ this.log.info('full reconnect cancelled before starting', {
26354
+ regionUrl
26355
+ });
26356
+ return;
26357
+ }
25793
26358
  if (!this.url || !this.token) {
25794
26359
  // permanent failure, don't attempt reconnection
25795
26360
  throw new UnexpectedConnectionState('could not reconnect, url or token not saved');
@@ -25801,6 +26366,12 @@ class RTCEngine extends eventsExports.EventEmitter {
25801
26366
  }
25802
26367
  yield this.cleanupPeerConnections();
25803
26368
  yield this.cleanupClient();
26369
+ if (abortSignal.aborted) {
26370
+ this.log.info('full reconnect cancelled after cleaning up the previous session', {
26371
+ regionUrl
26372
+ });
26373
+ return;
26374
+ }
25804
26375
  let joinResponse;
25805
26376
  try {
25806
26377
  if (!this.signalOpts) {
@@ -25808,13 +26379,19 @@ class RTCEngine extends eventsExports.EventEmitter {
25808
26379
  throw new SignalReconnectError();
25809
26380
  }
25810
26381
  // in case a regionUrl is passed, the region URL takes precedence
25811
- joinResponse = (yield this.join(regionUrl !== null && regionUrl !== void 0 ? regionUrl : this.url, this.token, this.signalOpts, undefined, !this.options.singlePeerConnection)).joinResponse;
26382
+ joinResponse = (yield this.join(regionUrl !== null && regionUrl !== void 0 ? regionUrl : this.url, this.token, this.signalOpts, abortSignal, !this.options.singlePeerConnection)).joinResponse;
25812
26383
  } catch (e) {
25813
26384
  if (e instanceof ConnectionError && e.reason === ConnectionErrorReason.NotAllowed) {
25814
26385
  throw new UnexpectedConnectionState('could not reconnect, token might be expired');
25815
26386
  }
25816
26387
  throw new SignalReconnectError();
25817
26388
  }
26389
+ if (abortSignal.aborted) {
26390
+ this.log.info('full reconnect cancelled after the signal join response', {
26391
+ regionUrl
26392
+ });
26393
+ return;
26394
+ }
25818
26395
  if (this.shouldFailNext) {
25819
26396
  this.shouldFailNext = false;
25820
26397
  throw new Error('simulated failure');
@@ -25822,6 +26399,12 @@ class RTCEngine extends eventsExports.EventEmitter {
25822
26399
  this.client.setReconnected();
25823
26400
  this.emit(EngineEvent.SignalRestarted, joinResponse);
25824
26401
  yield this.waitForPCReconnected();
26402
+ if (abortSignal.aborted) {
26403
+ this.log.info('full reconnect cancelled after peer connections reconnected', {
26404
+ regionUrl
26405
+ });
26406
+ return;
26407
+ }
25825
26408
  // re-check signal connection state before setting engine as resumed
25826
26409
  if (this.client.currentState !== SignalConnectionState.CONNECTED) {
25827
26410
  throw new SignalReconnectError('Signal connection got severed during reconnect');
@@ -25830,9 +26413,21 @@ class RTCEngine extends eventsExports.EventEmitter {
25830
26413
  // reconnect success
25831
26414
  this.emit(EngineEvent.Restarted);
25832
26415
  } catch (error) {
25833
- const nextRegionUrl = yield (_b = this.regionStrategy) === null || _b === void 0 ? void 0 : _b.getNextUrl();
26416
+ if (abortSignal.aborted) {
26417
+ this.log.info('full reconnect cancelled after a failed attempt', {
26418
+ regionUrl
26419
+ });
26420
+ return;
26421
+ }
26422
+ const nextRegionUrl = yield (_b = this.regionStrategy) === null || _b === void 0 ? void 0 : _b.getNextUrl(abortSignal);
26423
+ if (abortSignal.aborted) {
26424
+ this.log.info('full reconnect cancelled during region selection', {
26425
+ regionUrl
26426
+ });
26427
+ return;
26428
+ }
25834
26429
  if (nextRegionUrl) {
25835
- yield this.restartConnection(nextRegionUrl);
26430
+ yield this.restartConnection(abortSignal, nextRegionUrl);
25836
26431
  return;
25837
26432
  } else {
25838
26433
  // no more regions to try (or we're not on cloud)
@@ -26027,7 +26622,11 @@ class RTCEngine extends eventsExports.EventEmitter {
26027
26622
  */
26028
26623
  waitForBufferHeadroom(kind) {
26029
26624
  return __awaiter(this, void 0, void 0, function* () {
26030
- return this.flowControlFor(kind).waitForHeadroomWithLock();
26625
+ const dc = this.dataChannelForKind(kind);
26626
+ if (!dc) {
26627
+ throw new UnexpectedConnectionState("DataChannel not found, kind: ".concat(kind));
26628
+ }
26629
+ return this.flowControlFor(kind).waitForHeadroomWithLock(dc);
26031
26630
  });
26032
26631
  }
26033
26632
  /**
@@ -26887,6 +27486,16 @@ class TextStreamReader extends BaseStreamReader {
26887
27486
  this.byteStreamHandlers.delete(topic);
26888
27487
  }
26889
27488
  clearControllers() {
27489
+ // Error any in flight streams so readers do not wait forever for chunks that can no longer arrive.
27490
+ const abnormalEndError = new DataStreamError('Room disconnected in the middle of receiving data', DataStreamErrorReason.AbnormalEnd);
27491
+ for (const _ref2 of this.byteStreamControllers.values()) {
27492
+ const controller = _ref2.controller;
27493
+ controller.error(abnormalEndError);
27494
+ }
27495
+ for (const _ref3 of this.textStreamControllers.values()) {
27496
+ const controller = _ref3.controller;
27497
+ controller.error(abnormalEndError);
27498
+ }
26890
27499
  this.byteStreamControllers.clear();
26891
27500
  this.textStreamControllers.clear();
26892
27501
  this.bufferedPackets = [];
@@ -26897,18 +27506,18 @@ class TextStreamReader extends BaseStreamReader {
26897
27506
  const byteStreamsBeingSentByDisconnectingParticipant = Array.from(this.byteStreamControllers.entries()).filter(entry => entry[1].sendingParticipantIdentity === participantIdentity);
26898
27507
  if (textStreamsBeingSentByDisconnectingParticipant.length > 0 || byteStreamsBeingSentByDisconnectingParticipant.length > 0) {
26899
27508
  const abnormalEndError = new DataStreamError("Participant ".concat(participantIdentity, " unexpectedly disconnected in the middle of sending data"), DataStreamErrorReason.AbnormalEnd);
26900
- for (const _ref2 of byteStreamsBeingSentByDisconnectingParticipant) {
26901
- var _ref3 = _slicedToArray(_ref2, 2);
26902
- const id = _ref3[0];
26903
- const controller = _ref3[1];
26904
- controller.controller.error(abnormalEndError);
26905
- this.byteStreamControllers.delete(id);
26906
- }
26907
- for (const _ref4 of textStreamsBeingSentByDisconnectingParticipant) {
27509
+ for (const _ref4 of byteStreamsBeingSentByDisconnectingParticipant) {
26908
27510
  var _ref5 = _slicedToArray(_ref4, 2);
26909
27511
  const id = _ref5[0];
26910
27512
  const controller = _ref5[1];
26911
27513
  controller.controller.error(abnormalEndError);
27514
+ this.byteStreamControllers.delete(id);
27515
+ }
27516
+ for (const _ref6 of textStreamsBeingSentByDisconnectingParticipant) {
27517
+ var _ref7 = _slicedToArray(_ref6, 2);
27518
+ const id = _ref7[0];
27519
+ const controller = _ref7[1];
27520
+ controller.controller.error(abnormalEndError);
26912
27521
  this.textStreamControllers.delete(id);
26913
27522
  }
26914
27523
  }
@@ -27905,58 +28514,6 @@ function sendStreamTrailer(streamId, destinationIdentities, engine) {
27905
28514
  });
27906
28515
  yield engine.sendDataPacket(trailerPacket, DataChannelKind.RELIABLE);
27907
28516
  });
27908
- }/**
27909
- * Implementation of AbortSignal.any
27910
- * Creates a signal that will be aborted when any of the given signals is aborted.
27911
- * @link https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal/any
27912
- */
27913
- function abortSignalAny(signals) {
27914
- // Handle empty signals array
27915
- if (signals.length === 0) {
27916
- const controller = new AbortController();
27917
- return controller.signal;
27918
- }
27919
- // Fast path for single signal
27920
- if (signals.length === 1) {
27921
- return signals[0];
27922
- }
27923
- // Check if any signal is already aborted
27924
- for (const signal of signals) {
27925
- if (signal.aborted) {
27926
- return signal;
27927
- }
27928
- }
27929
- // Create a new controller for the combined signal
27930
- const controller = new AbortController();
27931
- const unlisteners = Array(signals.length);
27932
- // Function to clean up all event listeners
27933
- const cleanup = () => {
27934
- for (const unsubscribe of unlisteners) {
27935
- unsubscribe();
27936
- }
27937
- };
27938
- // Add event listeners to each signal
27939
- signals.forEach((signal, index) => {
27940
- const handler = () => {
27941
- controller.abort(signal.reason);
27942
- cleanup();
27943
- };
27944
- signal.addEventListener('abort', handler);
27945
- unlisteners[index] = () => signal.removeEventListener('abort', handler);
27946
- });
27947
- return controller.signal;
27948
- }
27949
- /**
27950
- * Implementation of AbortSignal.timeout
27951
- * Creates a signal that will be aborted after the specified timeout.
27952
- * @link https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal/timeout
27953
- */
27954
- function abortSignalTimeout(ms) {
27955
- const controller = new AbortController();
27956
- setTimeout(() => {
27957
- controller.abort(new DOMException("signal timed out after ".concat(ms, " ms"), 'TimeoutError'));
27958
- }, ms);
27959
- return controller.signal;
27960
28517
  }// Number type sizes
27961
28518
  const U8_LENGTH_BYTES = 1;
27962
28519
  const U16_LENGTH_BYTES = 2;
@@ -30313,6 +30870,12 @@ function truncateBytes(str, maxBytes) {
30313
30870
  high = mid - 1;
30314
30871
  }
30315
30872
  }
30873
+ // Never end on the first half of a surrogate pair: it would be encoded as a replacement
30874
+ // character, and the truncated string would no longer round trip.
30875
+ const lastCode = low > 0 ? str.charCodeAt(low - 1) : 0;
30876
+ if (lastCode >= 0xd800 && lastCode <= 0xdbff) {
30877
+ low -= 1;
30878
+ }
30316
30879
  return str.slice(0, low);
30317
30880
  }/**
30318
30881
  * Manages the client (caller) side of RPC: sending requests, tracking pending
@@ -30717,6 +31280,12 @@ class RpcServerManager extends eventsExports.EventEmitter {
30717
31280
  }class RemoteAudioTrack extends RemoteTrack {
30718
31281
  constructor(mediaTrack, sid, receiver, audioContext, audioOutput, loggerOptions) {
30719
31282
  super(mediaTrack, sid, Track.Kind.Audio, receiver, loggerOptions);
31283
+ /**
31284
+ * the nodes of the graph currently wired up, in order, so teardown can undo exactly the edges
31285
+ * `connectWebAudio` created. Reading `webAudioPluginNodes` instead would miss the old plugins
31286
+ * once `setWebAudioPlugins` has replaced them.
31287
+ */
31288
+ this.connectedNodes = [];
30720
31289
  this.monitorReceiver = () => __awaiter(this, void 0, void 0, function* () {
30721
31290
  if (!this.receiver) {
30722
31291
  this._currentBitrate = 0;
@@ -30739,10 +31308,11 @@ class RpcServerManager extends eventsExports.EventEmitter {
30739
31308
  */
30740
31309
  setVolume(volume) {
30741
31310
  var _a;
30742
- for (const el of this.attachedElements) {
30743
- if (this.audioContext) {
30744
- (_a = this.gainNode) === null || _a === void 0 ? void 0 : _a.gain.setTargetAtTime(volume, 0, 0.1);
30745
- } else {
31311
+ if (this.audioContext) {
31312
+ // playback goes through the web audio graph, the attached elements themselves stay silent
31313
+ (_a = this.gainNode) === null || _a === void 0 ? void 0 : _a.gain.setTargetAtTime(volume, 0, 0.1);
31314
+ } else {
31315
+ for (const el of this.attachedElements) {
30746
31316
  el.volume = volume;
30747
31317
  }
30748
31318
  }
@@ -30788,7 +31358,9 @@ class RpcServerManager extends eventsExports.EventEmitter {
30788
31358
  });
30789
31359
  }
30790
31360
  attach(element) {
30791
- const needsNewWebAudioConnection = this.attachedElements.length === 0;
31361
+ // the graph is fed by the track rather than by a specific element, so one connection covers
31362
+ // all attached elements
31363
+ const needsNewWebAudioConnection = !this.sourceNode;
30792
31364
  if (!element) {
30793
31365
  element = super.attach();
30794
31366
  } else {
@@ -30799,11 +31371,15 @@ class RpcServerManager extends eventsExports.EventEmitter {
30799
31371
  this.log.error('Failed to set sink id on remote audio track', e, this.logContext);
30800
31372
  });
30801
31373
  }
30802
- if (this.audioContext && needsNewWebAudioConnection) {
30803
- this.log.debug('using audio context mapping', this.logContext);
30804
- this.connectWebAudio(this.audioContext, element);
31374
+ if (this.audioContext) {
31375
+ // audio is routed through the web audio graph, so every attached element has to stay
31376
+ // silent, no matter whether it is the one the graph was built from
30805
31377
  element.volume = 0;
30806
31378
  element.muted = true;
31379
+ if (needsNewWebAudioConnection) {
31380
+ this.log.debug('using audio context mapping', this.logContext);
31381
+ this.connectWebAudio(this.audioContext, element);
31382
+ }
30807
31383
  }
30808
31384
  if (this.elementVolume !== undefined) {
30809
31385
  // make sure volume setting is being applied to the newly attached element
@@ -30835,11 +31411,27 @@ class RpcServerManager extends eventsExports.EventEmitter {
30835
31411
  * @experimental
30836
31412
  */
30837
31413
  setAudioContext(audioContext) {
31414
+ var _a;
31415
+ if (this.audioContext === audioContext) {
31416
+ return;
31417
+ }
30838
31418
  this.audioContext = audioContext;
30839
- if (audioContext && this.attachedElements.length > 0) {
30840
- this.connectWebAudio(audioContext, this.attachedElements[0]);
30841
- } else if (!audioContext) {
31419
+ if (audioContext) {
31420
+ // playback moves into the web audio graph, mute the elements to avoid double playback
31421
+ for (const element of this.attachedElements) {
31422
+ element.volume = 0;
31423
+ element.muted = true;
31424
+ }
31425
+ if (this.attachedElements.length > 0) {
31426
+ this.connectWebAudio(audioContext, this.attachedElements[0]);
31427
+ }
31428
+ } else {
30842
31429
  this.disconnectWebAudio();
31430
+ // playback moves back into the elements, which `attach` had muted in favour of the graph
31431
+ for (const element of this.attachedElements) {
31432
+ element.muted = false;
31433
+ element.volume = (_a = this.elementVolume) !== null && _a !== void 0 ? _a : 1;
31434
+ }
30843
31435
  }
30844
31436
  }
30845
31437
  /**
@@ -30858,12 +31450,15 @@ class RpcServerManager extends eventsExports.EventEmitter {
30858
31450
  // @ts-ignore attached elements always have a srcObject set
30859
31451
  this.sourceNode = context.createMediaStreamSource(element.srcObject);
30860
31452
  let lastNode = this.sourceNode;
31453
+ this.connectedNodes = [this.sourceNode];
30861
31454
  this.webAudioPluginNodes.forEach(node => {
30862
31455
  lastNode.connect(node);
30863
31456
  lastNode = node;
31457
+ this.connectedNodes.push(node);
30864
31458
  });
30865
31459
  this.gainNode = context.createGain();
30866
31460
  lastNode.connect(this.gainNode);
31461
+ this.connectedNodes.push(this.gainNode);
30867
31462
  this.gainNode.connect(context.destination);
30868
31463
  if (this.elementVolume !== undefined) {
30869
31464
  this.gainNode.gain.setTargetAtTime(this.elementVolume, 0, 0.1);
@@ -30881,6 +31476,19 @@ class RpcServerManager extends eventsExports.EventEmitter {
30881
31476
  }
30882
31477
  disconnectWebAudio() {
30883
31478
  var _a, _b;
31479
+ // the plugin nodes belong to whoever passed them in and may feed graphs of their own, so only
31480
+ // the edges this track created come down. A bare `node.disconnect()` would drop the
31481
+ // application's own routing with them
31482
+ for (let i = 0; i < this.connectedNodes.length - 1; i += 1) {
31483
+ try {
31484
+ this.connectedNodes[i].disconnect(this.connectedNodes[i + 1]);
31485
+ } catch (_c) {
31486
+ // a plugin node the application already disconnected itself, the rest of the chain still
31487
+ // has to come apart
31488
+ }
31489
+ }
31490
+ this.connectedNodes = [];
31491
+ // the source and gain nodes are ours alone, so every remaining edge of theirs is ours to drop
30884
31492
  (_a = this.gainNode) === null || _a === void 0 ? void 0 : _a.disconnect();
30885
31493
  (_b = this.sourceNode) === null || _b === void 0 ? void 0 : _b.disconnect();
30886
31494
  this.gainNode = undefined;
@@ -31542,8 +32150,10 @@ class Participant extends eventsExports.EventEmitter {
31542
32150
  * @internal
31543
32151
  */
31544
32152
  setAudioContext(ctx) {
31545
- this.audioContext = ctx;
31546
- this.audioTrackPublications.forEach(track => isAudioTrack(track.track) && track.track.setAudioContext(ctx));
32153
+ return __awaiter(this, void 0, void 0, function* () {
32154
+ this.audioContext = ctx;
32155
+ yield Promise.all(Array.from(this.audioTrackPublications.values()).map(publication => publication.track).filter(isAudioTrack).map(track => track.setAudioContext(ctx)));
32156
+ });
31547
32157
  }
31548
32158
  addTrackPublication(publication) {
31549
32159
  this.log.debug("adding track publication", {
@@ -31802,10 +32412,7 @@ class Participant extends eventsExports.EventEmitter {
31802
32412
  if (!track.isMuted) {
31803
32413
  this.log.debug('track ended, attempting to use a different device', getLogContextFromTrack(track));
31804
32414
  if (isLocalAudioTrack(track)) {
31805
- // fall back to default device if available
31806
- yield track.restartTrack({
31807
- deviceId: 'default'
31808
- });
32415
+ yield this.restartOnDefaultAudioDevice(track);
31809
32416
  } else {
31810
32417
  yield track.restartTrack();
31811
32418
  }
@@ -32152,10 +32759,10 @@ class Participant extends eventsExports.EventEmitter {
32152
32759
  loggerName: this.roomOptions.loggerName,
32153
32760
  loggerContextCb: () => this.logContext
32154
32761
  });
32155
- const localTracks = tracks.map(track => {
32762
+ const localTracks = yield Promise.all(tracks.map(track => __awaiter(this, void 0, void 0, function* () {
32156
32763
  if (isAudioTrack(track)) {
32157
32764
  this.microphoneError = undefined;
32158
- track.setAudioContext(this.audioContext);
32765
+ yield track.setAudioContext(this.audioContext);
32159
32766
  track.source = Track.Source.Microphone;
32160
32767
  this.emit(ParticipantEvent.AudioStreamAcquired);
32161
32768
  }
@@ -32164,7 +32771,7 @@ class Participant extends eventsExports.EventEmitter {
32164
32771
  track.source = Track.Source.Camera;
32165
32772
  }
32166
32773
  return track;
32167
- });
32774
+ })));
32168
32775
  return localTracks;
32169
32776
  } catch (err) {
32170
32777
  if (err instanceof Error) {
@@ -32274,7 +32881,7 @@ class Participant extends eventsExports.EventEmitter {
32274
32881
  return function* () {
32275
32882
  var _a, _b, _c, _d;
32276
32883
  if (isLocalAudioTrack(track)) {
32277
- track.setAudioContext(_this2.audioContext);
32884
+ yield track.setAudioContext(_this2.audioContext);
32278
32885
  }
32279
32886
  yield (_a = _this2.reconnectFuture) === null || _a === void 0 ? void 0 : _a.promise;
32280
32887
  if (_this2.republishPromise && !isRepublish) {
@@ -32874,19 +33481,36 @@ class Participant extends eventsExports.EventEmitter {
32874
33481
  return;
32875
33482
  }
32876
33483
  const packetTrailerFeatures = this.normalizeRequestedFrameMetadataOptions(track, opts);
33484
+ // the backup sender encodes the processed track, not the raw capture track.dimensions reports
33485
+ const _track$mediaStreamTra = track.mediaStreamTrack.getSettings(),
33486
+ width = _track$mediaStreamTra.width,
33487
+ height = _track$mediaStreamTra.height;
33488
+ const dims = width && height ? {
33489
+ width,
33490
+ height
33491
+ } : undefined;
33492
+ if (!dims) {
33493
+ this.log.warn('could not determine track dimensions, publishing backup codec without layers', getLogContextFromTrack(track));
33494
+ }
33495
+ // the server reads the layers off the codec entry for a request that carries a sid, and
33496
+ // falls back to the top level ones when it predates per codec layers
33497
+ const layers = dims ? videoLayersFromEncodings(dims.width, dims.height, encodings) : [];
32877
33498
  const req = new AddTrackRequest({
32878
33499
  cid: simulcastTrack.mediaStreamTrack.id,
32879
33500
  type: Track.kindToProto(track.kind),
32880
33501
  muted: track.isMuted,
32881
33502
  source: Track.sourceToProto(track.source),
32882
33503
  sid: track.sid,
33504
+ width: dims === null || dims === void 0 ? void 0 : dims.width,
33505
+ height: dims === null || dims === void 0 ? void 0 : dims.height,
32883
33506
  packetTrailerFeatures,
33507
+ layers,
32884
33508
  simulcastCodecs: [{
32885
33509
  codec: opts.videoCodec,
32886
- cid: simulcastTrack.mediaStreamTrack.id
33510
+ cid: simulcastTrack.mediaStreamTrack.id,
33511
+ layers
32887
33512
  }]
32888
33513
  });
32889
- req.layers = videoLayersFromEncodings(req.width, req.height, encodings);
32890
33514
  if (!this.engine || this.engine.isClosed) {
32891
33515
  throw new UnexpectedConnectionState('cannot publish track when not connected');
32892
33516
  }
@@ -33306,6 +33930,35 @@ class Participant extends eventsExports.EventEmitter {
33306
33930
  }
33307
33931
  return this.activeAgentFuture.promise;
33308
33932
  }
33933
+ /**
33934
+ * @internal
33935
+ */
33936
+ restartOnDefaultAudioDevice(track) {
33937
+ return __awaiter(this, void 0, void 0, function* () {
33938
+ var _a, _b;
33939
+ try {
33940
+ if (isChromiumBased()) {
33941
+ // only chrome has the notion of a literal `default` device
33942
+ yield track.setDeviceId({
33943
+ exact: 'default'
33944
+ });
33945
+ } else {
33946
+ // just try to restart for other browsers and try to change the device id as a fallback in the catch clause
33947
+ yield track.restartTrack();
33948
+ }
33949
+ } catch (e) {
33950
+ // restart() clears the manually-stopped flag on entry, so retrying a track the user stopped
33951
+ // while the first attempt was in flight would re-open the capture device behind their back
33952
+ if (!isDeviceAcquisitionFailure(e) || isPermissionDeniedError(e) || track.isStopped) {
33953
+ throw e;
33954
+ }
33955
+ this.log.debug('could not reacquire the audio device, retrying with an enumerated device', getLogContextFromTrack(track));
33956
+ // permission is already granted at this point, so skip the probe that would re-acquire media
33957
+ const allDevices = yield DeviceManager.getInstance().getDevices('audioinput', false);
33958
+ yield track.setDeviceId((_b = (_a = allDevices === null || allDevices === void 0 ? void 0 : allDevices[0]) === null || _a === void 0 ? void 0 : _a.deviceId) !== null && _b !== void 0 ? _b : 'default');
33959
+ }
33960
+ });
33961
+ }
33309
33962
  getPublicationForTrack(track) {
33310
33963
  let publication;
33311
33964
  this.trackPublications.forEach(pub => {
@@ -34016,6 +34669,30 @@ class DeferrableMap extends Map {
34016
34669
  })(ConnectionState || (ConnectionState = {}));
34017
34670
  const CONNECTION_RECONCILE_FREQUENCY_MS = 4 * 1000;
34018
34671
  const STATS_LOG_FREQUENCY_MS = 30 * 1000;
34672
+ const DUMMY_AUDIO_ELEMENT_ID = 'livekit-dummy-audio-el';
34673
+ /**
34674
+ * One silent audio element serves every room on the page, so it is reference counted: a room
34675
+ * disconnecting must not pull it out from under the rooms still playing through it. The element
34676
+ * and its track go away once the last room hands its reference back.
34677
+ */
34678
+ let sharedDummyAudioElement;
34679
+ function createSharedDummyAudioElement() {
34680
+ const element = document.createElement('audio');
34681
+ element.id = DUMMY_AUDIO_ELEMENT_ID;
34682
+ element.autoplay = true;
34683
+ element.hidden = true;
34684
+ const track = getEmptyAudioStreamTrack();
34685
+ track.enabled = true;
34686
+ const stream = new MediaStream([track]);
34687
+ element.srcObject = stream;
34688
+ document.body.append(element);
34689
+ return {
34690
+ element,
34691
+ stream,
34692
+ track,
34693
+ refCount: 0
34694
+ };
34695
+ }
34019
34696
  /**
34020
34697
  * In LiveKit, a room is the logical grouping for a list of participants.
34021
34698
  * Participants in a room can publish tracks, and subscribe to others' tracks.
@@ -34046,6 +34723,24 @@ class Room extends eventsExports.EventEmitter {
34046
34723
  /** reflects the sender encryption status of the local participant */
34047
34724
  this.isE2EEEnabled = false;
34048
34725
  this.audioEnabled = true;
34726
+ /**
34727
+ * whether `audioContext` was created by the SDK and therefore has to be closed by it.
34728
+ * A context handed in through `webAudioMix` is owned by the application instead.
34729
+ */
34730
+ this.ownsAudioContext = false;
34731
+ /**
34732
+ * set once the audio context has been released as part of a disconnect. Guards against
34733
+ * creating a new context outside of a connection lifecycle, where nothing would close it again.
34734
+ */
34735
+ this.audioContextReleased = false;
34736
+ /**
34737
+ * serialises `acquireAudioContext` against `releaseAudioContext`. Both await per-track graph
34738
+ * rebuilds, so without it a reconnect landing inside a disconnect's teardown leaves the two
34739
+ * racing to set the participants' context, and the loser's value is the one that sticks.
34740
+ */
34741
+ this.audioContextMutex = new _();
34742
+ /** silent audio tracks this room acquired, handed back to the shared source on disconnect */
34743
+ this.acquiredEmptyAudioTracks = new Set();
34049
34744
  this.e2eeStateMutex = new _();
34050
34745
  this.isVideoPlaybackBlocked = false;
34051
34746
  this.log = livekitLogger;
@@ -34218,7 +34913,9 @@ class Room extends eventsExports.EventEmitter {
34218
34913
  if ((_b = this.regionUrlProvider) === null || _b === void 0 ? void 0 : _b.isCloud()) {
34219
34914
  this.engine.setRegionStrategy(this.createRegionStrategy());
34220
34915
  }
34221
- this.acquireAudioContext();
34916
+ this.acquireAudioContext(true).catch(error => this.log.warn('Could not acquire audio context', Object.assign(Object.assign({}, this.logContext), {
34917
+ error
34918
+ })));
34222
34919
  this.connOptions = Object.assign(Object.assign({}, roomConnectOptionDefaults), opts);
34223
34920
  if (this.connOptions.rtcConfig) {
34224
34921
  this.engine.rtcConfig = this.connOptions.rtcConfig;
@@ -34337,44 +35034,8 @@ class Room extends eventsExports.EventEmitter {
34337
35034
  this.startAudio = () => __awaiter(this, void 0, void 0, function* () {
34338
35035
  const elements = [];
34339
35036
  const browser = getBrowser();
34340
- if (browser && browser.os === 'iOS') {
34341
- /**
34342
- * iOS blocks audio element playback if
34343
- * - user is not publishing audio themselves and
34344
- * - no other audio source is playing
34345
- *
34346
- * as a workaround, we create an audio element with an empty track, so that
34347
- * silent audio is always playing
34348
- */
34349
- const audioId = 'livekit-dummy-audio-el';
34350
- let dummyAudioEl = document.getElementById(audioId);
34351
- if (!dummyAudioEl) {
34352
- dummyAudioEl = document.createElement('audio');
34353
- dummyAudioEl.id = audioId;
34354
- dummyAudioEl.autoplay = true;
34355
- dummyAudioEl.hidden = true;
34356
- const track = getEmptyAudioStreamTrack();
34357
- track.enabled = true;
34358
- const stream = new MediaStream([track]);
34359
- dummyAudioEl.srcObject = stream;
34360
- document.addEventListener('visibilitychange', () => {
34361
- if (!dummyAudioEl) {
34362
- return;
34363
- }
34364
- // set the srcObject to null on page hide in order to prevent lock screen controls to show up for it
34365
- dummyAudioEl.srcObject = document.hidden ? null : stream;
34366
- if (!document.hidden) {
34367
- this.log.debug('page visible again, triggering startAudio to resume playback and update playback status');
34368
- this.startAudio();
34369
- }
34370
- });
34371
- document.body.append(dummyAudioEl);
34372
- this.once(RoomEvent.Disconnected, () => {
34373
- dummyAudioEl === null || dummyAudioEl === void 0 ? void 0 : dummyAudioEl.remove();
34374
- dummyAudioEl = null;
34375
- });
34376
- }
34377
- elements.push(dummyAudioEl);
35037
+ if (browser && browser.os === 'iOS' && !this.audioContextReleased) {
35038
+ elements.push(this.acquireDummyAudioElement());
34378
35039
  }
34379
35040
  this.remoteParticipants.forEach(p => {
34380
35041
  p.audioTrackPublications.forEach(t => {
@@ -34386,7 +35047,10 @@ class Room extends eventsExports.EventEmitter {
34386
35047
  });
34387
35048
  });
34388
35049
  try {
34389
- yield Promise.all([this.acquireAudioContext(), ...elements.map(e => {
35050
+ yield Promise.all([
35051
+ // once the audio context has been released as part of a disconnect there is nothing left
35052
+ // to unlock, and creating a new context here would leak it as no disconnect will follow
35053
+ ...(this.audioContextReleased ? [] : [this.acquireAudioContext()]), ...elements.map(e => {
34390
35054
  // when webAudioMix is enabled, attached elements are deliberately kept muted by
34391
35055
  // RemoteAudioTrack.attach() and audio is routed through the web audio graph instead.
34392
35056
  // unmuting them here would cause double playback on platforms where element.volume
@@ -35430,6 +36094,78 @@ class Room extends eventsExports.EventEmitter {
35430
36094
  }
35431
36095
  });
35432
36096
  }
36097
+ /**
36098
+ * iOS blocks audio element playback if
36099
+ * - user is not publishing audio themselves and
36100
+ * - no other audio source is playing
36101
+ *
36102
+ * as a workaround, we create an audio element with an empty track, so that
36103
+ * silent audio is always playing
36104
+ *
36105
+ * The element is shared with any other room on the page and handed back on disconnect, so it
36106
+ * outlives whichever room happened to create it.
36107
+ */
36108
+ acquireDummyAudioElement() {
36109
+ if (!sharedDummyAudioElement) {
36110
+ sharedDummyAudioElement = createSharedDummyAudioElement();
36111
+ }
36112
+ const shared = sharedDummyAudioElement;
36113
+ if (this.releaseDummyAudioElement) {
36114
+ // this room already holds a reference, a second one would never be handed back
36115
+ return shared.element;
36116
+ }
36117
+ shared.refCount += 1;
36118
+ // the listener is per room rather than shared, so that the room it resumes playback for is
36119
+ // still connected, and so that it goes away with the room that installed it
36120
+ const handleVisibilityChange = () => {
36121
+ // set the srcObject to null on page hide in order to prevent lock screen controls to show up for it
36122
+ shared.element.srcObject = document.hidden ? null : shared.stream;
36123
+ if (!document.hidden) {
36124
+ this.log.debug('page visible again, triggering startAudio to resume playback and update playback status');
36125
+ this.startAudio().catch(error => this.log.warn('Could not restart audio on page becoming visible', Object.assign(Object.assign({}, this.logContext), {
36126
+ error
36127
+ })));
36128
+ }
36129
+ };
36130
+ document.addEventListener('visibilitychange', handleVisibilityChange);
36131
+ this.releaseDummyAudioElement = () => {
36132
+ this.releaseDummyAudioElement = undefined;
36133
+ document.removeEventListener('visibilitychange', handleVisibilityChange);
36134
+ shared.refCount -= 1;
36135
+ if (shared.refCount > 0) {
36136
+ return;
36137
+ }
36138
+ shared.element.srcObject = null;
36139
+ shared.element.remove();
36140
+ sharedDummyAudioElement = undefined;
36141
+ releaseEmptyAudioStreamTrack(shared.track).catch(error => this.log.warn('Could not release the dummy audio element track', Object.assign(Object.assign({}, this.logContext), {
36142
+ error
36143
+ })));
36144
+ };
36145
+ return shared.element;
36146
+ }
36147
+ /**
36148
+ * Obtains a silent audio track from the shared source and keeps track of it, so that it can be
36149
+ * handed back on disconnect and the shared `AudioContext` behind it can be closed again.
36150
+ */
36151
+ acquireEmptyAudioStreamTrack() {
36152
+ const track = getEmptyAudioStreamTrack();
36153
+ this.acquiredEmptyAudioTracks.add(track);
36154
+ return track;
36155
+ }
36156
+ releaseAcquiredEmptyAudioTrack(track) {
36157
+ if (!this.acquiredEmptyAudioTracks.delete(track)) {
36158
+ return;
36159
+ }
36160
+ releaseEmptyAudioStreamTrack(track).catch(error => this.log.warn('Could not release empty audio stream track', Object.assign(Object.assign({}, this.logContext), {
36161
+ error
36162
+ })));
36163
+ }
36164
+ releaseAcquiredEmptyAudioTracks() {
36165
+ for (const track of this.acquiredEmptyAudioTracks) {
36166
+ this.releaseAcquiredEmptyAudioTrack(track);
36167
+ }
36168
+ }
35433
36169
  /**
35434
36170
  * Returns true if audio playback is enabled
35435
36171
  */
@@ -35712,7 +36448,7 @@ class Room extends eventsExports.EventEmitter {
35712
36448
  handleDisconnect() {
35713
36449
  let shouldStopTracks = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : true;
35714
36450
  let reason = arguments.length > 1 ? arguments[1] : undefined;
35715
- var _a, _b;
36451
+ var _a, _b, _c;
35716
36452
  this.clearConnectionReconcile();
35717
36453
  this.isResuming = false;
35718
36454
  this.bufferedEvents = [];
@@ -35729,6 +36465,9 @@ class Room extends eventsExports.EventEmitter {
35729
36465
  this.regionUrlProvider.notifyDisconnected();
35730
36466
  }
35731
36467
  try {
36468
+ // audio tracks that outlive this room are about to become unreachable through the local
36469
+ // participant, collect them while we can so they can be detached from the context below
36470
+ const retainedAudioTracks = shouldStopTracks ? [] : Array.from(this.localParticipant.audioTrackPublications.values()).map(pub => pub.track).filter(isLocalAudioTrack);
35732
36471
  this.remoteParticipants.forEach(p => {
35733
36472
  p.trackPublications.forEach(pub => {
35734
36473
  p.unpublishTrack(pub.trackSid);
@@ -35753,15 +36492,18 @@ class Room extends eventsExports.EventEmitter {
35753
36492
  this.remoteParticipants.clear();
35754
36493
  this.sidToIdentity.clear();
35755
36494
  this.activeSpeakers = [];
35756
- if (this.audioContext && typeof this.options.webAudioMix === 'boolean') {
35757
- this.audioContext.close();
35758
- this.audioContext = undefined;
35759
- }
36495
+ (_a = this.releaseDummyAudioElement) === null || _a === void 0 ? void 0 : _a.call(this);
36496
+ this.releaseAcquiredEmptyAudioTracks();
36497
+ // teardown is asynchronous (processors need to release their web audio nodes), but
36498
+ // `handleDisconnect` is not, so let it finish in the background
36499
+ this.releaseAudioContext(retainedAudioTracks).catch(error => this.log.warn('Could not release audio context', Object.assign(Object.assign({}, this.logContext), {
36500
+ error
36501
+ })));
35760
36502
  if (isWeb()) {
35761
36503
  window.removeEventListener('beforeunload', this.onPageLeave);
35762
36504
  window.removeEventListener('pagehide', this.onPageLeave);
35763
36505
  window.removeEventListener('freeze', this.onPageLeave);
35764
- (_b = (_a = navigator.mediaDevices) === null || _a === void 0 ? void 0 : _a.removeEventListener) === null || _b === void 0 ? void 0 : _b.call(_a, 'devicechange', this.handleDeviceChange);
36506
+ (_c = (_b = navigator.mediaDevices) === null || _b === void 0 ? void 0 : _b.removeEventListener) === null || _c === void 0 ? void 0 : _c.call(_b, 'devicechange', this.handleDeviceChange);
35765
36507
  }
35766
36508
  } finally {
35767
36509
  this.setAndEmitConnectionState(ConnectionState.Disconnected);
@@ -35836,39 +36578,139 @@ class Room extends eventsExports.EventEmitter {
35836
36578
  }
35837
36579
  });
35838
36580
  }
36581
+ /**
36582
+ * @param forConnect whether this acquisition belongs to a connect, which is the only caller
36583
+ * allowed to put a released room back on an audio context.
36584
+ */
35839
36585
  acquireAudioContext() {
35840
- return __awaiter(this, void 0, void 0, function* () {
35841
- var _a, _b;
35842
- if (typeof this.options.webAudioMix !== 'boolean' && this.options.webAudioMix.audioContext) {
35843
- // override audio context with custom audio context if supplied by user
35844
- this.audioContext = this.options.webAudioMix.audioContext;
35845
- } else if (!this.audioContext || this.audioContext.state === 'closed') {
35846
- // by using an AudioContext, it reduces lag on audio elements
35847
- // https://stackoverflow.com/questions/9811429/html5-audio-tag-on-safari-has-a-delay/54119854#54119854
35848
- this.audioContext = (_a = getNewAudioContext()) !== null && _a !== void 0 ? _a : undefined;
35849
- }
35850
- if (this.options.webAudioMix) {
35851
- this.remoteParticipants.forEach(participant => participant.setAudioContext(this.audioContext));
35852
- }
35853
- this.localParticipant.setAudioContext(this.audioContext);
35854
- if (this.audioContext && this.audioContext.state === 'suspended') {
35855
- // for iOS a newly created AudioContext is always in `suspended` state.
35856
- // we try our best to resume the context here, if that doesn't work, we just continue with regular processing
36586
+ return __awaiter(this, arguments, void 0, function () {
36587
+ var _this4 = this;
36588
+ let forConnect = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : false;
36589
+ return function* () {
36590
+ var _a, _b;
36591
+ const unlock = yield _this4.audioContextMutex.lock();
35857
36592
  try {
35858
- yield Promise.race([this.audioContext.resume(), sleep(200)]);
35859
- } catch (e) {
35860
- this.log.warn('Could not resume audio context', {
35861
- error: e
35862
- });
36593
+ if (_this4.audioContextReleased && !forConnect) {
36594
+ // a disconnect set the flag and took the lock while this call was queued behind it.
36595
+ // Creating a context now would leave one open on a room nothing tears down again
36596
+ return;
36597
+ }
36598
+ _this4.audioContextReleased = false;
36599
+ const providedAudioContext = typeof _this4.options.webAudioMix !== 'boolean' ? _this4.options.webAudioMix.audioContext : undefined;
36600
+ if (providedAudioContext) {
36601
+ // override audio context with custom audio context if supplied by user
36602
+ yield _this4.setRoomAudioContext(providedAudioContext, false);
36603
+ } else if (!_this4.audioContext || _this4.audioContext.state === 'closed') {
36604
+ // by using an AudioContext, it reduces lag on audio elements
36605
+ // https://stackoverflow.com/questions/9811429/html5-audio-tag-on-safari-has-a-delay/54119854#54119854
36606
+ yield _this4.setRoomAudioContext((_a = getNewAudioContext()) !== null && _a !== void 0 ? _a : undefined, true);
36607
+ }
36608
+ if (_this4.audioContext && _this4.audioContext.state === 'suspended') {
36609
+ // for iOS a newly created AudioContext is always in `suspended` state.
36610
+ // we try our best to resume the context here, if that doesn't work, we just continue with regular processing
36611
+ try {
36612
+ yield Promise.race([_this4.audioContext.resume(), sleep(200)]);
36613
+ } catch (e) {
36614
+ _this4.log.warn('Could not resume audio context', {
36615
+ error: e
36616
+ });
36617
+ }
36618
+ }
36619
+ const newContextIsRunning = ((_b = _this4.audioContext) === null || _b === void 0 ? void 0 : _b.state) === 'running';
36620
+ if (newContextIsRunning !== _this4.canPlaybackAudio) {
36621
+ _this4.audioEnabled = newContextIsRunning;
36622
+ _this4.emit(RoomEvent.AudioPlaybackStatusChanged, newContextIsRunning);
36623
+ }
36624
+ } finally {
36625
+ unlock();
35863
36626
  }
36627
+ }();
36628
+ });
36629
+ }
36630
+ /**
36631
+ * Points the room and its participants at `audioContext`, rebuilding the web audio graph of
36632
+ * every audio track in it. Closes the previous context if the SDK `owned` it.
36633
+ */
36634
+ setRoomAudioContext(audioContext, owned) {
36635
+ return __awaiter(this, void 0, void 0, function* () {
36636
+ const ownsAudioContext = !!audioContext && owned;
36637
+ if (this.audioContext === audioContext) {
36638
+ this.ownsAudioContext = ownsAudioContext;
36639
+ return;
35864
36640
  }
35865
- const newContextIsRunning = ((_b = this.audioContext) === null || _b === void 0 ? void 0 : _b.state) === 'running';
35866
- if (newContextIsRunning !== this.canPlaybackAudio) {
35867
- this.audioEnabled = newContextIsRunning;
35868
- this.emit(RoomEvent.AudioPlaybackStatusChanged, newContextIsRunning);
36641
+ const contextToClose = this.ownsAudioContext ? this.audioContext : undefined;
36642
+ this.audioContext = audioContext;
36643
+ this.ownsAudioContext = ownsAudioContext;
36644
+ try {
36645
+ // every audio track rebuilds its nodes in the new context, which has to happen before the
36646
+ // previous one is closed so that processors can move off it while it is still alive
36647
+ if (this.options.webAudioMix) {
36648
+ yield Promise.all(Array.from(this.remoteParticipants.values()).map(participant => participant.setAudioContext(audioContext)));
36649
+ }
36650
+ yield this.localParticipant.setAudioContext(audioContext);
36651
+ } catch (error) {
36652
+ // the context is being abandoned either way, so a track that failed to move off it must
36653
+ // not stop us closing it — that would leak the context this whole path exists to release
36654
+ this.log.warn('Could not move audio tracks to the new audio context', Object.assign(Object.assign({}, this.logContext), {
36655
+ error
36656
+ }));
36657
+ } finally {
36658
+ if (contextToClose && contextToClose.state !== 'closed') {
36659
+ try {
36660
+ yield contextToClose.close();
36661
+ } catch (error) {
36662
+ this.log.warn('Could not close audio context', Object.assign(Object.assign({}, this.logContext), {
36663
+ error
36664
+ }));
36665
+ }
36666
+ }
35869
36667
  }
35870
36668
  });
35871
36669
  }
36670
+ /**
36671
+ * Detaches the web audio graph the SDK built from the current audio context and closes that
36672
+ * context if the SDK created it. A context provided through `webAudioMix` is left untouched.
36673
+ */
36674
+ releaseAudioContext() {
36675
+ return __awaiter(this, arguments, void 0, function () {
36676
+ var _this5 = this;
36677
+ let retainedAudioTracks = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : [];
36678
+ return function* () {
36679
+ // both reads happen before the first await, so they describe the room as it was when the
36680
+ // disconnect started rather than after a reconnect has had a chance to run
36681
+ _this5.audioContextReleased = true;
36682
+ const contextToRelease = _this5.audioContext;
36683
+ const unlock = yield _this5.audioContextMutex.lock();
36684
+ try {
36685
+ if (!_this5.audioContextReleased || _this5.audioContext !== contextToRelease) {
36686
+ // a reconnect acquired a new context while we were waiting, closing it would leave the
36687
+ // reconnected room without one
36688
+ return;
36689
+ }
36690
+ if (!_this5.ownsAudioContext) {
36691
+ // a provided context stays open, so tracks that outlive the room stay functional on it.
36692
+ // The tracks this room detached have released their nodes during track teardown already.
36693
+ // read under the lock: an acquire queued behind an in-flight release flips this flag
36694
+ return;
36695
+ }
36696
+ try {
36697
+ // tracks that outlive the room aren't reachable through the local participant anymore,
36698
+ // but still hold web audio nodes in the context we're about to close
36699
+ yield Promise.all(retainedAudioTracks.map(track => track.setAudioContext(undefined)));
36700
+ } catch (error) {
36701
+ _this5.log.warn('Could not detach retained audio tracks from the audio context', Object.assign(Object.assign({}, _this5.logContext), {
36702
+ error
36703
+ }));
36704
+ }
36705
+ // the participant maps have already been cleared by `handleDisconnect`, so this reaches no
36706
+ // tracks of its own — `retainedAudioTracks` above is what detaches them
36707
+ yield _this5.setRoomAudioContext(undefined, false);
36708
+ } finally {
36709
+ unlock();
36710
+ }
36711
+ }();
36712
+ });
36713
+ }
35872
36714
  createParticipant(identity, info) {
35873
36715
  var _a;
35874
36716
  let participant;
@@ -35884,7 +36726,9 @@ class Room extends eventsExports.EventEmitter {
35884
36726
  });
35885
36727
  }
35886
36728
  if (this.options.webAudioMix) {
35887
- participant.setAudioContext(this.audioContext);
36729
+ participant.setAudioContext(this.audioContext).catch(error => this.log.warn('Could not set audio context', Object.assign(Object.assign({}, this.logContext), {
36730
+ error
36731
+ })));
35888
36732
  }
35889
36733
  if ((_a = this.options.audioOutput) === null || _a === void 0 ? void 0 : _a.deviceId) {
35890
36734
  participant.setAudioOutput(this.options.audioOutput).catch(e => this.log.warn("Could not set audio output: ".concat(e.message)));
@@ -36004,21 +36848,21 @@ class Room extends eventsExports.EventEmitter {
36004
36848
  }
36005
36849
  registerRpcDataStreamHandler() {
36006
36850
  this.incomingDataStreamManager.registerTextStreamHandler(RPC_REQUEST_DATA_STREAM_TOPIC, (reader_1, _a) => __awaiter(this, [reader_1, _a], void 0, function (reader, _ref7) {
36007
- var _this4 = this;
36851
+ var _this6 = this;
36008
36852
  let identity = _ref7.identity;
36009
36853
  return function* () {
36010
36854
  var _b;
36011
36855
  const attributes = (_b = reader.info.attributes) !== null && _b !== void 0 ? _b : {};
36012
- yield _this4.rpcServerManager.handleIncomingDataStream(reader, identity, attributes);
36856
+ yield _this6.rpcServerManager.handleIncomingDataStream(reader, identity, attributes);
36013
36857
  }();
36014
36858
  }));
36015
36859
  this.incomingDataStreamManager.registerTextStreamHandler(RPC_RESPONSE_DATA_STREAM_TOPIC, (reader_1, _a) => __awaiter(this, [reader_1, _a], void 0, function (reader, _ref8) {
36016
- var _this5 = this;
36860
+ var _this7 = this;
36017
36861
  let identity = _ref8.identity;
36018
36862
  return function* () {
36019
36863
  var _b;
36020
36864
  const attributes = (_b = reader.info.attributes) !== null && _b !== void 0 ? _b : {};
36021
- yield _this5.rpcClientManager.handleIncomingDataStream(reader, identity, attributes);
36865
+ yield _this7.rpcClientManager.handleIncomingDataStream(reader, identity, attributes);
36022
36866
  }();
36023
36867
  }));
36024
36868
  }
@@ -36177,7 +37021,7 @@ class Room extends eventsExports.EventEmitter {
36177
37021
  type: TrackType.AUDIO
36178
37022
  }), new LocalAudioTrack(publishOptions.useRealTracks && ((_c = navigator.mediaDevices) === null || _c === void 0 ? void 0 : _c.getUserMedia) ? (yield navigator.mediaDevices.getUserMedia({
36179
37023
  audio: true
36180
- })).getAudioTracks()[0] : getEmptyAudioStreamTrack(), undefined, false, this.audioContext, {
37024
+ })).getAudioTracks()[0] : this.acquireEmptyAudioStreamTrack(), undefined, false, this.audioContext, {
36181
37025
  loggerName: this.options.loggerName,
36182
37026
  loggerContextCb: () => this.logContext
36183
37027
  }), {
@@ -36208,7 +37052,7 @@ class Room extends eventsExports.EventEmitter {
36208
37052
  info.tracks = [...info.tracks, videoTrack];
36209
37053
  }
36210
37054
  if (participantOptions.audio) {
36211
- const dummyTrack = getEmptyAudioStreamTrack();
37055
+ const dummyTrack = this.acquireEmptyAudioStreamTrack();
36212
37056
  const audioTrack = new TrackInfo({
36213
37057
  source: TrackSource.MICROPHONE,
36214
37058
  sid: Math.floor(Math.random() * 10000).toString(),
@@ -37634,4 +38478,4 @@ const serializers = {
37634
38478
  json,
37635
38479
  raw,
37636
38480
  custom
37637
- };export{AudioPresets,BackupCodecPolicy,BaseKeyProvider,CLIENT_PROTOCOL_DATA_STREAM_RPC,CLIENT_PROTOCOL_DATA_STREAM_V2,CLIENT_PROTOCOL_DEFAULT,CheckStatus,Checker,ConnectionCheck,ConnectionError,ConnectionErrorReason,ConnectionQuality,ConnectionState,CriticalTimers,CryptorError,CryptorErrorReason,CryptorEvent,DataPacket_Kind,DataStreamError,DataStreamErrorReason,DataTrackPacket,DefaultReconnectPolicy,DeviceUnsupportedError,DisconnectReason,EncryptionEvent,Encryption_Type,EngineEvent,ExternalE2EEKeyProvider,FrameMetadataManager,KeyHandlerEvent,KeyProviderEvent,LivekitError,LivekitReasonedError,LocalAudioTrack,LocalDataTrack,LocalParticipant,LocalTrack,LocalTrackPublication,LocalTrackRecorder,LocalVideoTrack,LogLevel,LoggerNames,MediaDeviceFailure,_ as Mutex,NegotiationError,PacketTrailerManager,Participant,ParticipantEvent,ParticipantInfo_Kind as ParticipantKind,PublishDataError,PublishTrackError,RemoteAudioTrack,RemoteDataTrack,RemoteParticipant,RemoteTrack,RemoteTrackPublication,RemoteVideoTrack,Room,RoomEvent,RpcError,ScreenSharePresets,SignalReconnectError,SignalRequestError,SimulatedError,SubscriptionError,TokenSource,TokenSourceConfigurable,TokenSourceFixed,Track,TrackEvent,TrackInvalidError,TrackPublication,TrackType,UnexpectedConnectionState,UnsupportedServer,VideoPreset,VideoPresets,VideoPresets43,VideoQuality,areTokenSourceFetchOptionsEqual,asEncryptablePacket,attachToElement,attributeTypings as attributes,audioCodecs,canFailOverToAnotherRegion,clientProtocol,compareVersions,createAudioAnalyser,createE2EEKey,createKeyMaterialFromBuffer,createKeyMaterialFromString,createLocalAudioTrack,createLocalScreenTracks,createLocalTracks,createLocalVideoTrack,decodeTokenPayload,deriveKeys,detachTrack,facingModeFromDeviceLabel,facingModeFromLocalTrack,getBrowser,getEmptyAudioStreamTrack,getEmptyVideoStreamTrack,getLogger,importKey,isAudioCodec,isAudioTrack,isBackupCodec,isBackupVideoCodec,isBrowserSupported,isE2EESupported,isInsertableStreamSupported,isLocalParticipant,isLocalTrack,isRemoteParticipant,isRemoteTrack,isSVCCodec,isScriptTransformSupported,isSerializer,isVideoCodec,isVideoFrame,isVideoTrack,needsRbspUnescaping,parseRbsp,protocolVersion,ratchet,serializers,setLogExtension,setLogLevel,supportsAV1,supportsAdaptiveStream,supportsAudioOutputSelection,supportsDynacast,supportsH265,supportsVP9,version,videoCodecs,writeRbsp};//# sourceMappingURL=livekit-client.esm.mjs.map
38481
+ };export{AudioPresets,BackupCodecPolicy,BaseKeyProvider,CLIENT_PROTOCOL_DATA_STREAM_RPC,CLIENT_PROTOCOL_DATA_STREAM_V2,CLIENT_PROTOCOL_DEFAULT,CheckStatus,Checker,ConnectionCheck,ConnectionError,ConnectionErrorReason,ConnectionQuality,ConnectionState,CriticalTimers,CryptorError,CryptorErrorReason,CryptorEvent,DataPacket_Kind,DataStreamError,DataStreamErrorReason,DataTrackPacket,DefaultReconnectPolicy,DeviceUnsupportedError,DisconnectReason,EncryptionEvent,Encryption_Type,EngineEvent,ExternalE2EEKeyProvider,FrameMetadataManager,KeyHandlerEvent,KeyProviderEvent,LivekitError,LivekitReasonedError,LocalAudioTrack,LocalDataTrack,LocalParticipant,LocalTrack,LocalTrackPublication,LocalTrackRecorder,LocalVideoTrack,LogLevel,LoggerNames,MediaDeviceFailure,_ as Mutex,NegotiationError,PacketTrailerManager,Participant,ParticipantEvent,ParticipantInfo_Kind as ParticipantKind,PublishDataError,PublishTrackError,RemoteAudioTrack,RemoteDataTrack,RemoteParticipant,RemoteTrack,RemoteTrackPublication,RemoteVideoTrack,Room,RoomEvent,RpcError,ScreenSharePresets,SignalReconnectError,SignalRequestError,SimulatedError,SubscriptionError,TokenSource,TokenSourceConfigurable,TokenSourceFixed,Track,TrackEvent,TrackInvalidError,TrackPublication,TrackType,UnexpectedConnectionState,UnsupportedServer,VideoPreset,VideoPresets,VideoPresets43,VideoQuality,areTokenSourceFetchOptionsEqual,asEncryptablePacket,attachToElement,attributeTypings as attributes,audioCodecs,canFailOverToAnotherRegion,clientProtocol,compareVersions,createAudioAnalyser,createE2EEKey,createKeyMaterialFromBuffer,createKeyMaterialFromString,createLocalAudioTrack,createLocalScreenTracks,createLocalTracks,createLocalVideoTrack,decodeTokenPayload,deriveKeys,detachTrack,facingModeFromDeviceLabel,facingModeFromLocalTrack,getBrowser,getEmptyAudioStreamTrack,getEmptyVideoStreamTrack,getLogger,importKey,isAudioCodec,isAudioTrack,isBackupCodec,isBackupVideoCodec,isBrowserSupported,isE2EESupported,isInsertableStreamSupported,isLocalParticipant,isLocalTrack,isRemoteParticipant,isRemoteTrack,isSVCCodec,isScriptTransformSupported,isSerializer,isVideoCodec,isVideoFrame,isVideoTrack,needsRbspUnescaping,parseRbsp,protocolVersion,ratchet,releaseEmptyAudioStreamTrack,serializers,setLogExtension,setLogLevel,supportsAV1,supportsAdaptiveStream,supportsAudioOutputSelection,supportsDynacast,supportsH265,supportsVP9,version,videoCodecs,writeRbsp};//# sourceMappingURL=livekit-client.esm.mjs.map