@4players/odin
Version:
A cross-platform SDK enabling developers to integrate real-time VoIP chat technology into their projects
704 lines (703 loc) • 27.3 kB
JavaScript
import { __awaiter } from "tslib";
import { OdinEvent, } from './types';
import { OdinAudioService } from './audio';
import { OdinPeer } from './peer';
import { OdinMedia } from './media';
import { makeHandler } from './stream';
import { openStream } from './utils';
import { EVENT_SCHEMAS } from './schema-types';
/**
* Class describing an `OdinRoom`.
*/
export class OdinRoom {
/**
* Creates a new `OdinRoom` instance.
*
* @param _id The ID of the new room
* @param _user_id The user ID specified in the authentication token
* @param _address The address of the ODIN SFU this room lives on
* @param _mainStream The main stream connection this room is based on
* @ignore
*/
constructor(_id, _user_id, _address, _mainStream) {
this._id = _id;
this._user_id = _user_id;
this._address = _address;
this._mainStream = _mainStream;
/**
* An instance of `EventTarget` for handling events related to this room.
*/
this._eventTarget = new EventTarget();
/**
* The current connection state of the room, defaulting to 'disconnected'.
*/
this._connectionState = 'disconnected';
/**
* The arbitrary user data for the room.
*/
this._data = new Uint8Array();
/**
* A Map storing all remote `OdinPeer` instances within the room, using the peer ID as the key.
*/
this._remotePeers = new Map();
/**
* The customer identifier to which this room is assigned.
*/
this._customer = '<no customer>';
const audioService = OdinAudioService.getInstance();
if (audioService) {
this._audioService = audioService;
this._audioService.room = this;
}
}
/**
* The ID of the room.
*/
get id() {
return this._id;
}
/**
* The customer identifier this room is assigned to.
*/
get customer() {
return this._customer;
}
/**
* The arbitrary user data of the room.
*/
get data() {
return this._data;
}
/**
* The current state of the room stream connection.
*/
get connectionState() {
return this._connectionState;
}
/**
* Update the connection state of the room.
*/
set connectionState(state) {
const oldState = this._connectionState;
this._connectionState = state;
if (oldState !== state) {
this.eventTarget.dispatchEvent(new OdinEvent('ConnectionStateChanged', { oldState, newState: state }));
if (state === 'disconnected') {
this.eventTarget.dispatchEvent(new OdinEvent('Left', {
room: this,
}));
}
}
}
/**
* An instance of your own `OdinPeer` in the room.
*/
get ownPeer() {
return this._ownPeer;
}
/**
* A map of all remote `OdinPeer` instances in the room using the peer ID as index.
*/
get remotePeers() {
return this._remotePeers;
}
/**
* The current three-dimensional position of our own `OdinPeer` in the room.
*/
get position() {
return this._position;
}
/**
* An event target handler for the room.
*
* @ignore
*/
get eventTarget() {
return this._eventTarget;
}
/**
* The address of the voice server this room is living on.
*/
get serverAddress() {
return this._address;
}
/**
* Joins the room and returns your own peer instance after the room was successfully joined.
*
* @param userData Optional user data to set for the peer when connecting
* @param position Optional coordinates to set the three-dimensional position of the peer in the room when connecting
* @returns A promise of the own OdinPeer which yields when the room was joined
*/
join(userData, position) {
return __awaiter(this, void 0, void 0, function* () {
this.connectionState = 'connecting';
if (!position) {
position = [0.0, 0.0, 0.0];
}
if (!userData) {
userData = new Uint8Array();
}
const streamUrl = `wss://${this._address}/room`;
let streamId;
try {
try {
const result = (yield this._mainStream.request('JoinRoom', {
room_id: this.id,
user_data: userData,
position,
}));
streamId = result.stream_id;
}
catch (e) {
throw new Error('RPC JoinRoom failed\n' + e);
}
if (!streamId)
throw new Error('No stream ID received\n');
try {
this._roomStream = yield openStream(`${streamUrl}?${streamId}`, this.streamHandler.bind(this));
this._roomStream.onclose = () => {
this.disconnect();
};
}
catch (e) {
throw new Error('Failed to open room stream\n' + e);
}
yield new Promise((resolve, reject) => this.addEventListener('ConnectionStateChanged', (connection) => {
if (connection.payload.newState === 'connected') {
resolve();
}
}));
this._ownPeer.data = userData;
this.eventTarget.dispatchEvent(new OdinEvent('PeerJoined', { room: this, peer: this._ownPeer }));
this.eventTarget.dispatchEvent(new OdinEvent('Joined', { room: this }));
return this._ownPeer;
}
catch (e) {
this.connectionState = 'error';
throw new Error(`Failed to join room '${this.id}'\n` + e);
}
});
}
/**
* Changes the active capture stream (e.g. when switching to another input device).
*
* @param mediaStream The capture stream of the input device
*/
changeMediaStream(mediaStream) {
return __awaiter(this, void 0, void 0, function* () {
if (!this._audioService) {
throw new Error('Unable to change media stream; audio service is not available\n');
}
else if (this.connectionState !== 'connected') {
throw new Error('Unable to change media stream; room is not connected\n');
}
try {
yield this._audioService.updateInputStream(mediaStream);
}
catch (e) {
throw new Error('Failed to change media stream\n' + e);
}
});
}
/**
* Creates a new local media using the specified stream.
*
* @param mediaStream The capture stream of the input device
* @param audioSettings Optional audio settings like VAD or master volume used to initialize audio
* @returns A promise of the newly created OdinMedia
*/
createMedia(mediaStream, audioSettings) {
return __awaiter(this, void 0, void 0, function* () {
if (!this._audioService) {
throw new Error('Unable to create new media; audio service is not available\n');
}
else if (this.connectionState !== 'connected') {
throw new Error('Unable to create new media; room is not connected\n');
}
else if (!this._ownPeer) {
throw new Error('Unable to create new media; own peer information is not available\n');
}
if (audioSettings) {
this._audioService.setVoiceProcessingConfig(audioSettings);
}
yield this._audioService.updateInputStream(mediaStream);
const newMedia = this._ownPeer.createMedia();
this._ownPeer.eventTarget.dispatchEvent(new OdinEvent('MediaStarted', {
room: this,
peer: this._ownPeer,
media: newMedia,
}));
this.eventTarget.dispatchEvent(new OdinEvent('MediaStarted', {
room: this,
peer: this._ownPeer,
media: newMedia,
}));
return newMedia;
});
}
/**
* Adds a local media stream to the room.
*
* @param media The media instance to be added
* @ignore
*/
addMedia(media) {
var _a;
return __awaiter(this, void 0, void 0, function* () {
if (media.remote) {
throw new Error('Unable to add new media; media is owned by a remote peer\n');
}
yield ((_a = this._roomStream) === null || _a === void 0 ? void 0 : _a.request('StartMedia', {
media_id: media.id,
properties: {},
}));
});
}
/**
* Removes a local media stream from the room.
*
* @param media The media instance to be removed
* @ignore
*/
removeMedia(media) {
var _a;
return __awaiter(this, void 0, void 0, function* () {
if (media.remote) {
throw new Error('Unable to remove media; media is owned by a remote peer\n');
}
yield ((_a = this._roomStream) === null || _a === void 0 ? void 0 : _a.request('StopMedia', {
media_id: media.id,
properties: {},
}));
});
}
/**
* Pauses a media stream and stops receiving data on it.
*
* @param media The remote media instance or ID to be paused
* @ignore
*/
pauseMedia(media) {
var _a;
return __awaiter(this, void 0, void 0, function* () {
if (!media.remote) {
throw new Error('Unable to pause media; media is owned by a local peer\n');
}
else if (media.paused) {
throw new Error('Unable to pause media; media is already paused\n');
}
yield ((_a = this._roomStream) === null || _a === void 0 ? void 0 : _a.request('PauseMedia', {
media_id: media.id,
}));
media.paused = true;
});
}
/**
* Pauses a media stream and stops receiving data on it.
*
* @param media The remote media instance to be paused
* @ignore
*/
resumeMedia(media) {
var _a;
return __awaiter(this, void 0, void 0, function* () {
if (!media.remote) {
throw new Error('Unable to resume media; media is owned by a local peer\n');
}
else if (!media.paused) {
throw new Error('Unable to resume media; media is already resumed\n');
}
yield ((_a = this._roomStream) === null || _a === void 0 ? void 0 : _a.request('ResumeMedia', {
media_id: media.id,
}));
media.paused = false;
});
}
/**
* Returns the `OdinMedia` instance matching the specified ID.
*
* @param id The media ID to search for
* @returns The media instance if available
*/
getMediaById(id) {
var _a;
return (_a = this._audioService) === null || _a === void 0 ? void 0 : _a.getMedia(id);
}
/**
* Returns the `OdinPeer` instance matching the specified ID.
*
* @param id The peer ID to search for
* @returns The peer instance if available
*/
getPeerById(id) {
return this._ownPeer.id === id ? this._ownPeer : this._remotePeers.get(id);
}
/**
* Updates the three-dimensional position of our own `OdinPeer` in the room to apply server-side culling.
*
* @param offsetX The new X coordinate for the peers position in the room
* @param offsetY The new Y coordinate for the peers position in the room
* @param offsetZ The new Z coordinate for the peers position in the room
*/
setPosition(offsetX, offsetY, offsetZ) {
var _a;
this._position = [offsetX, offsetY, offsetZ];
if (this._connectionState === 'connected') {
(_a = this._roomStream) === null || _a === void 0 ? void 0 : _a.request('SetPeerPosition', {
position: [offsetX, offsetY, offsetZ],
});
}
}
/**
* Sends updated user data of your own peer to the server.
*/
flushOwnPeerDataUpdate() {
var _a;
return __awaiter(this, void 0, void 0, function* () {
yield ((_a = this._roomStream) === null || _a === void 0 ? void 0 : _a.request('UpdatePeer', {
user_data: this.ownPeer.data,
}));
});
}
/**
* Sends a message with arbitrary data to all peers in the room or optionally to a list of specified peers.
*
* @param message Byte array of arbitrary data to send
* @param targetPeerIds Optional list of target peer IDs
*/
sendMessage(message, targetPeerIds) {
var _a;
return __awaiter(this, void 0, void 0, function* () {
const params = { message };
if (targetPeerIds) {
params.target_peer_ids = targetPeerIds;
}
yield ((_a = this._roomStream) === null || _a === void 0 ? void 0 : _a.request('SendMessage', params));
});
}
/**
* Leaves the room and closes the connection to the server.
*
* @ignore
*/
disconnect(state = 'disconnected') {
var _a;
this._remotePeers.clear();
(_a = this._roomStream) === null || _a === void 0 ? void 0 : _a.close();
this.connectionState = state;
}
/**
* Internal handler for room stream events.
*
* @private
*/
streamHandler(method, params) {
const handler = makeHandler(EVENT_SCHEMAS, {
RoomUpdated(params, room) {
return __awaiter(this, void 0, void 0, function* () {
for (const update of params.updates) {
yield room.roomUpdated(update);
}
});
},
PeerUpdated(params, room) {
return __awaiter(this, void 0, void 0, function* () {
room.peerUpdated(params);
});
},
MessageReceived(params, room) {
return __awaiter(this, void 0, void 0, function* () {
const peer = room._remotePeers.get(params.sender_peer_id);
const payload = {
room,
senderId: params.sender_peer_id,
message: params.message,
};
peer === null || peer === void 0 ? void 0 : peer.eventTarget.dispatchEvent(new OdinEvent('MessageReceived', payload));
room.eventTarget.dispatchEvent(new OdinEvent('MessageReceived', payload));
});
},
}, this);
handler(method, params);
}
/**
* Internal handler for room updates.
*
* @private
*/
roomUpdated(roomUpdate) {
return __awaiter(this, void 0, void 0, function* () {
switch (roomUpdate.kind) {
case 'Joined': {
if (!roomUpdate.room || !roomUpdate.own_peer_id || !roomUpdate.media_ids) {
throw Error(`The room update of kind ${roomUpdate.kind} is missing fields.`);
}
this._data = roomUpdate.room.user_data;
this._customer = roomUpdate.room.customer;
this._ownPeer = new OdinPeer(this._roomStream, roomUpdate.own_peer_id, this._user_id, false);
this._ownPeer.setFreeMediaIds(roomUpdate.media_ids);
for (const remotePeer of roomUpdate.room.peers) {
const peer = this.addRemotePeer(remotePeer.id, remotePeer.user_id, remotePeer.medias, remotePeer.user_data);
this.eventTarget.dispatchEvent(new OdinEvent('PeerJoined', { room: this, peer }));
peer.medias.forEach((media) => {
peer.eventTarget.dispatchEvent(new OdinEvent('MediaStarted', { room: this, peer, media }));
this.eventTarget.dispatchEvent(new OdinEvent('MediaStarted', { room: this, peer, media }));
});
}
this.connectionState = 'connected';
break;
}
case 'UserDataChanged': {
if (!roomUpdate.user_data) {
throw Error(`The room update of kind ${roomUpdate.kind} is missing fields.`);
}
this._data = roomUpdate.user_data;
this.eventTarget.dispatchEvent(new OdinEvent('UserDataChanged', { room: this }));
break;
}
case 'PeerJoined': {
if (!roomUpdate.peer) {
throw Error(`The room update of kind ${roomUpdate.kind} is missing fields.`);
}
const peer = this.addRemotePeer(roomUpdate.peer.id, roomUpdate.peer.user_id, roomUpdate.peer.medias, roomUpdate.peer.user_data);
if (peer) {
this.eventTarget.dispatchEvent(new OdinEvent('PeerJoined', { room: this, peer }));
peer.medias.forEach((media) => {
peer.eventTarget.dispatchEvent(new OdinEvent('MediaStarted', { room: this, peer, media }));
this.eventTarget.dispatchEvent(new OdinEvent('MediaStarted', { room: this, peer, media }));
});
}
break;
}
case 'PeerLeft': {
if (!roomUpdate.peer_id) {
throw Error(`The room update of kind ${roomUpdate.kind} is missing fields.`);
}
yield this.removePeer(roomUpdate.peer_id);
break;
}
}
});
}
/**
* Internal handler for peer updates.
*
* @private
*/
peerUpdated(update) {
var _a;
const peer = this._remotePeers.get(update.peer_id);
if (!peer)
return;
switch (update.kind) {
case 'MediaStarted': {
if (!update.media) {
throw Error(`The peer update of kind ${update.kind} is missing fields.`);
}
else if (((_a = update.media.properties) === null || _a === void 0 ? void 0 : _a.kind) === 'video') {
// support for video medias will be added in a future sdk releases
return;
}
const media = new OdinMedia(update.media.id, update.peer_id, true);
if (media.paused) {
// audio medias are unpaused by default so we only set this if the media is explicitly announced as paused
media.paused = true;
}
peer.addMedia(media);
peer.eventTarget.dispatchEvent(new OdinEvent('MediaStarted', { room: this, peer, media }));
this.eventTarget.dispatchEvent(new OdinEvent('MediaStarted', { room: this, peer, media }));
break;
}
case 'MediaStopped': {
if (!update.media_id) {
throw Error(`The peer update of kind ${update.kind} is missing fields.`);
}
const media = peer.medias.get(update.media_id);
peer.removeMediaById(update.media_id);
if (media) {
peer.eventTarget.dispatchEvent(new OdinEvent('MediaStopped', { room: this, peer, media }));
this.eventTarget.dispatchEvent(new OdinEvent('MediaStopped', { room: this, peer, media }));
}
break;
}
case 'UserDataChanged': {
if (!update.user_data) {
throw Error(`The peer update of kind ${update.kind} is missing fields.`);
}
peer.data = update.user_data;
peer.eventTarget.dispatchEvent(new OdinEvent('UserDataChanged', { room: this, peer }));
this.eventTarget.dispatchEvent(new OdinEvent('PeerUserDataChanged', { room: this, peer }));
break;
}
}
}
/**
* Change the global master volume for the room (should be between 0 and 2).
*
* @param volume The new volume
*/
changeVolume(volume) {
if (!this._audioService)
return;
this._audioService.audioWorker.postMessage({
type: 'set_volume',
media_id: 0,
value: volume,
});
}
/**
* Creates a new peer instance and adds it to the room.
*
* @param peerId The ID of the peer
* @param userId The identifier of the peer specified during authentication
* @param medias A list of media IDs to initialize for the peer
* @param data The user data for the peer
*/
addRemotePeer(peerId, userId, medias, data) {
if (peerId === this._ownPeer.id) {
throw new Error('Unable to add remote peer; invalid type\n');
}
const peer = new OdinPeer(this._roomStream, peerId, userId, true);
peer.data = data;
medias.forEach((media) => {
var _a;
if (((_a = media.properties) === null || _a === void 0 ? void 0 : _a.kind) === 'video') {
// support for video medias will be added in a future sdk releases
return;
}
const mediaInstance = new OdinMedia(media.id, peerId, true);
if (media.paused) {
// audio medias are unpaused by default so we only set this if the media is explicitly announced as paused
mediaInstance.paused = true;
}
peer.addMedia(mediaInstance);
});
this._remotePeers.set(peerId, peer);
return peer;
}
/**
* Removes the peer with the specified ID from the room.
*
* @param peerId The id of the peer to remove
*/
removePeer(peerId) {
return __awaiter(this, void 0, void 0, function* () {
if (peerId === this._ownPeer.id) {
return;
}
const peer = this._remotePeers.get(peerId);
if (!peer) {
return;
}
for (const media of peer.medias.entries()) {
peer.eventTarget.dispatchEvent(new OdinEvent('MediaStopped', { room: this, peer, media: media[1] }));
this.eventTarget.dispatchEvent(new OdinEvent('MediaStopped', { room: this, peer, media: media[1] }));
yield media[1].stop();
peer.medias.delete(media[1].id);
}
this.eventTarget.dispatchEvent(new OdinEvent('PeerLeft', { room: this, peer }));
this._remotePeers.delete(peerId);
return peer;
});
}
/**
* Disables RNN-based voice activity detection.
*/
disableVAD() {
if (!this._audioService)
return;
const config = this._audioService.getVoiceProcessingConfig();
config.voiceActivityDetection = false;
this._audioService.setVoiceProcessingConfig(config);
}
/**
* Enables RNN-based voice activity detection.
*/
enableVAD() {
if (!this._audioService)
return;
const config = this._audioService.getVoiceProcessingConfig();
config.voiceActivityDetection = true;
this._audioService.setVoiceProcessingConfig(config);
}
/**
* Enables emitting of RNN-based voice activity detection statistics.
*/
startVADMeter() {
if (!this._audioService)
return;
this._audioService.setVoiceProcessingStatsEnabled(true);
}
/**
* Disables emitting of RNN-based voice activity detection statistics.
*/
stopVADMeter() {
if (!this._audioService)
return;
this._audioService.setVoiceProcessingStatsEnabled(false);
}
/**
* Updates thresholds for vice activity detection (between 0 and 1).
*
* @param attackProbability Voice probability value when the VAD should engage
* @param releaseProbability Voice probability value when the VAD should disengage
*/
updateVADThresholds(attackProbability, releaseProbability) {
if (!this._audioService)
return;
const config = this._audioService.getVoiceProcessingConfig();
config.voiceActivityDetectionAttackProbability = attackProbability;
config.voiceActivityDetectionReleaseProbability = releaseProbability !== null && releaseProbability !== void 0 ? releaseProbability : attackProbability - 0.1;
this._audioService.setVoiceProcessingConfig(config);
}
/**
* Disables RNN-based voice activity detection.
*/
disableVolumeGate() {
if (!this._audioService)
return;
const config = this._audioService.getVoiceProcessingConfig();
config.volumeGate = false;
this._audioService.setVoiceProcessingConfig(config);
}
/**
* Enables RNN-based voice activity detection.
*/
enableVolumeGate() {
if (!this._audioService)
return;
const config = this._audioService.getVoiceProcessingConfig();
config.volumeGate = true;
this._audioService.setVoiceProcessingConfig(config);
}
/**
* Updates thresholds for the input volume gate (between -90 and 0).
*
* @param attackLoudness Root mean square power (dBFS) when the volume gate should engage
* @param releaseLoudness Root mean square power (dBFS) when the volume gate should disengage
*/
updateVolumeGateThresholds(attackLoudness, releaseLoudness) {
if (!this._audioService)
return;
const config = this._audioService.getVoiceProcessingConfig();
config.volumeGateAttackLoudness = attackLoudness;
config.volumeGateReleaseLoudness = releaseLoudness !== null && releaseLoudness !== void 0 ? releaseLoudness : attackLoudness - 10;
this._audioService.setVoiceProcessingConfig(config);
}
/**
* Returns the current voice processing config for VAD and volume gate.
*/
getAudioSettings() {
var _a;
return (_a = this._audioService) === null || _a === void 0 ? void 0 : _a.getVoiceProcessingConfig();
}
/**
* Register to peer events from `IOdinRoomEvents`.
*
* @param eventName The name of the event to listen to
* @param handler The callback to handle the event
*/
addEventListener(eventName, handler) {
this._eventTarget.addEventListener(eventName, handler);
}
}