livekit-client 2.22.3 → 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 (272) hide show
  1. package/README.md +35 -0
  2. package/dist/livekit-client.e2ee.worker.js +1 -1
  3. package/dist/livekit-client.e2ee.worker.js.map +1 -1
  4. package/dist/livekit-client.e2ee.worker.mjs +71 -8
  5. package/dist/livekit-client.e2ee.worker.mjs.map +1 -1
  6. package/dist/livekit-client.esm.mjs +1746 -547
  7. package/dist/livekit-client.esm.mjs.map +1 -1
  8. package/dist/livekit-client.fm.worker.js +1 -1
  9. package/dist/livekit-client.fm.worker.js.map +1 -1
  10. package/dist/livekit-client.fm.worker.mjs +2 -1
  11. package/dist/livekit-client.fm.worker.mjs.map +1 -1
  12. package/dist/livekit-client.umd.js +1 -1
  13. package/dist/livekit-client.umd.js.map +1 -1
  14. package/dist/src/api/SignalClient.d.ts +4 -2
  15. package/dist/src/api/SignalClient.d.ts.map +1 -1
  16. package/dist/src/api/WebSocketStream.d.ts +1 -0
  17. package/dist/src/api/WebSocketStream.d.ts.map +1 -1
  18. package/dist/src/api/utils.d.ts +1 -1
  19. package/dist/src/api/utils.d.ts.map +1 -1
  20. package/dist/src/e2ee/E2eeManager.d.ts.map +1 -1
  21. package/dist/src/e2ee/worker/FrameCryptor.d.ts.map +1 -1
  22. package/dist/src/e2ee/worker/naluUtils.d.ts +12 -1
  23. package/dist/src/e2ee/worker/naluUtils.d.ts.map +1 -1
  24. package/dist/src/index.d.ts +2 -2
  25. package/dist/src/index.d.ts.map +1 -1
  26. package/dist/src/options.d.ts +14 -0
  27. package/dist/src/options.d.ts.map +1 -1
  28. package/dist/src/room/DefaultReconnectPolicy.d.ts.map +1 -1
  29. package/dist/src/room/PCTransport.d.ts +100 -7
  30. package/dist/src/room/PCTransport.d.ts.map +1 -1
  31. package/dist/src/room/PCTransportManager.d.ts.map +1 -1
  32. package/dist/src/room/RTCEngine.d.ts +16 -0
  33. package/dist/src/room/RTCEngine.d.ts.map +1 -1
  34. package/dist/src/room/Room.d.ts +54 -1
  35. package/dist/src/room/Room.d.ts.map +1 -1
  36. package/dist/src/room/data-channel/DataChannelManager.d.ts +9 -8
  37. package/dist/src/room/data-channel/DataChannelManager.d.ts.map +1 -1
  38. package/dist/src/room/data-channel/FlowControlledDataChannel.d.ts +25 -12
  39. package/dist/src/room/data-channel/FlowControlledDataChannel.d.ts.map +1 -1
  40. package/dist/src/room/data-channel/LossyDataChannel.d.ts.map +1 -1
  41. package/dist/src/room/data-channel/ReliableDataChannel.d.ts.map +1 -1
  42. package/dist/src/room/data-stream/incoming/IncomingDataStreamManager.d.ts.map +1 -1
  43. package/dist/src/room/defaults.d.ts.map +1 -1
  44. package/dist/src/room/errors.d.ts +18 -0
  45. package/dist/src/room/errors.d.ts.map +1 -1
  46. package/dist/src/room/events.d.ts +3 -2
  47. package/dist/src/room/events.d.ts.map +1 -1
  48. package/dist/src/room/participant/LocalParticipant.d.ts +4 -0
  49. package/dist/src/room/participant/LocalParticipant.d.ts.map +1 -1
  50. package/dist/src/room/participant/Participant.d.ts +1 -1
  51. package/dist/src/room/participant/Participant.d.ts.map +1 -1
  52. package/dist/src/room/participant/publishUtils.d.ts.map +1 -1
  53. package/dist/src/room/rpc/client/RpcClientManager.d.ts.map +1 -1
  54. package/dist/src/room/rpc/utils.d.ts.map +1 -1
  55. package/dist/src/room/track/LocalAudioTrack.d.ts +24 -1
  56. package/dist/src/room/track/LocalAudioTrack.d.ts.map +1 -1
  57. package/dist/src/room/track/LocalTrack.d.ts +18 -0
  58. package/dist/src/room/track/LocalTrack.d.ts.map +1 -1
  59. package/dist/src/room/track/LocalVideoTrack.d.ts +9 -0
  60. package/dist/src/room/track/LocalVideoTrack.d.ts.map +1 -1
  61. package/dist/src/room/track/RemoteAudioTrack.d.ts +6 -0
  62. package/dist/src/room/track/RemoteAudioTrack.d.ts.map +1 -1
  63. package/dist/src/room/track/processor/types.d.ts +11 -0
  64. package/dist/src/room/track/processor/types.d.ts.map +1 -1
  65. package/dist/src/room/track/utils.d.ts +13 -0
  66. package/dist/src/room/track/utils.d.ts.map +1 -1
  67. package/dist/src/room/utils.d.ts +60 -1
  68. package/dist/src/room/utils.d.ts.map +1 -1
  69. package/dist/src/test/compressionStreamStubs.d.ts +12 -0
  70. package/dist/src/test/compressionStreamStubs.d.ts.map +1 -0
  71. package/dist/ts4.2/api/SignalClient.d.ts +187 -185
  72. package/dist/ts4.2/api/SignalClientStateMachine.d.ts +85 -85
  73. package/dist/ts4.2/api/WebSocketStream.d.ts +34 -33
  74. package/dist/ts4.2/api/utils.d.ts +9 -9
  75. package/dist/ts4.2/connectionHelper/ConnectionCheck.d.ts +30 -30
  76. package/dist/ts4.2/connectionHelper/checks/Checker.d.ts +62 -62
  77. package/dist/ts4.2/connectionHelper/checks/cloudRegion.d.ts +18 -18
  78. package/dist/ts4.2/connectionHelper/checks/connectionProtocol.d.ts +20 -20
  79. package/dist/ts4.2/connectionHelper/checks/publishAudio.d.ts +6 -6
  80. package/dist/ts4.2/connectionHelper/checks/publishVideo.d.ts +7 -7
  81. package/dist/ts4.2/connectionHelper/checks/reconnect.d.ts +6 -6
  82. package/dist/ts4.2/connectionHelper/checks/turn.d.ts +6 -6
  83. package/dist/ts4.2/connectionHelper/checks/webrtc.d.ts +6 -6
  84. package/dist/ts4.2/connectionHelper/checks/websocket.d.ts +6 -6
  85. package/dist/ts4.2/e2ee/E2eeManager.d.ts +111 -111
  86. package/dist/ts4.2/e2ee/KeyProvider.d.ts +54 -54
  87. package/dist/ts4.2/e2ee/constants.d.ts +21 -21
  88. package/dist/ts4.2/e2ee/errors.d.ts +12 -12
  89. package/dist/ts4.2/e2ee/events.d.ts +39 -39
  90. package/dist/ts4.2/e2ee/index.d.ts +6 -6
  91. package/dist/ts4.2/e2ee/types.d.ts +231 -231
  92. package/dist/ts4.2/e2ee/utils.d.ts +32 -32
  93. package/dist/ts4.2/e2ee/worker/DataCryptor.d.ts +16 -16
  94. package/dist/ts4.2/e2ee/worker/ErrorRateLimiter.d.ts +21 -21
  95. package/dist/ts4.2/e2ee/worker/FrameCryptor.d.ts +209 -209
  96. package/dist/ts4.2/e2ee/worker/ParticipantKeyHandler.d.ts +81 -84
  97. package/dist/ts4.2/e2ee/worker/e2ee.worker.d.ts +2 -2
  98. package/dist/ts4.2/e2ee/worker/naluUtils.d.ts +39 -28
  99. package/dist/ts4.2/e2ee/worker/sifPayload.d.ts +23 -23
  100. package/dist/ts4.2/frameMetadata/FrameMetadataManager.d.ts +53 -53
  101. package/dist/ts4.2/frameMetadata/frameMetadata.d.ts +33 -33
  102. package/dist/ts4.2/frameMetadata/types.d.ts +62 -62
  103. package/dist/ts4.2/frameMetadata/utils.d.ts +9 -9
  104. package/dist/ts4.2/frameMetadata/worker/frameMetadata.worker.d.ts +2 -2
  105. package/dist/ts4.2/index.d.ts +76 -76
  106. package/dist/ts4.2/logger.d.ts +66 -66
  107. package/dist/ts4.2/options.d.ts +163 -149
  108. package/dist/ts4.2/room/BackOffStrategy.d.ts +27 -27
  109. package/dist/ts4.2/room/DefaultReconnectPolicy.d.ts +8 -8
  110. package/dist/ts4.2/room/DeviceManager.d.ts +12 -12
  111. package/dist/ts4.2/room/PCTransport.d.ts +268 -175
  112. package/dist/ts4.2/room/PCTransportManager.d.ts +77 -77
  113. package/dist/ts4.2/room/RTCEngine.d.ts +277 -262
  114. package/dist/ts4.2/room/ReconnectPolicy.d.ts +24 -24
  115. package/dist/ts4.2/room/RegionUrlProvider.d.ts +36 -36
  116. package/dist/ts4.2/room/Room.d.ts +407 -365
  117. package/dist/ts4.2/room/attribute-typings.d.ts +35 -35
  118. package/dist/ts4.2/room/data-channel/DataChannelManager.d.ts +63 -62
  119. package/dist/ts4.2/room/data-channel/FlowControlledDataChannel.d.ts +107 -95
  120. package/dist/ts4.2/room/data-channel/LossyDataChannel.d.ts +41 -41
  121. package/dist/ts4.2/room/data-channel/ReliableDataChannel.d.ts +57 -57
  122. package/dist/ts4.2/room/data-channel/types.d.ts +12 -12
  123. package/dist/ts4.2/room/data-stream/compression.d.ts +49 -49
  124. package/dist/ts4.2/room/data-stream/constants.d.ts +18 -18
  125. package/dist/ts4.2/room/data-stream/incoming/IncomingDataStreamManager.d.ts +29 -29
  126. package/dist/ts4.2/room/data-stream/incoming/StreamReader.d.ts +80 -80
  127. package/dist/ts4.2/room/data-stream/outgoing/OutgoingDataStreamManager.d.ts +73 -73
  128. package/dist/ts4.2/room/data-stream/outgoing/StreamWriter.d.ts +16 -16
  129. package/dist/ts4.2/room/data-stream/outgoing/header-utils.d.ts +18 -18
  130. package/dist/ts4.2/room/data-track/LocalDataTrack.d.ts +79 -80
  131. package/dist/ts4.2/room/data-track/RemoteDataTrack.d.ts +50 -50
  132. package/dist/ts4.2/room/data-track/depacketizer.d.ts +59 -59
  133. package/dist/ts4.2/room/data-track/frame.d.ts +22 -22
  134. package/dist/ts4.2/room/data-track/handle.d.ts +27 -27
  135. package/dist/ts4.2/room/data-track/incoming/IncomingDataTrackManager.d.ts +124 -124
  136. package/dist/ts4.2/room/data-track/incoming/errors.d.ts +24 -24
  137. package/dist/ts4.2/room/data-track/incoming/pipeline.d.ts +41 -41
  138. package/dist/ts4.2/room/data-track/incoming/types.d.ts +20 -20
  139. package/dist/ts4.2/room/data-track/outgoing/OutgoingDataTrackManager.d.ts +126 -126
  140. package/dist/ts4.2/room/data-track/outgoing/errors.d.ts +74 -74
  141. package/dist/ts4.2/room/data-track/outgoing/pipeline.d.ts +23 -23
  142. package/dist/ts4.2/room/data-track/outgoing/types.d.ts +48 -48
  143. package/dist/ts4.2/room/data-track/packet/constants.d.ts +20 -20
  144. package/dist/ts4.2/room/data-track/packet/errors.d.ts +40 -40
  145. package/dist/ts4.2/room/data-track/packet/extensions.d.ts +69 -69
  146. package/dist/ts4.2/room/data-track/packet/index.d.ts +98 -98
  147. package/dist/ts4.2/room/data-track/packet/serializable.d.ts +13 -13
  148. package/dist/ts4.2/room/data-track/packetizer.d.ts +43 -43
  149. package/dist/ts4.2/room/data-track/track-interfaces.d.ts +23 -23
  150. package/dist/ts4.2/room/data-track/types.d.ts +22 -22
  151. package/dist/ts4.2/room/data-track/utils.d.ts +60 -60
  152. package/dist/ts4.2/room/debounce.d.ts +11 -11
  153. package/dist/ts4.2/room/defaults.d.ts +9 -9
  154. package/dist/ts4.2/room/errors.d.ts +180 -162
  155. package/dist/ts4.2/room/events.d.ts +638 -637
  156. package/dist/ts4.2/room/participant/LocalParticipant.d.ts +291 -287
  157. package/dist/ts4.2/room/participant/Participant.d.ts +142 -144
  158. package/dist/ts4.2/room/participant/ParticipantTrackPermission.d.ts +26 -26
  159. package/dist/ts4.2/room/participant/RemoteParticipant.d.ts +85 -86
  160. package/dist/ts4.2/room/participant/publishUtils.d.ts +53 -53
  161. package/dist/ts4.2/room/rpc/client/RpcClientManager.d.ts +43 -43
  162. package/dist/ts4.2/room/rpc/client/events.d.ts +8 -8
  163. package/dist/ts4.2/room/rpc/index.d.ts +7 -7
  164. package/dist/ts4.2/room/rpc/server/RpcServerManager.d.ts +44 -44
  165. package/dist/ts4.2/room/rpc/server/events.d.ts +8 -8
  166. package/dist/ts4.2/room/rpc/utils.d.ts +131 -131
  167. package/dist/ts4.2/room/stats.d.ts +71 -71
  168. package/dist/ts4.2/room/statsSummary.d.ts +13 -13
  169. package/dist/ts4.2/room/timers.d.ts +12 -12
  170. package/dist/ts4.2/room/token-source/TokenSource.d.ts +82 -82
  171. package/dist/ts4.2/room/token-source/types.d.ts +70 -70
  172. package/dist/ts4.2/room/token-source/utils.d.ts +8 -8
  173. package/dist/ts4.2/room/track/FrameMetadataExtractor.d.ts +19 -19
  174. package/dist/ts4.2/room/track/LocalAudioTrack.d.ts +62 -42
  175. package/dist/ts4.2/room/track/LocalTrack.d.ts +143 -125
  176. package/dist/ts4.2/room/track/LocalTrackPublication.d.ts +42 -42
  177. package/dist/ts4.2/room/track/LocalVideoTrack.d.ts +109 -100
  178. package/dist/ts4.2/room/track/RemoteAudioTrack.d.ts +61 -55
  179. package/dist/ts4.2/room/track/RemoteTrack.d.ts +38 -38
  180. package/dist/ts4.2/room/track/RemoteTrackPublication.d.ts +72 -75
  181. package/dist/ts4.2/room/track/RemoteVideoTrack.d.ts +73 -76
  182. package/dist/ts4.2/room/track/Track.d.ts +156 -166
  183. package/dist/ts4.2/room/track/TrackPublication.d.ts +75 -81
  184. package/dist/ts4.2/room/track/create.d.ts +25 -25
  185. package/dist/ts4.2/room/track/facingMode.d.ts +41 -41
  186. package/dist/ts4.2/room/track/options.d.ts +403 -403
  187. package/dist/ts4.2/room/track/processor/types.d.ts +42 -31
  188. package/dist/ts4.2/room/track/record.d.ts +13 -13
  189. package/dist/ts4.2/room/track/types.d.ts +28 -28
  190. package/dist/ts4.2/room/track/utils.d.ts +60 -47
  191. package/dist/ts4.2/room/types.d.ts +127 -127
  192. package/dist/ts4.2/room/utils.d.ts +273 -214
  193. package/dist/ts4.2/test/MockMediaStreamTrack.d.ts +26 -26
  194. package/dist/ts4.2/test/compressionStreamStubs.d.ts +12 -0
  195. package/dist/ts4.2/test/mocks.d.ts +12 -12
  196. package/dist/ts4.2/test/promiseState.d.ts +12 -12
  197. package/dist/ts4.2/test/signalToken.d.ts +26 -26
  198. package/dist/ts4.2/type-polyfills/non-shared-typed-arrays.d.ts +2 -2
  199. package/dist/ts4.2/utils/AsyncQueue.d.ts +23 -23
  200. package/dist/ts4.2/utils/TypedPromise.d.ts +17 -17
  201. package/dist/ts4.2/utils/abort-signal-polyfill.d.ts +13 -13
  202. package/dist/ts4.2/utils/browserParser.d.ts +13 -13
  203. package/dist/ts4.2/utils/camelToSnakeCase.d.ts +8 -8
  204. package/dist/ts4.2/utils/cloneDeep.d.ts +2 -2
  205. package/dist/ts4.2/utils/dataPacketBuffer.d.ts +34 -34
  206. package/dist/ts4.2/utils/deferrable-map.d.ts +32 -32
  207. package/dist/ts4.2/utils/machineInspector.d.ts +54 -54
  208. package/dist/ts4.2/utils/serializer.d.ts +48 -48
  209. package/dist/ts4.2/utils/subscribeToEvents.d.ts +15 -15
  210. package/dist/ts4.2/utils/ttlmap.d.ts +20 -20
  211. package/dist/ts4.2/version.d.ts +15 -15
  212. package/package.json +5 -4
  213. package/src/api/SignalClient.e2e.test.ts +1 -3
  214. package/src/api/SignalClient.test.ts +394 -10
  215. package/src/api/SignalClient.ts +132 -76
  216. package/src/api/WebSocketStream.test.ts +11 -0
  217. package/src/api/WebSocketStream.ts +22 -1
  218. package/src/api/utils.ts +1 -1
  219. package/src/e2ee/E2eeManager.test.ts +43 -0
  220. package/src/e2ee/E2eeManager.ts +6 -2
  221. package/src/e2ee/worker/FrameCryptor.test.ts +212 -90
  222. package/src/e2ee/worker/FrameCryptor.ts +21 -4
  223. package/src/e2ee/worker/naluUtils.test.ts +123 -0
  224. package/src/e2ee/worker/naluUtils.ts +57 -2
  225. package/src/index.ts +2 -0
  226. package/src/options.ts +16 -0
  227. package/src/room/DefaultReconnectPolicy.test.ts +58 -0
  228. package/src/room/DefaultReconnectPolicy.ts +14 -2
  229. package/src/room/PCTransport.test.ts +327 -9
  230. package/src/room/PCTransport.ts +339 -36
  231. package/src/room/PCTransportManager.ts +3 -1
  232. package/src/room/RTCEngine.test.ts +318 -2
  233. package/src/room/RTCEngine.ts +126 -12
  234. package/src/room/Room.test.ts +148 -6
  235. package/src/room/Room.ts +312 -80
  236. package/src/room/data-channel/DataChannelManager.test.ts +24 -7
  237. package/src/room/data-channel/DataChannelManager.ts +43 -38
  238. package/src/room/data-channel/FlowControlledDataChannel.test.ts +60 -18
  239. package/src/room/data-channel/FlowControlledDataChannel.ts +40 -22
  240. package/src/room/data-channel/LossyDataChannel.test.ts +38 -0
  241. package/src/room/data-channel/LossyDataChannel.ts +10 -1
  242. package/src/room/data-channel/ReliableDataChannel.test.ts +72 -0
  243. package/src/room/data-channel/ReliableDataChannel.ts +16 -5
  244. package/src/room/data-stream/incoming/IncomingDataStreamManager.test.ts +47 -20
  245. package/src/room/data-stream/incoming/IncomingDataStreamManager.ts +16 -5
  246. package/src/room/data-stream/outgoing/OutgoingDataStreamManager.test.ts +60 -39
  247. package/src/room/data-stream/outgoing/OutgoingDataStreamManager.ts +4 -4
  248. package/src/room/defaults.ts +1 -0
  249. package/src/room/errors.test.ts +36 -0
  250. package/src/room/errors.ts +27 -0
  251. package/src/room/events.ts +2 -1
  252. package/src/room/participant/LocalParticipant.test.ts +136 -1
  253. package/src/room/participant/LocalParticipant.ts +66 -17
  254. package/src/room/participant/Participant.ts +6 -3
  255. package/src/room/participant/publishUtils.ts +0 -19
  256. package/src/room/participant/restartOnDefaultAudioDevice.test.ts +177 -0
  257. package/src/room/rpc/client/RpcClientManager.test.ts +51 -0
  258. package/src/room/rpc/client/RpcClientManager.ts +32 -15
  259. package/src/room/rpc/utils.test.ts +32 -0
  260. package/src/room/rpc/utils.ts +7 -0
  261. package/src/room/track/LocalAudioTrack.ts +101 -10
  262. package/src/room/track/LocalTrack.ts +87 -19
  263. package/src/room/track/LocalVideoTrack.ts +17 -3
  264. package/src/room/track/RemoteAudioTrack.ts +59 -14
  265. package/src/room/track/RemoteVideoTrack.test.ts +210 -0
  266. package/src/room/track/RemoteVideoTrack.ts +29 -3
  267. package/src/room/track/processor/types.ts +11 -0
  268. package/src/room/track/utils.test.ts +206 -2
  269. package/src/room/track/utils.ts +117 -5
  270. package/src/room/utils.test.ts +250 -0
  271. package/src/room/utils.ts +152 -19
  272. package/src/test/compressionStreamStubs.ts +53 -0
@@ -1,7 +1,12 @@
1
- import { describe, expect, it } from 'vitest';
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
2
  import { audioDefaults, videoDefaults } from '../defaults';
3
3
  import { type AudioCaptureOptions, VideoPresets } from './options';
4
- import { constraintsForOptions, diffAttributes, mergeDefaultOptions } from './utils';
4
+ import {
5
+ constraintsForOptions,
6
+ diffAttributes,
7
+ mergeDefaultOptions,
8
+ waitForFirstVideoFrame,
9
+ } from './utils';
5
10
 
6
11
  describe('mergeDefaultOptions', () => {
7
12
  it('does not enable undefined options', () => {
@@ -100,6 +105,11 @@ describe('constraintsForOptions', () => {
100
105
  expect(videoOpts.frameRate).toEqual(VideoPresets.h720.resolution.frameRate);
101
106
  expect(videoOpts.aspectRatio).toEqual(VideoPresets.h720.resolution.aspectRatio);
102
107
  });
108
+
109
+ it('leaves an explicitly set exact deviceId untouched', () => {
110
+ const constraints = constraintsForOptions({ audio: { deviceId: { exact: 'default' } } });
111
+ expect((constraints.audio as MediaTrackConstraints).deviceId).toEqual({ exact: 'default' });
112
+ });
103
113
  });
104
114
 
105
115
  describe('diffAttributes', () => {
@@ -135,3 +145,197 @@ describe('diffAttributes', () => {
135
145
  expect(diff.a).toBe('value');
136
146
  });
137
147
  });
148
+
149
+ class FakeVideoTrack extends EventTarget {
150
+ kind = 'video';
151
+
152
+ id = 'fake-video-track';
153
+
154
+ label = 'fake';
155
+
156
+ enabled = true;
157
+
158
+ muted = false;
159
+
160
+ readyState: MediaStreamTrackState = 'live';
161
+
162
+ settings: MediaTrackSettings;
163
+
164
+ constructor(settings: MediaTrackSettings = { width: 1280, height: 720 }) {
165
+ super();
166
+ this.settings = settings;
167
+ }
168
+
169
+ getSettings() {
170
+ return this.settings;
171
+ }
172
+
173
+ getConstraints() {
174
+ return {};
175
+ }
176
+ }
177
+
178
+ const asMediaStreamTrack = (track: FakeVideoTrack) => track as unknown as MediaStreamTrack;
179
+
180
+ describe('waitForFirstVideoFrame', () => {
181
+ let frameCallbacks: Map<number, VideoFrameRequestCallback>;
182
+ let cancelledHandles: number[];
183
+ let createdVideoElements: HTMLVideoElement[];
184
+
185
+ /** happy-dom has no requestVideoFrameCallback, install a controllable one */
186
+ function enableFrameCallbacks() {
187
+ let nextHandle = 1;
188
+ HTMLVideoElement.prototype.requestVideoFrameCallback = function requestVideoFrameCallback(
189
+ callback: VideoFrameRequestCallback,
190
+ ) {
191
+ const handle = nextHandle;
192
+ nextHandle += 1;
193
+ frameCallbacks.set(handle, callback);
194
+ return handle;
195
+ };
196
+ HTMLVideoElement.prototype.cancelVideoFrameCallback = function cancelVideoFrameCallback(
197
+ handle: number,
198
+ ) {
199
+ cancelledHandles.push(handle);
200
+ frameCallbacks.delete(handle);
201
+ };
202
+ }
203
+
204
+ function deliverFrame() {
205
+ Array.from(frameCallbacks.values()).forEach((callback) =>
206
+ callback(0, {} as VideoFrameCallbackMetadata),
207
+ );
208
+ }
209
+
210
+ beforeEach(() => {
211
+ frameCallbacks = new Map();
212
+ cancelledHandles = [];
213
+ createdVideoElements = [];
214
+ const createElement = document.createElement.bind(document);
215
+ vi.spyOn(document, 'createElement').mockImplementation(((
216
+ tagName: string,
217
+ options?: ElementCreationOptions,
218
+ ) => {
219
+ const element = createElement(tagName, options);
220
+ if (tagName === 'video') {
221
+ createdVideoElements.push(element as HTMLVideoElement);
222
+ }
223
+ return element;
224
+ }) as typeof document.createElement);
225
+ });
226
+
227
+ afterEach(() => {
228
+ vi.restoreAllMocks();
229
+ // @ts-expect-error removing the stand-in we installed above
230
+ delete HTMLVideoElement.prototype.requestVideoFrameCallback;
231
+ // @ts-expect-error removing the stand-in we installed above
232
+ delete HTMLVideoElement.prototype.cancelVideoFrameCallback;
233
+ });
234
+
235
+ it('resolves once a frame has been delivered', async () => {
236
+ enableFrameCallbacks();
237
+ const pending = waitForFirstVideoFrame(asMediaStreamTrack(new FakeVideoTrack()), 1000);
238
+ expect(createdVideoElements).toHaveLength(1);
239
+
240
+ deliverFrame();
241
+
242
+ await expect(pending).resolves.toBe(true);
243
+ });
244
+
245
+ it('gives up once the timeout elapses', async () => {
246
+ enableFrameCallbacks();
247
+
248
+ await expect(
249
+ waitForFirstVideoFrame(asMediaStreamTrack(new FakeVideoTrack()), 20),
250
+ ).resolves.toBe(false);
251
+ });
252
+
253
+ it.each([
254
+ ['ended', { readyState: 'ended' as MediaStreamTrackState }],
255
+ ['muted', { muted: true }],
256
+ ['disabled', { enabled: false }],
257
+ ])('does not wait on a track that is %s', async (_label, overrides) => {
258
+ enableFrameCallbacks();
259
+ const track = Object.assign(new FakeVideoTrack(), overrides);
260
+
261
+ await expect(waitForFirstVideoFrame(asMediaStreamTrack(track), 10_000)).resolves.toBe(false);
262
+ expect(createdVideoElements).toHaveLength(0);
263
+ });
264
+
265
+ it('falls back to loadeddata when requestVideoFrameCallback is unavailable', async () => {
266
+ const pending = waitForFirstVideoFrame(asMediaStreamTrack(new FakeVideoTrack()), 1000);
267
+
268
+ createdVideoElements[0].dispatchEvent(new Event('loadeddata'));
269
+
270
+ await expect(pending).resolves.toBe(true);
271
+ });
272
+
273
+ it('keeps waiting when play() is rejected', async () => {
274
+ enableFrameCallbacks();
275
+ vi.spyOn(HTMLMediaElement.prototype, 'play').mockRejectedValue(new Error('NotAllowedError'));
276
+ const pending = waitForFirstVideoFrame(asMediaStreamTrack(new FakeVideoTrack()), 1000);
277
+
278
+ deliverFrame();
279
+
280
+ await expect(pending).resolves.toBe(true);
281
+ });
282
+
283
+ it('reuses an element that already renders the track', async () => {
284
+ enableFrameCallbacks();
285
+ const track = new FakeVideoTrack();
286
+ const attached = document.createElement('video');
287
+ attached.srcObject = new MediaStream([asMediaStreamTrack(track)]);
288
+ createdVideoElements.length = 0;
289
+
290
+ const pending = waitForFirstVideoFrame(asMediaStreamTrack(track), 1000, [attached]);
291
+ expect(createdVideoElements).toHaveLength(0);
292
+ deliverFrame();
293
+
294
+ await expect(pending).resolves.toBe(true);
295
+ // the caller owns that element, it must be left rendering
296
+ expect(attached.srcObject).not.toBeNull();
297
+ });
298
+
299
+ it('reuses an element that has not decoded a frame yet without requestVideoFrameCallback', async () => {
300
+ const track = new FakeVideoTrack();
301
+ const attached = document.createElement('video');
302
+ attached.srcObject = new MediaStream([asMediaStreamTrack(track)]);
303
+ Object.defineProperty(attached, 'readyState', { value: 1, configurable: true });
304
+ createdVideoElements.length = 0;
305
+
306
+ const pending = waitForFirstVideoFrame(asMediaStreamTrack(track), 1000, [attached]);
307
+ expect(createdVideoElements).toHaveLength(0);
308
+ // `loadeddata` has not fired on that element yet, so listening for it still works
309
+ attached.dispatchEvent(new Event('loadeddata'));
310
+
311
+ await expect(pending).resolves.toBe(true);
312
+ });
313
+
314
+ it('declines an already loaded element without requestVideoFrameCallback', async () => {
315
+ const track = new FakeVideoTrack();
316
+ const attached = document.createElement('video');
317
+ attached.srcObject = new MediaStream([asMediaStreamTrack(track)]);
318
+ Object.defineProperty(attached, 'readyState', { value: 2, configurable: true });
319
+ createdVideoElements.length = 0;
320
+
321
+ const pending = waitForFirstVideoFrame(asMediaStreamTrack(track), 1000, [attached]);
322
+ // `loadeddata` already fired on that element and is never replayed, so we need our own
323
+ expect(createdVideoElements).toHaveLength(1);
324
+ createdVideoElements[0].dispatchEvent(new Event('loadeddata'));
325
+
326
+ await expect(pending).resolves.toBe(true);
327
+ });
328
+
329
+ it('tears down the element it created', async () => {
330
+ enableFrameCallbacks();
331
+ const pause = vi.spyOn(HTMLMediaElement.prototype, 'pause');
332
+ const pending = waitForFirstVideoFrame(asMediaStreamTrack(new FakeVideoTrack()), 1000);
333
+
334
+ deliverFrame();
335
+ await pending;
336
+
337
+ expect(createdVideoElements[0].srcObject).toBeNull();
338
+ expect(pause).toHaveBeenCalled();
339
+ expect(cancelledHandles).toHaveLength(1);
340
+ });
341
+ });
@@ -1,5 +1,6 @@
1
1
  import { TrackInfo, TrackPublishedResponse, TrackSource, VideoQuality } from '@livekit/protocol';
2
2
  import type { AudioProcessorOptions, TrackProcessor, VideoProcessorOptions } from '../..';
3
+ import log from '../../logger';
3
4
  import { cloneDeep } from '../../utils/cloneDeep';
4
5
  import { isSafari, sleep } from '../utils';
5
6
  import { Track } from './Track';
@@ -107,22 +108,46 @@ export function constraintsForOptions(options: CreateLocalTracksOptions): MediaS
107
108
  */
108
109
  export async function detectSilence(track: AudioTrack, timeOffset = 200): Promise<boolean> {
109
110
  const ctx = getNewAudioContext();
110
- if (ctx) {
111
- const analyser = ctx.createAnalyser();
111
+ if (!ctx) {
112
+ return false;
113
+ }
114
+ let source: MediaStreamAudioSourceNode | undefined;
115
+ let analyser: AnalyserNode | undefined;
116
+ try {
117
+ analyser = ctx.createAnalyser();
112
118
  analyser.fftSize = 2048;
113
119
 
114
120
  const bufferLength = analyser.frequencyBinCount;
115
121
  const dataArray = new Uint8Array(bufferLength);
116
- const source = ctx.createMediaStreamSource(new MediaStream([track.mediaStreamTrack]));
122
+ source = ctx.createMediaStreamSource(new MediaStream([track.mediaStreamTrack]));
117
123
 
118
124
  source.connect(analyser);
125
+ // read through a function so that `state` isn't narrowed to its value from before `resume`
126
+ const isRunning = () => ctx.state === 'running';
127
+ if (!isRunning()) {
128
+ try {
129
+ // `resume` can stay pending indefinitely on a blocked page rather than rejecting, which
130
+ // would keep the context and its nodes alive past the `finally` below
131
+ await Promise.race([ctx.resume(), sleep(200)]);
132
+ } catch {
133
+ // autoplay policies can keep us from resuming the context
134
+ }
135
+ if (!isRunning()) {
136
+ // a context that isn't running doesn't process any audio, so the analyser would only ever
137
+ // read silence. Report the track as not silent rather than raising a false positive.
138
+ return false;
139
+ }
140
+ }
119
141
  await sleep(timeOffset);
120
142
  analyser.getByteTimeDomainData(dataArray);
121
143
  const someNoise = dataArray.some((sample) => sample !== 128 && sample !== 0);
122
- ctx.close();
123
144
  return !someNoise;
145
+ } finally {
146
+ source?.disconnect();
147
+ analyser?.disconnect();
148
+ // don't let a failing teardown mask the detection result
149
+ await ctx.close().catch(() => {});
124
150
  }
125
- return false;
126
151
  }
127
152
 
128
153
  /**
@@ -351,3 +376,90 @@ export function layerDimensionsFor(
351
376
  ): Track.Dimensions | undefined {
352
377
  return trackInfo.layers?.find((l) => l.quality === quality);
353
378
  }
379
+
380
+ /**
381
+ * Waits until `track` has actually produced a video frame.
382
+ *
383
+ * iOS keeps reporting the camera's sensor frame from `getSettings()` until the camera has
384
+ * delivered its first picture, so a portrait capture initially reads as landscape. The arrival
385
+ * of that first frame is the only reliable signal that the reported settings describe reality.
386
+ *
387
+ * Best effort, never throws. Resolves `true` when a frame was observed, `false` when no frame
388
+ * can be observed (the track is already ended, muted or disabled) or `timeoutMs` elapsed first.
389
+ *
390
+ * @internal
391
+ */
392
+ export async function waitForFirstVideoFrame(
393
+ track: MediaStreamTrack,
394
+ timeoutMs: number,
395
+ candidateElements: HTMLMediaElement[] = [],
396
+ ): Promise<boolean> {
397
+ if (typeof document === 'undefined') {
398
+ return false;
399
+ }
400
+ if (track.readyState === 'ended' || track.muted || !track.enabled) {
401
+ // no frame is ever going to arrive, don't hold the caller up for the full timeout
402
+ return false;
403
+ }
404
+
405
+ // requestVideoFrameCallback (Safari 15.4 and up) is a platform capability, so the answer is the
406
+ // same for every element
407
+ const supportsFrameCallback =
408
+ typeof HTMLVideoElement.prototype.requestVideoFrameCallback === 'function';
409
+
410
+ // prefer an element that is already rendering this track (a local preview, typically) over
411
+ // spinning up a second decode just to observe a frame
412
+ const existingElement = candidateElements.find(
413
+ (el): el is HTMLVideoElement =>
414
+ el instanceof HTMLVideoElement &&
415
+ el.srcObject instanceof MediaStream &&
416
+ el.srcObject.getVideoTracks().includes(track) &&
417
+ // without requestVideoFrameCallback we depend on `loadeddata`, which is one-shot: an element
418
+ // that already decoded a frame (readyState >= HAVE_CURRENT_DATA) never fires it again.
419
+ // Leave those alone and set up our own element, which starts out empty.
420
+ (supportsFrameCallback || el.readyState < 2),
421
+ );
422
+
423
+ const element = existingElement ?? document.createElement('video');
424
+ if (!existingElement) {
425
+ element.playsInline = true;
426
+ element.muted = true;
427
+ element.srcObject = new MediaStream([track]);
428
+ // the element is deliberately never added to the DOM, it only exists to pull frames.
429
+ // WebKit delivers frames to a paused element too, so a rejected play() (Safari's low power
430
+ // mode, for instance) is not a reason to stop waiting
431
+ element.play().catch((error) => {
432
+ log.debug('could not play element while waiting for the first video frame', { error });
433
+ });
434
+ }
435
+
436
+ let timer: ReturnType<typeof setTimeout> | undefined;
437
+ let frameHandle: number | undefined;
438
+ let onLoadedData: (() => void) | undefined;
439
+
440
+ try {
441
+ return await new Promise<boolean>((resolve) => {
442
+ timer = setTimeout(() => resolve(false), timeoutMs);
443
+ if (supportsFrameCallback) {
444
+ frameHandle = element.requestVideoFrameCallback(() => resolve(true));
445
+ } else {
446
+ // Safari below 15.4 has no requestVideoFrameCallback. `loadeddata` fires once the first
447
+ // frame has been decoded, which is the same signal a few milliseconds earlier.
448
+ onLoadedData = () => resolve(true);
449
+ element.addEventListener('loadeddata', onLoadedData);
450
+ }
451
+ });
452
+ } finally {
453
+ clearTimeout(timer);
454
+ if (frameHandle !== undefined) {
455
+ element.cancelVideoFrameCallback?.(frameHandle);
456
+ }
457
+ if (onLoadedData) {
458
+ element.removeEventListener('loadeddata', onLoadedData);
459
+ }
460
+ if (!existingElement) {
461
+ element.pause();
462
+ element.srcObject = null;
463
+ }
464
+ }
465
+ }
@@ -1,12 +1,23 @@
1
1
  import { ClientInfo_Capability } from '@livekit/protocol';
2
2
  import { afterEach, describe, expect, it, vi } from 'vitest';
3
+ import {
4
+ stubMissingCompressionStreams,
5
+ stubMissingDeflateRawSupport,
6
+ } from '../test/compressionStreamStubs';
7
+ import { getBrowser } from '../utils/browserParser';
3
8
  import {
4
9
  ddExtensionURI,
5
10
  extractMaxAgeFromRequestHeaders,
6
11
  getClientInfo,
12
+ isAppleMobile,
13
+ isCompressionStreamSupported,
14
+ isDeflateRawCompressionSupported,
15
+ isIPadOS,
16
+ isPermissionDeniedError,
7
17
  isSVCSimulcast,
8
18
  isSVCSimulcastSupportedByServer,
9
19
  negotiateDependencyDescriptor,
20
+ resetDeflateRawCompressionSupportCache,
10
21
  splitUtf8,
11
22
  supportsAdaptiveStream,
12
23
  toWebsocketUrl,
@@ -367,3 +378,242 @@ describe('usesLegacySVCEncodings', () => {
367
378
  expect(usesLegacySVCEncodings()).toBe(true);
368
379
  });
369
380
  });
381
+
382
+ describe('isIPadOS / isAppleMobile', () => {
383
+ const stub = (userAgent: string, maxTouchPoints: number) =>
384
+ vi.stubGlobal('navigator', { userAgent, maxTouchPoints, product: 'Gecko' });
385
+
386
+ afterEach(() => {
387
+ vi.unstubAllGlobals();
388
+ });
389
+
390
+ // measured on an iPad 6 running iPadOS 17: the default (desktop) user agent, maxTouchPoints 5
391
+ const IPAD_DESKTOP_UA =
392
+ 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.14 Safari/605.1.15';
393
+ // measured on macOS Safari 26.4: the same shape of user agent, maxTouchPoints 0
394
+ const MAC_SAFARI_UA =
395
+ 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.4 Safari/605.1.15';
396
+ const IPHONE_UA =
397
+ 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.6 Mobile/15E148 Safari/604.1';
398
+ const IPAD_MOBILE_UA =
399
+ 'Mozilla/5.0 (iPad; CPU OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1';
400
+ const MAC_CHROME_UA =
401
+ 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36';
402
+ const ANDROID_CHROME_UA =
403
+ 'Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Mobile Safari/537.36';
404
+
405
+ it('detects an iPad sending the desktop user agent', () => {
406
+ stub(IPAD_DESKTOP_UA, 5);
407
+ expect(isIPadOS()).toBe(true);
408
+ expect(isAppleMobile()).toBe(true);
409
+ });
410
+
411
+ it('is the case getBrowser() alone gets wrong', () => {
412
+ // the gap this exists to close: iPadOS parses as macOS, so `os === 'iOS'` misses it
413
+ stub(IPAD_DESKTOP_UA, 5);
414
+ expect(getBrowser()?.os).toBe('macOS');
415
+ });
416
+
417
+ it('leaves macOS Safari alone, where the camera reports correctly and frames are slow', () => {
418
+ stub(MAC_SAFARI_UA, 0);
419
+ expect(isIPadOS()).toBe(false);
420
+ expect(isAppleMobile()).toBe(false);
421
+ });
422
+
423
+ it('covers iPhones through the existing iOS check', () => {
424
+ stub(IPHONE_UA, 5);
425
+ expect(isIPadOS()).toBe(false);
426
+ expect(isAppleMobile()).toBe(true);
427
+ });
428
+
429
+ it('covers an iPad set to request the mobile site', () => {
430
+ stub(IPAD_MOBILE_UA, 5);
431
+ expect(isIPadOS()).toBe(false);
432
+ expect(isAppleMobile()).toBe(true);
433
+ });
434
+
435
+ it('does not fire for a Mac with a touchscreen attached, which is not Safari', () => {
436
+ stub(MAC_CHROME_UA, 5);
437
+ expect(isIPadOS()).toBe(false);
438
+ expect(isAppleMobile()).toBe(false);
439
+ });
440
+
441
+ it('does not fire on android', () => {
442
+ stub(ANDROID_CHROME_UA, 5);
443
+ expect(isIPadOS()).toBe(false);
444
+ expect(isAppleMobile()).toBe(false);
445
+ });
446
+
447
+ it('does not fire on a desktop safari reporting no touch support', () => {
448
+ stub(MAC_SAFARI_UA, 1);
449
+ expect(isIPadOS()).toBe(false);
450
+ });
451
+ });
452
+
453
+ describe('isDeflateRawCompressionSupported', () => {
454
+ afterEach(() => {
455
+ resetDeflateRawCompressionSupportCache();
456
+ });
457
+
458
+ it('is true in a runtime that supports the deflate-raw format', () => {
459
+ expect(isDeflateRawCompressionSupported()).toBe(true);
460
+ });
461
+
462
+ it('is false without the compression streams api at all', () => {
463
+ const restoreCompressionStreams = stubMissingCompressionStreams();
464
+ try {
465
+ expect(isDeflateRawCompressionSupported()).toBe(false);
466
+ expect(isCompressionStreamSupported()).toBe(false);
467
+ } finally {
468
+ restoreCompressionStreams();
469
+ }
470
+ });
471
+
472
+ it('is false when the api exists but deflate-raw is not a supported format', () => {
473
+ const restoreCompressionStreams = stubMissingDeflateRawSupport();
474
+ try {
475
+ expect(isDeflateRawCompressionSupported()).toBe(false);
476
+ // Chromium 80-102 still compresses gzip, which is all the wrapped join request needs.
477
+ expect(isCompressionStreamSupported()).toBe(true);
478
+ } finally {
479
+ restoreCompressionStreams();
480
+ }
481
+ });
482
+
483
+ it('probes the runtime only once', () => {
484
+ expect(isDeflateRawCompressionSupported()).toBe(true);
485
+
486
+ const originalCompressionStream = globalThis.CompressionStream;
487
+ try {
488
+ (globalThis as any).CompressionStream = undefined;
489
+ expect(isDeflateRawCompressionSupported()).toBe(true);
490
+ } finally {
491
+ (globalThis as any).CompressionStream = originalCompressionStream;
492
+ }
493
+ });
494
+ });
495
+
496
+ describe('isPermissionDeniedError', () => {
497
+ function errorNamed(name: string) {
498
+ const e = new Error(name);
499
+ e.name = name;
500
+ return e;
501
+ }
502
+
503
+ it('matches the permission denial names and their legacy aliases', () => {
504
+ expect(isPermissionDeniedError(errorNamed('NotAllowedError'))).toBe(true);
505
+ expect(isPermissionDeniedError(errorNamed('SecurityError'))).toBe(true);
506
+ expect(isPermissionDeniedError(errorNamed('PermissionDeniedError'))).toBe(true);
507
+ expect(isPermissionDeniedError(errorNamed('PermissionDismissedError'))).toBe(true);
508
+ });
509
+
510
+ it('does not match failures that a softer constraint could still satisfy', () => {
511
+ expect(isPermissionDeniedError(errorNamed('OverconstrainedError'))).toBe(false);
512
+ expect(isPermissionDeniedError(errorNamed('ConstraintNotSatisfiedError'))).toBe(false);
513
+ expect(isPermissionDeniedError(errorNamed('NotFoundError'))).toBe(false);
514
+ expect(isPermissionDeniedError(errorNamed('SomeNameNobodyHasShippedYet'))).toBe(false);
515
+ });
516
+
517
+ it('does not match a plain error, undefined or null', () => {
518
+ expect(isPermissionDeniedError(new Error('boom'))).toBe(false);
519
+ expect(isPermissionDeniedError(undefined)).toBe(false);
520
+ expect(isPermissionDeniedError(null)).toBe(false);
521
+ });
522
+ });
523
+
524
+ // `freshUtils` below calls `vi.resetModules()`, so a dynamic import of `./utils` from a later
525
+ // block would get a different module instance than the static import at the top of this file.
526
+ // Keep this block last.
527
+ describe('empty audio stream track refcounting', () => {
528
+ let closedContexts: number;
529
+
530
+ function makeTrack(): MediaStreamTrack {
531
+ const track = {
532
+ enabled: true,
533
+ stopped: false,
534
+ stop() {
535
+ track.stopped = true;
536
+ },
537
+ clone: () => makeTrack(),
538
+ };
539
+ return track as unknown as MediaStreamTrack;
540
+ }
541
+
542
+ /** reloads `./utils` so its module level context and refcount start empty */
543
+ async function freshUtils() {
544
+ closedContexts = 0;
545
+ vi.resetModules();
546
+ vi.stubGlobal(
547
+ 'AudioContext',
548
+ class {
549
+ createOscillator() {
550
+ return { connect: () => {}, start: () => {} };
551
+ }
552
+
553
+ createGain() {
554
+ return { gain: { setValueAtTime: () => {} }, connect: () => {} };
555
+ }
556
+
557
+ createMediaStreamDestination() {
558
+ return { stream: { getAudioTracks: () => [makeTrack()] } };
559
+ }
560
+
561
+ async close() {
562
+ closedContexts += 1;
563
+ }
564
+ },
565
+ );
566
+ return import('./utils');
567
+ }
568
+
569
+ afterEach(() => {
570
+ vi.unstubAllGlobals();
571
+ });
572
+
573
+ it('closes the shared context once every track handed out is released', async () => {
574
+ const { getEmptyAudioStreamTrack, releaseEmptyAudioStreamTrack } = await freshUtils();
575
+ const first = getEmptyAudioStreamTrack();
576
+ const second = getEmptyAudioStreamTrack();
577
+
578
+ await releaseEmptyAudioStreamTrack(first);
579
+ expect(closedContexts).toBe(0);
580
+
581
+ await releaseEmptyAudioStreamTrack(second);
582
+ expect(closedContexts).toBe(1);
583
+ });
584
+
585
+ it('ignores a second release of the same track', async () => {
586
+ const { getEmptyAudioStreamTrack, releaseEmptyAudioStreamTrack } = await freshUtils();
587
+ const first = getEmptyAudioStreamTrack();
588
+ const second = getEmptyAudioStreamTrack();
589
+
590
+ await releaseEmptyAudioStreamTrack(first);
591
+ await releaseEmptyAudioStreamTrack(first);
592
+ expect(closedContexts).toBe(0);
593
+
594
+ await releaseEmptyAudioStreamTrack(second);
595
+ expect(closedContexts).toBe(1);
596
+ });
597
+
598
+ it('leaves a track it never handed out alone', async () => {
599
+ const { getEmptyAudioStreamTrack, releaseEmptyAudioStreamTrack } = await freshUtils();
600
+ const owned = getEmptyAudioStreamTrack();
601
+ const foreign = makeTrack();
602
+
603
+ await releaseEmptyAudioStreamTrack(foreign);
604
+ expect(closedContexts).toBe(0);
605
+ expect((foreign as unknown as { stopped: boolean }).stopped).toBe(false);
606
+
607
+ await releaseEmptyAudioStreamTrack(owned);
608
+ expect(closedContexts).toBe(1);
609
+ });
610
+
611
+ it('builds a fresh context for an acquire that arrives after the last release', async () => {
612
+ const { getEmptyAudioStreamTrack, releaseEmptyAudioStreamTrack } = await freshUtils();
613
+ await releaseEmptyAudioStreamTrack(getEmptyAudioStreamTrack());
614
+ expect(closedContexts).toBe(1);
615
+
616
+ await releaseEmptyAudioStreamTrack(getEmptyAudioStreamTrack());
617
+ expect(closedContexts).toBe(2);
618
+ });
619
+ });