From 87a1370ad454838f93b31108a3e1cba58bd35413 Mon Sep 17 00:00:00 2001 From: Benoit Date: Wed, 1 Jul 2026 14:14:26 +0200 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20ajouter=20les=20server=20audio=20us?= =?UTF-8?q?ers=20(participants=20LiveKit=20c=C3=B4t=C3=A9=20serveur=20avec?= =?UTF-8?q?=20I/O=20physique)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Chaque server audio user est un participant LiveKit indépendant géré par le serveur : - publie un canal physique d'entrée comme track audio - reçoit et mixe l'audio de tous les autres participants (mix-minus naturel) - sort le mix vers un canal physique dédié Nouvelle classe ServerAudioUser.js, intégration dans AudioBridge et AudioBridgeManager, section server_audio_users dans config.yaml (vide par défaut, exemple commenté). --- server/bridge/AudioBridge.js | 75 ++++++++++- server/bridge/AudioBridgeManager.js | 42 +++++++ server/bridge/ServerAudioUser.js | 188 ++++++++++++++++++++++++++++ server/config/config.yaml | 16 +++ 4 files changed, 320 insertions(+), 1 deletion(-) create mode 100644 server/bridge/ServerAudioUser.js diff --git a/server/bridge/AudioBridge.js b/server/bridge/AudioBridge.js index 472d78b..b2c1856 100644 --- a/server/bridge/AudioBridge.js +++ b/server/bridge/AudioBridge.js @@ -19,6 +19,7 @@ import OpusCodec, { OpusPresets } from './OpusCodec.js'; import JitterBuffer, { JitterBufferPresets } from './JitterBuffer.js'; import LiveKitClient from './LiveKitClient.js'; import GroupAudioRouter from './GroupAudioRouter.js'; +import ServerAudioUser from './ServerAudioUser.js'; export class AudioBridge extends EventEmitter { constructor(options = {}) { @@ -68,6 +69,9 @@ export class AudioBridge extends EventEmitter { // Frame accumulators pour LiveKit (240 samples → 960 samples) this.liveKitFrameAccumulators = new Map(); // Map + // Utilisateurs audio gérés côté serveur (participants LiveKit avec I/O physique dédiés) + this.serverAudioUsers = new Map(); // Map + // Pool de buffers pré-alloués pour éviter allocations répétées this.bufferPool = { float32: [], // Pool de Float32Array réutilisables @@ -120,7 +124,10 @@ export class AudioBridge extends EventEmitter { // 5. Connexion à LiveKit await this._initLiveKit(); - // 6. Démarrage du routing audio + // 6. Initialisation des server audio users + await this._initServerAudioUsers(); + + // 7. Démarrage du routing audio await this._startAudioRouting(); this.isRunning = true; @@ -386,6 +393,57 @@ export class AudioBridge extends EventEmitter { console.log(`✓ ${this.liveKitClients.size} connexions LiveKit établies`); } + /** + * Initialise les utilisateurs audio serveur (participants LiveKit avec I/O physique) + * @private + */ + async _initServerAudioUsers() { + const users = this.options.serverAudioUsers; + if (!users || users.length === 0) return; + + console.log(`🎤 Initialisation ${users.length} server audio user(s)...`); + + for (const userConfig of users) { + const user = new ServerAudioUser({ + name: userConfig.name, + groupId: userConfig.groupId, + inputChannel: userConfig.inputChannel, + outputChannel: userConfig.outputChannel, + liveKitUrl: this.options.liveKitUrl, + token: userConfig.token, + sampleRate: this.options.sampleRate, + frameSize: this.options.frameSize + }); + + // Quand une frame de mix est prête, l'envoyer vers le canal physique de sortie + const outputCh = userConfig.outputChannel; + user.on('outputReady', (mixBuffer) => { + if (!this.audioBackend) return; + const numChannels = this.options.channels || 1; + const frameSize = this.options.frameSize; + + if (numChannels <= 1) { + const pcmBuffer = this._float32ToBuffer(mixBuffer); + this.audioBackend.queueAudio(pcmBuffer); + } else { + // Construire un buffer multi-canaux avec l'audio du user sur son canal de sortie + const interleaved = new Float32Array(frameSize * numChannels); + for (let i = 0; i < frameSize; i++) { + interleaved[i * numChannels + outputCh] = mixBuffer[i]; + } + const pcmBuffer = this._float32ToBuffer(interleaved); + this.audioBackend.queueAudio(pcmBuffer); + } + }); + + await user.start(); + this.serverAudioUsers.set(userConfig.name, user); + console.log(`✓ Server audio user "${userConfig.name}" démarré (entrée canal ${userConfig.inputChannel} → sortie canal ${userConfig.outputChannel}, room: ${userConfig.groupId})`); + } + + console.log(`✓ ${this.serverAudioUsers.size} server audio user(s) initialisés`); + } + /** * Démarre le routing audio bidirectionnel complet * @private @@ -424,6 +482,14 @@ export class AudioBridge extends EventEmitter { } } + // ÉTAPE 0 : Envoyer les données de chaque canal vers les server audio users + for (const [, user] of this.serverAudioUsers) { + const channelData = this.inputChannelBuffers.get(user.inputChannel); + if (channelData) { + user.sendAudio(channelData); + } + } + // ÉTAPE 1 : Inputs physiques → Groupes (via GroupAudioRouter) const groupBuffers = this.groupAudioRouter.processInputsToGroups( this.inputChannelBuffers @@ -760,6 +826,13 @@ export class AudioBridge extends EventEmitter { } this.liveKitClients.clear(); + // Arrêter les server audio users + for (const [name, user] of this.serverAudioUsers.entries()) { + console.log(`🔌 Arrêt server audio user "${name}"...`); + await user.stop(); + } + this.serverAudioUsers.clear(); + if (this.groupAudioRouter) { this.groupAudioRouter.destroy(); this.groupAudioRouter = null; diff --git a/server/bridge/AudioBridgeManager.js b/server/bridge/AudioBridgeManager.js index 5b3caec..112f406 100644 --- a/server/bridge/AudioBridgeManager.js +++ b/server/bridge/AudioBridgeManager.js @@ -89,6 +89,46 @@ class AudioBridgeManager extends EventEmitter { return; } + // Générer un token JWT par server audio user + const serverAudioUsers = []; + + for (const user of config.server_audio_users || []) { + const groupId = slugify(user.group); + + const token = new AccessToken( + config.server?.livekit?.apiKey || 'devkey', + config.server?.livekit?.apiSecret || 'secret', + { + identity: `server-${user.name}`, + name: `Server Audio - ${user.name}`, + metadata: JSON.stringify({ + role: 'server-audio-user', + group: groupId + }) + } + ); + + token.addGrant({ + room: groupId, + roomJoin: true, + canPublish: true, + canSubscribe: true, + canPublishData: true + }); + + const jwt = await token.toJwt(); + + serverAudioUsers.push({ + name: user.name, + groupId, + inputChannel: user.input_channel ?? user.inputChannel ?? 0, + outputChannel: user.output_channel ?? user.outputChannel ?? 0, + token: jwt + }); + + console.log(`✓ Token JWT généré pour server audio user "${user.name}" (room: ${groupId})`); + } + // Import dynamique du AudioBridge const { AudioBridge } = await import('./AudioBridge.js'); @@ -123,6 +163,8 @@ class AudioBridgeManager extends EventEmitter { // Options LiveKit (multi-rooms) liveKitUrl, liveKitTokens, // Tableau de { groupName, groupId, token } + // Server audio users + serverAudioUsers, // Options de routing routing: config.audio?.routing || {}, groups: config.groups || [], diff --git a/server/bridge/ServerAudioUser.js b/server/bridge/ServerAudioUser.js new file mode 100644 index 0000000..7083612 --- /dev/null +++ b/server/bridge/ServerAudioUser.js @@ -0,0 +1,188 @@ +/** + * ServerAudioUser.js + * Utilisateur audio géré côté serveur : participant LiveKit indépendant + * avec un canal physique d'entrée dédié et un canal physique de sortie dédié. + * + * Chaque instance : + * - Publie son canal physique d'entrée comme track LiveKit + * - Reçoit l'audio de tous les autres participants (mix-minus naturel) + * - Émet 'outputReady' avec le mix Float32 quand une frame complète est prête + */ + +import { EventEmitter } from 'events'; +import LiveKitClient from './LiveKitClient.js'; + +class ServerAudioUser extends EventEmitter { + constructor(options) { + super(); + + this.name = options.name; + this.inputChannel = parseInt(options.inputChannel, 10); + this.outputChannel = parseInt(options.outputChannel, 10); + this.groupId = options.groupId; + this.frameSize = options.frameSize || 960; + this.sampleRate = options.sampleRate || 48000; + + this.client = new LiveKitClient({ + url: options.liveKitUrl, + token: options.token, + roomName: options.groupId, + participantName: `server-${options.name}`, + sampleRate: this.sampleRate, + channels: 1, + }); + + // Accumulateurs PCM par participant distant (pour pouvoir mixer leurs frames) + this.participantAccumulators = new Map(); // Map + + // Dernier mix calculé (prêt à être envoyé vers le canal physique de sortie) + this.mixedOutput = null; // Float32Array de frameSize samples + + this._setupClientEvents(); + } + + _setupClientEvents() { + this.client.on('connected', () => { + console.log(`[ServerAudioUser:${this.name}] Connecté à room "${this.groupId}" (in:${this.inputChannel} → out:${this.outputChannel})`); + this.emit('connected'); + }); + + this.client.on('disconnected', (data) => { + console.warn(`[ServerAudioUser:${this.name}] Déconnecté:`, data?.reason || 'unknown'); + this.emit('disconnected', data); + }); + + // Réception audio depuis les autres participants → accumulation et mix + this.client.on('audioData', ({ participantSid, pcmData }) => { + this._accumulate(participantSid, pcmData); + }); + + // Nettoyage des buffers quand un participant quitte + this.client.on('participantDisconnected', (participant) => { + this.participantAccumulators.delete(participant.sid); + }); + } + + /** + * Démarre la connexion LiveKit + */ + async start() { + await this.client.connect(); + } + + /** + * Envoie les données audio du canal d'entrée physique vers LiveKit. + * Appelé par AudioBridge à chaque frame de capture. + * @param {Float32Array} float32Data - Données PCM normalisées [-1.0, 1.0] + */ + sendAudio(float32Data) { + if (!this.client.isConnected) return; + + const pcmBuffer = this._float32ToBuffer(float32Data); + this.client.sendAudioData(pcmBuffer); + } + + /** + * Retourne le dernier mix calculé, ou null si aucune frame reçue. + * @returns {Float32Array|null} + */ + getMixedOutput() { + return this.mixedOutput; + } + + /** + * Accumule les frames PCM reçues d'un participant. + * Quand une frame complète est disponible, calcule le mix. + * @private + */ + _accumulate(participantSid, pcmData) { + const float32 = this._bufferToFloat32(pcmData); + + if (!this.participantAccumulators.has(participantSid)) { + this.participantAccumulators.set(participantSid, { + buffer: new Float32Array(this.frameSize), + offset: 0 + }); + } + + const acc = this.participantAccumulators.get(participantSid); + const toCopy = Math.min(float32.length, this.frameSize - acc.offset); + + if (toCopy > 0) { + acc.buffer.set(float32.subarray(0, toCopy), acc.offset); + acc.offset += toCopy; + } + + if (acc.offset >= this.frameSize) { + this._computeMix(); + acc.offset = 0; + acc.buffer.fill(0); + } + } + + /** + * Calcule le mix additif de tous les participants et émet 'outputReady'. + * @private + */ + _computeMix() { + const mix = new Float32Array(this.frameSize); + + for (const { buffer } of this.participantAccumulators.values()) { + for (let i = 0; i < this.frameSize; i++) { + mix[i] += buffer[i]; + } + } + + // Clamp + for (let i = 0; i < mix.length; i++) { + mix[i] = Math.max(-1.0, Math.min(1.0, mix[i])); + } + + this.mixedOutput = mix; + this.emit('outputReady', mix); + } + + /** + * Convertit Buffer/Int16Array PCM 16-bit → Float32Array [-1.0, 1.0] + * @private + */ + _bufferToFloat32(buffer) { + if (buffer instanceof Int16Array) { + const f = new Float32Array(buffer.length); + for (let i = 0; i < buffer.length; i++) f[i] = buffer[i] / 32768.0; + return f; + } + if (!(buffer instanceof Buffer)) buffer = Buffer.from(buffer); + const samples = buffer.length / 2; + const f = new Float32Array(samples); + for (let i = 0; i < samples; i++) { + f[i] = buffer.readInt16LE(i * 2) / 32768.0; + } + return f; + } + + /** + * Convertit Float32Array [-1.0, 1.0] → Buffer PCM 16-bit + * @private + */ + _float32ToBuffer(float32) { + const buf = Buffer.alloc(float32.length * 2); + for (let i = 0; i < float32.length; i++) { + const clamped = Math.max(-1.0, Math.min(1.0, float32[i])); + buf.writeInt16LE(Math.round(clamped * 32767), i * 2); + } + return buf; + } + + /** + * Arrête l'utilisateur et libère les ressources. + */ + async stop() { + await this.client.destroy(); + this.participantAccumulators.clear(); + this.mixedOutput = null; + this.removeAllListeners(); + } +} + +export default ServerAudioUser; diff --git a/server/config/config.yaml b/server/config/config.yaml index 221548f..e9e8ac5 100644 --- a/server/config/config.yaml +++ b/server/config/config.yaml @@ -39,6 +39,22 @@ audio: "0": L "1": R "2": Talkback Console +# Utilisateurs audio gérés côté serveur. +# Chaque entrée crée un participant LiveKit indépendant avec un canal physique +# d'entrée (microphone/ligne) et un canal physique de sortie dédié (mix-minus naturel). +# +# Exemple (décommenter et adapter) : +# server_audio_users: +# - name: foh +# group: default # ID du groupe LiveKit (room) à rejoindre +# input_channel: 1 # Index canal physique d'entrée (depuis inputDeviceId) +# output_channel: 2 # Index canal physique de sortie (vers outputDeviceId) +# - name: returns +# group: default +# input_channel: 2 +# output_channel: 3 +server_audio_users: [] + groups: - name: Default audioBitrate: 96 -- 2.52.0 From 9a2bec6d2fa93ed237eefa96858135f173d1760c Mon Sep 17 00:00:00 2001 From: Benoit Date: Wed, 1 Jul 2026 14:27:35 +0200 Subject: [PATCH 2/4] =?UTF-8?q?feat:=20page=20Routing=20compl=C3=A8te=20+?= =?UTF-8?q?=20gestion=20server=5Faudio=5Fusers=20dans=20Config?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Page Routing : matrices Entrées→Groupes et Groupes→Sorties avec checkboxes, éditeur de noms de canaux (ajout/suppression dynamique), sauvegarde YAML directe - Page Config : section Utilisateurs Audio Serveur (CRUD complet, modal avec sélecteur de groupe, canaux entrée/sortie) - IPC main.js : handlers server-audio-users:list/create/update/delete, routing:get, routing:save - preload.js : namespaces electronAPI.serverAudioUsers et electronAPI.routing - showModal : support du type 'select' avec options - loadViewData : routing et config lisibles sans serveur (YAML direct) --- electron/main.js | 83 ++++++++ electron/preload.js | 14 ++ electron/ui/app.js | 422 +++++++++++++++++++++++++++++++++++++++-- electron/ui/index.html | 68 +++++++ electron/ui/styles.css | 154 +++++++++++++++ 5 files changed, 723 insertions(+), 18 deletions(-) diff --git a/electron/main.js b/electron/main.js index 7aadda2..fb815d3 100644 --- a/electron/main.js +++ b/electron/main.js @@ -436,6 +436,89 @@ app.whenReady().then(async () => { } }); + // ========== Server Audio Users (lecture/écriture YAML directe) ========== + + ipcMain.handle('server-audio-users:list', () => { + try { + const config = readConfig(); + return { users: config.server_audio_users || [] }; + } catch (error) { + return { users: [], error: error.message }; + } + }); + + ipcMain.handle('server-audio-users:create', (event, { name, group, input_channel, output_channel }) => { + try { + const config = readConfig(); + const users = config.server_audio_users || []; + if (users.find(u => u.name === name)) { + return { success: false, error: `Un utilisateur "${name}" existe déjà` }; + } + const user = { name, group, input_channel: parseInt(input_channel), output_channel: parseInt(output_channel) }; + config.server_audio_users = [...users, user]; + writeConfig(config); + return { success: true, user }; + } catch (error) { + return { success: false, error: error.message }; + } + }); + + ipcMain.handle('server-audio-users:update', (event, { name, group, input_channel, output_channel }) => { + try { + const config = readConfig(); + const users = config.server_audio_users || []; + const idx = users.findIndex(u => u.name === name); + if (idx === -1) return { success: false, error: `Utilisateur "${name}" introuvable` }; + config.server_audio_users[idx] = { name, group, input_channel: parseInt(input_channel), output_channel: parseInt(output_channel) }; + writeConfig(config); + return { success: true }; + } catch (error) { + return { success: false, error: error.message }; + } + }); + + ipcMain.handle('server-audio-users:delete', (event, { name }) => { + try { + const config = readConfig(); + const users = config.server_audio_users || []; + const idx = users.findIndex(u => u.name === name); + if (idx === -1) return { success: false, error: `Utilisateur "${name}" introuvable` }; + config.server_audio_users.splice(idx, 1); + writeConfig(config); + return { success: true }; + } catch (error) { + return { success: false, error: error.message }; + } + }); + + // ========== Routing (lecture/écriture YAML directe) ========== + + ipcMain.handle('routing:get', () => { + try { + const config = readConfig(); + return { + routing: config.audio?.routing || { inputToGroup: {}, groupToOutput: {}, gains: {} }, + channelNames: config.audio?.channelNames || { inputs: {}, outputs: {} }, + groups: config.groups || [] + }; + } catch (error) { + return { error: error.message }; + } + }); + + ipcMain.handle('routing:save', (event, { routing, channelNames }) => { + try { + const config = readConfig(); + if (!config.audio) config.audio = {}; + config.audio.routing = routing; + config.audio.channelNames = channelNames; + writeConfig(config); + return { success: true }; + } catch (error) { + return { success: false, error: error.message }; + } + }); + ipcMain.handle('config:export', async () => { const configPath = path.join(__dirname, '..', 'server', 'config', 'config.yaml'); diff --git a/electron/preload.js b/electron/preload.js index a6d2f2f..920b449 100644 --- a/electron/preload.js +++ b/electron/preload.js @@ -51,6 +51,20 @@ contextBridge.exposeInMainWorld('electronAPI', { delete: (data) => ipcRenderer.invoke('groups:delete', data) }, + // Utilisateurs audio serveur : lecture/écriture YAML directe (fonctionne sans serveur) + serverAudioUsers: { + list: () => ipcRenderer.invoke('server-audio-users:list'), + create: (data) => ipcRenderer.invoke('server-audio-users:create', data), + update: (data) => ipcRenderer.invoke('server-audio-users:update', data), + delete: (data) => ipcRenderer.invoke('server-audio-users:delete', data) + }, + + // Routing audio : lecture/écriture YAML directe (fonctionne sans serveur) + routing: { + get: () => ipcRenderer.invoke('routing:get'), + save: (data) => ipcRenderer.invoke('routing:save', data) + }, + // Helpers platform: process.platform, version: process.env.npm_package_version || '0.3.0' diff --git a/electron/ui/app.js b/electron/ui/app.js index 009fc3f..c0046c0 100644 --- a/electron/ui/app.js +++ b/electron/ui/app.js @@ -9,6 +9,7 @@ let serverRunning = false; let statsInterval = null; let logsBuffer = []; let audioLevelsWS = null; +let routingData = null; let audioLevelsData = { inputs: {}, groups: {}, @@ -572,12 +573,26 @@ async function loadInitialData() { } async function loadViewData(view) { - // Les groupes sont lisibles même sans serveur (config.yaml direct) + // Ces vues lisent config.yaml directement (fonctionne sans serveur) if (view === 'groups') { await fetchGroups(); return; } + if (view === 'routing') { + await fetchRouting(); + return; + } + + if (view === 'config') { + await fetchServerAudioUsers(); + if (serverRunning) { + await fetchDevices(); + await fetchConfig(); + } + return; + } + if (!serverRunning) return; switch (view) { @@ -586,10 +601,6 @@ async function loadViewData(view) { await fetchUsers(); await generateQRCode(); break; - case 'config': - await fetchDevices(); - await fetchConfig(); - break; case 'monitoring': renderVUMeters(); break; @@ -778,8 +789,372 @@ document.addEventListener('DOMContentLoaded', () => { } }); } + + // Bouton ajouter utilisateur audio serveur + const btnAddSAU = document.getElementById('btn-add-server-audio-user'); + if (btnAddSAU) { + btnAddSAU.addEventListener('click', addServerAudioUser); + } + + // Délégation modifier/supprimer utilisateur audio serveur + const sauList = document.getElementById('server-audio-users-list'); + if (sauList) { + sauList.addEventListener('click', async (e) => { + const btn = e.target.closest('[data-sau-action]'); + if (!btn) return; + const action = btn.dataset.sauAction; + const name = btn.dataset.sauName; + if (action === 'edit') { + await editServerAudioUser(name, btn.dataset.sauGroup, parseInt(btn.dataset.sauInput), parseInt(btn.dataset.sauOutput)); + } else if (action === 'delete') { + await deleteServerAudioUser(name); + } + }); + } + + // Boutons routing + document.getElementById('btn-save-routing')?.addEventListener('click', saveRouting); + document.getElementById('btn-reload-routing')?.addEventListener('click', fetchRouting); + document.getElementById('btn-add-input-channel')?.addEventListener('click', () => addChannelRow('input')); + document.getElementById('btn-add-output-channel')?.addEventListener('click', () => addChannelRow('output')); + + // Délégation suppression lignes canal (boutons ✕) + document.addEventListener('click', (e) => { + const btn = e.target.closest('.channel-name-delete'); + if (!btn) return; + deleteChannelRow(btn.dataset.dir, btn.dataset.channel); + }); }); +// ========== Server Audio Users ========== + +async function fetchServerAudioUsers() { + const container = document.getElementById('server-audio-users-list'); + if (!container) return; + + const data = await window.electronAPI.serverAudioUsers.list(); + + const serverNote = serverRunning ? '' : + '

Serveur arrêté — les modifications seront appliquées au prochain démarrage.

'; + + if (!data.users || data.users.length === 0) { + container.innerHTML = serverNote + '

Aucun utilisateur audio serveur configuré

'; + return; + } + + container.innerHTML = serverNote + data.users.map(user => ` +
+
+

${escapeHtml(user.name)}

+

Groupe: ${escapeHtml(user.group)} · Entrée: canal ${user.input_channel} · Sortie: canal ${user.output_channel}

+
+
+ + +
+
+ `).join(''); +} + +async function addServerAudioUser() { + const groupsData = await window.electronAPI.groups.list(); + const groupOptions = (groupsData.groups || []).map(g => ({ + value: slugify(g.name), + label: g.name + })); + const defaultGroup = groupOptions[0]?.value || 'default'; + + const result = await showModal({ + title: 'Nouvel utilisateur audio serveur', + fields: [ + { name: 'name', label: 'Nom (identifiant unique, ex: foh)' }, + { name: 'group', label: 'Groupe', type: 'select', options: groupOptions, default: defaultGroup }, + { name: 'input_channel', label: 'Canal entrée (index physique)', type: 'number', default: 0, min: 0, max: 63, step: 1 }, + { name: 'output_channel', label: 'Canal sortie (index physique)', type: 'number', default: 0, min: 0, max: 63, step: 1 } + ], + confirmLabel: 'Ajouter' + }); + + if (!result || !result.name.trim()) return; + + const res = await window.electronAPI.serverAudioUsers.create({ + name: result.name.trim(), + group: result.group, + input_channel: parseInt(result.input_channel), + output_channel: parseInt(result.output_channel) + }); + + if (res.success) { + showNotification('Utilisateur audio serveur ajouté', 'success'); + await fetchServerAudioUsers(); + } else { + showNotification('Erreur: ' + (res.error || 'Création échouée'), 'error'); + } +} + +async function editServerAudioUser(name, group, input_channel, output_channel) { + const groupsData = await window.electronAPI.groups.list(); + const groupOptions = (groupsData.groups || []).map(g => ({ + value: slugify(g.name), + label: g.name + })); + + const result = await showModal({ + title: `Modifier "${name}"`, + fields: [ + { name: 'group', label: 'Groupe', type: 'select', options: groupOptions, default: group }, + { name: 'input_channel', label: 'Canal entrée (index physique)', type: 'number', default: input_channel, min: 0, max: 63, step: 1 }, + { name: 'output_channel', label: 'Canal sortie (index physique)', type: 'number', default: output_channel, min: 0, max: 63, step: 1 } + ], + confirmLabel: 'Modifier' + }); + + if (!result) return; + + const res = await window.electronAPI.serverAudioUsers.update({ + name, + group: result.group, + input_channel: parseInt(result.input_channel), + output_channel: parseInt(result.output_channel) + }); + + if (res.success) { + showNotification('Utilisateur audio serveur modifié', 'success'); + await fetchServerAudioUsers(); + } else { + showNotification('Erreur: ' + (res.error || 'Modification échouée'), 'error'); + } +} + +async function deleteServerAudioUser(name) { + const confirmed = await showModal({ + title: 'Supprimer l\'utilisateur audio serveur', + message: `Supprimer "${name}" ? Cette action est irréversible.`, + confirmLabel: 'Supprimer', + confirmClass: 'btn-danger' + }); + + if (!confirmed) return; + + const res = await window.electronAPI.serverAudioUsers.delete({ name }); + + if (res.success) { + showNotification('Utilisateur audio serveur supprimé', 'success'); + await fetchServerAudioUsers(); + } else { + showNotification('Erreur: ' + (res.error || 'Suppression échouée'), 'error'); + } +} + +// ========== Routing ========== + +async function fetchRouting() { + const data = await window.electronAPI.routing.get(); + if (data.error) { + showNotification('Erreur chargement routing: ' + data.error, 'error'); + return; + } + routingData = data; + renderRoutingView(); +} + +function renderRoutingView() { + if (!routingData) return; + renderChannelNamesEditor(); + renderRoutingMatrices(); +} + +function renderChannelNamesEditor() { + const { channelNames } = routingData; + const inputs = channelNames?.inputs || {}; + const outputs = channelNames?.outputs || {}; + + const inputChannels = Object.keys(inputs).sort((a, b) => parseInt(a) - parseInt(b)); + const outputChannels = Object.keys(outputs).sort((a, b) => parseInt(a) - parseInt(b)); + + const inputsContainer = document.getElementById('channel-names-inputs'); + const outputsContainer = document.getElementById('channel-names-outputs'); + + if (inputsContainer) { + inputsContainer.innerHTML = inputChannels.length > 0 + ? inputChannels.map(ch => channelNameRow('input', ch, inputs[ch] || '')).join('') + : '

Aucun canal d\'entrée défini.

'; + } + + if (outputsContainer) { + outputsContainer.innerHTML = outputChannels.length > 0 + ? outputChannels.map(ch => channelNameRow('output', ch, outputs[ch] || '')).join('') + : '

Aucun canal de sortie défini.

'; + } +} + +function channelNameRow(dir, ch, name) { + return ` +
+ Canal ${ch} + + +
`; +} + +function addChannelRow(dir) { + const containerId = dir === 'input' ? 'channel-names-inputs' : 'channel-names-outputs'; + const container = document.getElementById(containerId); + if (!container) return; + + // Retirer le message "Aucun canal" si présent + const emptyMsg = container.querySelector('.config-note'); + if (emptyMsg) emptyMsg.remove(); + + const existing = Array.from(container.querySelectorAll('.channel-name-row')) + .map(r => parseInt(r.dataset.channel)) + .filter(n => !isNaN(n)); + const nextCh = existing.length > 0 ? Math.max(...existing) + 1 : 0; + + const row = document.createElement('div'); + row.innerHTML = channelNameRow(dir, nextCh, ''); + const newRow = row.firstElementChild; + container.appendChild(newRow); + newRow.querySelector('input')?.focus(); +} + +function deleteChannelRow(dir, channel) { + const containerId = dir === 'input' ? 'channel-names-inputs' : 'channel-names-outputs'; + const container = document.getElementById(containerId); + const row = container?.querySelector(`.channel-name-row[data-channel="${channel}"][data-dir="${dir}"]`); + if (!row) return; + row.remove(); + if (!container.querySelector('.channel-name-row')) { + container.innerHTML = `

Aucun canal de ${dir === 'input' ? 'entrée' : 'sortie'} défini.

`; + } +} + +function renderRoutingMatrices() { + const { routing, channelNames, groups } = routingData; + const inputs = channelNames?.inputs || {}; + const outputs = channelNames?.outputs || {}; + const inputToGroup = routing?.inputToGroup || {}; + const groupToOutput = routing?.groupToOutput || {}; + + const inputChannels = Object.keys(inputs).sort((a, b) => parseInt(a) - parseInt(b)); + const outputChannels = Object.keys(outputs).sort((a, b) => parseInt(a) - parseInt(b)); + + // Matrice Entrées → Groupes + const inputMatrixEl = document.getElementById('routing-input-matrix'); + if (inputMatrixEl) { + if (inputChannels.length === 0 || groups.length === 0) { + inputMatrixEl.innerHTML = '

Définissez des canaux d\'entrée et des groupes pour configurer le routing.

'; + } else { + let html = '
'; + html += ''; + groups.forEach(g => { html += ``; }); + html += ''; + + inputChannels.forEach(ch => { + const chGroups = inputToGroup[ch] || []; + html += ''; + html += ``; + groups.forEach(g => { + const gId = slugify(g.name); + const checked = chGroups.includes(gId) ? 'checked' : ''; + html += ``; + }); + html += ''; + }); + + html += '
Canal Entrée${escapeHtml(g.name)}
${ch}${escapeHtml(inputs[ch] || `Canal ${ch}`)}
'; + inputMatrixEl.innerHTML = html; + } + } + + // Matrice Groupes → Sorties + const outputMatrixEl = document.getElementById('routing-output-matrix'); + if (outputMatrixEl) { + if (groups.length === 0 || outputChannels.length === 0) { + outputMatrixEl.innerHTML = '

Définissez des canaux de sortie et des groupes pour configurer le routing.

'; + } else { + let html = '
'; + html += ''; + outputChannels.forEach(ch => { + html += ``; + }); + html += ''; + + groups.forEach(g => { + const gId = slugify(g.name); + const gOutputs = groupToOutput[gId] || []; + html += ''; + html += ``; + outputChannels.forEach(ch => { + const checked = gOutputs.includes(ch) ? 'checked' : ''; + html += ``; + }); + html += ''; + }); + + html += '
Groupe${ch}
${escapeHtml(outputs[ch] || `Canal ${ch}`)}
${escapeHtml(g.name)}
'; + outputMatrixEl.innerHTML = html; + } + } +} + +async function saveRouting() { + if (!routingData) return; + + // Collecter les noms de canaux depuis le DOM + const newChannelNames = { inputs: {}, outputs: {} }; + document.querySelectorAll('.channel-name-input[data-dir="input"]').forEach(el => { + newChannelNames.inputs[el.dataset.channel] = el.value; + }); + document.querySelectorAll('.channel-name-input[data-dir="output"]').forEach(el => { + newChannelNames.outputs[el.dataset.channel] = el.value; + }); + + // Collecter le routing depuis les checkboxes + const newInputToGroup = {}; + const newGroupToOutput = {}; + + document.querySelectorAll('.routing-check[data-direction="input"]:checked').forEach(cb => { + const ch = cb.dataset.channel; + if (!newInputToGroup[ch]) newInputToGroup[ch] = []; + if (!newInputToGroup[ch].includes(cb.dataset.group)) newInputToGroup[ch].push(cb.dataset.group); + }); + + document.querySelectorAll('.routing-check[data-direction="output"]:checked').forEach(cb => { + const g = cb.dataset.group; + if (!newGroupToOutput[g]) newGroupToOutput[g] = []; + if (!newGroupToOutput[g].includes(cb.dataset.channel)) newGroupToOutput[g].push(cb.dataset.channel); + }); + + const newRouting = { + ...(routingData.routing || {}), + inputToGroup: newInputToGroup, + groupToOutput: newGroupToOutput + }; + + const result = await window.electronAPI.routing.save({ routing: newRouting, channelNames: newChannelNames }); + + if (result.success) { + routingData.routing = newRouting; + routingData.channelNames = newChannelNames; + renderRoutingView(); + showNotification('Routing sauvegardé', 'success'); + const note = document.getElementById('routing-server-note'); + if (note) note.classList.remove('hidden'); + } else { + showNotification('Erreur: ' + (result.error || 'Sauvegarde échouée'), 'error'); + } +} + // ========== Helpers ========== /** @@ -802,19 +1177,30 @@ function showModal({ title, fields = [], confirmLabel = 'Confirmer', confirmClas if (message) { bodyEl.innerHTML = ``; } else { - bodyEl.innerHTML = fields.map(field => ` -
- - -
- `).join(''); + bodyEl.innerHTML = fields.map(field => { + if (field.type === 'select') { + const optionsHtml = (field.options || []).map(opt => + `` + ).join(''); + return ` +
+ + +
`; + } + return ` +
+ + +
`; + }).join(''); } overlay.classList.remove('hidden'); diff --git a/electron/ui/index.html b/electron/ui/index.html index 83050fd..ad8ee0a 100644 --- a/electron/ui/index.html +++ b/electron/ui/index.html @@ -40,6 +40,9 @@ + @@ -123,6 +126,15 @@ +
+

🔗 Utilisateurs Audio Serveur

+

Participants LiveKit côté serveur avec canaux physiques d'entrée et de sortie dédiés (mix-minus natif). Voir aussi la page Routing pour le câblage entre canaux et groupes.

+ +
+

Chargement...

+
+
+

💾 Sauvegarde de configuration

@@ -163,6 +175,62 @@
+ +
+

Routing Audio

+ + +
+

🏷️ Noms des Canaux

+

Définissez les canaux physiques disponibles. Les matrices de routing se mettent à jour après la sauvegarde.

+
+
+
+

Canaux Entrée

+ +
+
+

Chargement...

+
+
+
+
+

Canaux Sortie

+ +
+
+

Chargement...

+
+
+
+
+ + +
+

🎙️ Entrées → Groupes

+

Quels canaux physiques d'entrée alimentent quels groupes LiveKit.

+
+

Chargement...

+
+
+ + +
+

🔊 Groupes → Sorties

+

Vers quels canaux physiques de sortie chaque groupe est routé.

+
+

Chargement...

+
+
+ + +
+ + + +
+
+

Monitoring Audio

diff --git a/electron/ui/styles.css b/electron/ui/styles.css index 2698b57..a9889d5 100644 --- a/electron/ui/styles.css +++ b/electron/ui/styles.css @@ -800,3 +800,157 @@ body { opacity: 0.3; } } + +/* ========== Routing View ========== */ + +.routing-matrix-wrapper { + overflow-x: auto; +} + +.routing-matrix-scroll { + overflow-x: auto; + max-width: 100%; +} + +.routing-matrix { + border-collapse: collapse; + font-size: 0.875rem; + min-width: 100%; +} + +.routing-matrix th, +.routing-matrix td { + padding: 0.5rem 0.75rem; + border: 1px solid var(--border-color); + text-align: center; + vertical-align: middle; +} + +.routing-matrix thead th { + background: var(--bg-tertiary); + font-weight: 600; + position: sticky; + top: 0; + z-index: 1; +} + +.matrix-label-cell { + text-align: left; + font-weight: 600; + background: var(--bg-tertiary); + min-width: 200px; + position: sticky; + left: 0; + z-index: 2; +} + +.matrix-group-header { + min-width: 110px; + font-size: 0.8125rem; + line-height: 1.4; +} + +.matrix-channel-label { + text-align: left; + white-space: nowrap; + background: var(--bg-secondary); + position: sticky; + left: 0; + z-index: 1; +} + +.ch-index { + display: inline-block; + background: var(--bg-primary); + color: var(--accent-primary); + font-family: 'Courier New', monospace; + font-size: 0.75rem; + padding: 0.1rem 0.4rem; + border-radius: 3px; + margin-right: 0.5rem; + min-width: 24px; + text-align: center; +} + +.ch-name { + color: var(--text-secondary); + font-size: 0.8125rem; +} + +.matrix-cell { + text-align: center; +} + +.matrix-cell input[type="checkbox"] { + width: 18px; + height: 18px; + cursor: pointer; + accent-color: var(--accent-primary); +} + +.routing-matrix tbody tr:hover .matrix-channel-label, +.routing-matrix tbody tr:hover td { + background: rgba(74, 158, 255, 0.05); +} + +/* Channel Names Editor */ + +.channel-names-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 2rem; +} + +.channel-names-col-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 0.75rem; + padding-bottom: 0.5rem; + border-bottom: 1px solid var(--border-color); +} + +.channel-names-col-header h4 { + font-size: 0.875rem; + color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: 0.06em; + margin: 0; +} + +.channel-names-list { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.channel-name-row { + display: flex; + align-items: center; + gap: 0.625rem; +} + +.channel-index { + min-width: 58px; + font-size: 0.8125rem; + color: var(--text-secondary); + font-family: 'Courier New', monospace; + flex-shrink: 0; +} + +/* Routing actions bar */ + +.routing-actions { + display: flex; + align-items: center; + gap: 1rem; + padding: 0.5rem 0 1.5rem; +} + +.routing-restart-note { + color: var(--accent-warning); +} + +.routing-restart-note.hidden { + display: none; +} -- 2.52.0 From bf960f49bb7303280b896632ab3e9953794ba11a Mon Sep 17 00:00:00 2001 From: Benoit Date: Fri, 3 Jul 2026 14:55:37 +0200 Subject: [PATCH 3/4] =?UTF-8?q?refactor:=20supprimer=20compl=C3=A8tement?= =?UTF-8?q?=20les=20matrices=20de=20routing=20inputToGroup/groupToOutput?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le paradigme devient : pour brancher un canal physique sur un groupe, créer un server audio user. Les matrices sont retirées de l'UI Electron, de l'admin PWA, de l'API REST et du backend (GroupAudioRouter supprimé). AudioBridgeManager ne génère plus de tokens per-group. Option "aucune sortie" ajoutée pour les server audio users. --- client/src/Admin.jsx | 5 - client/src/components/AudioRoutingMatrix.css | 245 ----------- client/src/components/AudioRoutingMatrix.jsx | 349 ---------------- server/api/admin.js | 60 --- server/bridge/GroupAudioRouter.js | 417 ------------------- 5 files changed, 1076 deletions(-) delete mode 100644 client/src/components/AudioRoutingMatrix.css delete mode 100644 client/src/components/AudioRoutingMatrix.jsx delete mode 100644 server/bridge/GroupAudioRouter.js diff --git a/client/src/Admin.jsx b/client/src/Admin.jsx index 82f275b..49c8c7a 100644 --- a/client/src/Admin.jsx +++ b/client/src/Admin.jsx @@ -1,6 +1,5 @@ import { useState, useEffect, useRef } from 'react'; import './Admin.css'; -import AudioRoutingMatrix from './components/AudioRoutingMatrix'; const API_URL = import.meta.env.VITE_API_URL || '/api'; @@ -409,9 +408,6 @@ function Admin() {
-

- Le routing audio se configure dans l'onglet "Audio" via la matrice de routing. -

- {currentDevice && currentDevice.inputDeviceId && (
diff --git a/client/src/components/AudioRoutingMatrix.css b/client/src/components/AudioRoutingMatrix.css deleted file mode 100644 index aa844ce..0000000 --- a/client/src/components/AudioRoutingMatrix.css +++ /dev/null @@ -1,245 +0,0 @@ -.routing-matrix-container { - background: var(--color-surface); - border: 1px solid var(--color-border); - border-radius: 12px; - padding: var(--spacing-xl); - margin-top: var(--spacing-lg); -} - -.routing-actions { - margin-top: var(--spacing-xl); - display: flex; - justify-content: flex-start; - gap: var(--spacing-sm); -} - -.routing-matrix-header { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: var(--spacing-xl); -} - -.routing-matrix-header h3 { - margin: 0; - font-size: 1.25rem; - font-weight: 600; -} - -.ws-status { - font-size: 0.8rem; - font-weight: 600; - padding: 4px 10px; - border-radius: 12px; - white-space: nowrap; -} - -.ws-status.connected { - color: #44ff44; - background: rgba(68, 255, 68, 0.1); -} - -.ws-status.disconnected { - color: #888; - background: rgba(136, 136, 136, 0.1); -} - -.routing-section { - margin-bottom: var(--spacing-xl); -} - -.routing-section:last-child { - margin-bottom: 0; -} - -.routing-section h4 { - margin: 0 0 var(--spacing-sm) 0; - font-size: 1rem; - font-weight: 600; - color: var(--color-text); -} - -.routing-description { - margin: 0 0 var(--spacing-lg) 0; - color: var(--color-text-secondary); - font-size: 0.9rem; -} - -.routing-matrix { - display: inline-grid; - gap: 2px; - background: var(--color-border); - border: 1px solid var(--color-border); - border-radius: 8px; - overflow-x: auto; - max-width: 100%; -} - -.matrix-corner { - background: var(--color-surface-hover); - min-height: 50px; -} - -.matrix-header-cell { - background: var(--color-surface-hover); - padding: var(--spacing-sm); - font-size: 0.85rem; - font-weight: 600; - color: var(--color-text-secondary); - text-align: center; - display: flex; - align-items: center; - justify-content: center; - min-height: 50px; - word-break: break-word; - hyphens: auto; -} - -.matrix-label-cell { - background: var(--color-surface-hover); - padding: var(--spacing-sm); - font-size: 0.85rem; - font-weight: 600; - color: var(--color-text-secondary); - display: flex; - align-items: center; - min-width: 120px; - word-break: break-word; -} - -.label-content { - display: flex; - flex-direction: column; - gap: 4px; - width: 100%; -} - -.label-text { - flex: 1; -} - -.header-content { - display: flex; - flex-direction: column; - gap: 4px; - width: 100%; - align-items: center; -} - -.header-text { - text-align: center; -} - -.matrix-cell { - background: var(--color-bg); - padding: var(--spacing-sm); - min-height: 60px; - min-width: 80px; - transition: all 0.2s; - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - gap: var(--spacing-xs); - position: relative; -} - -.cell-checkbox { - width: 100%; - min-height: 30px; - display: flex; - align-items: center; - justify-content: center; - cursor: pointer; -} - -.matrix-cell:hover { - background: var(--color-surface-hover); - border: 1px solid var(--color-primary); -} - -.matrix-cell.active { - background: var(--color-primary); - color: white; -} - -.matrix-cell.active:hover { - background: var(--color-primary-hover); -} - -.checkmark { - font-size: 1.2rem; - font-weight: bold; -} - -.gain-select { - width: 100%; - padding: 4px 8px; - font-size: 0.75rem; - background: rgba(59, 130, 246, 0.2); - color: #ffffff; - border: 1px solid rgba(255, 255, 255, 1); - border-radius: 4px; - cursor: pointer; - font-weight: 600; - text-align: center; -} - -.gain-select:focus { - outline: none; - background: rgba(59, 130, 246, 0.3); - border-color: rgba(255, 255, 255, 1); - box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.2); -} - -@media (max-width: 1024px) { - .matrix-header-cell, - .matrix-label-cell { - font-size: 0.75rem; - padding: var(--spacing-xs); - min-width: 80px; - } - - .matrix-cell { - min-width: 70px; - min-height: 50px; - padding: var(--spacing-sm); - } - - .gain-select { - font-size: 0.7rem; - padding: 3px 6px; - } -} - -@media (max-width: 768px) { - .routing-matrix-container { - padding: var(--spacing-md); - } - - .routing-matrix-header { - flex-direction: column; - gap: var(--spacing-md); - align-items: stretch; - } - - .matrix-header-cell, - .matrix-label-cell { - font-size: 0.7rem; - min-width: 60px; - } - - .matrix-cell { - min-width: 65px; - min-height: 45px; - } - - .checkmark { - font-size: 1rem; - } - - .gain-select { - font-size: 0.65rem; - padding: 2px 4px; - } -} diff --git a/client/src/components/AudioRoutingMatrix.jsx b/client/src/components/AudioRoutingMatrix.jsx deleted file mode 100644 index 5662f1c..0000000 --- a/client/src/components/AudioRoutingMatrix.jsx +++ /dev/null @@ -1,349 +0,0 @@ -import React, { useState, useEffect } from 'react'; -import './AudioRoutingMatrix.css'; -import VUMeter from './VUMeter.jsx'; -import { useAudioLevels } from '../hooks/useAudioLevels.js'; - -const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000'; - -function AudioRoutingMatrix({ groups, channelNames }) { - const { levels, connected: wsConnected } = useAudioLevels(); - const [routing, setRouting] = useState({ inputToGroup: {}, groupToOutput: {}, gains: {} }); - const [loading, setLoading] = useState(true); - const [showOnlyNamedChannels, setShowOnlyNamedChannels] = useState(false); - const [audioDevice, setAudioDevice] = useState({ inputChannels: 8, outputChannels: 8 }); - - useEffect(() => { - loadRouting(); - loadAudioDevice(); - }, []); - - const loadRouting = async () => { - try { - const res = await fetch(`${API_URL}/admin/audio/routing`); - if (!res.ok) { - throw new Error(`HTTP error! status: ${res.status}`); - } - const data = await res.json(); - setRouting(data.routing || { inputToGroup: {}, groupToOutput: {}, gains: {} }); - } catch (error) { - console.error('Erreur chargement routing:', error); - } finally { - setLoading(false); - } - }; - - const loadAudioDevice = async () => { - try { - const res = await fetch(`${API_URL}/admin/audio/device`); - if (res.ok) { - const data = await res.json(); - setAudioDevice({ - inputChannels: data.device?.inputChannels || 8, - outputChannels: data.device?.outputChannels || 8 - }); - } - } catch (error) { - console.error('Erreur chargement audio device:', error); - } - }; - - const saveRouting = async () => { - try { - const res = await fetch(`${API_URL}/admin/audio/routing`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(routing) - }); - - if (res.ok) { - alert('Configuration de routing sauvegardée!'); - } else { - const errorText = await res.text(); - console.error('Erreur serveur:', errorText); - alert(`Erreur: ${res.status} - ${errorText}`); - } - } catch (error) { - console.error('Erreur sauvegarde routing:', error); - alert('Erreur lors de la sauvegarde'); - } - }; - - const toggleInputToGroup = (inputId, groupId) => { - setRouting(prev => { - const inputToGroup = { ...prev.inputToGroup }; - if (!inputToGroup[inputId]) { - inputToGroup[inputId] = []; - } - - const groupArray = [...inputToGroup[inputId]]; - const index = groupArray.indexOf(groupId); - - if (index > -1) { - groupArray.splice(index, 1); - } else { - groupArray.push(groupId); - } - - inputToGroup[inputId] = groupArray; - - return { ...prev, inputToGroup }; - }); - }; - - const toggleGroupToOutput = (groupId, outputId) => { - setRouting(prev => { - const groupToOutput = { ...prev.groupToOutput }; - if (!groupToOutput[groupId]) { - groupToOutput[groupId] = []; - } - - const outputArray = [...groupToOutput[groupId]]; - const index = outputArray.indexOf(outputId); - - if (index > -1) { - outputArray.splice(index, 1); - } else { - outputArray.push(outputId); - } - - groupToOutput[groupId] = outputArray; - - return { ...prev, groupToOutput }; - }); - }; - - const isInputRoutedToGroup = (inputId, groupId) => { - return routing.inputToGroup[inputId]?.includes(groupId) || false; - }; - - const isGroupRoutedToOutput = (groupId, outputId) => { - return routing.groupToOutput[groupId]?.includes(outputId) || false; - }; - - const getGainForInputToGroup = (inputId, groupId) => { - const key = `in_${inputId}_${groupId}`; - return routing.gains?.[key] || 0.0; - }; - - const getGainForGroupToOutput = (groupId, outputId) => { - const key = `${groupId}_out_${outputId}`; - return routing.gains?.[key] || 0.0; - }; - - const setGainForInputToGroup = (inputId, groupId, gainDb) => { - setRouting(prev => { - const gains = { ...prev.gains }; - const key = `in_${inputId}_${groupId}`; - gains[key] = parseFloat(gainDb); - return { ...prev, gains }; - }); - }; - - const setGainForGroupToOutput = (groupId, outputId, gainDb) => { - setRouting(prev => { - const gains = { ...prev.gains }; - const key = `${groupId}_out_${outputId}`; - gains[key] = parseFloat(gainDb); - return { ...prev, gains }; - }); - }; - - const formatGain = (gainDb) => { - if (gainDb === 0) return '0dB'; - return gainDb > 0 ? `+${gainDb}dB` : `${gainDb}dB`; - }; - - const getChannelName = (type, id) => { - const name = channelNames?.[type]?.[id]; - return name || `${type === 'inputs' ? 'Input' : 'Output'} ${id}`; - }; - - const hasCustomName = (type, id) => { - return channelNames?.[type]?.[id] !== undefined; - }; - - const getVisibleInputChannels = () => { - const allInputs = Array.from({length: audioDevice.inputChannels}, (_, i) => i); - if (showOnlyNamedChannels) { - return allInputs.filter(i => hasCustomName('inputs', i)); - } - return allInputs; - }; - - const getVisibleOutputChannels = () => { - const allOutputs = Array.from({length: audioDevice.outputChannels}, (_, i) => i); - if (showOnlyNamedChannels) { - return allOutputs.filter(i => hasCustomName('outputs', i)); - } - return allOutputs; - }; - - if (loading) { - return
Chargement...
; - } - - return ( -
-
-
-

Matrice de routing audio

- - {wsConnected ? '● Live' : '○ Offline'} - -
- -
- -
-

Inputs vers Groupes

-

- Sélectionnez quels inputs audio alimentent chaque groupe -

- -
-
- - {groups.map(group => ( -
- {group.name} -
- ))} - - {getVisibleInputChannels().map(i => ( - -
-
- {getChannelName('inputs', i)} - {wsConnected && levels.inputs[i] && ( - - )} -
-
- - {groups.map(group => { - const isRouted = isInputRoutedToGroup(String(i), group.id); - const gain = getGainForInputToGroup(String(i), group.id); - - return ( -
-
toggleInputToGroup(String(i), group.id)} - > - {isRouted && } -
- {isRouted && ( - - )} -
- ); - })} -
- ))} -
-
- -
-

Groupes vers Outputs

-

- Sélectionnez vers quels outputs chaque groupe envoie son audio -

- -
-
- - {getVisibleOutputChannels().map(i => ( -
-
- {getChannelName('outputs', i)} - {wsConnected && levels.outputs[i] && ( - - )} -
-
- ))} - - {groups.map(group => ( - -
-
- {group.name} - {wsConnected && levels.groups[group.id] && ( - - )} -
-
- - {getVisibleOutputChannels().map(i => { - const isRouted = isGroupRoutedToOutput(group.id, String(i)); - const gain = getGainForGroupToOutput(group.id, String(i)); - - return ( -
-
toggleGroupToOutput(group.id, String(i))} - > - {isRouted && } -
- {isRouted && ( - - )} -
- ); - })} -
- ))} -
-
- -
- -
-
- ); -} - -export default AudioRoutingMatrix; diff --git a/server/api/admin.js b/server/api/admin.js index 8f65f3e..b71a8c7 100644 --- a/server/api/admin.js +++ b/server/api/admin.js @@ -529,66 +529,6 @@ router.put('/audio/channels/names', (req, res) => { } }); -/** - * GET /admin/audio/routing - * Récupère la configuration de routing actuelle - * Format: { inputToGroup: { "0": ["production"], "1": ["technique"] }, groupToOutput: { "production": ["0", "1"] } } - */ -router.get('/audio/routing', (req, res) => { - try { - const config = configManager.get(); - const routing = config.audio?.routing || { inputToGroup: {}, groupToOutput: {}, gains: {} }; - - res.json({ - routing - }); - } catch (error) { - console.error('Erreur GET /admin/audio/routing:', error); - res.status(500).json({ error: 'Failed to load routing' }); - } -}); - -/** - * POST /audio/routing - * Sauvegarde la configuration de routing - * Body: { inputToGroup: {...}, groupToOutput: {...}, gains: {...} } - */ -router.post('/audio/routing', (req, res) => { - try { - const { inputToGroup, groupToOutput, gains } = req.body; - - const config = configManager.get(); - - if (!config.audio.routing) { - config.audio.routing = { inputToGroup: {}, groupToOutput: {}, gains: {} }; - } - - if (inputToGroup !== undefined) { - config.audio.routing.inputToGroup = inputToGroup; - } - - if (groupToOutput !== undefined) { - config.audio.routing.groupToOutput = groupToOutput; - } - - if (gains !== undefined) { - config.audio.routing.gains = gains; - } - - configManager.save(config); - - addLog('info', 'Audio routing updated'); - - res.json({ - message: 'Audio routing updated', - routing: config.audio.routing - }); - - } catch (error) { - console.error('Erreur POST /admin/audio/routing:', error); - res.status(500).json({ error: 'Failed to update routing' }); - } -}); /** * POST /admin/audio/device diff --git a/server/bridge/GroupAudioRouter.js b/server/bridge/GroupAudioRouter.js deleted file mode 100644 index 2b2e158..0000000 --- a/server/bridge/GroupAudioRouter.js +++ /dev/null @@ -1,417 +0,0 @@ -/** - * GroupAudioRouter.js - * Gestion du routing audio multi-canaux entre entrées physiques, groupes LiveKit et sorties physiques - * - * Architecture : - * - Mix de plusieurs canaux physiques vers un groupe (avec gains individuels) - * - Distribution d'un groupe vers plusieurs canaux physiques (avec gains individuels) - * - Support canaux partagés (mixage additif) - * - Gestion gains par route (-120dB à +6dB) - */ - -import { EventEmitter } from 'events'; -import { getLogger } from '../utils/Logger.js'; - -const logger = getLogger('Routing'); - -/** - * Représente une route audio avec gain - */ -class AudioRoute { - constructor(source, destination, gain = 0.0) { - this.source = source; // Numéro de canal ou nom de groupe - this.destination = destination; // Nom de groupe ou numéro de canal - this.gain = gain; // Gain en dB (-120 à +6) - this.linearGain = this._dbToLinear(gain); - } - - /** - * Met à jour le gain en dB - */ - setGain(gainDb) { - this.gain = Math.max(-120, Math.min(6, gainDb)); - this.linearGain = this._dbToLinear(this.gain); - } - - /** - * Convertit dB en gain linéaire - */ - _dbToLinear(db) { - if (db <= -120) return 0.0; - return Math.pow(10, db / 20); - } -} - -/** - * Router audio principal - */ -export class GroupAudioRouter extends EventEmitter { - constructor(config = {}) { - super(); - - this.config = { - sampleRate: config.sampleRate || 48000, - frameSize: config.frameSize || 960, // 20ms à 48kHz - maxInputChannels: config.maxInputChannels || 32, - maxOutputChannels: config.maxOutputChannels || 32, - groups: config.groups || [] - }; - - // Routes : input -> group - this.inputToGroupRoutes = new Map(); // Map - // Routes : group -> output - this.groupToOutputRoutes = new Map(); // Map - - // Buffers audio - this.inputBuffers = new Map(); // Map - this.groupBuffers = new Map(); // Map - this.outputBuffers = new Map(); // Map - - // Statistiques - this.stats = { - framesProcessed: 0, - clippingEvents: 0, - routesActive: 0 - }; - } - - /** - * Configure le routing depuis la config YAML - */ - configure(routingConfig) { - logger.info('Configuration du routing audio...'); - logger.debug(' Groupes disponibles:', this.config.groups.map(g => `${g.name || g} (id: ${g.id || g})`).join(', ')); - logger.debug(' inputToGroup:', JSON.stringify(routingConfig.inputToGroup || {})); - logger.debug(' groupToOutput:', JSON.stringify(routingConfig.groupToOutput || {})); - - // Réinitialise les routes - this.inputToGroupRoutes.clear(); - this.groupToOutputRoutes.clear(); - - // Configure input -> group - if (routingConfig.inputToGroup) { - Object.entries(routingConfig.inputToGroup).forEach(([channelId, groups]) => { - const channel = parseInt(channelId); - - groups.forEach(groupName => { - this.addInputToGroupRoute(channel, groupName, this._getGain(routingConfig.gains, `in_${channel}_${groupName}`)); - }); - }); - } - - // Configure group -> output - if (routingConfig.groupToOutput) { - Object.entries(routingConfig.groupToOutput).forEach(([groupName, channels]) => { - channels.forEach(channelId => { - const channel = parseInt(channelId); - this.addGroupToOutputRoute(groupName, channel, this._getGain(routingConfig.gains, `${groupName}_out_${channel}`)); - }); - }); - } - - this._updateStatsActiveRoutes(); - logger.success(`Routing configuré : ${this.stats.routesActive} routes actives`); - this.emit('configured', this.stats); - } - - /** - * Récupère le gain depuis la config - */ - _getGain(gainsConfig, routeKey) { - return gainsConfig && gainsConfig[routeKey] ? gainsConfig[routeKey] : 0.0; - } - - /** - * Ajoute une route input -> group - */ - addInputToGroupRoute(inputChannel, groupName, gainDb = 0.0) { - const key = `in_${inputChannel}`; - - if (!this.inputToGroupRoutes.has(key)) { - this.inputToGroupRoutes.set(key, []); - } - - const route = new AudioRoute(inputChannel, groupName, gainDb); - this.inputToGroupRoutes.get(key).push(route); - - logger.info(`Input ${inputChannel} → Group "${groupName}" (${gainDb}dB)`); - this._updateStatsActiveRoutes(); - } - - /** - * Ajoute une route group -> output - */ - addGroupToOutputRoute(groupName, outputChannel, gainDb = 0.0) { - const key = groupName; - - if (!this.groupToOutputRoutes.has(key)) { - this.groupToOutputRoutes.set(key, []); - } - - const route = new AudioRoute(groupName, outputChannel, gainDb); - this.groupToOutputRoutes.get(key).push(route); - - logger.info(`Group "${groupName}" → Output ${outputChannel} (${gainDb}dB)`); - this._updateStatsActiveRoutes(); - } - - /** - * Supprime toutes les routes d'une entrée - */ - removeInputRoutes(inputChannel) { - this.inputToGroupRoutes.delete(`in_${inputChannel}`); - this._updateStatsActiveRoutes(); - } - - /** - * Supprime toutes les routes d'un groupe vers les sorties - */ - removeGroupOutputRoutes(groupName) { - this.groupToOutputRoutes.delete(groupName); - this._updateStatsActiveRoutes(); - } - - /** - * Met à jour le gain d'une route spécifique - */ - setRouteGain(source, destination, gainDb) { - // Cherche dans input -> group - const inputKey = typeof source === 'number' ? `in_${source}` : null; - if (inputKey && this.inputToGroupRoutes.has(inputKey)) { - const routes = this.inputToGroupRoutes.get(inputKey); - const route = routes.find(r => r.destination === destination); - if (route) { - route.setGain(gainDb); - console.log(`Gain modifié : Input ${source} -> Group "${destination}" = ${gainDb}dB`); - return true; - } - } - - // Cherche dans group -> output - if (typeof source === 'string' && this.groupToOutputRoutes.has(source)) { - const routes = this.groupToOutputRoutes.get(source); - const route = routes.find(r => r.destination === destination); - if (route) { - route.setGain(gainDb); - console.log(`Gain modifié : Group "${source}" -> Output ${destination} = ${gainDb}dB`); - return true; - } - } - - return false; - } - - /** - * ÉTAPE 1 : Traite les entrées audio physiques vers les buffers de groupe - * Mixe plusieurs canaux d'entrée vers chaque groupe (avec gains individuels) - * - * @param {Map} inputChannelsData - Données PCM par canal d'entrée - */ - processInputsToGroups(inputChannelsData) { - // Réinitialise les buffers de groupe - this.groupBuffers.clear(); - this.config.groups.forEach(group => { - // Utiliser l'ID (slugifié) plutôt que le nom pour correspondre au routing - const groupId = group.id || group.name || group; - this.groupBuffers.set(groupId, new Float32Array(this.config.frameSize)); - }); - - // Compter le nombre de sources par groupe pour normalisation - const groupSourceCount = new Map(); - inputChannelsData.forEach((_, channelId) => { - const key = `in_${channelId}`; - const routes = this.inputToGroupRoutes.get(key); - if (routes) { - routes.forEach(route => { - groupSourceCount.set( - route.destination, - (groupSourceCount.get(route.destination) || 0) + 1 - ); - }); - } - }); - - // Pour chaque canal d'entrée - inputChannelsData.forEach((pcmData, channelId) => { - const key = `in_${channelId}`; - const routes = this.inputToGroupRoutes.get(key); - - if (!routes || routes.length === 0) return; - - // Stocke le buffer d'entrée - this.inputBuffers.set(channelId, pcmData); - - // Applique chaque route (mixage additif vers les groupes) - routes.forEach(route => { - const groupBuffer = this.groupBuffers.get(route.destination); - if (!groupBuffer) { - logger.warn(`Buffer groupe "${route.destination}" introuvable pour routing depuis Input ${channelId}`); - return; - } - - // Mixage avec gain + atténuation par nombre de sources - const sourceCount = groupSourceCount.get(route.destination) || 1; - const mixGain = route.linearGain / sourceCount; - - for (let i = 0; i < pcmData.length && i < groupBuffer.length; i++) { - groupBuffer[i] += pcmData[i] * mixGain; - } - }); - }); - - // Normalisation anti-clipping (soft limiter simple) - this.groupBuffers.forEach((buffer, groupName) => { - for (let i = 0; i < buffer.length; i++) { - if (Math.abs(buffer[i]) > 1.0) { - this.stats.clippingEvents++; - if (this.stats.clippingEvents % 1000 === 1) { - logger.warn(`Clipping détecté sur groupe "${groupName}" (${this.stats.clippingEvents} événements)`); - } - buffer[i] = Math.sign(buffer[i]) * 1.0; // Hard clipping - } - } - }); - - this.stats.framesProcessed++; - return this.groupBuffers; - } - - /** - * ÉTAPE 2 : Traite les buffers de groupe vers les sorties audio physiques - * Distribue chaque groupe vers plusieurs canaux de sortie (avec gains individuels) - * Support du mixage additif si plusieurs groupes vont vers la même sortie - * - * @param {Map} groupBuffersData - Données PCM par groupe (depuis LiveKit) - * @returns {Map} Buffers de sortie par canal physique - */ - processGroupsToOutputs(groupBuffersData) { - // Réinitialise les buffers de sortie - this.outputBuffers.clear(); - - // Pour chaque groupe - groupBuffersData.forEach((pcmData, groupName) => { - const routes = this.groupToOutputRoutes.get(groupName); - - if (!routes || routes.length === 0) { - return; - } - - // Applique chaque route vers les sorties - routes.forEach(route => { - const outputChannel = route.destination; - - // Crée le buffer de sortie si nécessaire - if (!this.outputBuffers.has(outputChannel)) { - this.outputBuffers.set(outputChannel, new Float32Array(this.config.frameSize)); - } - - const outputBuffer = this.outputBuffers.get(outputChannel); - - // Mixage avec gain (additif si canal partagé) - for (let i = 0; i < pcmData.length && i < outputBuffer.length; i++) { - outputBuffer[i] += pcmData[i] * route.linearGain; - } - }); - }); - - // Normalisation anti-clipping sur les sorties - this.outputBuffers.forEach((buffer, channelId) => { - for (let i = 0; i < buffer.length; i++) { - if (Math.abs(buffer[i]) > 1.0) { - this.stats.clippingEvents++; - buffer[i] = Math.sign(buffer[i]) * 1.0; // Hard clipping - } - } - }); - - return this.outputBuffers; - } - - /** - * Récupère le buffer d'un groupe spécifique - */ - getGroupBuffer(groupName) { - return this.groupBuffers.get(groupName) || null; - } - - /** - * Récupère le buffer d'une sortie spécifique - */ - getOutputBuffer(channelId) { - return this.outputBuffers.get(channelId) || null; - } - - /** - * Récupère toutes les routes configurées - */ - getRoutingConfig() { - const inputToGroup = {}; - const groupToOutput = {}; - const gains = {}; - - // Input -> Group - this.inputToGroupRoutes.forEach((routes, key) => { - const inputChannel = key.replace('in_', ''); - inputToGroup[inputChannel] = routes.map(r => r.destination); - - routes.forEach(route => { - if (route.gain !== 0.0) { - gains[`in_${inputChannel}_${route.destination}`] = route.gain; - } - }); - }); - - // Group -> Output - this.groupToOutputRoutes.forEach((routes, groupName) => { - groupToOutput[groupName] = routes.map(r => r.destination); - - routes.forEach(route => { - if (route.gain !== 0.0) { - gains[`${groupName}_out_${route.destination}`] = route.gain; - } - }); - }); - - return { inputToGroup, groupToOutput, gains }; - } - - /** - * Récupère les statistiques - */ - getStats() { - return { - framesProcessed: this.stats.framesProcessed, - clippingEvents: this.stats.clippingEvents, - routesActive: this.stats.routesActive, - inputToGroupRoutes: this.inputToGroupRoutes.size, - groupToOutputRoutes: this.groupToOutputRoutes.size, - activeGroups: this.groupBuffers.size, - activeOutputs: this.outputBuffers.size - }; - } - - /** - * Met à jour le compteur de routes actives - */ - _updateStatsActiveRoutes() { - let count = 0; - this.inputToGroupRoutes.forEach(routes => count += routes.length); - this.groupToOutputRoutes.forEach(routes => count += routes.length); - this.stats.routesActive = count; - } - - /** - * Détruit le router et libère les ressources - */ - destroy() { - this.inputToGroupRoutes.clear(); - this.groupToOutputRoutes.clear(); - this.inputBuffers.clear(); - this.groupBuffers.clear(); - this.outputBuffers.clear(); - this.removeAllListeners(); - logger.info('GroupAudioRouter détruit'); - } -} - -export default GroupAudioRouter; -- 2.52.0 From 06cb6a7dd14e76ad75d3a17a755d7625b6158031 Mon Sep 17 00:00:00 2001 From: Benoit Date: Fri, 3 Jul 2026 14:55:55 +0200 Subject: [PATCH 4/4] refactor: simplifier AudioBridge, filtrer bridge dans PWA, option aucune sortie MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AudioBridge: retire GroupAudioRouter, LiveKitClient, routing per-group - AudioBridgeManager: génère tokens uniquement pour server_audio_users - ServerAudioUser: outputChannel null = pas d'émission outputReady - PWA useLiveKit: filtre les participants role=bridge de la liste - Electron UI: page Routing sans matrices, noms canaux + server audio users - config.yaml: nettoyé (pas de section routing) --- .gitignore | 1 + client/src/hooks/useLiveKit.js | 11 +- electron/main.js | 52 +++- electron/preload.js | 5 + electron/ui/app.js | 392 ++++++++++++---------------- electron/ui/index.html | 56 ++-- electron/ui/styles.css | 138 ++++++++-- server/bridge/AudioBridge.js | 332 +---------------------- server/bridge/AudioBridgeManager.js | 54 +--- server/bridge/ServerAudioUser.js | 10 +- server/config/config.yaml | 57 +--- server/index.js | 20 +- 12 files changed, 397 insertions(+), 731 deletions(-) diff --git a/.gitignore b/.gitignore index cbb1fd9..8cec756 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ pnpm-lock.yaml .env.local .env.*.local server/.env +server/config/config.yaml client/.env # Keep .env.example files (templates) diff --git a/client/src/hooks/useLiveKit.js b/client/src/hooks/useLiveKit.js index 3c8b003..536f13b 100644 --- a/client/src/hooks/useLiveKit.js +++ b/client/src/hooks/useLiveKit.js @@ -283,8 +283,17 @@ export default function useLiveKit() { }); }); - // Participants distants (utilisateurs WebRTC) + // Participants distants (utilisateurs WebRTC + server audio users) + // Exclure les participants internes de routage (role: 'bridge') room.remoteParticipants.forEach((participant) => { + let role = null; + try { + const meta = participant.metadata ? JSON.parse(participant.metadata) : {}; + role = meta.role || null; + } catch (_) {} + + if (role === 'bridge') return; + const audioTracks = participant.audioTracks ? Array.from(participant.audioTracks.values()) : []; const audioPublication = audioTracks[0]; const isSpeaking = room.activeSpeakers.some(s => s.identity === participant.identity); diff --git a/electron/main.js b/electron/main.js index fb815d3..4fade6c 100644 --- a/electron/main.js +++ b/electron/main.js @@ -454,7 +454,7 @@ app.whenReady().then(async () => { if (users.find(u => u.name === name)) { return { success: false, error: `Un utilisateur "${name}" existe déjà` }; } - const user = { name, group, input_channel: parseInt(input_channel), output_channel: parseInt(output_channel) }; + const user = { name, group, input_channel: parseInt(input_channel), output_channel: output_channel !== null && output_channel !== '' ? parseInt(output_channel) : null }; config.server_audio_users = [...users, user]; writeConfig(config); return { success: true, user }; @@ -469,7 +469,7 @@ app.whenReady().then(async () => { const users = config.server_audio_users || []; const idx = users.findIndex(u => u.name === name); if (idx === -1) return { success: false, error: `Utilisateur "${name}" introuvable` }; - config.server_audio_users[idx] = { name, group, input_channel: parseInt(input_channel), output_channel: parseInt(output_channel) }; + config.server_audio_users[idx] = { name, group, input_channel: parseInt(input_channel), output_channel: output_channel !== null && output_channel !== '' ? parseInt(output_channel) : null }; writeConfig(config); return { success: true }; } catch (error) { @@ -497,20 +497,60 @@ app.whenReady().then(async () => { try { const config = readConfig(); return { - routing: config.audio?.routing || { inputToGroup: {}, groupToOutput: {}, gains: {} }, channelNames: config.audio?.channelNames || { inputs: {}, outputs: {} }, - groups: config.groups || [] + groups: config.groups || [], + serverAudioUsers: config.server_audio_users || [] }; } catch (error) { return { error: error.message }; } }); - ipcMain.handle('routing:save', (event, { routing, channelNames }) => { + // ========== Devices : découverte canaux physiques ========== + + ipcMain.handle('devices:getChannels', () => { + try { + const config = readConfig(); + const inputDeviceName = config.audio?.device?.inputDeviceId; + const outputDeviceName = config.audio?.device?.outputDeviceId; + + let inputDevice = { name: inputDeviceName || 'Non configuré', channels: 0 }; + let outputDevice = { name: outputDeviceName || 'Non configuré', channels: 0 }; + + if (process.platform === 'darwin') { + try { + const { execSync } = require('child_process'); + const raw = execSync('system_profiler SPAudioDataType -json', { encoding: 'utf8', timeout: 5000 }); + const data = JSON.parse(raw); + + if (data.SPAudioDataType) { + data.SPAudioDataType.forEach(item => { + (item._items || []).forEach(dev => { + const name = dev._name || ''; + const inCh = parseInt(dev.coreaudio_device_input) || 0; + const outCh = parseInt(dev.coreaudio_device_output) || 0; + if (inputDeviceName && name === inputDeviceName && inCh > 0) { + inputDevice = { name, channels: inCh }; + } + if (outputDeviceName && name === outputDeviceName && outCh > 0) { + outputDevice = { name, channels: outCh }; + } + }); + }); + } + } catch (_) { /* detection failed, keep defaults */ } + } + + return { inputDevice, outputDevice }; + } catch (error) { + return { error: error.message, inputDevice: { name: 'Inconnu', channels: 0 }, outputDevice: { name: 'Inconnu', channels: 0 } }; + } + }); + + ipcMain.handle('routing:save', (event, { channelNames }) => { try { const config = readConfig(); if (!config.audio) config.audio = {}; - config.audio.routing = routing; config.audio.channelNames = channelNames; writeConfig(config); return { success: true }; diff --git a/electron/preload.js b/electron/preload.js index 920b449..4900864 100644 --- a/electron/preload.js +++ b/electron/preload.js @@ -65,6 +65,11 @@ contextBridge.exposeInMainWorld('electronAPI', { save: (data) => ipcRenderer.invoke('routing:save', data) }, + // Découverte canaux physiques de la carte son sélectionnée + devices: { + getChannels: () => ipcRenderer.invoke('devices:getChannels') + }, + // Helpers platform: process.platform, version: process.env.npm_package_version || '0.3.0' diff --git a/electron/ui/app.js b/electron/ui/app.js index c0046c0..49c96a5 100644 --- a/electron/ui/app.js +++ b/electron/ui/app.js @@ -10,6 +10,7 @@ let statsInterval = null; let logsBuffer = []; let audioLevelsWS = null; let routingData = null; +let deviceChannels = null; let audioLevelsData = { inputs: {}, groups: {}, @@ -585,7 +586,6 @@ async function loadViewData(view) { } if (view === 'config') { - await fetchServerAudioUsers(); if (serverRunning) { await fetchDevices(); await fetchConfig(); @@ -790,94 +790,119 @@ document.addEventListener('DOMContentLoaded', () => { }); } - // Bouton ajouter utilisateur audio serveur - const btnAddSAU = document.getElementById('btn-add-server-audio-user'); - if (btnAddSAU) { - btnAddSAU.addEventListener('click', addServerAudioUser); - } - - // Délégation modifier/supprimer utilisateur audio serveur - const sauList = document.getElementById('server-audio-users-list'); - if (sauList) { - sauList.addEventListener('click', async (e) => { - const btn = e.target.closest('[data-sau-action]'); - if (!btn) return; - const action = btn.dataset.sauAction; - const name = btn.dataset.sauName; - if (action === 'edit') { - await editServerAudioUser(name, btn.dataset.sauGroup, parseInt(btn.dataset.sauInput), parseInt(btn.dataset.sauOutput)); - } else if (action === 'delete') { - await deleteServerAudioUser(name); - } - }); - } - // Boutons routing document.getElementById('btn-save-routing')?.addEventListener('click', saveRouting); document.getElementById('btn-reload-routing')?.addEventListener('click', fetchRouting); - document.getElementById('btn-add-input-channel')?.addEventListener('click', () => addChannelRow('input')); - document.getElementById('btn-add-output-channel')?.addEventListener('click', () => addChannelRow('output')); + document.getElementById('btn-refresh-channels')?.addEventListener('click', fetchRouting); + document.getElementById('btn-add-server-audio-user')?.addEventListener('click', addServerAudioUser); - // Délégation suppression lignes canal (boutons ✕) - document.addEventListener('click', (e) => { - const btn = e.target.closest('.channel-name-delete'); + // Délégation modifier/supprimer participant serveur + document.getElementById('server-audio-users-list')?.addEventListener('click', async (e) => { + const btn = e.target.closest('[data-sau-action]'); if (!btn) return; - deleteChannelRow(btn.dataset.dir, btn.dataset.channel); + const action = btn.dataset.sauAction; + const name = btn.dataset.sauName; + if (action === 'edit') { + await editServerAudioUser(name, btn.dataset.sauGroup, parseInt(btn.dataset.sauInput), parseInt(btn.dataset.sauOutput)); + } else if (action === 'delete') { + await deleteServerAudioUser(name); + } }); }); -// ========== Server Audio Users ========== +// ========== Server Audio Users (dans la vue Routing) ========== -async function fetchServerAudioUsers() { +function renderServerAudioUsers() { const container = document.getElementById('server-audio-users-list'); if (!container) return; - const data = await window.electronAPI.serverAudioUsers.list(); + const users = routingData?.serverAudioUsers || []; + const inputs = routingData?.channelNames?.inputs || {}; + const outputs = routingData?.channelNames?.outputs || {}; - const serverNote = serverRunning ? '' : - '

Serveur arrêté — les modifications seront appliquées au prochain démarrage.

'; + const chLabel = (ch, dir) => { + if (ch === null || ch === undefined) return 'Aucune'; + const name = dir === 'input' ? inputs[ch] : outputs[ch]; + return name ? `Ch ${ch} · ${name}` : `Ch ${ch}`; + }; - if (!data.users || data.users.length === 0) { - container.innerHTML = serverNote + '

Aucun utilisateur audio serveur configuré

'; + if (users.length === 0) { + container.innerHTML = '

Aucun participant serveur configuré

'; return; } - container.innerHTML = serverNote + data.users.map(user => ` -
-
-

${escapeHtml(user.name)}

-

Groupe: ${escapeHtml(user.group)} · Entrée: canal ${user.input_channel} · Sortie: canal ${user.output_channel}

-
-
- - -
-
- `).join(''); + container.innerHTML = ` + + + + + + ${users.map(u => ` + + + + + + + `).join('')} + +
NomGroupeEntréeSortie
${escapeHtml(u.name)}${escapeHtml(u.group)}${chLabel(u.input_channel, 'input')}${chLabel(u.output_channel, 'output')} + + +
`; +} + +function buildChannelOptions(dir) { + const channels = dir === 'input' + ? routingData?.deviceChannels?.inputDevice?.channels || 0 + : routingData?.deviceChannels?.outputDevice?.channels || 0; + const names = dir === 'input' + ? routingData?.channelNames?.inputs || {} + : routingData?.channelNames?.outputs || {}; + + if (channels === 0) return null; // fallback to number input + + const opts = Array.from({ length: channels }, (_, i) => ({ + value: String(i), + label: names[i] ? `Ch ${i}: ${names[i]}` : `Ch ${i}` + })); + + if (dir === 'output') { + opts.unshift({ value: '', label: 'Aucune sortie' }); + } + + return opts; } async function addServerAudioUser() { const groupsData = await window.electronAPI.groups.list(); - const groupOptions = (groupsData.groups || []).map(g => ({ - value: slugify(g.name), - label: g.name - })); + const groupOptions = (groupsData.groups || []).map(g => ({ value: slugify(g.name), label: g.name })); const defaultGroup = groupOptions[0]?.value || 'default'; + const inOpts = buildChannelOptions('input'); + const outOpts = buildChannelOptions('output'); + + const inputField = inOpts + ? { name: 'input_channel', label: 'Canal d\'entrée', type: 'select', options: inOpts, default: '0' } + : { name: 'input_channel', label: 'Canal entrée (index)', type: 'number', default: 0, min: 0, max: 63 }; + const outputField = outOpts + ? { name: 'output_channel', label: 'Canal de sortie', type: 'select', options: outOpts, default: '' } + : { name: 'output_channel', label: 'Canal sortie (index, vide = aucune)', type: 'number', default: '', min: 0, max: 63 }; + const result = await showModal({ - title: 'Nouvel utilisateur audio serveur', + title: 'Nouveau participant serveur', fields: [ { name: 'name', label: 'Nom (identifiant unique, ex: foh)' }, { name: 'group', label: 'Groupe', type: 'select', options: groupOptions, default: defaultGroup }, - { name: 'input_channel', label: 'Canal entrée (index physique)', type: 'number', default: 0, min: 0, max: 63, step: 1 }, - { name: 'output_channel', label: 'Canal sortie (index physique)', type: 'number', default: 0, min: 0, max: 63, step: 1 } + inputField, + outputField ], confirmLabel: 'Ajouter' }); @@ -888,12 +913,12 @@ async function addServerAudioUser() { name: result.name.trim(), group: result.group, input_channel: parseInt(result.input_channel), - output_channel: parseInt(result.output_channel) + output_channel: result.output_channel !== '' ? parseInt(result.output_channel) : null }); if (res.success) { - showNotification('Utilisateur audio serveur ajouté', 'success'); - await fetchServerAudioUsers(); + showNotification('Participant serveur ajouté', 'success'); + await fetchRouting(); } else { showNotification('Erreur: ' + (res.error || 'Création échouée'), 'error'); } @@ -901,17 +926,25 @@ async function addServerAudioUser() { async function editServerAudioUser(name, group, input_channel, output_channel) { const groupsData = await window.electronAPI.groups.list(); - const groupOptions = (groupsData.groups || []).map(g => ({ - value: slugify(g.name), - label: g.name - })); + const groupOptions = (groupsData.groups || []).map(g => ({ value: slugify(g.name), label: g.name })); + + const inOpts = buildChannelOptions('input'); + const outOpts = buildChannelOptions('output'); + + const inputField = inOpts + ? { name: 'input_channel', label: 'Canal d\'entrée', type: 'select', options: inOpts, default: String(input_channel) } + : { name: 'input_channel', label: 'Canal entrée (index)', type: 'number', default: input_channel, min: 0, max: 63 }; + const outputDefault = output_channel !== null && output_channel !== undefined ? String(output_channel) : ''; + const outputField = outOpts + ? { name: 'output_channel', label: 'Canal de sortie', type: 'select', options: outOpts, default: outputDefault } + : { name: 'output_channel', label: 'Canal sortie (index, vide = aucune)', type: 'number', default: outputDefault, min: 0, max: 63 }; const result = await showModal({ title: `Modifier "${name}"`, fields: [ { name: 'group', label: 'Groupe', type: 'select', options: groupOptions, default: group }, - { name: 'input_channel', label: 'Canal entrée (index physique)', type: 'number', default: input_channel, min: 0, max: 63, step: 1 }, - { name: 'output_channel', label: 'Canal sortie (index physique)', type: 'number', default: output_channel, min: 0, max: 63, step: 1 } + inputField, + outputField ], confirmLabel: 'Modifier' }); @@ -922,12 +955,12 @@ async function editServerAudioUser(name, group, input_channel, output_channel) { name, group: result.group, input_channel: parseInt(result.input_channel), - output_channel: parseInt(result.output_channel) + output_channel: result.output_channel !== '' ? parseInt(result.output_channel) : null }); if (res.success) { - showNotification('Utilisateur audio serveur modifié', 'success'); - await fetchServerAudioUsers(); + showNotification('Participant serveur modifié', 'success'); + await fetchRouting(); } else { showNotification('Erreur: ' + (res.error || 'Modification échouée'), 'error'); } @@ -935,7 +968,7 @@ async function editServerAudioUser(name, group, input_channel, output_channel) { async function deleteServerAudioUser(name) { const confirmed = await showModal({ - title: 'Supprimer l\'utilisateur audio serveur', + title: 'Supprimer le participant serveur', message: `Supprimer "${name}" ? Cette action est irréversible.`, confirmLabel: 'Supprimer', confirmClass: 'btn-danger' @@ -946,8 +979,8 @@ async function deleteServerAudioUser(name) { const res = await window.electronAPI.serverAudioUsers.delete({ name }); if (res.success) { - showNotification('Utilisateur audio serveur supprimé', 'success'); - await fetchServerAudioUsers(); + showNotification('Participant serveur supprimé', 'success'); + await fetchRouting(); } else { showNotification('Erreur: ' + (res.error || 'Suppression échouée'), 'error'); } @@ -956,161 +989,83 @@ async function deleteServerAudioUser(name) { // ========== Routing ========== async function fetchRouting() { - const data = await window.electronAPI.routing.get(); - if (data.error) { - showNotification('Erreur chargement routing: ' + data.error, 'error'); + const [routingResult, devResult] = await Promise.all([ + window.electronAPI.routing.get(), + window.electronAPI.devices.getChannels() + ]); + + if (routingResult.error) { + showNotification('Erreur chargement routing: ' + routingResult.error, 'error'); return; } - routingData = data; + + deviceChannels = devResult; + routingData = { ...routingResult, deviceChannels: devResult }; renderRoutingView(); } function renderRoutingView() { if (!routingData) return; - renderChannelNamesEditor(); - renderRoutingMatrices(); + renderDeviceBanner(); + renderChannelLabels(); + renderServerAudioUsers(); } -function renderChannelNamesEditor() { - const { channelNames } = routingData; +function renderDeviceBanner() { + const el = document.getElementById('routing-device-info'); + if (!el) return; + + const dc = routingData.deviceChannels; + if (!dc || dc.error || (dc.inputDevice?.channels === 0 && dc.outputDevice?.channels === 0)) { + el.innerHTML = `Aucun device configuré — sélectionnez une carte son dans Configuration`; + return; + } + + const { inputDevice, outputDevice } = dc; + el.innerHTML = ` + Entrée : ${escapeHtml(inputDevice.name)} + ${inputDevice.channels} ch + · + Sortie : ${escapeHtml(outputDevice.name)} + ${outputDevice.channels} ch`; +} + +function renderChannelLabels() { + const { channelNames, deviceChannels: dc } = routingData; const inputs = channelNames?.inputs || {}; const outputs = channelNames?.outputs || {}; + const inputCount = dc?.inputDevice?.channels || 0; + const outputCount = dc?.outputDevice?.channels || 0; - const inputChannels = Object.keys(inputs).sort((a, b) => parseInt(a) - parseInt(b)); - const outputChannels = Object.keys(outputs).sort((a, b) => parseInt(a) - parseInt(b)); + const inputsEl = document.getElementById('channel-names-inputs'); + const outputsEl = document.getElementById('channel-names-outputs'); - const inputsContainer = document.getElementById('channel-names-inputs'); - const outputsContainer = document.getElementById('channel-names-outputs'); - - if (inputsContainer) { - inputsContainer.innerHTML = inputChannels.length > 0 - ? inputChannels.map(ch => channelNameRow('input', ch, inputs[ch] || '')).join('') - : '

Aucun canal d\'entrée défini.

'; + if (inputsEl) { + inputsEl.innerHTML = inputCount > 0 + ? Array.from({ length: inputCount }, (_, i) => channelLabelRow('input', i, inputs[i] || '')).join('') + : '

Aucun device d\'entrée détecté.

'; } - if (outputsContainer) { - outputsContainer.innerHTML = outputChannels.length > 0 - ? outputChannels.map(ch => channelNameRow('output', ch, outputs[ch] || '')).join('') - : '

Aucun canal de sortie défini.

'; + if (outputsEl) { + outputsEl.innerHTML = outputCount > 0 + ? Array.from({ length: outputCount }, (_, i) => channelLabelRow('output', i, outputs[i] || '')).join('') + : '

Aucun device de sortie détecté.

'; } } -function channelNameRow(dir, ch, name) { +function channelLabelRow(dir, ch, name) { return `
- Canal ${ch} + Ch ${ch} - + value="${escapeHtml(name)}" placeholder="Label canal ${ch}">
`; } -function addChannelRow(dir) { - const containerId = dir === 'input' ? 'channel-names-inputs' : 'channel-names-outputs'; - const container = document.getElementById(containerId); - if (!container) return; - - // Retirer le message "Aucun canal" si présent - const emptyMsg = container.querySelector('.config-note'); - if (emptyMsg) emptyMsg.remove(); - - const existing = Array.from(container.querySelectorAll('.channel-name-row')) - .map(r => parseInt(r.dataset.channel)) - .filter(n => !isNaN(n)); - const nextCh = existing.length > 0 ? Math.max(...existing) + 1 : 0; - - const row = document.createElement('div'); - row.innerHTML = channelNameRow(dir, nextCh, ''); - const newRow = row.firstElementChild; - container.appendChild(newRow); - newRow.querySelector('input')?.focus(); -} - -function deleteChannelRow(dir, channel) { - const containerId = dir === 'input' ? 'channel-names-inputs' : 'channel-names-outputs'; - const container = document.getElementById(containerId); - const row = container?.querySelector(`.channel-name-row[data-channel="${channel}"][data-dir="${dir}"]`); - if (!row) return; - row.remove(); - if (!container.querySelector('.channel-name-row')) { - container.innerHTML = `

Aucun canal de ${dir === 'input' ? 'entrée' : 'sortie'} défini.

`; - } -} - -function renderRoutingMatrices() { - const { routing, channelNames, groups } = routingData; - const inputs = channelNames?.inputs || {}; - const outputs = channelNames?.outputs || {}; - const inputToGroup = routing?.inputToGroup || {}; - const groupToOutput = routing?.groupToOutput || {}; - - const inputChannels = Object.keys(inputs).sort((a, b) => parseInt(a) - parseInt(b)); - const outputChannels = Object.keys(outputs).sort((a, b) => parseInt(a) - parseInt(b)); - - // Matrice Entrées → Groupes - const inputMatrixEl = document.getElementById('routing-input-matrix'); - if (inputMatrixEl) { - if (inputChannels.length === 0 || groups.length === 0) { - inputMatrixEl.innerHTML = '

Définissez des canaux d\'entrée et des groupes pour configurer le routing.

'; - } else { - let html = '
'; - html += ''; - groups.forEach(g => { html += ``; }); - html += ''; - - inputChannels.forEach(ch => { - const chGroups = inputToGroup[ch] || []; - html += ''; - html += ``; - groups.forEach(g => { - const gId = slugify(g.name); - const checked = chGroups.includes(gId) ? 'checked' : ''; - html += ``; - }); - html += ''; - }); - - html += '
Canal Entrée${escapeHtml(g.name)}
${ch}${escapeHtml(inputs[ch] || `Canal ${ch}`)}
'; - inputMatrixEl.innerHTML = html; - } - } - - // Matrice Groupes → Sorties - const outputMatrixEl = document.getElementById('routing-output-matrix'); - if (outputMatrixEl) { - if (groups.length === 0 || outputChannels.length === 0) { - outputMatrixEl.innerHTML = '

Définissez des canaux de sortie et des groupes pour configurer le routing.

'; - } else { - let html = '
'; - html += ''; - outputChannels.forEach(ch => { - html += ``; - }); - html += ''; - - groups.forEach(g => { - const gId = slugify(g.name); - const gOutputs = groupToOutput[gId] || []; - html += ''; - html += ``; - outputChannels.forEach(ch => { - const checked = gOutputs.includes(ch) ? 'checked' : ''; - html += ``; - }); - html += ''; - }); - - html += '
Groupe${ch}
${escapeHtml(outputs[ch] || `Canal ${ch}`)}
${escapeHtml(g.name)}
'; - outputMatrixEl.innerHTML = html; - } - } -} - async function saveRouting() { if (!routingData) return; - // Collecter les noms de canaux depuis le DOM const newChannelNames = { inputs: {}, outputs: {} }; document.querySelectorAll('.channel-name-input[data-dir="input"]').forEach(el => { newChannelNames.inputs[el.dataset.channel] = el.value; @@ -1119,37 +1074,12 @@ async function saveRouting() { newChannelNames.outputs[el.dataset.channel] = el.value; }); - // Collecter le routing depuis les checkboxes - const newInputToGroup = {}; - const newGroupToOutput = {}; - - document.querySelectorAll('.routing-check[data-direction="input"]:checked').forEach(cb => { - const ch = cb.dataset.channel; - if (!newInputToGroup[ch]) newInputToGroup[ch] = []; - if (!newInputToGroup[ch].includes(cb.dataset.group)) newInputToGroup[ch].push(cb.dataset.group); - }); - - document.querySelectorAll('.routing-check[data-direction="output"]:checked').forEach(cb => { - const g = cb.dataset.group; - if (!newGroupToOutput[g]) newGroupToOutput[g] = []; - if (!newGroupToOutput[g].includes(cb.dataset.channel)) newGroupToOutput[g].push(cb.dataset.channel); - }); - - const newRouting = { - ...(routingData.routing || {}), - inputToGroup: newInputToGroup, - groupToOutput: newGroupToOutput - }; - - const result = await window.electronAPI.routing.save({ routing: newRouting, channelNames: newChannelNames }); + const result = await window.electronAPI.routing.save({ channelNames: newChannelNames }); if (result.success) { - routingData.routing = newRouting; routingData.channelNames = newChannelNames; renderRoutingView(); - showNotification('Routing sauvegardé', 'success'); - const note = document.getElementById('routing-server-note'); - if (note) note.classList.remove('hidden'); + showNotification('Noms de canaux sauvegardés', 'success'); } else { showNotification('Erreur: ' + (result.error || 'Sauvegarde échouée'), 'error'); } diff --git a/electron/ui/index.html b/electron/ui/index.html index ad8ee0a..c1c5326 100644 --- a/electron/ui/index.html +++ b/electron/ui/index.html @@ -126,15 +126,6 @@
-
-

🔗 Utilisateurs Audio Serveur

-

Participants LiveKit côté serveur avec canaux physiques d'entrée et de sortie dédiés (mix-minus natif). Voir aussi la page Routing pour le câblage entre canaux et groupes.

- -
-

Chargement...

-
-
-

💾 Sauvegarde de configuration

@@ -179,25 +170,27 @@

Routing Audio

- +
-

🏷️ Noms des Canaux

-

Définissez les canaux physiques disponibles. Les matrices de routing se mettent à jour après la sauvegarde.

+
+
Chargement...
+ +
+
+ + +
+

🏷️ Canaux Physiques

+

Labels des canaux de votre carte son. La liste est déterminée par le device sélectionné en Configuration.

-
-

Canaux Entrée

- -
+

Entrées

Chargement...

-
-

Canaux Sortie

- -
+

Sorties

Chargement...

@@ -205,29 +198,18 @@
- +
-

🎙️ Entrées → Groupes

-

Quels canaux physiques d'entrée alimentent quels groupes LiveKit.

-
-

Chargement...

-
-
- - -
-

🔊 Groupes → Sorties

-

Vers quels canaux physiques de sortie chaque groupe est routé.

-
-

Chargement...

-
+

🎙️ Participants Serveur

+

Participants LiveKit côté serveur — chaque ligne branche des canaux physiques d'E/S sur un groupe.

+ +
- + -
diff --git a/electron/ui/styles.css b/electron/ui/styles.css index a9889d5..066949a 100644 --- a/electron/ui/styles.css +++ b/electron/ui/styles.css @@ -893,7 +893,54 @@ body { background: rgba(74, 158, 255, 0.05); } -/* Channel Names Editor */ +/* Device banner */ + +.routing-device-banner { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + padding: 0.75rem 1rem; + background: var(--bg-tertiary); + border: 1px solid var(--border-color); + border-radius: 6px; +} + +.routing-device-info { + display: flex; + align-items: center; + gap: 1rem; + flex-wrap: wrap; + font-size: 0.875rem; +} + +.device-entry { + display: flex; + align-items: center; + gap: 0.4rem; +} + +.device-sep { + color: var(--text-secondary); +} + +.device-ch-badge { + display: inline-block; + background: var(--accent-primary); + color: #fff; + font-size: 0.7rem; + font-weight: 600; + padding: 0.1rem 0.4rem; + border-radius: 10px; + letter-spacing: 0.02em; +} + +.device-unset { + color: var(--text-secondary); + font-size: 0.875rem; +} + +/* Channel labels */ .channel-names-grid { display: grid; @@ -901,21 +948,14 @@ body { gap: 2rem; } -.channel-names-col-header { - display: flex; - align-items: center; - justify-content: space-between; - margin-bottom: 0.75rem; - padding-bottom: 0.5rem; - border-bottom: 1px solid var(--border-color); -} - -.channel-names-col-header h4 { - font-size: 0.875rem; +.channel-names-col h4 { + font-size: 0.8125rem; color: var(--text-secondary); text-transform: uppercase; letter-spacing: 0.06em; - margin: 0; + margin: 0 0 0.75rem; + padding-bottom: 0.5rem; + border-bottom: 1px solid var(--border-color); } .channel-names-list { @@ -931,13 +971,79 @@ body { } .channel-index { - min-width: 58px; - font-size: 0.8125rem; - color: var(--text-secondary); + min-width: 40px; + font-size: 0.8rem; + color: var(--accent-primary); font-family: 'Courier New', monospace; + font-weight: 600; flex-shrink: 0; } +/* Server Audio Users table */ + +.sau-list { + margin-top: 1rem; +} + +.sau-table { + width: 100%; + border-collapse: collapse; + font-size: 0.875rem; + margin-top: 0.75rem; +} + +.sau-table th { + background: var(--bg-tertiary); + padding: 0.5rem 0.75rem; + text-align: left; + font-size: 0.8rem; + color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: 0.05em; + border-bottom: 1px solid var(--border-color); +} + +.sau-table td { + padding: 0.5rem 0.75rem; + border-bottom: 1px solid var(--border-color); + vertical-align: middle; +} + +.sau-table tbody tr:hover td { + background: rgba(74, 158, 255, 0.04); +} + +.sau-name { + font-weight: 600; + color: var(--text-primary); +} + +.sau-actions { + text-align: right; + white-space: nowrap; + display: flex; + gap: 0.4rem; + justify-content: flex-end; +} + +.ch-badge { + display: inline-block; + background: var(--bg-tertiary); + border: 1px solid var(--border-color); + color: var(--text-secondary); + font-size: 0.78rem; + padding: 0.1rem 0.5rem; + border-radius: 4px; + font-family: 'Courier New', monospace; +} + +.ch-badge-group { + background: rgba(74, 158, 255, 0.12); + border-color: rgba(74, 158, 255, 0.3); + color: var(--accent-primary); + font-family: inherit; +} + /* Routing actions bar */ .routing-actions { diff --git a/server/bridge/AudioBridge.js b/server/bridge/AudioBridge.js index b2c1856..a32eb50 100644 --- a/server/bridge/AudioBridge.js +++ b/server/bridge/AudioBridge.js @@ -17,8 +17,6 @@ import JACKBackend from './backends/JACKBackend.js'; import PipeWireBackend from './backends/PipeWireBackend.js'; import OpusCodec, { OpusPresets } from './OpusCodec.js'; import JitterBuffer, { JitterBufferPresets } from './JitterBuffer.js'; -import LiveKitClient from './LiveKitClient.js'; -import GroupAudioRouter from './GroupAudioRouter.js'; import ServerAudioUser from './ServerAudioUser.js'; export class AudioBridge extends EventEmitter { @@ -55,8 +53,6 @@ export class AudioBridge extends EventEmitter { this.opusEncoder = null; this.opusDecoder = null; this.jitterBuffer = null; - this.liveKitClients = new Map(); // Map - un client par groupe - this.groupAudioRouter = null; // État this.isRunning = false; @@ -64,10 +60,6 @@ export class AudioBridge extends EventEmitter { // Buffers pour routing multi-canaux this.inputChannelBuffers = new Map(); // Map - this.groupBuffersFromLiveKit = new Map(); // Map - - // Frame accumulators pour LiveKit (240 samples → 960 samples) - this.liveKitFrameAccumulators = new Map(); // Map // Utilisateurs audio gérés côté serveur (participants LiveKit avec I/O physique dédiés) this.serverAudioUsers = new Map(); // Map @@ -118,16 +110,10 @@ export class AudioBridge extends EventEmitter { // 3. Initialisation du jitter buffer this._initJitterBuffer(); - // 4. Initialisation du GroupAudioRouter - this._initGroupAudioRouter(); - - // 5. Connexion à LiveKit - await this._initLiveKit(); - - // 6. Initialisation des server audio users + // 4. Initialisation des server audio users await this._initServerAudioUsers(); - // 7. Démarrage du routing audio + // 5. Démarrage du routing audio await this._startAudioRouting(); this.isRunning = true; @@ -307,92 +293,6 @@ export class AudioBridge extends EventEmitter { console.log(`✓ Jitter buffer : cible ${bufferConfig.targetSize} frames`); } - /** - * Initialise le GroupAudioRouter pour le routing multi-canaux - * @private - */ - _initGroupAudioRouter() { - this.groupAudioRouter = new GroupAudioRouter({ - sampleRate: this.options.sampleRate, - frameSize: this.options.frameSize, - maxInputChannels: this.options.maxInputChannels || 32, - maxOutputChannels: this.options.maxOutputChannels || 32, - groups: this.options.groups || [] - }); - - // Charger la configuration de routing depuis les options - if (this.options.routing) { - this.groupAudioRouter.configure(this.options.routing); - } - - // Events du router - this.groupAudioRouter.on('configured', (stats) => { - console.log(`✓ GroupAudioRouter configuré : ${stats.routesActive} routes`); - }); - - console.log('✓ GroupAudioRouter initialisé'); - } - - /** - * Initialise les connexions LiveKit (une par groupe) - * @private - */ - async _initLiveKit() { - if (!this.options.liveKitTokens || !Array.isArray(this.options.liveKitTokens)) { - throw new Error('liveKitTokens requis (tableau d\'objets { groupName, groupId, token })'); - } - - 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, - roomName, - participantName: `AudioBridge-${groupId}`, - sampleRate: this.options.sampleRate, - channels: this.options.channels, - audioBitrate: this.opusEncoder.options.bitrate - }); - - // Events LiveKit pour ce groupe - client.on('connected', () => { - console.log(`✓ LiveKit connecté pour groupe "${groupName}" (room: ${roomName})`); - }); - - client.on('disconnected', (data) => { - const reason = data?.reason || 'unknown'; - console.warn(`⚠️ LiveKit déconnecté pour groupe "${groupName}":`, reason); - this.stats.errors.network++; - }); - - client.on('reconnecting', () => { - console.log(`🔄 LiveKit reconnexion pour groupe "${groupName}"...`); - }); - - client.on('audioTrackSubscribed', ({ track, participant }) => { - console.log(`🎵 Nouveau track audio dans groupe "${groupName}": ${participant.identity}`); - }); - - // Réception audio depuis les clients LiveKit de ce groupe - client.on('audioData', ({ participantName, pcmData, sampleRate, channels }) => { - // Router vers le bon groupe - this.emit('groupAudioIn', { groupName: groupId, pcmBuffer: pcmData }); - }); - - // Connexion - await client.connect(); - - // Stocker le client par groupId - this.liveKitClients.set(groupId, client); - } - - console.log(`✓ ${this.liveKitClients.size} connexions LiveKit établies`); - } - /** * Initialise les utilisateurs audio serveur (participants LiveKit avec I/O physique) * @private @@ -445,44 +345,31 @@ export class AudioBridge extends EventEmitter { } /** - * Démarre le routing audio bidirectionnel complet + * Démarre le routing audio : capture physique → server audio users * @private */ async _startAudioRouting() { - console.log('🔄 Démarrage routing audio bidirectionnel...'); + console.log('🔄 Démarrage routing audio...'); - // ===== FLUX 1 : CAPTURE (Carte Son → Groupes → LiveKit → Clients) ===== this.audioBackend.on('audioData', (pcmData) => { try { - // Convertir PCM Buffer → Float32Array (pour GroupAudioRouter) const float32Data = this._bufferToFloat32(pcmData); - - // Séparer les canaux si audio multi-canaux (entrelacé) const numChannels = this.options.channels || 1; if (numChannels === 1) { - // Mono : un seul canal - const channelId = this.options.inputDeviceChannel || 0; - this.inputChannelBuffers.set(channelId, float32Data); + this.inputChannelBuffers.set(0, float32Data); } else { - // Multi-canaux : dé-entrelacer les samples - // Format entrelacé : [L0, R0, L1, R1, ...] → [L0, L1, ...] et [R0, R1, ...] const samplesPerChannel = float32Data.length / numChannels; - for (let ch = 0; ch < numChannels; ch++) { const channelBuffer = new Float32Array(samplesPerChannel); - for (let i = 0; i < samplesPerChannel; i++) { channelBuffer[i] = float32Data[i * numChannels + ch]; } - - // Mapper canal hardware → canal logique (peut être configuré) - const logicalChannelId = this.options.channelMapping?.[ch] ?? ch; - this.inputChannelBuffers.set(logicalChannelId, channelBuffer); + this.inputChannelBuffers.set(ch, channelBuffer); } } - // ÉTAPE 0 : Envoyer les données de chaque canal vers les server audio users + // Alimenter chaque server audio user avec son canal d'entrée for (const [, user] of this.serverAudioUsers) { const channelData = this.inputChannelBuffers.get(user.inputChannel); if (channelData) { @@ -490,206 +377,18 @@ export class AudioBridge extends EventEmitter { } } - // ÉTAPE 1 : Inputs physiques → Groupes (via GroupAudioRouter) - const groupBuffers = this.groupAudioRouter.processInputsToGroups( - this.inputChannelBuffers - ); - - if (this.stats.framesCapture % 100 === 0) { - // Détecter si l'audio est du silence (toutes les samples < 0.001) - let totalEnergy = 0; - this.inputChannelBuffers.forEach((buffer) => { - for (let i = 0; i < buffer.length; i++) { - totalEnergy += Math.abs(buffer[i]); - } - }); - const avgEnergy = totalEnergy / (this.inputChannelBuffers.size * (this.options.frameSize || 960)); - console.log(`[AudioBridge] Frame ${this.stats.framesCapture}: ${this.inputChannelBuffers.size} inputs → ${groupBuffers.size} groupes | Énergie audio: ${avgEnergy.toFixed(6)}`); - } - - // ÉTAPE 2 : Pour chaque groupe, envoyer vers le LiveKitClient correspondant - groupBuffers.forEach((groupBuffer, groupName) => { - // Les groupes sont MONO (Float32Array de N samples) - // Mais la config globale peut être STÉRÉO (channels=2) - // → Adapter selon la configuration - - let pcmBuffer; - const configChannels = this.options.channels || 1; - - if (configChannels === 1) { - // Config MONO : envoyer directement - pcmBuffer = this._float32ToBuffer(groupBuffer); - } else if (configChannels === 2) { - // Config STÉRÉO : dupliquer le canal mono - const samplesPerChannel = groupBuffer.length; - const stereoBuffer = new Float32Array(samplesPerChannel * 2); - - // Entrelacer : [M0, M1, M2, ...] → [M0, M0, M1, M1, M2, M2, ...] - for (let i = 0; i < samplesPerChannel; i++) { - stereoBuffer[i * 2] = groupBuffer[i]; // Canal gauche - stereoBuffer[i * 2 + 1] = groupBuffer[i]; // Canal droit (dupliqué) - } - - pcmBuffer = this._float32ToBuffer(stereoBuffer); - } else { - console.error(`❌ Nombre de canaux non supporté: ${configChannels}`); - return; - } - - // Récupérer le client LiveKit pour ce groupe - const client = this.liveKitClients.get(groupName); - - // Envoi vers LiveKit via sendAudioData (prend du PCM 16-bit) - // Note: LiveKit gère lui-même l'encodage Opus en interne - if (client && client.isConnected) { - client.sendAudioData(pcmBuffer); - if (this.stats.framesCapture % 100 === 0) { - const channelLabel = configChannels === 1 ? 'mono' : `${configChannels}ch`; - console.log(`[AudioBridge] → LiveKit groupe "${groupName}": ${pcmBuffer.length} bytes (${channelLabel})`); - } - } else { - if (this.stats.framesCapture % 100 === 0) { - console.log(`[AudioBridge] ⚠️ LiveKit non connecté pour groupe "${groupName}", audio non envoyé`); - } - } - - // Émettre aussi pour monitoring/debug - this.emit('groupAudioOut', { groupName, pcmBuffer }); - }); - - // ÉTAPE 3 : Loopback local - Groupes → Outputs physiques (sans passer par LiveKit) - const outputBuffers = this.groupAudioRouter.processGroupsToOutputs(groupBuffers); - - if (this.stats.framesCapture % 100 === 0) { - console.log(`[AudioBridge] Loopback local: ${groupBuffers.size} groupes → ${outputBuffers.size} outputs`); - } - - // ÉTAPE 4 : Envoyer chaque output à la carte son - const numOutputChannels = this.options.channels || 1; - - if (numOutputChannels === 1) { - // Mono : un seul output - if (outputBuffers.size > 0) { - const [firstChannelId, outputBuffer] = outputBuffers.entries().next().value; - const pcmBuffer = this._float32ToBuffer(outputBuffer); - this.audioBackend.queueAudio(pcmBuffer); - - if (this.stats.framesCapture % 100 === 0) { - console.log(`[AudioBridge] → Output mono (canal ${firstChannelId}): ${pcmBuffer.length} bytes`); - } - } - } else { - // Multi-canaux : entrelacer les samples - // Récupérer les buffers dans l'ordre des canaux hardware - const channelBuffers = []; - const samplesPerChannel = this.options.frameSize; - - for (let ch = 0; ch < numOutputChannels; ch++) { - const logicalChannelId = this.options.channelMapping?.[ch] ?? ch; - const buffer = outputBuffers.get(logicalChannelId); - - if (buffer && buffer.length === samplesPerChannel) { - channelBuffers.push(buffer); - } else { - // Canal absent ou taille incorrecte : silence - channelBuffers.push(new Float32Array(samplesPerChannel)); - } - } - - // Entrelacer : [L0, L1, ...] et [R0, R1, ...] → [L0, R0, L1, R1, ...] - const interleavedBuffer = new Float32Array(samplesPerChannel * numOutputChannels); - - for (let i = 0; i < samplesPerChannel; i++) { - for (let ch = 0; ch < numOutputChannels; ch++) { - interleavedBuffer[i * numOutputChannels + ch] = channelBuffers[ch][i]; - } - } - - const pcmBuffer = this._float32ToBuffer(interleavedBuffer); - this.audioBackend.queueAudio(pcmBuffer); - - if (this.stats.framesCapture % 100 === 0) { - console.log(`[AudioBridge] → Output multi-canaux (${numOutputChannels}ch): ${pcmBuffer.length} bytes`); - } - } - this.stats.framesCapture++; - this.stats.framesPlayback++; } catch (error) { console.error('Erreur routing capture:', error); this.stats.errors.capture++; } }); - // ===== FLUX 2 : LECTURE (Clients → LiveKit → Groupes → Carte Son) ===== - - // Écouter l'audio entrant de LiveKit (sera connecté par LiveKitServerBridge) - this.on('groupAudioIn', ({ groupName, pcmBuffer }) => { - try { - // Convertir PCM Buffer → Float32Array - const float32Data = this._bufferToFloat32(pcmBuffer); - const samplesReceived = float32Data.length; - - // Initialiser l'accumulateur pour ce groupe si nécessaire - if (!this.liveKitFrameAccumulators.has(groupName)) { - this.liveKitFrameAccumulators.set(groupName, { - buffer: new Float32Array(960), // Frame size attendu par GroupRouter - offset: 0 - }); - } - - const accumulator = this.liveKitFrameAccumulators.get(groupName); - - // Vérifier que le buffer ne débordera pas - const availableSpace = 960 - accumulator.offset; - const samplesToCopy = Math.min(samplesReceived, availableSpace); - - // Copier les samples dans l'accumulateur - if (samplesToCopy > 0) { - accumulator.buffer.set(float32Data.subarray(0, samplesToCopy), accumulator.offset); - accumulator.offset += samplesToCopy; - } - - // Si on a accumulé assez de samples (960), router vers les outputs - if (accumulator.offset >= 960) { - // Vérifier que le backend est toujours actif (évite crash pendant shutdown) - if (!this.audioBackend) { - return; - } - - // Stocker le buffer complet pour le routing - this.groupBuffersFromLiveKit.set(groupName, accumulator.buffer); - - // ÉTAPE 3 : Groupes → Outputs physiques (via GroupAudioRouter) - const outputBuffers = this.groupAudioRouter.processGroupsToOutputs( - this.groupBuffersFromLiveKit - ); - - // ÉTAPE 4 : Envoyer chaque output à la carte son - outputBuffers.forEach((outputBuffer, channelId) => { - const pcmBuffer = this._float32ToBuffer(outputBuffer); - this.audioBackend.queueAudio(pcmBuffer); - }); - - // Réinitialiser l'accumulateur - accumulator.offset = 0; - accumulator.buffer.fill(0); - - this.stats.framesPlayback++; - } - } catch (error) { - console.error('Erreur routing lecture:', error); - this.stats.errors.playback++; - } - }); - - // Démarrage des streams audio await this.audioBackend.startCapture(); await this.audioBackend.startPlayback(); - console.log('✓ Routing audio bidirectionnel actif'); - console.log(' → Carte Son → GroupRouter → LiveKit → Clients'); - console.log(' ← Carte Son ← GroupRouter ← LiveKit ← Clients'); + console.log('✓ Routing audio actif'); + console.log(' → Carte Son → Server Audio Users → LiveKit → Clients'); } /** @@ -819,13 +518,6 @@ export class AudioBridge extends EventEmitter { this.audioBackend = 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(); - // Arrêter les server audio users for (const [name, user] of this.serverAudioUsers.entries()) { console.log(`🔌 Arrêt server audio user "${name}"...`); @@ -833,11 +525,6 @@ export class AudioBridge extends EventEmitter { } this.serverAudioUsers.clear(); - if (this.groupAudioRouter) { - this.groupAudioRouter.destroy(); - this.groupAudioRouter = null; - } - if (this.jitterBuffer) { this.jitterBuffer.destroy(); this.jitterBuffer = null; @@ -855,7 +542,6 @@ export class AudioBridge extends EventEmitter { // Nettoyer les buffers this.inputChannelBuffers.clear(); - this.groupBuffersFromLiveKit.clear(); // Nettoyer le pool de buffers this.bufferPool.float32 = []; diff --git a/server/bridge/AudioBridgeManager.js b/server/bridge/AudioBridgeManager.js index 112f406..e79e0af 100644 --- a/server/bridge/AudioBridgeManager.js +++ b/server/bridge/AudioBridgeManager.js @@ -34,15 +34,12 @@ class AudioBridgeManager extends EventEmitter { 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, '') + .replace(/[̀-ͯ]/g, '') .toLowerCase() .trim() .replace(/\s+/g, '-') @@ -50,45 +47,6 @@ class AudioBridgeManager extends EventEmitter { .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; - } - // Générer un token JWT par server audio user const serverAudioUsers = []; @@ -118,11 +76,13 @@ class AudioBridgeManager extends EventEmitter { const jwt = await token.toJwt(); + const outputChannel = user.output_channel ?? user.outputChannel; + serverAudioUsers.push({ name: user.name, groupId, inputChannel: user.input_channel ?? user.inputChannel ?? 0, - outputChannel: user.output_channel ?? user.outputChannel ?? 0, + outputChannel: outputChannel !== null && outputChannel !== undefined ? outputChannel : null, token: jwt }); @@ -160,17 +120,11 @@ class AudioBridgeManager extends EventEmitter { // Créer l'instance avec la config this.bridge = new AudioBridge({ ...audioConfig, - // Options LiveKit (multi-rooms) liveKitUrl, - liveKitTokens, // Tableau de { groupName, groupId, token } - // Server audio users serverAudioUsers, - // Options de routing - routing: config.audio?.routing || {}, groups: config.groups || [], maxInputChannels: 32, maxOutputChannels: 32, - // Device IDs extraits inputDeviceId, outputDeviceId }); diff --git a/server/bridge/ServerAudioUser.js b/server/bridge/ServerAudioUser.js index 7083612..231b223 100644 --- a/server/bridge/ServerAudioUser.js +++ b/server/bridge/ServerAudioUser.js @@ -18,7 +18,9 @@ class ServerAudioUser extends EventEmitter { this.name = options.name; this.inputChannel = parseInt(options.inputChannel, 10); - this.outputChannel = parseInt(options.outputChannel, 10); + this.outputChannel = (options.outputChannel !== null && options.outputChannel !== undefined) + ? parseInt(options.outputChannel, 10) + : null; this.groupId = options.groupId; this.frameSize = options.frameSize || 960; this.sampleRate = options.sampleRate || 48000; @@ -43,7 +45,7 @@ class ServerAudioUser extends EventEmitter { _setupClientEvents() { this.client.on('connected', () => { - console.log(`[ServerAudioUser:${this.name}] Connecté à room "${this.groupId}" (in:${this.inputChannel} → out:${this.outputChannel})`); + console.log(`[ServerAudioUser:${this.name}] Connecté à room "${this.groupId}" (in:${this.inputChannel} → out:${this.outputChannel ?? 'aucune'})`); this.emit('connected'); }); @@ -139,7 +141,9 @@ class ServerAudioUser extends EventEmitter { } this.mixedOutput = mix; - this.emit('outputReady', mix); + if (this.outputChannel !== null) { + this.emit('outputReady', mix); + } } /** diff --git a/server/config/config.yaml b/server/config/config.yaml index e9e8ac5..d562913 100644 --- a/server/config/config.yaml +++ b/server/config/config.yaml @@ -6,55 +6,22 @@ audio: jitterBufferMs: 40 device: inputDeviceId: Loopback Audio 4 - outputDeviceId: Haut-parleurs MacBook Pro + outputDeviceId: Périphérique agrégé sampleRate: 48000 - routing: - inputToGroup: - "0": - - default - "1": [] - "2": [] - "4": - - technique - "5": - - technique - groupToOutput: - technique: - - "1" - production: - - "0" - - "1" - default: - - "0" - gains: {} channelNames: inputs: - "0": Mac - "1": Talkback FOH - "2": Retour Console - "3": Liaison Scène - "4": Monitor Mix - "5": Spare 1 + "0": Loopback L + "1": Loopback R outputs: - "0": L - "1": R - "2": Talkback Console -# Utilisateurs audio gérés côté serveur. -# Chaque entrée crée un participant LiveKit indépendant avec un canal physique -# d'entrée (microphone/ligne) et un canal physique de sortie dédié (mix-minus naturel). -# -# Exemple (décommenter et adapter) : -# server_audio_users: -# - name: foh -# group: default # ID du groupe LiveKit (room) à rejoindre -# input_channel: 1 # Index canal physique d'entrée (depuis inputDeviceId) -# output_channel: 2 # Index canal physique de sortie (vers outputDeviceId) -# - name: returns -# group: default -# input_channel: 2 -# output_channel: 3 -server_audio_users: [] - + "0": Casque L + "1": Casque R + "2": Mac L + "3": Mac R +server_audio_users: + - name: Utilisateur Serveur + group: default + input_channel: 0 + output_channel: 0 groups: - name: Default audioBitrate: 96 diff --git a/server/index.js b/server/index.js index 63eafe2..3cfdb07 100644 --- a/server/index.js +++ b/server/index.js @@ -334,30 +334,12 @@ apiRouter.post('/token', async (req, res) => { // Enregistrer l'utilisateur dans le système admin registerUser(participantIdentity, username, groupId, roomName); - // Générer les canaux virtuels depuis le routing (inputs uniquement) - const virtualChannels = []; - const inputToGroup = config.audio?.routing?.inputToGroup || {}; - const channelNames = config.audio?.channelNames?.inputs || {}; - - // Trouver tous les canaux physiques routés vers ce groupe - for (const [inputChannel, groups] of Object.entries(inputToGroup)) { - if (groups.includes(groupId)) { - const channelName = channelNames[inputChannel] || `Canal ${inputChannel}`; - virtualChannels.push({ - id: `input-${inputChannel}`, - name: channelName, - isVirtual: true, - audioInput: parseInt(inputChannel, 10) - }); - } - } - res.json({ token, url: LIVEKIT_URL, roomName, participantIdentity, - virtualChannels + virtualChannels: [] }); } catch (error) { -- 2.52.0