livekit-client 1.5.0 → 1.6.0

Sign up to get free protection for your applications and to get access to all the features.
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
+ }