Skip to content

Commit

Permalink
Add method to populate an offline room with simulated participants (#531
Browse files Browse the repository at this point in the history
)

* add experimental method to simulate participants in a room without connection

* register events on local participant

* fix imports

* also publish local audio

* set local name

* cleanup

* remove log

* rename

* changeset
  • Loading branch information
lukasIO committed Dec 9, 2022
1 parent 3744210 commit b6cb814
Show file tree
Hide file tree
Showing 4 changed files with 180 additions and 26 deletions.
5 changes: 5 additions & 0 deletions .changeset/eight-rice-lick.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'livekit-client': patch
---

Add util function to simulate participants within a room
146 changes: 132 additions & 14 deletions src/room/Room.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ import {
Room as RoomModel,
ServerInfo,
SpeakerInfo,
TrackInfo,
TrackSource,
TrackType,
UserPacket,
} from '../proto/livekit_models';
import {
Expand All @@ -42,15 +45,24 @@ 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';
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',
Expand Down Expand Up @@ -307,18 +319,7 @@ class Room extends (EventEmitter as new () => TypedEmitter<RoomEventCallbacks>)

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) => {
Expand Down Expand Up @@ -635,6 +636,21 @@ class Room extends (EventEmitter as new () => TypedEmitter<RoomEventCallbacks>)
}
}

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 */
Expand Down Expand Up @@ -1242,6 +1258,108 @@ class Room extends (EventEmitter as new () => TypedEmitter<RoomEventCallbacks>)
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<E extends keyof RoomEventCallbacks>(
event: E,
Expand Down
12 changes: 12 additions & 0 deletions src/room/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
export type SimulationOptions = {
publish?: {
audio?: boolean;
video?: boolean;
};
participants?: {
count?: number;
aspectRatios?: Array<number>;
audio?: boolean;
video?: boolean;
};
};
43 changes: 31 additions & 12 deletions src/room/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down

0 comments on commit b6cb814

Please sign in to comment.