6630ced079
Architecture refactorisée pour supporter plusieurs connexions LiveKit simultanées : - AudioBridge : Map<groupName, LiveKitClient> au lieu d'un seul client - AudioBridgeManager : génère un token JWT par groupe avec room dédiée - Routing audio bidirectionnel par groupe : * FLUX 1 (carte son → LiveKit) : envoie vers le bon client selon groupName * FLUX 2 (LiveKit → carte son) : reçoit audio avec groupName correct - Chaque groupe a sa propre room LiveKit (nom = groupId slugifié) Fixes l'issue où les clients connectés à "production" ne recevaient pas l'audio car AudioBridge était connecté uniquement à "main". 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
231 lines
6.8 KiB
JavaScript
231 lines
6.8 KiB
JavaScript
/**
|
|
* AudioBridgeManager.js
|
|
* Gestionnaire du bridge audio avec support hot-reload
|
|
* Phase 2.5
|
|
*/
|
|
|
|
import { EventEmitter } from 'events';
|
|
import { AccessToken } from 'livekit-server-sdk';
|
|
import configManager from '../config/ConfigManager.js';
|
|
|
|
class AudioBridgeManager extends EventEmitter {
|
|
constructor() {
|
|
super();
|
|
this.bridge = null;
|
|
this.isRunning = false;
|
|
|
|
// Écouter les événements de configuration
|
|
configManager.on('audio-device-updated', this.handleDeviceUpdate.bind(this));
|
|
configManager.on('config-updated', this.handleConfigUpdate.bind(this));
|
|
}
|
|
|
|
/**
|
|
* Démarre le bridge audio avec la configuration actuelle
|
|
* @param {Object} options - Options de démarrage
|
|
* @param {string} options.liveKitUrl - URL LiveKit résolue (déjà avec IP si AUTO)
|
|
*/
|
|
async start(options = {}) {
|
|
if (this.isRunning) {
|
|
console.warn('⚠️ AudioBridge déjà démarré');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const config = configManager.get();
|
|
console.log('🎵 Démarrage AudioBridge avec configuration:', config.audio);
|
|
|
|
// Générer un token JWT par groupe
|
|
const liveKitTokens = [];
|
|
|
|
// Fonction pour slugifier le nom (identique à admin.js)
|
|
const slugify = (text) => {
|
|
return text
|
|
.toString()
|
|
.normalize('NFD')
|
|
.replace(/[\u0300-\u036f]/g, '')
|
|
.toLowerCase()
|
|
.trim()
|
|
.replace(/\s+/g, '-')
|
|
.replace(/[^\w-]+/g, '')
|
|
.replace(/--+/g, '-');
|
|
};
|
|
|
|
for (const group of config.groups || []) {
|
|
const groupId = slugify(group.name);
|
|
const groupName = group.name;
|
|
|
|
const token = new AccessToken(
|
|
config.server?.livekit?.apiKey || 'devkey',
|
|
config.server?.livekit?.apiSecret || 'secret',
|
|
{
|
|
identity: `AudioBridge-${groupId}`,
|
|
name: `Audio Bridge - ${groupName}`,
|
|
metadata: JSON.stringify({
|
|
role: 'bridge',
|
|
group: groupId,
|
|
capabilities: ['audio-routing', 'monitoring']
|
|
})
|
|
}
|
|
);
|
|
|
|
// Permissions complètes pour ce groupe
|
|
token.addGrant({
|
|
room: groupId, // Chaque groupe a sa propre room
|
|
roomJoin: true,
|
|
canPublish: true,
|
|
canSubscribe: true,
|
|
canPublishData: true
|
|
});
|
|
|
|
const jwt = await token.toJwt();
|
|
liveKitTokens.push({ groupName, groupId, token: jwt });
|
|
|
|
console.log(`✓ Token JWT généré pour groupe "${groupName}" (room: ${groupId})`);
|
|
}
|
|
|
|
if (liveKitTokens.length === 0) {
|
|
console.warn('⚠️ Aucun groupe configuré, AudioBridge ne pourra pas démarrer');
|
|
this.isRunning = false;
|
|
return;
|
|
}
|
|
|
|
// Import dynamique du AudioBridge
|
|
const { AudioBridge } = await import('./AudioBridge.js');
|
|
|
|
// Préparer la config avec conversion explicite des valeurs numériques
|
|
const audioConfig = { ...config.audio };
|
|
|
|
// Conversion explicite des paramètres numériques (depuis YAML ils peuvent être strings)
|
|
if (audioConfig.sampleRate) audioConfig.sampleRate = parseInt(audioConfig.sampleRate, 10);
|
|
if (audioConfig.channels) audioConfig.channels = parseInt(audioConfig.channels, 10);
|
|
|
|
// frameSize en millisecondes → conversion en nombre d'échantillons
|
|
// Ex: 20ms à 48kHz = 960 échantillons
|
|
if (audioConfig.frameSize) {
|
|
const frameSizeMs = parseInt(audioConfig.frameSize, 10);
|
|
const sampleRate = audioConfig.sampleRate || 48000;
|
|
audioConfig.frameSize = Math.floor((frameSizeMs * sampleRate) / 1000);
|
|
}
|
|
|
|
if (audioConfig.defaultBitrate) audioConfig.defaultBitrate = parseInt(audioConfig.defaultBitrate, 10);
|
|
if (audioConfig.customOpusBitrate) audioConfig.customOpusBitrate = parseInt(audioConfig.customOpusBitrate, 10);
|
|
|
|
// Extraire les device IDs depuis le sous-objet device
|
|
const inputDeviceId = audioConfig.device?.inputDeviceId || null;
|
|
const outputDeviceId = audioConfig.device?.outputDeviceId || null;
|
|
|
|
// Utiliser l'URL résolue passée en option, sinon fallback config
|
|
const liveKitUrl = options.liveKitUrl || config.server?.livekit?.url || 'ws://localhost:7880';
|
|
|
|
// Créer l'instance avec la config
|
|
this.bridge = new AudioBridge({
|
|
...audioConfig,
|
|
// Options LiveKit (multi-rooms)
|
|
liveKitUrl,
|
|
liveKitTokens, // Tableau de { groupName, groupId, token }
|
|
// Options de routing
|
|
routing: config.audio?.routing || {},
|
|
groups: config.groups || [],
|
|
maxInputChannels: 32,
|
|
maxOutputChannels: 32,
|
|
// Device IDs extraits
|
|
inputDeviceId,
|
|
outputDeviceId
|
|
});
|
|
|
|
// Démarrer le bridge
|
|
await this.bridge.start();
|
|
|
|
this.isRunning = true;
|
|
console.log('✓ AudioBridge démarré avec succès');
|
|
|
|
this.emit('started');
|
|
} catch (error) {
|
|
console.error('❌ Erreur démarrage AudioBridge:', error);
|
|
// Ne pas throw pour éviter de bloquer le serveur si pas de carte son
|
|
console.warn('⚠️ Le serveur continue sans AudioBridge actif');
|
|
this.isRunning = false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Arrête le bridge audio
|
|
*/
|
|
async stop() {
|
|
if (!this.isRunning) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
console.log('⏹ Arrêt AudioBridge...');
|
|
|
|
if (this.bridge) {
|
|
await this.bridge.stop();
|
|
this.bridge = null;
|
|
}
|
|
|
|
this.isRunning = false;
|
|
console.log('✓ AudioBridge arrêté');
|
|
|
|
this.emit('stopped');
|
|
} catch (error) {
|
|
console.error('❌ Erreur arrêt AudioBridge:', error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Recharge le bridge avec la nouvelle configuration
|
|
*/
|
|
async reload() {
|
|
try {
|
|
console.log('🔄 Rechargement AudioBridge...');
|
|
|
|
await this.stop();
|
|
await this.start();
|
|
|
|
console.log('✓ AudioBridge rechargé avec succès');
|
|
this.emit('reloaded');
|
|
} catch (error) {
|
|
console.error('❌ Erreur rechargement AudioBridge:', error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Gestionnaire événement mise à jour device audio
|
|
*/
|
|
async handleDeviceUpdate(deviceConfig) {
|
|
console.log('🔧 Device audio mis à jour:', deviceConfig);
|
|
console.log('→ Rechargement AudioBridge requis...');
|
|
|
|
// Auto-reload du bridge
|
|
if (this.isRunning) {
|
|
await this.reload();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Gestionnaire événement mise à jour configuration
|
|
*/
|
|
handleConfigUpdate(config) {
|
|
console.log('🔧 Configuration mise à jour');
|
|
// Peut déclencher un reload si nécessaire
|
|
}
|
|
|
|
/**
|
|
* Retourne l'état actuel du bridge
|
|
*/
|
|
getStatus() {
|
|
return {
|
|
running: this.isRunning,
|
|
config: configManager.get().audio
|
|
};
|
|
}
|
|
}
|
|
|
|
// Singleton
|
|
const audioBridgeManager = new AudioBridgeManager();
|
|
|
|
export default audioBridgeManager;
|