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