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:
@@ -54,7 +54,7 @@ export class AudioBridge extends EventEmitter {
|
||||
this.opusEncoder = null;
|
||||
this.opusDecoder = null;
|
||||
this.jitterBuffer = null;
|
||||
this.liveKitClient = null;
|
||||
this.liveKitClients = new Map(); // Map<groupName, LiveKitClient> - un client par groupe
|
||||
this.groupAudioRouter = null;
|
||||
|
||||
// État
|
||||
@@ -324,56 +324,67 @@ export class AudioBridge extends EventEmitter {
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialise la connexion LiveKit
|
||||
* Initialise les connexions LiveKit (une par groupe)
|
||||
* @private
|
||||
*/
|
||||
async _initLiveKit() {
|
||||
if (!this.options.liveKitToken) {
|
||||
throw new Error('Token LiveKit requis');
|
||||
if (!this.options.liveKitTokens || !Array.isArray(this.options.liveKitTokens)) {
|
||||
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,
|
||||
token: this.options.liveKitToken,
|
||||
roomName: this.options.roomName,
|
||||
participantName: 'AudioBridge',
|
||||
token,
|
||||
roomName,
|
||||
participantName: `AudioBridge-${groupId}`,
|
||||
sampleRate: this.options.sampleRate,
|
||||
channels: this.options.channels,
|
||||
audioBitrate: this.opusEncoder.options.bitrate
|
||||
});
|
||||
|
||||
// Events LiveKit
|
||||
this.liveKitClient.on('connected', () => {
|
||||
console.log('✓ LiveKit connecté');
|
||||
// Events LiveKit pour ce groupe
|
||||
client.on('connected', () => {
|
||||
console.log(`✓ LiveKit connecté pour groupe "${groupName}" (room: ${roomName})`);
|
||||
});
|
||||
|
||||
this.liveKitClient.on('disconnected', (data) => {
|
||||
client.on('disconnected', (data) => {
|
||||
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.liveKitClient.on('reconnecting', () => {
|
||||
console.log('🔄 LiveKit reconnexion...');
|
||||
client.on('reconnecting', () => {
|
||||
console.log(`🔄 LiveKit reconnexion pour groupe "${groupName}"...`);
|
||||
});
|
||||
|
||||
this.liveKitClient.on('audioTrackSubscribed', ({ track, participant }) => {
|
||||
console.log(`🎵 Nouveau track audio : ${participant.identity}`);
|
||||
client.on('audioTrackSubscribed', ({ track, participant }) => {
|
||||
console.log(`🎵 Nouveau track audio dans groupe "${groupName}": ${participant.identity}`);
|
||||
});
|
||||
|
||||
// Réception audio depuis les clients LiveKit
|
||||
this.liveKitClient.on('audioData', ({ participantName, pcmData, sampleRate, channels }) => {
|
||||
console.log(`[AudioBridge FLUX 2] Audio reçu de ${participantName}: ${pcmData.length} bytes (${sampleRate}Hz, ${channels}ch)`);
|
||||
// Réception audio depuis les clients LiveKit de ce groupe
|
||||
client.on('audioData', ({ participantName, pcmData, sampleRate, channels }) => {
|
||||
console.log(`[AudioBridge FLUX 2] Audio reçu de ${participantName} (groupe "${groupName}"): ${pcmData.length} bytes`);
|
||||
|
||||
// Pour l'instant, on route vers le groupe principal
|
||||
// TODO: Mapper les participants aux groupes selon la configuration
|
||||
const groupName = 'Equipe'; // Groupe par défaut
|
||||
this.emit('groupAudioIn', { groupName, pcmBuffer: pcmData });
|
||||
// Router vers le bon groupe
|
||||
this.emit('groupAudioIn', { groupName: groupId, 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`);
|
||||
}
|
||||
|
||||
// ÉTAPE 2 : Pour chaque groupe, envoyer vers LiveKit
|
||||
// ÉTAPE 2 : Pour chaque groupe, envoyer vers le LiveKitClient correspondant
|
||||
groupBuffers.forEach((groupBuffer, groupName) => {
|
||||
// Convertir Float32Array → PCM Buffer
|
||||
const pcmBuffer = this._float32ToBuffer(groupBuffer);
|
||||
@@ -414,16 +425,19 @@ export class AudioBridge extends EventEmitter {
|
||||
if (opusData) {
|
||||
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)
|
||||
// Note: LiveKit gère lui-même l'encodage Opus en interne
|
||||
if (this.liveKitClient && this.liveKitClient.isConnected) {
|
||||
this.liveKitClient.sendAudioData(pcmBuffer);
|
||||
if (client && client.isConnected) {
|
||||
client.sendAudioData(pcmBuffer);
|
||||
if (this.stats.framesCapture % 100 === 0) {
|
||||
console.log(`[AudioBridge] → LiveKit groupe "${groupName}": ${pcmBuffer.length} bytes`);
|
||||
}
|
||||
} else {
|
||||
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;
|
||||
}
|
||||
|
||||
if (this.liveKitClient) {
|
||||
await this.liveKitClient.destroy();
|
||||
this.liveKitClient = null;
|
||||
// Déconnecter tous les clients LiveKit
|
||||
for (const [groupName, client] of this.liveKitClients.entries()) {
|
||||
console.log(`🔌 Déconnexion LiveKit groupe "${groupName}"...`);
|
||||
await client.destroy();
|
||||
}
|
||||
this.liveKitClients.clear();
|
||||
|
||||
if (this.groupAudioRouter) {
|
||||
this.groupAudioRouter.destroy();
|
||||
|
||||
@@ -34,31 +34,60 @@ class AudioBridgeManager extends EventEmitter {
|
||||
const config = configManager.get();
|
||||
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(
|
||||
config.server?.livekit?.apiKey || 'devkey',
|
||||
config.server?.livekit?.apiSecret || 'secret',
|
||||
{
|
||||
identity: 'AudioBridge',
|
||||
name: 'Audio Bridge Server',
|
||||
identity: `AudioBridge-${groupId}`,
|
||||
name: `Audio Bridge - ${groupName}`,
|
||||
metadata: JSON.stringify({
|
||||
role: 'bridge',
|
||||
group: groupId,
|
||||
capabilities: ['audio-routing', 'monitoring']
|
||||
})
|
||||
}
|
||||
);
|
||||
|
||||
// Permissions complètes pour le bridge serveur
|
||||
// Permissions complètes pour ce groupe
|
||||
token.addGrant({
|
||||
room: 'main',
|
||||
room: groupId, // Chaque groupe a sa propre room
|
||||
roomJoin: true,
|
||||
canPublish: true,
|
||||
canSubscribe: true,
|
||||
canPublishData: true
|
||||
});
|
||||
|
||||
const liveKitToken = await token.toJwt();
|
||||
console.log('✓ Token JWT généré pour AudioBridge');
|
||||
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');
|
||||
@@ -91,10 +120,9 @@ class AudioBridgeManager extends EventEmitter {
|
||||
// Créer l'instance avec la config
|
||||
this.bridge = new AudioBridge({
|
||||
...audioConfig,
|
||||
// Options LiveKit
|
||||
// Options LiveKit (multi-rooms)
|
||||
liveKitUrl,
|
||||
liveKitToken,
|
||||
roomName: 'main',
|
||||
liveKitTokens, // Tableau de { groupName, groupId, token }
|
||||
// Options de routing
|
||||
routing: config.audio?.routing || {},
|
||||
groups: config.groups || [],
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user