livekit-client 1.5.0 → 1.6.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 (81) hide show
  1. package/dist/livekit-client.esm.mjs +2031 -5393
  2. package/dist/livekit-client.esm.mjs.map +1 -1
  3. package/dist/livekit-client.umd.js +1 -1
  4. package/dist/livekit-client.umd.js.map +1 -1
  5. package/dist/src/api/SignalClient.d.ts +3 -2
  6. package/dist/src/api/SignalClient.d.ts.map +1 -1
  7. package/dist/src/connectionHelper/ConnectionCheck.d.ts +1 -1
  8. package/dist/src/connectionHelper/ConnectionCheck.d.ts.map +1 -1
  9. package/dist/src/connectionHelper/checks/Checker.d.ts +4 -4
  10. package/dist/src/connectionHelper/checks/Checker.d.ts.map +1 -1
  11. package/dist/src/index.d.ts +3 -2
  12. package/dist/src/index.d.ts.map +1 -1
  13. package/dist/src/logger.d.ts +3 -3
  14. package/dist/src/logger.d.ts.map +1 -1
  15. package/dist/src/options.d.ts +4 -1
  16. package/dist/src/options.d.ts.map +1 -1
  17. package/dist/src/proto/google/protobuf/timestamp.d.ts +4 -4
  18. package/dist/src/proto/google/protobuf/timestamp.d.ts.map +1 -1
  19. package/dist/src/proto/livekit_models.d.ts +4 -4
  20. package/dist/src/proto/livekit_models.d.ts.map +1 -1
  21. package/dist/src/proto/livekit_rtc.d.ts +4 -4
  22. package/dist/src/proto/livekit_rtc.d.ts.map +1 -1
  23. package/dist/src/room/RTCEngine.d.ts +4 -3
  24. package/dist/src/room/RTCEngine.d.ts.map +1 -1
  25. package/dist/src/room/Room.d.ts +21 -4
  26. package/dist/src/room/Room.d.ts.map +1 -1
  27. package/dist/src/room/events.d.ts +4 -0
  28. package/dist/src/room/events.d.ts.map +1 -1
  29. package/dist/src/room/participant/Participant.d.ts +1 -1
  30. package/dist/src/room/participant/Participant.d.ts.map +1 -1
  31. package/dist/src/room/track/LocalTrack.d.ts.map +1 -1
  32. package/dist/src/room/track/RemoteVideoTrack.d.ts.map +1 -1
  33. package/dist/src/room/track/Track.d.ts +2 -1
  34. package/dist/src/room/track/Track.d.ts.map +1 -1
  35. package/dist/src/room/track/TrackPublication.d.ts +1 -1
  36. package/dist/src/room/track/TrackPublication.d.ts.map +1 -1
  37. package/dist/src/room/track/options.d.ts +3 -3
  38. package/dist/src/room/track/options.d.ts.map +1 -1
  39. package/dist/src/room/track/types.d.ts +3 -3
  40. package/dist/src/room/track/types.d.ts.map +1 -1
  41. package/dist/src/room/types.d.ts +13 -0
  42. package/dist/src/room/types.d.ts.map +1 -0
  43. package/dist/src/room/utils.d.ts +44 -0
  44. package/dist/src/room/utils.d.ts.map +1 -1
  45. package/dist/ts4.2/src/api/SignalClient.d.ts +3 -2
  46. package/dist/ts4.2/src/connectionHelper/ConnectionCheck.d.ts +1 -1
  47. package/dist/ts4.2/src/connectionHelper/checks/Checker.d.ts +4 -4
  48. package/dist/ts4.2/src/index.d.ts +3 -2
  49. package/dist/ts4.2/src/logger.d.ts +3 -3
  50. package/dist/ts4.2/src/options.d.ts +4 -1
  51. package/dist/ts4.2/src/proto/google/protobuf/timestamp.d.ts +4 -4
  52. package/dist/ts4.2/src/proto/livekit_models.d.ts +4 -4
  53. package/dist/ts4.2/src/proto/livekit_rtc.d.ts +4 -4
  54. package/dist/ts4.2/src/room/RTCEngine.d.ts +4 -3
  55. package/dist/ts4.2/src/room/Room.d.ts +21 -4
  56. package/dist/ts4.2/src/room/events.d.ts +4 -0
  57. package/dist/ts4.2/src/room/participant/Participant.d.ts +1 -1
  58. package/dist/ts4.2/src/room/track/Track.d.ts +2 -1
  59. package/dist/ts4.2/src/room/track/TrackPublication.d.ts +1 -1
  60. package/dist/ts4.2/src/room/track/options.d.ts +3 -3
  61. package/dist/ts4.2/src/room/track/types.d.ts +3 -3
  62. package/dist/ts4.2/src/room/types.d.ts +13 -0
  63. package/dist/ts4.2/src/room/utils.d.ts +44 -0
  64. package/package.json +21 -21
  65. package/src/api/SignalClient.ts +40 -16
  66. package/src/connectionHelper/checks/turn.ts +1 -1
  67. package/src/connectionHelper/checks/websocket.ts +1 -1
  68. package/src/index.ts +5 -0
  69. package/src/options.ts +5 -1
  70. package/src/room/RTCEngine.ts +35 -26
  71. package/src/room/Room.ts +209 -61
  72. package/src/room/events.ts +4 -0
  73. package/src/room/participant/LocalParticipant.ts +3 -3
  74. package/src/room/participant/publishUtils.ts +1 -1
  75. package/src/room/track/LocalAudioTrack.ts +1 -1
  76. package/src/room/track/LocalTrack.ts +1 -0
  77. package/src/room/track/LocalVideoTrack.ts +1 -1
  78. package/src/room/track/RemoteVideoTrack.ts +4 -0
  79. package/src/room/track/Track.ts +1 -0
  80. package/src/room/types.ts +12 -0
  81. package/src/room/utils.ts +150 -12
package/src/room/Room.ts CHANGED
@@ -17,6 +17,9 @@ import {
17
17
  Room as RoomModel,
18
18
  ServerInfo,
19
19
  SpeakerInfo,
20
+ TrackInfo,
21
+ TrackSource,
22
+ TrackType,
20
23
  UserPacket,
21
24
  } from '../proto/livekit_models';
22
25
  import {
@@ -42,7 +45,7 @@ import type { ConnectionQuality } from './participant/Participant';
42
45
  import RemoteParticipant from './participant/RemoteParticipant';
43
46
  import RTCEngine from './RTCEngine';
44
47
  import LocalAudioTrack from './track/LocalAudioTrack';
45
- import type LocalTrackPublication from './track/LocalTrackPublication';
48
+ import LocalTrackPublication from './track/LocalTrackPublication';
46
49
  import LocalVideoTrack from './track/LocalVideoTrack';
47
50
  import type RemoteTrack from './track/RemoteTrack';
48
51
  import RemoteTrackPublication from './track/RemoteTrackPublication';
@@ -50,7 +53,16 @@ import { Track } from './track/Track';
50
53
  import type { TrackPublication } from './track/TrackPublication';
51
54
  import type { AdaptiveStreamSettings } from './track/types';
52
55
  import { getNewAudioContext } from './track/utils';
53
- import { Future, isWeb, supportsSetSinkId, unpackStreamId } from './utils';
56
+ import type { SimulationOptions } from './types';
57
+ import {
58
+ Future,
59
+ createDummyVideoStreamTrack,
60
+ getEmptyAudioStreamTrack,
61
+ isWeb,
62
+ Mutex,
63
+ supportsSetSinkId,
64
+ unpackStreamId,
65
+ } from './utils';
54
66
 
55
67
  export enum ConnectionState {
56
68
  Disconnected = 'disconnected',
@@ -118,6 +130,8 @@ class Room extends (EventEmitter as new () => TypedEmitter<RoomEventCallbacks>)
118
130
  /** future holding client initiated connection attempt */
119
131
  private connectFuture?: Future<void>;
120
132
 
133
+ private disconnectLock: Mutex;
134
+
121
135
  /**
122
136
  * Creates a new Room, the primary construct for a LiveKit session.
123
137
  * @param options
@@ -144,6 +158,8 @@ class Room extends (EventEmitter as new () => TypedEmitter<RoomEventCallbacks>)
144
158
 
145
159
  this.maybeCreateEngine();
146
160
 
161
+ this.disconnectLock = new Mutex();
162
+
147
163
  this.localParticipant = new LocalParticipant('', '', this.engine, this.options);
148
164
  }
149
165
 
@@ -303,18 +319,7 @@ class Room extends (EventEmitter as new () => TypedEmitter<RoomEventCallbacks>)
303
319
 
304
320
  this.localParticipant.updateInfo(pi);
305
321
  // forward metadata changed for the local participant
306
- this.localParticipant
307
- .on(ParticipantEvent.ParticipantMetadataChanged, this.onLocalParticipantMetadataChanged)
308
- .on(ParticipantEvent.TrackMuted, this.onLocalTrackMuted)
309
- .on(ParticipantEvent.TrackUnmuted, this.onLocalTrackUnmuted)
310
- .on(ParticipantEvent.LocalTrackPublished, this.onLocalTrackPublished)
311
- .on(ParticipantEvent.LocalTrackUnpublished, this.onLocalTrackUnpublished)
312
- .on(ParticipantEvent.ConnectionQualityChanged, this.onLocalConnectionQualityChanged)
313
- .on(ParticipantEvent.MediaDevicesError, this.onMediaDevicesError)
314
- .on(
315
- ParticipantEvent.ParticipantPermissionsChanged,
316
- this.onLocalParticipantPermissionsChanged,
317
- );
322
+ this.setupLocalParticipantEvents();
318
323
 
319
324
  // populate remote participants, these should not trigger new events
320
325
  joinResponse.otherParticipants.forEach((info) => {
@@ -342,7 +347,12 @@ class Room extends (EventEmitter as new () => TypedEmitter<RoomEventCallbacks>)
342
347
  } catch (err) {
343
348
  this.recreateEngine();
344
349
  this.handleDisconnect(this.options.stopLocalTrackOnUnpublish);
345
- reject(new ConnectionError('could not establish signal connection'));
350
+ let errorMessage = '';
351
+ if (err instanceof Error) {
352
+ errorMessage = err.message;
353
+ log.debug(`error trying to establish signal connection`, { error: err });
354
+ }
355
+ reject(new ConnectionError(`could not establish signal connection: ${errorMessage}`));
346
356
  return;
347
357
  }
348
358
 
@@ -389,26 +399,38 @@ class Room extends (EventEmitter as new () => TypedEmitter<RoomEventCallbacks>)
389
399
  * disconnects the room, emits [[RoomEvent.Disconnected]]
390
400
  */
391
401
  disconnect = async (stopTracks = true) => {
392
- log.info('disconnect from room', { identity: this.localParticipant.identity });
393
- if (this.state === ConnectionState.Connecting || this.state === ConnectionState.Reconnecting) {
394
- // try aborting pending connection attempt
395
- log.warn('abort connection attempt');
396
- this.abortController?.abort();
397
- // in case the abort controller didn't manage to cancel the connection attempt, reject the connect promise explicitly
398
- this.connectFuture?.reject?.(new ConnectionError('Client initiated disconnect'));
399
- this.connectFuture = undefined;
400
- }
401
- // send leave
402
- if (this.engine?.client.isConnected) {
403
- await this.engine.client.sendLeave();
404
- }
405
- // close engine (also closes client)
406
- if (this.engine) {
407
- this.engine.close();
402
+ const unlock = await this.disconnectLock.lock();
403
+ try {
404
+ if (this.state === ConnectionState.Disconnected) {
405
+ log.debug('already disconnected');
406
+ return;
407
+ }
408
+ log.info('disconnect from room', { identity: this.localParticipant.identity });
409
+ if (
410
+ this.state === ConnectionState.Connecting ||
411
+ this.state === ConnectionState.Reconnecting
412
+ ) {
413
+ // try aborting pending connection attempt
414
+ log.warn('abort connection attempt');
415
+ this.abortController?.abort();
416
+ // in case the abort controller didn't manage to cancel the connection attempt, reject the connect promise explicitly
417
+ this.connectFuture?.reject?.(new ConnectionError('Client initiated disconnect'));
418
+ this.connectFuture = undefined;
419
+ }
420
+ // send leave
421
+ if (this.engine?.client.isConnected) {
422
+ await this.engine.client.sendLeave();
423
+ }
424
+ // close engine (also closes client)
425
+ if (this.engine) {
426
+ await this.engine.close();
427
+ }
428
+ this.handleDisconnect(stopTracks, DisconnectReason.CLIENT_INITIATED);
429
+ /* @ts-ignore */
430
+ this.engine = undefined;
431
+ } finally {
432
+ unlock();
408
433
  }
409
- this.handleDisconnect(stopTracks, DisconnectReason.CLIENT_INITIATED);
410
- /* @ts-ignore */
411
- this.engine = undefined;
412
434
  };
413
435
 
414
436
  /**
@@ -440,12 +462,12 @@ class Room extends (EventEmitter as new () => TypedEmitter<RoomEventCallbacks>)
440
462
  /**
441
463
  * @internal for testing
442
464
  */
443
- simulateScenario(scenario: string) {
465
+ async simulateScenario(scenario: string) {
444
466
  let postAction = () => {};
445
467
  let req: SimulateScenario | undefined;
446
468
  switch (scenario) {
447
469
  case 'signal-reconnect':
448
- this.engine.client.close();
470
+ await this.engine.client.close();
449
471
  if (this.engine.client.onClose) {
450
472
  this.engine.client.onClose('simulate disconnect');
451
473
  }
@@ -508,8 +530,8 @@ class Room extends (EventEmitter as new () => TypedEmitter<RoomEventCallbacks>)
508
530
  }
509
531
  }
510
532
 
511
- private onBeforeUnload = () => {
512
- this.disconnect();
533
+ private onBeforeUnload = async () => {
534
+ await this.disconnect();
513
535
  };
514
536
 
515
537
  /**
@@ -520,7 +542,7 @@ class Room extends (EventEmitter as new () => TypedEmitter<RoomEventCallbacks>)
520
542
  * - `getUserMedia`
521
543
  */
522
544
  async startAudio() {
523
- this.acquireAudioContext();
545
+ await this.acquireAudioContext();
524
546
 
525
547
  const elements: Array<HTMLMediaElement> = [];
526
548
  this.participants.forEach((p) => {
@@ -550,7 +572,18 @@ class Room extends (EventEmitter as new () => TypedEmitter<RoomEventCallbacks>)
550
572
  }
551
573
 
552
574
  /**
553
- * Switches all active device used in this room to the given device.
575
+ * Returns the active audio output device used in this room.
576
+ *
577
+ * Note: to get the active `audioinput` or `videoinput` use [[LocalTrack.getDeviceId()]]
578
+ *
579
+ * @return the previously successfully set audio output device ID or an empty string if the default device is used.
580
+ */
581
+ getActiveAudioOutputDevice(): string {
582
+ return this.options.audioOutput?.deviceId ?? '';
583
+ }
584
+
585
+ /**
586
+ * Switches all active devices used in this room to the given device.
554
587
  *
555
588
  * Note: setting AudioOutput is not supported on some browsers. See [setSinkId](https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/setSinkId#browser_compatibility)
556
589
  *
@@ -592,16 +625,10 @@ class Room extends (EventEmitter as new () => TypedEmitter<RoomEventCallbacks>)
592
625
  this.options.audioOutput ??= {};
593
626
  const prevDeviceId = this.options.audioOutput.deviceId;
594
627
  this.options.audioOutput.deviceId = deviceId;
595
- const promises: Promise<void>[] = [];
596
- this.participants.forEach((p) => {
597
- promises.push(
598
- p.setAudioOutput({
599
- deviceId,
600
- }),
601
- );
602
- });
603
628
  try {
604
- await Promise.all(promises);
629
+ await Promise.all(
630
+ Array.from(this.participants.values()).map((p) => p.setAudioOutput({ deviceId })),
631
+ );
605
632
  } catch (e) {
606
633
  this.options.audioOutput.deviceId = prevDeviceId;
607
634
  throw e;
@@ -609,6 +636,21 @@ class Room extends (EventEmitter as new () => TypedEmitter<RoomEventCallbacks>)
609
636
  }
610
637
  }
611
638
 
639
+ private setupLocalParticipantEvents() {
640
+ this.localParticipant
641
+ .on(ParticipantEvent.ParticipantMetadataChanged, this.onLocalParticipantMetadataChanged)
642
+ .on(ParticipantEvent.TrackMuted, this.onLocalTrackMuted)
643
+ .on(ParticipantEvent.TrackUnmuted, this.onLocalTrackUnmuted)
644
+ .on(ParticipantEvent.LocalTrackPublished, this.onLocalTrackPublished)
645
+ .on(ParticipantEvent.LocalTrackUnpublished, this.onLocalTrackUnpublished)
646
+ .on(ParticipantEvent.ConnectionQualityChanged, this.onLocalConnectionQualityChanged)
647
+ .on(ParticipantEvent.MediaDevicesError, this.onMediaDevicesError)
648
+ .on(
649
+ ParticipantEvent.ParticipantPermissionsChanged,
650
+ this.onLocalParticipantPermissionsChanged,
651
+ );
652
+ }
653
+
612
654
  private recreateEngine() {
613
655
  this.engine?.close();
614
656
  /* @ts-ignore */
@@ -773,7 +815,7 @@ class Room extends (EventEmitter as new () => TypedEmitter<RoomEventCallbacks>)
773
815
 
774
816
  this.participants.clear();
775
817
  this.activeSpeakers = [];
776
- if (this.audioContext) {
818
+ if (this.audioContext && typeof this.options.expWebAudioMix === 'boolean') {
777
819
  this.audioContext.close();
778
820
  this.audioContext = undefined;
779
821
  }
@@ -986,18 +1028,22 @@ class Room extends (EventEmitter as new () => TypedEmitter<RoomEventCallbacks>)
986
1028
  });
987
1029
  };
988
1030
 
989
- private acquireAudioContext() {
990
- if (this.audioContext) {
991
- this.audioContext.close();
1031
+ private async acquireAudioContext() {
1032
+ if (
1033
+ typeof this.options.expWebAudioMix !== 'boolean' &&
1034
+ this.options.expWebAudioMix.audioContext
1035
+ ) {
1036
+ // override audio context with custom audio context if supplied by user
1037
+ this.audioContext = this.options.expWebAudioMix.audioContext;
1038
+ await this.audioContext.resume();
1039
+ } else {
1040
+ // by using an AudioContext, it reduces lag on audio elements
1041
+ // https://stackoverflow.com/questions/9811429/html5-audio-tag-on-safari-has-a-delay/54119854#54119854
1042
+ this.audioContext = getNewAudioContext() ?? undefined;
992
1043
  }
993
- // by using an AudioContext, it reduces lag on audio elements
994
- // https://stackoverflow.com/questions/9811429/html5-audio-tag-on-safari-has-a-delay/54119854#54119854
995
- const ctx = getNewAudioContext();
996
- if (ctx) {
997
- this.audioContext = ctx;
998
- if (this.options.expWebAudioMix) {
999
- this.participants.forEach((participant) => participant.setAudioContext(this.audioContext));
1000
- }
1044
+
1045
+ if (this.options.expWebAudioMix) {
1046
+ this.participants.forEach((participant) => participant.setAudioContext(this.audioContext));
1001
1047
  }
1002
1048
  }
1003
1049
 
@@ -1212,6 +1258,108 @@ class Room extends (EventEmitter as new () => TypedEmitter<RoomEventCallbacks>)
1212
1258
  this.emit(RoomEvent.ParticipantPermissionsChanged, prevPermissions, this.localParticipant);
1213
1259
  };
1214
1260
 
1261
+ /**
1262
+ * Allows to populate a room with simulated participants.
1263
+ * No actual connection to a server will be established, all state is
1264
+ * @experimental
1265
+ */
1266
+ simulateParticipants(options: SimulationOptions) {
1267
+ const publishOptions = {
1268
+ audio: true,
1269
+ video: true,
1270
+ ...options.publish,
1271
+ };
1272
+ const participantOptions = {
1273
+ count: 9,
1274
+ audio: false,
1275
+ video: true,
1276
+ aspectRatios: [1.66, 1.7, 1.3],
1277
+ ...options.participants,
1278
+ };
1279
+ this.handleDisconnect();
1280
+ this.name = 'simulated-room';
1281
+ this.localParticipant.identity = 'simulated-local';
1282
+ this.localParticipant.name = 'simulated-local';
1283
+ this.setupLocalParticipantEvents();
1284
+ this.emit(RoomEvent.SignalConnected);
1285
+ this.emit(RoomEvent.Connected);
1286
+ this.setAndEmitConnectionState(ConnectionState.Connected);
1287
+ if (publishOptions.video) {
1288
+ const camPub = new LocalTrackPublication(
1289
+ Track.Kind.Video,
1290
+ TrackInfo.fromPartial({
1291
+ source: TrackSource.CAMERA,
1292
+ sid: Math.floor(Math.random() * 10_000).toString(),
1293
+ type: TrackType.AUDIO,
1294
+ name: 'video-dummy',
1295
+ }),
1296
+ new LocalVideoTrack(
1297
+ createDummyVideoStreamTrack(
1298
+ 160 * participantOptions.aspectRatios[0] ?? 1,
1299
+ 160,
1300
+ true,
1301
+ true,
1302
+ ),
1303
+ ),
1304
+ );
1305
+ // @ts-ignore
1306
+ this.localParticipant.addTrackPublication(camPub);
1307
+ this.localParticipant.emit(ParticipantEvent.LocalTrackPublished, camPub);
1308
+ }
1309
+ if (publishOptions.audio) {
1310
+ const audioPub = new LocalTrackPublication(
1311
+ Track.Kind.Audio,
1312
+ TrackInfo.fromPartial({
1313
+ source: TrackSource.MICROPHONE,
1314
+ sid: Math.floor(Math.random() * 10_000).toString(),
1315
+ type: TrackType.AUDIO,
1316
+ }),
1317
+ new LocalAudioTrack(getEmptyAudioStreamTrack()),
1318
+ );
1319
+ // @ts-ignore
1320
+ this.localParticipant.addTrackPublication(audioPub);
1321
+ this.localParticipant.emit(ParticipantEvent.LocalTrackPublished, audioPub);
1322
+ }
1323
+
1324
+ for (let i = 0; i < participantOptions.count - 1; i += 1) {
1325
+ let info: ParticipantInfo = ParticipantInfo.fromPartial({
1326
+ sid: Math.floor(Math.random() * 10_000).toString(),
1327
+ identity: `simulated-${i}`,
1328
+ state: ParticipantInfo_State.ACTIVE,
1329
+ tracks: [],
1330
+ joinedAt: Date.now(),
1331
+ });
1332
+ const p = this.getOrCreateParticipant(info.identity, info);
1333
+ if (participantOptions.video) {
1334
+ const dummyVideo = createDummyVideoStreamTrack(
1335
+ 160 * participantOptions.aspectRatios[i % participantOptions.aspectRatios.length] ?? 1,
1336
+ 160,
1337
+ false,
1338
+ true,
1339
+ );
1340
+ const videoTrack = TrackInfo.fromPartial({
1341
+ source: TrackSource.CAMERA,
1342
+ sid: Math.floor(Math.random() * 10_000).toString(),
1343
+ type: TrackType.AUDIO,
1344
+ });
1345
+ p.addSubscribedMediaTrack(dummyVideo, videoTrack.sid, new MediaStream([dummyVideo]));
1346
+ info.tracks = [...info.tracks, videoTrack];
1347
+ }
1348
+ if (participantOptions.audio) {
1349
+ const dummyTrack = getEmptyAudioStreamTrack();
1350
+ const audioTrack = TrackInfo.fromPartial({
1351
+ source: TrackSource.MICROPHONE,
1352
+ sid: Math.floor(Math.random() * 10_000).toString(),
1353
+ type: TrackType.AUDIO,
1354
+ });
1355
+ p.addSubscribedMediaTrack(dummyTrack, audioTrack.sid, new MediaStream([dummyTrack]));
1356
+ info.tracks = [...info.tracks, audioTrack];
1357
+ }
1358
+
1359
+ p.updateInfo(info);
1360
+ }
1361
+ }
1362
+
1215
1363
  // /** @internal */
1216
1364
  emit<E extends keyof RoomEventCallbacks>(
1217
1365
  event: E,
@@ -427,6 +427,10 @@ export enum TrackEvent {
427
427
  Message = 'message',
428
428
  Muted = 'muted',
429
429
  Unmuted = 'unmuted',
430
+ /**
431
+ * Only fires on LocalTracks
432
+ */
433
+ Restarted = 'restarted',
430
434
  Ended = 'ended',
431
435
  Subscribed = 'subscribed',
432
436
  Unsubscribed = 'unsubscribed',
@@ -964,7 +964,7 @@ export default class LocalParticipant extends Participant {
964
964
  });
965
965
  this.unpublishTrack(track);
966
966
  } else if (track.isUserProvided) {
967
- await track.pauseUpstream();
967
+ await track.mute();
968
968
  } else if (track instanceof LocalAudioTrack || track instanceof LocalVideoTrack) {
969
969
  try {
970
970
  if (isWeb()) {
@@ -993,8 +993,8 @@ export default class LocalParticipant extends Participant {
993
993
  log.debug('track ended, attempting to use a different device');
994
994
  await track.restartTrack();
995
995
  } catch (e) {
996
- log.warn(`could not restart track, pausing upstream instead`);
997
- await track.pauseUpstream();
996
+ log.warn(`could not restart track, muting instead`);
997
+ await track.mute();
998
998
  }
999
999
  }
1000
1000
  };
@@ -305,7 +305,7 @@ function encodingsFromPresets(
305
305
  const rid = videoRids[idx];
306
306
  encodings.push({
307
307
  rid,
308
- scaleResolutionDownBy: size / Math.min(preset.width, preset.height),
308
+ scaleResolutionDownBy: Math.max(1, size / Math.min(preset.width, preset.height)),
309
309
  maxBitrate: preset.encoding.maxBitrate,
310
310
  /* @ts-ignore */
311
311
  maxFramerate: preset.encoding.maxFramerate,
@@ -114,7 +114,7 @@ export default class LocalAudioTrack extends LocalTrack {
114
114
  };
115
115
 
116
116
  async getSenderStats(): Promise<AudioSenderStats | undefined> {
117
- if (!this.sender) {
117
+ if (!this.sender?.getStats) {
118
118
  return undefined;
119
119
  }
120
120
 
@@ -183,6 +183,7 @@ export default abstract class LocalTrack extends Track {
183
183
 
184
184
  this.mediaStream = mediaStream;
185
185
  this.constraints = constraints;
186
+ this.emit(TrackEvent.Restarted, this);
186
187
  return this;
187
188
  }
188
189
 
@@ -113,7 +113,7 @@ export default class LocalVideoTrack extends LocalTrack {
113
113
  }
114
114
 
115
115
  async getSenderStats(): Promise<VideoSenderStats[]> {
116
- if (!this.sender) {
116
+ if (!this.sender?.getStats) {
117
117
  return [];
118
118
  }
119
119
 
@@ -118,6 +118,10 @@ export default class RemoteVideoTrack extends RemoteTrack {
118
118
  * @internal
119
119
  */
120
120
  stopObservingElementInfo(elementInfo: ElementInfo) {
121
+ if (!this.isAdaptiveStream) {
122
+ log.warn('stopObservingElementInfo ignored');
123
+ return;
124
+ }
121
125
  const stopElementInfos = this.elementInfos.filter((info) => info === elementInfo);
122
126
  for (const info of stopElementInfos) {
123
127
  info.stopObserving();
@@ -398,6 +398,7 @@ export type TrackEventCallbacks = {
398
398
  message: () => void;
399
399
  muted: (track?: any) => void;
400
400
  unmuted: (track?: any) => void;
401
+ restarted: (track?: any) => void;
401
402
  ended: (track?: any) => void;
402
403
  updateSettings: () => void;
403
404
  updateSubscription: () => void;
@@ -0,0 +1,12 @@
1
+ export type SimulationOptions = {
2
+ publish?: {
3
+ audio?: boolean;
4
+ video?: boolean;
5
+ };
6
+ participants?: {
7
+ count?: number;
8
+ aspectRatios?: Array<number>;
9
+ audio?: boolean;
10
+ video?: boolean;
11
+ };
12
+ };
package/src/room/utils.ts CHANGED
@@ -1,6 +1,9 @@
1
1
  import UAParser from 'ua-parser-js';
2
2
  import { ClientInfo, ClientInfo_SDK } from '../proto/livekit_models';
3
3
  import { protocolVersion, version } from '../version';
4
+ import type LocalAudioTrack from './track/LocalAudioTrack';
5
+ import type RemoteAudioTrack from './track/RemoteAudioTrack';
6
+ import { getNewAudioContext } from './track/utils';
4
7
 
5
8
  const separator = '|';
6
9
 
@@ -178,22 +181,41 @@ let emptyVideoStreamTrack: MediaStreamTrack | undefined;
178
181
 
179
182
  export function getEmptyVideoStreamTrack() {
180
183
  if (!emptyVideoStreamTrack) {
181
- const canvas = document.createElement('canvas');
182
- // the canvas size is set to 16, because electron apps seem to fail with smaller values
183
- canvas.width = 16;
184
- canvas.height = 16;
185
- canvas.getContext('2d')?.fillRect(0, 0, canvas.width, canvas.height);
186
- // @ts-ignore
187
- const emptyStream = canvas.captureStream();
188
- [emptyVideoStreamTrack] = emptyStream.getTracks();
189
- if (!emptyVideoStreamTrack) {
190
- throw Error('Could not get empty media stream video track');
191
- }
192
- emptyVideoStreamTrack.enabled = false;
184
+ emptyVideoStreamTrack = createDummyVideoStreamTrack();
193
185
  }
194
186
  return emptyVideoStreamTrack;
195
187
  }
196
188
 
189
+ export function createDummyVideoStreamTrack(
190
+ width: number = 16,
191
+ height: number = 16,
192
+ enabled: boolean = false,
193
+ paintContent: boolean = false,
194
+ ) {
195
+ const canvas = document.createElement('canvas');
196
+ // the canvas size is set to 16 by default, because electron apps seem to fail with smaller values
197
+ canvas.width = width;
198
+ canvas.height = height;
199
+ const ctx = canvas.getContext('2d');
200
+ ctx?.fillRect(0, 0, canvas.width, canvas.height);
201
+ if (paintContent && ctx) {
202
+ ctx.beginPath();
203
+ ctx.arc(width / 2, height / 2, 50, 0, Math.PI * 2, true);
204
+ ctx.closePath();
205
+ ctx.fillStyle = 'grey';
206
+ ctx.fill();
207
+ }
208
+ // @ts-ignore
209
+ const dummyStream = canvas.captureStream();
210
+ const [dummyTrack] = dummyStream.getTracks();
211
+ if (!dummyTrack) {
212
+ throw Error('Could not get empty media stream video track');
213
+ }
214
+ dummyTrack.enabled = enabled;
215
+
216
+ return dummyTrack;
217
+ }
218
+
197
219
  let emptyAudioStreamTrack: MediaStreamTrack | undefined;
198
220
 
199
221
  export function getEmptyAudioStreamTrack() {
@@ -236,3 +258,119 @@ export class Future<T> {
236
258
  }).finally(() => this.onFinally?.());
237
259
  }
238
260
  }
261
+
262
+ export type AudioAnalyserOptions = {
263
+ /**
264
+ * If set to true, the analyser will use a cloned version of the underlying mediastreamtrack, which won't be impacted by muting the track.
265
+ * Useful for local tracks when implementing things like "seems like you're muted, but trying to speak".
266
+ * Defaults to false
267
+ */
268
+ cloneTrack?: boolean;
269
+ /**
270
+ * see https://developer.mozilla.org/en-US/docs/Web/API/AnalyserNode/fftSize
271
+ */
272
+ fftSize?: number;
273
+ /**
274
+ * see https://developer.mozilla.org/en-US/docs/Web/API/AnalyserNode/smoothingTimeConstant
275
+ */
276
+ smoothingTimeConstant?: number;
277
+ /**
278
+ * see https://developer.mozilla.org/en-US/docs/Web/API/AnalyserNode/minDecibels
279
+ */
280
+ minDecibels?: number;
281
+ /**
282
+ * see https://developer.mozilla.org/en-US/docs/Web/API/AnalyserNode/maxDecibels
283
+ */
284
+ maxDecibels?: number;
285
+ };
286
+
287
+ /**
288
+ * Creates and returns an analyser web audio node that is attached to the provided track.
289
+ * Additionally returns a convenience method `calculateVolume` to perform instant volume readings on that track.
290
+ * Call the returned `cleanup` function to close the audioContext that has been created for the instance of this helper
291
+ */
292
+ export function createAudioAnalyser(
293
+ track: LocalAudioTrack | RemoteAudioTrack,
294
+ options?: AudioAnalyserOptions,
295
+ ) {
296
+ const opts = {
297
+ cloneTrack: false,
298
+ fftSize: 2048,
299
+ smoothingTimeConstant: 0.8,
300
+ minDecibels: -100,
301
+ maxDecibels: -80,
302
+ ...options,
303
+ };
304
+ const audioContext = getNewAudioContext();
305
+
306
+ if (!audioContext) {
307
+ throw new Error('Audio Context not supported on this browser');
308
+ }
309
+ const streamTrack = opts.cloneTrack ? track.mediaStreamTrack.clone() : track.mediaStreamTrack;
310
+ const mediaStreamSource = audioContext.createMediaStreamSource(new MediaStream([streamTrack]));
311
+ const analyser = audioContext.createAnalyser();
312
+ analyser.minDecibels = opts.minDecibels;
313
+ analyser.maxDecibels = opts.maxDecibels;
314
+ analyser.fftSize = opts.fftSize;
315
+ analyser.smoothingTimeConstant = opts.smoothingTimeConstant;
316
+
317
+ mediaStreamSource.connect(analyser);
318
+ const dataArray = new Uint8Array(analyser.frequencyBinCount);
319
+
320
+ /**
321
+ * Calculates the current volume of the track in the range from 0 to 1
322
+ */
323
+ const calculateVolume = () => {
324
+ analyser.getByteFrequencyData(dataArray);
325
+ let sum = 0;
326
+ for (const amplitude of dataArray) {
327
+ sum += Math.pow(amplitude / 255, 2);
328
+ }
329
+ const volume = Math.sqrt(sum / dataArray.length);
330
+ return volume;
331
+ };
332
+
333
+ const cleanup = () => {
334
+ audioContext.close();
335
+ if (opts.cloneTrack) {
336
+ streamTrack.stop();
337
+ }
338
+ };
339
+
340
+ return { calculateVolume, analyser, cleanup };
341
+ }
342
+
343
+ export class Mutex {
344
+ private _locking: Promise<void>;
345
+
346
+ private _locks: number;
347
+
348
+ constructor() {
349
+ this._locking = Promise.resolve();
350
+ this._locks = 0;
351
+ }
352
+
353
+ isLocked() {
354
+ return this._locks > 0;
355
+ }
356
+
357
+ lock() {
358
+ this._locks += 1;
359
+
360
+ let unlockNext: () => void;
361
+
362
+ const willLock = new Promise<void>(
363
+ (resolve) =>
364
+ (unlockNext = () => {
365
+ this._locks -= 1;
366
+ resolve();
367
+ }),
368
+ );
369
+
370
+ const willUnlock = this._locking.then(() => unlockNext);
371
+
372
+ this._locking = this._locking.then(() => willLock);
373
+
374
+ return willUnlock;
375
+ }
376
+ }