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>
This commit is contained in:
2026-05-28 14:15:40 +02:00
parent 7e6798cf92
commit 6630ced079
3 changed files with 615 additions and 71 deletions
+49 -33
View File
@@ -54,7 +54,7 @@ export class AudioBridge extends EventEmitter {
this.opusEncoder = null; this.opusEncoder = null;
this.opusDecoder = null; this.opusDecoder = null;
this.jitterBuffer = null; this.jitterBuffer = null;
this.liveKitClient = null; this.liveKitClients = new Map(); // Map<groupName, LiveKitClient> - un client par groupe
this.groupAudioRouter = null; this.groupAudioRouter = null;
// État // État
@@ -324,56 +324,67 @@ export class AudioBridge extends EventEmitter {
} }
/** /**
* Initialise la connexion LiveKit * Initialise les connexions LiveKit (une par groupe)
* @private * @private
*/ */
async _initLiveKit() { async _initLiveKit() {
if (!this.options.liveKitToken) { if (!this.options.liveKitTokens || !Array.isArray(this.options.liveKitTokens)) {
throw new Error('Token LiveKit requis'); throw new Error('liveKitTokens requis (tableau d\'objets { groupName, groupId, token })');
} }
this.liveKitClient = new LiveKitClient({ console.log(`🔌 Initialisation ${this.options.liveKitTokens.length} connexions LiveKit (une par groupe)...`);
// Créer un LiveKitClient pour chaque groupe
for (const { groupName, groupId, token } of this.options.liveKitTokens) {
const roomName = groupId; // La room porte le nom du groupId (slugifié)
const client = new LiveKitClient({
url: this.options.liveKitUrl, url: this.options.liveKitUrl,
token: this.options.liveKitToken, token,
roomName: this.options.roomName, roomName,
participantName: 'AudioBridge', participantName: `AudioBridge-${groupId}`,
sampleRate: this.options.sampleRate, sampleRate: this.options.sampleRate,
channels: this.options.channels, channels: this.options.channels,
audioBitrate: this.opusEncoder.options.bitrate audioBitrate: this.opusEncoder.options.bitrate
}); });
// Events LiveKit // Events LiveKit pour ce groupe
this.liveKitClient.on('connected', () => { client.on('connected', () => {
console.log('✓ LiveKit connecté'); console.log(`✓ LiveKit connecté pour groupe "${groupName}" (room: ${roomName})`);
}); });
this.liveKitClient.on('disconnected', (data) => { client.on('disconnected', (data) => {
const reason = data?.reason || 'unknown'; const reason = data?.reason || 'unknown';
console.warn('⚠️ LiveKit déconnecté:', reason); console.warn(`⚠️ LiveKit déconnecté pour groupe "${groupName}":`, reason);
this.stats.errors.network++; this.stats.errors.network++;
}); });
this.liveKitClient.on('reconnecting', () => { client.on('reconnecting', () => {
console.log('🔄 LiveKit reconnexion...'); console.log(`🔄 LiveKit reconnexion pour groupe "${groupName}"...`);
}); });
this.liveKitClient.on('audioTrackSubscribed', ({ track, participant }) => { client.on('audioTrackSubscribed', ({ track, participant }) => {
console.log(`🎵 Nouveau track audio : ${participant.identity}`); console.log(`🎵 Nouveau track audio dans groupe "${groupName}": ${participant.identity}`);
}); });
// Réception audio depuis les clients LiveKit // Réception audio depuis les clients LiveKit de ce groupe
this.liveKitClient.on('audioData', ({ participantName, pcmData, sampleRate, channels }) => { client.on('audioData', ({ participantName, pcmData, sampleRate, channels }) => {
console.log(`[AudioBridge FLUX 2] Audio reçu de ${participantName}: ${pcmData.length} bytes (${sampleRate}Hz, ${channels}ch)`); console.log(`[AudioBridge FLUX 2] Audio reçu de ${participantName} (groupe "${groupName}"): ${pcmData.length} bytes`);
// Pour l'instant, on route vers le groupe principal // Router vers le bon groupe
// TODO: Mapper les participants aux groupes selon la configuration this.emit('groupAudioIn', { groupName: groupId, pcmBuffer: pcmData });
const groupName = 'Equipe'; // Groupe par défaut
this.emit('groupAudioIn', { groupName, pcmBuffer: pcmData });
console.log(`[AudioBridge FLUX 2] Événement groupAudioIn émis pour groupe "${groupName}"`); console.log(`[AudioBridge FLUX 2] Événement groupAudioIn émis pour groupe "${groupId}"`);
}); });
await this.liveKitClient.connect(); // Connexion
await client.connect();
// Stocker le client par groupId
this.liveKitClients.set(groupId, client);
}
console.log(`${this.liveKitClients.size} connexions LiveKit établies`);
} }
/** /**
@@ -403,7 +414,7 @@ export class AudioBridge extends EventEmitter {
console.log(`[AudioBridge] Frame ${this.stats.framesCapture}: ${this.inputChannelBuffers.size} inputs → ${groupBuffers.size} groupes`); console.log(`[AudioBridge] Frame ${this.stats.framesCapture}: ${this.inputChannelBuffers.size} inputs → ${groupBuffers.size} groupes`);
} }
// ÉTAPE 2 : Pour chaque groupe, envoyer vers LiveKit // ÉTAPE 2 : Pour chaque groupe, envoyer vers le LiveKitClient correspondant
groupBuffers.forEach((groupBuffer, groupName) => { groupBuffers.forEach((groupBuffer, groupName) => {
// Convertir Float32Array → PCM Buffer // Convertir Float32Array → PCM Buffer
const pcmBuffer = this._float32ToBuffer(groupBuffer); const pcmBuffer = this._float32ToBuffer(groupBuffer);
@@ -414,16 +425,19 @@ export class AudioBridge extends EventEmitter {
if (opusData) { if (opusData) {
this.stats.bytesEncoded += opusData.length; this.stats.bytesEncoded += opusData.length;
// Récupérer le client LiveKit pour ce groupe
const client = this.liveKitClients.get(groupName);
// Envoi vers LiveKit via sendAudioData (prend du PCM, pas de l'Opus) // Envoi vers LiveKit via sendAudioData (prend du PCM, pas de l'Opus)
// Note: LiveKit gère lui-même l'encodage Opus en interne // Note: LiveKit gère lui-même l'encodage Opus en interne
if (this.liveKitClient && this.liveKitClient.isConnected) { if (client && client.isConnected) {
this.liveKitClient.sendAudioData(pcmBuffer); client.sendAudioData(pcmBuffer);
if (this.stats.framesCapture % 100 === 0) { if (this.stats.framesCapture % 100 === 0) {
console.log(`[AudioBridge] → LiveKit groupe "${groupName}": ${pcmBuffer.length} bytes`); console.log(`[AudioBridge] → LiveKit groupe "${groupName}": ${pcmBuffer.length} bytes`);
} }
} else { } else {
if (this.stats.framesCapture % 100 === 0) { if (this.stats.framesCapture % 100 === 0) {
console.log(`[AudioBridge] ⚠️ LiveKit non connecté, audio non envoyé`); console.log(`[AudioBridge] ⚠️ LiveKit non connecté pour groupe "${groupName}", audio non envoyé`);
} }
} }
@@ -613,10 +627,12 @@ export class AudioBridge extends EventEmitter {
this.audioBackend = null; this.audioBackend = null;
} }
if (this.liveKitClient) { // Déconnecter tous les clients LiveKit
await this.liveKitClient.destroy(); for (const [groupName, client] of this.liveKitClients.entries()) {
this.liveKitClient = null; console.log(`🔌 Déconnexion LiveKit groupe "${groupName}"...`);
await client.destroy();
} }
this.liveKitClients.clear();
if (this.groupAudioRouter) { if (this.groupAudioRouter) {
this.groupAudioRouter.destroy(); this.groupAudioRouter.destroy();
+38 -10
View File
@@ -34,31 +34,60 @@ class AudioBridgeManager extends EventEmitter {
const config = configManager.get(); const config = configManager.get();
console.log('🎵 Démarrage AudioBridge avec configuration:', config.audio); console.log('🎵 Démarrage AudioBridge avec configuration:', config.audio);
// Génération du token JWT pour le participant serveur // 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( const token = new AccessToken(
config.server?.livekit?.apiKey || 'devkey', config.server?.livekit?.apiKey || 'devkey',
config.server?.livekit?.apiSecret || 'secret', config.server?.livekit?.apiSecret || 'secret',
{ {
identity: 'AudioBridge', identity: `AudioBridge-${groupId}`,
name: 'Audio Bridge Server', name: `Audio Bridge - ${groupName}`,
metadata: JSON.stringify({ metadata: JSON.stringify({
role: 'bridge', role: 'bridge',
group: groupId,
capabilities: ['audio-routing', 'monitoring'] capabilities: ['audio-routing', 'monitoring']
}) })
} }
); );
// Permissions complètes pour le bridge serveur // Permissions complètes pour ce groupe
token.addGrant({ token.addGrant({
room: 'main', room: groupId, // Chaque groupe a sa propre room
roomJoin: true, roomJoin: true,
canPublish: true, canPublish: true,
canSubscribe: true, canSubscribe: true,
canPublishData: true canPublishData: true
}); });
const liveKitToken = await token.toJwt(); const jwt = await token.toJwt();
console.log('✓ Token JWT généré pour AudioBridge'); 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 // Import dynamique du AudioBridge
const { AudioBridge } = await import('./AudioBridge.js'); const { AudioBridge } = await import('./AudioBridge.js');
@@ -91,10 +120,9 @@ class AudioBridgeManager extends EventEmitter {
// Créer l'instance avec la config // Créer l'instance avec la config
this.bridge = new AudioBridge({ this.bridge = new AudioBridge({
...audioConfig, ...audioConfig,
// Options LiveKit // Options LiveKit (multi-rooms)
liveKitUrl, liveKitUrl,
liveKitToken, liveKitTokens, // Tableau de { groupName, groupId, token }
roomName: 'main',
// Options de routing // Options de routing
routing: config.audio?.routing || {}, routing: config.audio?.routing || {},
groups: config.groups || [], groups: config.groups || [],
File diff suppressed because one or more lines are too long