'use strict'; const config = require('./config'); const Logger = require('./Logger'); const log = new Logger('Room'); module.exports = class Room { constructor(room_id, worker, io) { this.id = room_id; this.worker = worker; this.webRtcServer = worker.appData.webRtcServer; this.webRtcServerActive = config.mediasoup.webRtcServerActive; this.io = io; this.audioLevelObserver = null; this.audioLevelObserverEnabled = true; this.audioLastUpdateTime = 0; // ########################## this._isBroadcasting = false; // ########################## this._isLocked = false; this._isLobbyEnabled = false; this._roomPassword = null; this._hostOnlyRecording = false; // ########################## this._recSyncServerRecording = config?.server?.recording?.enabled || false; // ########################## this._moderator = { audio_start_muted: false, video_start_hidden: false, audio_cant_unmute: false, video_cant_unhide: false, screen_cant_share: false, chat_cant_privately: false, chat_cant_chatgpt: false, }; this.survey = config.survey; this.redirect = config.redirect; this.peers = new Map(); this.bannedPeers = []; this.webRtcTransport = config.mediasoup.webRtcTransport; this.router = null; this.routerSettings = config.mediasoup.router; this.createTheRouter(); } // #################################################### // ROOM INFO // #################################################### toJson() { return { id: this.id, broadcasting: this._isBroadcasting, recSyncServerRecording: this._recSyncServerRecording, config: { isLocked: this._isLocked, isLobbyEnabled: this._isLobbyEnabled, hostOnlyRecording: this._hostOnlyRecording, }, moderator: this._moderator, survey: this.survey, redirect: this.redirect, peers: JSON.stringify([...this.peers]), }; } // #################################################### // ROUTER // #################################################### createTheRouter() { const { mediaCodecs } = this.routerSettings; this.worker .createRouter({ mediaCodecs, }) .then((router) => { this.router = router; if (this.audioLevelObserverEnabled) { this.startAudioLevelObservation(); } this.router.observer.on('close', () => { log.info('---------------> Router is now closed as the last peer has left the room', { room: this.id, }); }); }); } closeRouter() { this.router.close(); } // #################################################### // PRODUCER AUDIO LEVEL OBSERVER // #################################################### async startAudioLevelObservation() { log.debug('Start audioLevelObserver for signaling active speaker...'); this.audioLevelObserver = await this.router.createAudioLevelObserver({ maxEntries: 1, threshold: -70, interval: 100, }); this.audioLevelObserver.on('volumes', (volumes) => { this.sendActiveSpeakerVolume(volumes); }); this.audioLevelObserver.on('silence', () => { //log.debug('audioLevelObserver', { volume: 'silence' }); }); } sendActiveSpeakerVolume(volumes) { try { if (!Array.isArray(volumes) || volumes.length === 0) { throw new Error('Invalid volumes array'); } if (Date.now() > this.audioLastUpdateTime + 100) { this.audioLastUpdateTime = Date.now(); const { producer, volume } = volumes[0]; const audioVolume = Math.round(Math.pow(10, volume / 70) * 10); // Scale volume to 1-10 if (audioVolume > 1) { this.peers.forEach((peer) => { const { id, peer_audio, peer_name } = peer; peer.producers.forEach((peerProducer) => { if (peerProducer.id === producer.id && peerProducer.kind === 'audio' && peer_audio) { const data = { peer_id: id, peer_name: peer_name, audioVolume: audioVolume, }; // Uncomment the following line for debugging // log.debug('Sending audio volume', data); this.sendToAll('audioVolume', data); } }); }); } } } catch (error) { log.error('Error sending active speaker volume', error.message); } } addProducerToAudioLevelObserver(producer) { if (this.audioLevelObserverEnabled) { this.audioLevelObserver.addProducer(producer); } } getRtpCapabilities() { return this.router.rtpCapabilities; } // #################################################### // ROOM MODERATOR // #################################################### updateRoomModeratorALL(data) { this._moderator = data; log.debug('Update room moderator all data', this._moderator); } updateRoomModerator(data) { log.debug('Update room moderator', data); switch (data.type) { case 'audio_start_muted': this._moderator.audio_start_muted = data.status; break; case 'video_start_hidden': this._moderator.video_start_hidden = data.status; case 'audio_cant_unmute': this._moderator.audio_cant_unmute = data.status; break; case 'video_cant_unhide': this._moderator.video_cant_unhide = data.status; case 'screen_cant_share': this._moderator.screen_cant_share = data.status; break; case 'chat_cant_privately': this._moderator.chat_cant_privately = data.status; break; case 'chat_cant_chatgpt': this._moderator.chat_cant_chatgpt = data.status; break; default: break; } } // #################################################### // PEERS // #################################################### addPeer(peer) { this.peers.set(peer.id, peer); } getPeer(socket_id) { // if (!this.peers.has(socket_id)) { log.error('---> Peer not found for socket ID', socket_id); return null; } const peer = this.peers.get(socket_id); return peer; } getPeers() { return this.peers; } getPeersCount() { return this.peers.size; } getProducerListForPeer() { const producerList = []; this.peers.forEach((peer) => { const { peer_name, peer_info } = peer; peer.producers.forEach((producer) => { producerList.push({ producer_id: producer.id, peer_name: peer_name, peer_info: peer_info, type: producer.appData.mediaType, }); }); }); return producerList; } async removePeer(socket_id) { const peer = this.getPeer(socket_id); if (!peer || typeof peer !== 'object') { return; } const { id, peer_name } = peer; peer.close(); this.peers.delete(socket_id); if (this.getPeers().size === 0) { this.closeRouter(); } const peerTransports = peer.getTransports(); const peerProducers = peer.getProducers(); const peerConsumers = peer.getConsumers(); log.debug('REMOVE PEER', { peer_id: id, peer_name: peer_name, peerTransports: peerTransports, peerProducers: peerProducers, peerConsumers: peerConsumers, }); } // #################################################### // WebRTC TRANSPORT // #################################################### async createWebRtcTransport(socket_id) { const { maxIncomingBitrate, initialAvailableOutgoingBitrate, listenInfos } = this.webRtcTransport; const webRtcTransportOptions = { ...(this.webRtcServerActive ? { webRtcServer: this.webRtcServer } : { listenInfos: listenInfos }), enableUdp: true, enableTcp: true, preferUdp: true, iceConsentTimeout: 20, initialAvailableOutgoingBitrate, }; log.debug('webRtcTransportOptions ----->', webRtcTransportOptions); const transport = await this.router.createWebRtcTransport(webRtcTransportOptions); if (!transport) { return this.callback('[Room|createWebRtcTransport] Failed to create WebRTC transport'); } const { id, iceParameters, iceCandidates, dtlsParameters } = transport; if (maxIncomingBitrate) { try { await transport.setMaxIncomingBitrate(maxIncomingBitrate); } catch (error) { log.debug('Transport setMaxIncomingBitrate error', error.message); } } const peer = this.getPeer(socket_id); if (!peer || typeof peer !== 'object') { return this.callback(`[Room|createWebRtcTransport] Peer object not found for socket ID: ${socket_id}`); } const { peer_name } = peer; transport.on('icestatechange', (iceState) => { if (iceState === 'disconnected' || iceState === 'closed') { log.debug('Transport closed "icestatechange" event', { peer_name: peer_name, transport_id: id, iceState: iceState, }); transport.close(); } }); transport.on('sctpstatechange', (sctpState) => { log.debug('Transport "sctpstatechange" event', { peer_name: peer_name, transport_id: id, sctpState: sctpState, }); }); transport.on('dtlsstatechange', (dtlsState) => { if (dtlsState === 'failed' || dtlsState === 'closed') { log.debug('Transport closed "dtlsstatechange" event', { peer_name: peer_name, transport_id: id, dtlsState: dtlsState, }); transport.close(); } }); transport.on('close', () => { log.debug('Transport closed', { peer_name: peer_name, transport_id: transport.id }); }); peer.addTransport(transport); log.debug('Transport created', { transportId: id }); return { id: id, iceParameters: iceParameters, iceCandidates: iceCandidates, dtlsParameters: dtlsParameters, }; } async connectPeerTransport(socket_id, transport_id, dtlsParameters) { try { if (!socket_id || !transport_id || !dtlsParameters) { return this.callback('[Room|connectPeerTransport] Invalid input parameters'); } const peer = this.getPeer(socket_id); if (!peer || typeof peer !== 'object') { return this.callback(`[Room|connectPeerTransport] Peer object not found for socket ID: ${socket_id}`); } const connectTransport = await peer.connectTransport(transport_id, dtlsParameters); if (!connectTransport) { return this.callback(`[Room|connectPeerTransport] error: Transport with ID ${transport_id} not found`); } return '[Room|connectPeerTransport] done'; } catch (error) { log.error('Error connecting peer transport', error.message); return this.callback(`[Room|connectPeerTransport] error: ${error.message}`); } } // #################################################### // PRODUCE // #################################################### async produce(socket_id, producerTransportId, rtpParameters, kind, type) { // if (!socket_id || !producerTransportId || !rtpParameters || !kind || !type) { return this.callback('[Room|produce] Invalid input parameters'); } const peer = this.getPeer(socket_id); if (!peer || typeof peer !== 'object') { return this.callback(`[Room|produce] Peer object not found for socket ID: ${socket_id}`); } const peerProducer = await peer.createProducer(producerTransportId, rtpParameters, kind, type); if (!peerProducer || !peerProducer.id) { return this.callback(`[Room|produce] Peer producer error: '${peerProducer}'`); } const { id } = peerProducer; const { peer_name, peer_info } = peer; this.broadCast(socket_id, 'newProducers', [ { producer_id: id, producer_socket_id: socket_id, peer_name: peer_name, peer_info: peer_info, type: type, }, ]); return id; } closeProducer(socket_id, producer_id) { if (!socket_id || !producer_id) return; const peer = this.getPeer(socket_id); if (!peer || typeof peer !== 'object') { return; } peer.closeProducer(producer_id); } // #################################################### // CONSUME // #################################################### async consume(socket_id, consumer_transport_id, producer_id, rtpCapabilities) { try { if (!socket_id || !consumer_transport_id || !producer_id || !rtpCapabilities) { return this.callback('[Room|consume] Invalid input parameters'); } if (!this.router.canConsume({ producerId: producer_id, rtpCapabilities })) { log.warn('Cannot consume', { socket_id, consumer_transport_id, producer_id, }); return this.callback(`[Room|consume] Room router cannot consume producer_id: '${producer_id}'`); } const peer = this.getPeer(socket_id); if (!peer || typeof peer !== 'object') { return this.callback(`[Room|consume] Peer object not found for socket ID: ${socket_id}`); } const peerConsumer = await peer.createConsumer(consumer_transport_id, producer_id, rtpCapabilities); if (!peerConsumer || !peerConsumer.consumer || !peerConsumer.params) { log.debug('peerConsumer or params are not defined'); return this.callback(`[Room|consume] peerConsumer error: '${peerConsumer}'`); } const { consumer, params } = peerConsumer; const { id, kind } = consumer; consumer.on('producerclose', () => { log.debug('Consumer closed due to "producerclose" event'); peer.removeConsumer(id); // Notify the client that consumer is closed this.send(socket_id, 'consumerClosed', { consumer_id: id, consumer_kind: kind, }); }); return params; } catch (error) { log.error('Error occurred during consumption', error.message); return this.callback(`[Room|consume] ${error.message}`); } } // #################################################### // HANDLE BANNED PEERS // #################################################### addBannedPeer(uuid) { if (!this.bannedPeers.includes(uuid)) { this.bannedPeers.push(uuid); log.debug('Added to the banned list', { uuid: uuid, banned: this.bannedPeers, }); } } isBanned(uuid) { return this.bannedPeers.includes(uuid); } // #################################################### // ROOM STATUS // #################################################### // GET isBroadcasting() { return this._isBroadcasting; } getPassword() { return this._roomPassword; } // BOOL isLocked() { return this._isLocked; } isLobbyEnabled() { return this._isLobbyEnabled; } isHostOnlyRecording() { return this._hostOnlyRecording; } // SET setIsBroadcasting(status) { this._isBroadcasting = status; } setLocked(status, password) { this._isLocked = status; this._roomPassword = password; } setLobbyEnabled(status) { this._isLobbyEnabled = status; } setHostOnlyRecording(status) { this._hostOnlyRecording = status; } // #################################################### // ERRORS // #################################################### callback(message) { return { error: message }; } // #################################################### // SENDER // #################################################### broadCast(socket_id, action, data) { for (let otherID of Array.from(this.peers.keys()).filter((id) => id !== socket_id)) { this.send(otherID, action, data); } } sendTo(socket_id, action, data) { for (let peer_id of Array.from(this.peers.keys()).filter((id) => id === socket_id)) { this.send(peer_id, action, data); } } sendToAll(action, data) { for (let peer_id of Array.from(this.peers.keys())) { this.send(peer_id, action, data); } } send(socket_id, action, data) { this.io.to(socket_id).emit(action, data); } };