feat: implement AudioSocketModule and WebRTCHandler for audio streaming; refactor SocketIOService to support module-based event handling

This commit is contained in:
Marco Beretta 2025-04-05 10:37:53 +02:00
parent 77ca00c87b
commit 2eda62cf67
No known key found for this signature in database
GPG key ID: D918033D8E74CC11
4 changed files with 292 additions and 156 deletions

View file

@ -0,0 +1,40 @@
const { AudioHandler } = require('./WebRTCHandler');
const { logger } = require('~/config');
class AudioSocketModule {
constructor(socketIOService) {
this.socketIOService = socketIOService;
this.audioHandler = new AudioHandler();
this.moduleId = 'audio-handler';
this.registerHandlers();
}
registerHandlers() {
this.socketIOService.registerModule(this.moduleId, {
connection: (socket) => this.handleConnection(socket),
disconnect: (socket) => this.handleDisconnect(socket),
});
}
handleConnection(socket) {
// Register WebRTC-specific event handlers for this socket
this.audioHandler.registerSocketHandlers(socket, this.config);
logger.debug(`Audio handler registered for client: ${socket.id}`);
}
handleDisconnect(socket) {
// Cleanup audio resources for disconnected client
this.audioHandler.cleanup(socket.id);
logger.debug(`Audio handler cleaned up for client: ${socket.id}`);
}
// Used for app shutdown
cleanup() {
this.audioHandler.cleanupAll();
this.socketIOService.unregisterModule(this.moduleId);
}
}
module.exports = { AudioSocketModule };

View file

@ -0,0 +1,179 @@
const { RTCPeerConnection, RTCIceCandidate, MediaStream } = require('wrtc');
const { logger } = require('~/config');
class WebRTCConnection {
constructor(socket, config) {
this.socket = socket;
this.config = config;
this.peerConnection = null;
this.audioTransceiver = null;
this.pendingCandidates = [];
this.state = 'idle';
}
async handleOffer(offer) {
try {
if (!this.peerConnection) {
this.peerConnection = new RTCPeerConnection(this.config.rtcConfig);
this.setupPeerConnectionListeners();
}
await this.peerConnection.setRemoteDescription(offer);
const mediaStream = new MediaStream();
this.audioTransceiver = this.peerConnection.addTransceiver('audio', {
direction: 'sendrecv',
streams: [mediaStream],
});
const answer = await this.peerConnection.createAnswer();
await this.peerConnection.setLocalDescription(answer);
this.socket.emit('webrtc-answer', answer);
} catch (error) {
logger.error(`Error handling offer: ${error}`);
this.socket.emit('webrtc-error', {
message: error.message,
code: 'OFFER_ERROR',
});
}
}
setupPeerConnectionListeners() {
if (!this.peerConnection) {
return;
}
this.peerConnection.ontrack = ({ track }) => {
logger.info(`Received ${track.kind} track from client`);
if (track.kind === 'audio') {
this.handleIncomingAudio(track);
}
track.onended = () => {
logger.info(`${track.kind} track ended`);
};
};
this.peerConnection.onicecandidate = ({ candidate }) => {
if (candidate) {
this.socket.emit('icecandidate', candidate);
}
};
this.peerConnection.onconnectionstatechange = () => {
if (!this.peerConnection) {
return;
}
const state = this.peerConnection.connectionState;
logger.info(`Connection state changed to ${state}`);
this.state = state;
if (state === 'failed' || state === 'closed') {
this.cleanup();
}
};
}
handleIncomingAudio(track) {
if (this.peerConnection) {
const stream = new MediaStream([track]);
this.peerConnection.addTrack(track, stream);
}
}
async addIceCandidate(candidate) {
try {
if (this.peerConnection?.remoteDescription) {
if (candidate && candidate.candidate) {
await this.peerConnection.addIceCandidate(new RTCIceCandidate(candidate));
} else {
logger.warn('Invalid ICE candidate');
}
} else {
this.pendingCandidates.push(candidate);
}
} catch (error) {
logger.error(`Error adding ICE candidate: ${error}`);
}
}
cleanup() {
if (this.peerConnection) {
try {
this.peerConnection.close();
} catch (error) {
logger.error(`Error closing peer connection: ${error}`);
}
this.peerConnection = null;
}
this.audioTransceiver = null;
this.pendingCandidates = [];
this.state = 'idle';
}
}
class AudioHandler {
constructor() {
this.connections = new Map();
this.defaultRTCConfig = {
iceServers: [
{
urls: ['stun:stun.l.google.com:19302', 'stun:stun1.l.google.com:19302'],
},
],
iceCandidatePoolSize: 10,
bundlePolicy: 'max-bundle',
rtcpMuxPolicy: 'require',
};
}
registerSocketHandlers(socket) {
const rtcConfig = {
rtcConfig: this.defaultRTCConfig,
};
const rtcConnection = new WebRTCConnection(socket, rtcConfig);
this.connections.set(socket.id, rtcConnection);
socket.on('webrtc-offer', (offer) => {
logger.debug(`Received WebRTC offer from ${socket.id}`);
rtcConnection.handleOffer(offer);
});
socket.on('icecandidate', (candidate) => {
rtcConnection.addIceCandidate(candidate);
});
socket.on('vad-status', (status) => {
logger.debug(`VAD status from ${socket.id}: ${JSON.stringify(status)}`);
});
socket.on('disconnect', () => {
rtcConnection.cleanup();
this.connections.delete(socket.id);
});
return rtcConnection;
}
cleanup(socketId) {
const connection = this.connections.get(socketId);
if (connection) {
connection.cleanup();
this.connections.delete(socketId);
}
}
cleanupAll() {
for (const connection of this.connections.values()) {
connection.cleanup();
}
this.connections.clear();
}
}
module.exports = { AudioHandler, WebRTCConnection };