diff --git a/.changeset/eight-rice-lick.md b/.changeset/eight-rice-lick.md new file mode 100644 index 000000000..26c378612 --- /dev/null +++ b/.changeset/eight-rice-lick.md @@ -0,0 +1,5 @@ +--- +'livekit-client': patch +--- + +Add util function to simulate participants within a room diff --git a/src/room/Room.ts b/src/room/Room.ts index 67c9b1a96..c625e7d87 100644 --- a/src/room/Room.ts +++ b/src/room/Room.ts @@ -17,6 +17,9 @@ import { Room as RoomModel, ServerInfo, SpeakerInfo, + TrackInfo, + TrackSource, + TrackType, UserPacket, } from '../proto/livekit_models'; import { @@ -42,7 +45,7 @@ import type { ConnectionQuality } from './participant/Participant'; import RemoteParticipant from './participant/RemoteParticipant'; import RTCEngine from './RTCEngine'; import LocalAudioTrack from './track/LocalAudioTrack'; -import type LocalTrackPublication from './track/LocalTrackPublication'; +import LocalTrackPublication from './track/LocalTrackPublication'; import LocalVideoTrack from './track/LocalVideoTrack'; import type RemoteTrack from './track/RemoteTrack'; import RemoteTrackPublication from './track/RemoteTrackPublication'; @@ -50,7 +53,16 @@ import { Track } from './track/Track'; import type { TrackPublication } from './track/TrackPublication'; import type { AdaptiveStreamSettings } from './track/types'; import { getNewAudioContext } from './track/utils'; -import { Future, isWeb, Mutex, supportsSetSinkId, unpackStreamId } from './utils'; +import type { SimulationOptions } from './types'; +import { + Future, + createDummyVideoStreamTrack, + getEmptyAudioStreamTrack, + isWeb, + Mutex, + supportsSetSinkId, + unpackStreamId, +} from './utils'; export enum ConnectionState { Disconnected = 'disconnected', @@ -307,18 +319,7 @@ class Room extends (EventEmitter as new () => TypedEmitter) this.localParticipant.updateInfo(pi); // forward metadata changed for the local participant - this.localParticipant - .on(ParticipantEvent.ParticipantMetadataChanged, this.onLocalParticipantMetadataChanged) - .on(ParticipantEvent.TrackMuted, this.onLocalTrackMuted) - .on(ParticipantEvent.TrackUnmuted, this.onLocalTrackUnmuted) - .on(ParticipantEvent.LocalTrackPublished, this.onLocalTrackPublished) - .on(ParticipantEvent.LocalTrackUnpublished, this.onLocalTrackUnpublished) - .on(ParticipantEvent.ConnectionQualityChanged, this.onLocalConnectionQualityChanged) - .on(ParticipantEvent.MediaDevicesError, this.onMediaDevicesError) - .on( - ParticipantEvent.ParticipantPermissionsChanged, - this.onLocalParticipantPermissionsChanged, - ); + this.setupLocalParticipantEvents(); // populate remote participants, these should not trigger new events joinResponse.otherParticipants.forEach((info) => { @@ -635,6 +636,21 @@ class Room extends (EventEmitter as new () => TypedEmitter) } } + private setupLocalParticipantEvents() { + this.localParticipant + .on(ParticipantEvent.ParticipantMetadataChanged, this.onLocalParticipantMetadataChanged) + .on(ParticipantEvent.TrackMuted, this.onLocalTrackMuted) + .on(ParticipantEvent.TrackUnmuted, this.onLocalTrackUnmuted) + .on(ParticipantEvent.LocalTrackPublished, this.onLocalTrackPublished) + .on(ParticipantEvent.LocalTrackUnpublished, this.onLocalTrackUnpublished) + .on(ParticipantEvent.ConnectionQualityChanged, this.onLocalConnectionQualityChanged) + .on(ParticipantEvent.MediaDevicesError, this.onMediaDevicesError) + .on( + ParticipantEvent.ParticipantPermissionsChanged, + this.onLocalParticipantPermissionsChanged, + ); + } + private recreateEngine() { this.engine?.close(); /* @ts-ignore */ @@ -1242,6 +1258,108 @@ class Room extends (EventEmitter as new () => TypedEmitter) this.emit(RoomEvent.ParticipantPermissionsChanged, prevPermissions, this.localParticipant); }; + /** + * Allows to populate a room with simulated participants. + * No actual connection to a server will be established, all state is + * @experimental + */ + simulateParticipants(options: SimulationOptions) { + const publishOptions = { + audio: true, + video: true, + ...options.publish, + }; + const participantOptions = { + count: 9, + audio: false, + video: true, + aspectRatios: [1.66, 1.7, 1.3], + ...options.participants, + }; + this.handleDisconnect(); + this.name = 'simulated-room'; + this.localParticipant.identity = 'simulated-local'; + this.localParticipant.name = 'simulated-local'; + this.setupLocalParticipantEvents(); + this.emit(RoomEvent.SignalConnected); + this.emit(RoomEvent.Connected); + this.setAndEmitConnectionState(ConnectionState.Connected); + if (publishOptions.video) { + const camPub = new LocalTrackPublication( + Track.Kind.Video, + TrackInfo.fromPartial({ + source: TrackSource.CAMERA, + sid: Math.floor(Math.random() * 10_000).toString(), + type: TrackType.AUDIO, + name: 'video-dummy', + }), + new LocalVideoTrack( + createDummyVideoStreamTrack( + 160 * participantOptions.aspectRatios[0] ?? 1, + 160, + true, + true, + ), + ), + ); + // @ts-ignore + this.localParticipant.addTrackPublication(camPub); + this.localParticipant.emit(ParticipantEvent.LocalTrackPublished, camPub); + } + if (publishOptions.audio) { + const audioPub = new LocalTrackPublication( + Track.Kind.Audio, + TrackInfo.fromPartial({ + source: TrackSource.MICROPHONE, + sid: Math.floor(Math.random() * 10_000).toString(), + type: TrackType.AUDIO, + }), + new LocalAudioTrack(getEmptyAudioStreamTrack()), + ); + // @ts-ignore + this.localParticipant.addTrackPublication(audioPub); + this.localParticipant.emit(ParticipantEvent.LocalTrackPublished, audioPub); + } + + for (let i = 0; i < participantOptions.count - 1; i += 1) { + let info: ParticipantInfo = ParticipantInfo.fromPartial({ + sid: Math.floor(Math.random() * 10_000).toString(), + identity: `simulated-${i}`, + state: ParticipantInfo_State.ACTIVE, + tracks: [], + joinedAt: Date.now(), + }); + const p = this.getOrCreateParticipant(info.identity, info); + if (participantOptions.video) { + const dummyVideo = createDummyVideoStreamTrack( + 160 * participantOptions.aspectRatios[i % participantOptions.aspectRatios.length] ?? 1, + 160, + false, + true, + ); + const videoTrack = TrackInfo.fromPartial({ + source: TrackSource.CAMERA, + sid: Math.floor(Math.random() * 10_000).toString(), + type: TrackType.AUDIO, + }); + p.addSubscribedMediaTrack(dummyVideo, videoTrack.sid, new MediaStream([dummyVideo])); + info.tracks = [...info.tracks, videoTrack]; + } + if (participantOptions.audio) { + const dummyTrack = getEmptyAudioStreamTrack(); + const audioTrack = TrackInfo.fromPartial({ + source: TrackSource.MICROPHONE, + sid: Math.floor(Math.random() * 10_000).toString(), + type: TrackType.AUDIO, + }); + p.addSubscribedMediaTrack(dummyTrack, audioTrack.sid, new MediaStream([dummyTrack])); + info.tracks = [...info.tracks, audioTrack]; + } + + p.updateInfo(info); + } + } + // /** @internal */ emit( event: E, diff --git a/src/room/types.ts b/src/room/types.ts new file mode 100644 index 000000000..fc74cedb0 --- /dev/null +++ b/src/room/types.ts @@ -0,0 +1,12 @@ +export type SimulationOptions = { + publish?: { + audio?: boolean; + video?: boolean; + }; + participants?: { + count?: number; + aspectRatios?: Array; + audio?: boolean; + video?: boolean; + }; +}; diff --git a/src/room/utils.ts b/src/room/utils.ts index 03debee80..fe65dcbdc 100644 --- a/src/room/utils.ts +++ b/src/room/utils.ts @@ -181,22 +181,41 @@ let emptyVideoStreamTrack: MediaStreamTrack | undefined; export function getEmptyVideoStreamTrack() { if (!emptyVideoStreamTrack) { - const canvas = document.createElement('canvas'); - // the canvas size is set to 16, because electron apps seem to fail with smaller values - canvas.width = 16; - canvas.height = 16; - canvas.getContext('2d')?.fillRect(0, 0, canvas.width, canvas.height); - // @ts-ignore - const emptyStream = canvas.captureStream(); - [emptyVideoStreamTrack] = emptyStream.getTracks(); - if (!emptyVideoStreamTrack) { - throw Error('Could not get empty media stream video track'); - } - emptyVideoStreamTrack.enabled = false; + emptyVideoStreamTrack = createDummyVideoStreamTrack(); } return emptyVideoStreamTrack; } +export function createDummyVideoStreamTrack( + width: number = 16, + height: number = 16, + enabled: boolean = false, + paintContent: boolean = false, +) { + const canvas = document.createElement('canvas'); + // the canvas size is set to 16 by default, because electron apps seem to fail with smaller values + canvas.width = width; + canvas.height = height; + const ctx = canvas.getContext('2d'); + ctx?.fillRect(0, 0, canvas.width, canvas.height); + if (paintContent && ctx) { + ctx.beginPath(); + ctx.arc(width / 2, height / 2, 50, 0, Math.PI * 2, true); + ctx.closePath(); + ctx.fillStyle = 'grey'; + ctx.fill(); + } + // @ts-ignore + const dummyStream = canvas.captureStream(); + const [dummyTrack] = dummyStream.getTracks(); + if (!dummyTrack) { + throw Error('Could not get empty media stream video track'); + } + dummyTrack.enabled = enabled; + + return dummyTrack; +} + let emptyAudioStreamTrack: MediaStreamTrack | undefined; export function getEmptyAudioStreamTrack() {