Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add method to populate an offline room with simulated participants #531

Merged
merged 10 commits into from
Dec 9, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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