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.
- package/dist/livekit-client.esm.mjs +2031 -5393
- package/dist/livekit-client.esm.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 +3 -2
- package/dist/src/api/SignalClient.d.ts.map +1 -1
- package/dist/src/connectionHelper/ConnectionCheck.d.ts +1 -1
- package/dist/src/connectionHelper/ConnectionCheck.d.ts.map +1 -1
- package/dist/src/connectionHelper/checks/Checker.d.ts +4 -4
- package/dist/src/connectionHelper/checks/Checker.d.ts.map +1 -1
- package/dist/src/index.d.ts +3 -2
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/logger.d.ts +3 -3
- package/dist/src/logger.d.ts.map +1 -1
- package/dist/src/options.d.ts +4 -1
- package/dist/src/options.d.ts.map +1 -1
- package/dist/src/proto/google/protobuf/timestamp.d.ts +4 -4
- package/dist/src/proto/google/protobuf/timestamp.d.ts.map +1 -1
- package/dist/src/proto/livekit_models.d.ts +4 -4
- package/dist/src/proto/livekit_models.d.ts.map +1 -1
- package/dist/src/proto/livekit_rtc.d.ts +4 -4
- package/dist/src/proto/livekit_rtc.d.ts.map +1 -1
- package/dist/src/room/RTCEngine.d.ts +4 -3
- package/dist/src/room/RTCEngine.d.ts.map +1 -1
- package/dist/src/room/Room.d.ts +21 -4
- package/dist/src/room/Room.d.ts.map +1 -1
- package/dist/src/room/events.d.ts +4 -0
- package/dist/src/room/events.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/track/LocalTrack.d.ts.map +1 -1
- package/dist/src/room/track/RemoteVideoTrack.d.ts.map +1 -1
- package/dist/src/room/track/Track.d.ts +2 -1
- package/dist/src/room/track/Track.d.ts.map +1 -1
- package/dist/src/room/track/TrackPublication.d.ts +1 -1
- package/dist/src/room/track/TrackPublication.d.ts.map +1 -1
- package/dist/src/room/track/options.d.ts +3 -3
- package/dist/src/room/track/options.d.ts.map +1 -1
- package/dist/src/room/track/types.d.ts +3 -3
- package/dist/src/room/track/types.d.ts.map +1 -1
- package/dist/src/room/types.d.ts +13 -0
- package/dist/src/room/types.d.ts.map +1 -0
- package/dist/src/room/utils.d.ts +44 -0
- package/dist/src/room/utils.d.ts.map +1 -1
- package/dist/ts4.2/src/api/SignalClient.d.ts +3 -2
- package/dist/ts4.2/src/connectionHelper/ConnectionCheck.d.ts +1 -1
- package/dist/ts4.2/src/connectionHelper/checks/Checker.d.ts +4 -4
- package/dist/ts4.2/src/index.d.ts +3 -2
- package/dist/ts4.2/src/logger.d.ts +3 -3
- package/dist/ts4.2/src/options.d.ts +4 -1
- package/dist/ts4.2/src/proto/google/protobuf/timestamp.d.ts +4 -4
- package/dist/ts4.2/src/proto/livekit_models.d.ts +4 -4
- package/dist/ts4.2/src/proto/livekit_rtc.d.ts +4 -4
- package/dist/ts4.2/src/room/RTCEngine.d.ts +4 -3
- package/dist/ts4.2/src/room/Room.d.ts +21 -4
- package/dist/ts4.2/src/room/events.d.ts +4 -0
- package/dist/ts4.2/src/room/participant/Participant.d.ts +1 -1
- package/dist/ts4.2/src/room/track/Track.d.ts +2 -1
- package/dist/ts4.2/src/room/track/TrackPublication.d.ts +1 -1
- package/dist/ts4.2/src/room/track/options.d.ts +3 -3
- package/dist/ts4.2/src/room/track/types.d.ts +3 -3
- package/dist/ts4.2/src/room/types.d.ts +13 -0
- package/dist/ts4.2/src/room/utils.d.ts +44 -0
- package/package.json +21 -21
- package/src/api/SignalClient.ts +40 -16
- package/src/connectionHelper/checks/turn.ts +1 -1
- package/src/connectionHelper/checks/websocket.ts +1 -1
- package/src/index.ts +5 -0
- package/src/options.ts +5 -1
- package/src/room/RTCEngine.ts +35 -26
- package/src/room/Room.ts +209 -61
- package/src/room/events.ts +4 -0
- package/src/room/participant/LocalParticipant.ts +3 -3
- package/src/room/participant/publishUtils.ts +1 -1
- package/src/room/track/LocalAudioTrack.ts +1 -1
- package/src/room/track/LocalTrack.ts +1 -0
- package/src/room/track/LocalVideoTrack.ts +1 -1
- package/src/room/track/RemoteVideoTrack.ts +4 -0
- package/src/room/track/Track.ts +1 -0
- package/src/room/types.ts +12 -0
- 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  | 
| 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 {  | 
| 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. | 
| 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 | 
            -
                     | 
| 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 | 
            -
                 | 
| 393 | 
            -
                 | 
| 394 | 
            -
                   | 
| 395 | 
            -
             | 
| 396 | 
            -
             | 
| 397 | 
            -
                   | 
| 398 | 
            -
                   | 
| 399 | 
            -
                   | 
| 400 | 
            -
             | 
| 401 | 
            -
             | 
| 402 | 
            -
             | 
| 403 | 
            -
             | 
| 404 | 
            -
             | 
| 405 | 
            -
             | 
| 406 | 
            -
             | 
| 407 | 
            -
             | 
| 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 | 
            -
               *  | 
| 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( | 
| 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 ( | 
| 991 | 
            -
                  this. | 
| 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 | 
            -
             | 
| 994 | 
            -
                 | 
| 995 | 
            -
             | 
| 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,
         | 
    
        package/src/room/events.ts
    CHANGED
    
    | @@ -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. | 
| 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,  | 
| 997 | 
            -
                    await track. | 
| 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,
         | 
| @@ -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();
         | 
    
        package/src/room/track/Track.ts
    CHANGED
    
    | @@ -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;
         | 
    
        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 | 
            -
                 | 
| 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 | 
            +
            }
         |