Files
PTT-Live/server/bridge/AudioBridgeManager.js
benoit 6630ced079 feat: support multi-rooms LiveKit (un par groupe)
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>
2026-05-28 14:15:40 +02:00

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;