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.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();
|
||||||
|
|||||||
@@ -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
Reference in New Issue
Block a user