From 9350c9410ccc70620a2940ad95b17e9198a6f573 Mon Sep 17 00:00:00 2001 From: Benoit Date: Mon, 25 May 2026 09:45:59 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20syst=C3=A8me=20hot-reload=20bridge=20au?= =?UTF-8?q?dio=20avec=20ConfigManager=20(Phase=202.5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ConfigManager: gestionnaire centralisé config avec EventEmitter - AudioBridgeManager: gestion bridge avec auto-reload sur changement config - Intégration dans serveur principal (index.js) - Événements 'audio-device-updated' et 'config-updated' - Reload automatique du bridge sans redémarrer serveur - Mode placeholder pour développement (vrai bridge Phase 3) --- server/api/admin.js | 35 ++---- server/bridge/AudioBridgeManager.js | 129 +++++++++++++++++++++ server/config/ConfigManager.js | 173 ++++++++++++++++++++++++++++ server/index.js | 54 +++------ 4 files changed, 332 insertions(+), 59 deletions(-) create mode 100644 server/bridge/AudioBridgeManager.js create mode 100644 server/config/ConfigManager.js diff --git a/server/api/admin.js b/server/api/admin.js index 4c4f16b..5132520 100644 --- a/server/api/admin.js +++ b/server/api/admin.js @@ -10,6 +10,7 @@ import YAML from 'yaml'; import { fileURLToPath } from 'url'; import { dirname } from 'path'; import { CoreAudioBackend } from '../bridge/backends/CoreAudioBackend.js'; +import configManager from '../config/ConfigManager.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); const router = Router(); @@ -503,8 +504,8 @@ router.get('/audio/devices', (req, res) => { */ router.get('/audio/device', (req, res) => { try { - const config = loadConfig(); - const audioDevice = config.audio.device || {}; + const config = configManager.get(); + const audioDevice = config.audio?.device || {}; res.json({ device: audioDevice @@ -524,31 +525,19 @@ router.post('/audio/device', (req, res) => { try { const { inputDeviceId, outputDeviceId, sampleRate, bufferSize } = req.body; - const config = loadConfig(); - - // Initialiser la section device si elle n'existe pas - if (!config.audio.device) { - config.audio.device = {}; - } - - // Mettre à jour les paramètres fournis - if (inputDeviceId !== undefined) config.audio.device.inputDeviceId = inputDeviceId; - if (outputDeviceId !== undefined) config.audio.device.outputDeviceId = outputDeviceId; - if (sampleRate !== undefined) { - config.audio.device.sampleRate = sampleRate; - config.audio.sampleRate = sampleRate; // Sync avec config globale - } - if (bufferSize !== undefined) config.audio.device.bufferSize = bufferSize; - - saveConfig(config); + // Utiliser le ConfigManager pour mettre à jour et émettre l'événement + const deviceConfig = configManager.updateAudioDevice({ + inputDeviceId, + outputDeviceId, + sampleRate, + bufferSize + }); addLog('info', 'Audio device configured', { inputDeviceId, outputDeviceId, sampleRate, bufferSize }); - // TODO Phase 2.5 : Émettre événement pour reload du bridge audio - res.json({ - message: 'Audio device configured', - device: config.audio.device + message: 'Audio device configured (bridge audio sera rechargé)', + device: deviceConfig }); } catch (error) { diff --git a/server/bridge/AudioBridgeManager.js b/server/bridge/AudioBridgeManager.js new file mode 100644 index 0000000..eb792d1 --- /dev/null +++ b/server/bridge/AudioBridgeManager.js @@ -0,0 +1,129 @@ +/** + * AudioBridgeManager.js + * Gestionnaire du bridge audio avec support hot-reload + * Phase 2.5 + */ + +import { EventEmitter } from 'events'; +import configManager from '../config/ConfigManager.js'; + +class AudioBridgeManager extends EventEmitter { + constructor() { + super(); + this.bridge = null; + this.isRunning = false; + + // Écouter les événements de configuration + configManager.on('audio-device-updated', this.handleDeviceUpdate.bind(this)); + configManager.on('config-updated', this.handleConfigUpdate.bind(this)); + } + + /** + * Démarre le bridge audio avec la configuration actuelle + */ + async start() { + if (this.isRunning) { + console.warn('⚠️ AudioBridge déjà démarré'); + return; + } + + try { + const config = configManager.get(); + console.log('🎵 Démarrage AudioBridge avec configuration:', config.audio); + + // TODO Phase 3: Implémenter le vrai bridge audio + // const AudioBridge = await import('./AudioBridge.js'); + // this.bridge = new AudioBridge(config.audio); + // await this.bridge.start(); + + this.isRunning = true; + console.log('✓ AudioBridge démarré (mode placeholder)'); + + this.emit('started'); + } catch (error) { + console.error('❌ Erreur démarrage AudioBridge:', error); + throw error; + } + } + + /** + * Arrête le bridge audio + */ + async stop() { + if (!this.isRunning) { + return; + } + + try { + console.log('⏹ Arrêt AudioBridge...'); + + // TODO Phase 3: Arrêter le vrai bridge + // if (this.bridge) { + // await this.bridge.stop(); + // this.bridge = null; + // } + + this.isRunning = false; + console.log('✓ AudioBridge arrêté'); + + this.emit('stopped'); + } catch (error) { + console.error('❌ Erreur arrêt AudioBridge:', error); + throw error; + } + } + + /** + * Recharge le bridge avec la nouvelle configuration + */ + async reload() { + try { + console.log('🔄 Rechargement AudioBridge...'); + + await this.stop(); + await this.start(); + + console.log('✓ AudioBridge rechargé avec succès'); + this.emit('reloaded'); + } catch (error) { + console.error('❌ Erreur rechargement AudioBridge:', error); + throw error; + } + } + + /** + * Gestionnaire événement mise à jour device audio + */ + async handleDeviceUpdate(deviceConfig) { + console.log('🔧 Device audio mis à jour:', deviceConfig); + console.log('→ Rechargement AudioBridge requis...'); + + // Auto-reload du bridge + if (this.isRunning) { + await this.reload(); + } + } + + /** + * Gestionnaire événement mise à jour configuration + */ + handleConfigUpdate(config) { + console.log('🔧 Configuration mise à jour'); + // Peut déclencher un reload si nécessaire + } + + /** + * Retourne l'état actuel du bridge + */ + getStatus() { + return { + running: this.isRunning, + config: configManager.get().audio + }; + } +} + +// Singleton +const audioBridgeManager = new AudioBridgeManager(); + +export default audioBridgeManager; diff --git a/server/config/ConfigManager.js b/server/config/ConfigManager.js new file mode 100644 index 0000000..ca016ba --- /dev/null +++ b/server/config/ConfigManager.js @@ -0,0 +1,173 @@ +/** + * ConfigManager.js + * Gestionnaire centralisé de configuration avec support événements + * Phase 2.5 + */ + +import { EventEmitter } from 'events'; +import { readFileSync, writeFileSync } from 'fs'; +import { join } from 'path'; +import YAML from 'yaml'; +import { fileURLToPath } from 'url'; +import { dirname } from 'path'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const configPath = join(__dirname, 'config.yaml'); + +/** + * Génère un ID slug à partir d'un nom + */ +function slugify(text) { + return text + .toString() + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, '') + .toLowerCase() + .trim() + .replace(/\s+/g, '-') + .replace(/[^\w-]+/g, '') + .replace(/--+/g, '-'); +} + +class ConfigManager extends EventEmitter { + constructor() { + super(); + this.config = null; + this.load(); + } + + /** + * Charge la configuration depuis le fichier YAML + */ + load() { + try { + const configFile = readFileSync(configPath, 'utf8'); + this.config = YAML.parse(configFile); + + // Générer les IDs pour les groupes et canaux + this.config.groups = this.config.groups.map(group => { + const groupId = slugify(group.name); + return { + ...group, + id: groupId, + channels: group.channels ? group.channels.map(channel => ({ + ...channel, + id: channel.id || `${groupId}-${slugify(channel.name)}` + })) : [] + }; + }); + + return this.config; + } catch (error) { + console.error('Erreur chargement configuration:', error); + throw error; + } + } + + /** + * Récupère la configuration actuelle + */ + get() { + return this.config; + } + + /** + * Sauvegarde la configuration dans le fichier YAML + * Ne sauvegarde PAS les IDs (ils sont générés dynamiquement) + */ + save(config) { + try { + // Nettoyer les IDs avant de sauvegarder + const cleanConfig = { + ...config, + groups: config.groups.map(group => { + const { id, ...groupWithoutId } = group; + return { + ...groupWithoutId, + channels: group.channels ? group.channels.map(channel => { + const { id: channelId, ...channelWithoutId } = channel; + return channelWithoutId; + }) : [] + }; + }) + }; + + const yamlContent = YAML.stringify(cleanConfig); + writeFileSync(configPath, yamlContent, 'utf8'); + + // Recharger pour synchroniser + this.load(); + + // Émettre événement de changement + this.emit('config-updated', this.config); + + return this.config; + } catch (error) { + console.error('Erreur sauvegarde configuration:', error); + throw error; + } + } + + /** + * Met à jour la configuration audio device + */ + updateAudioDevice(deviceConfig) { + if (!this.config.audio) { + this.config.audio = {}; + } + + if (!this.config.audio.device) { + this.config.audio.device = {}; + } + + // Mettre à jour les paramètres fournis + if (deviceConfig.inputDeviceId !== undefined) { + this.config.audio.device.inputDeviceId = deviceConfig.inputDeviceId; + } + if (deviceConfig.outputDeviceId !== undefined) { + this.config.audio.device.outputDeviceId = deviceConfig.outputDeviceId; + } + if (deviceConfig.sampleRate !== undefined) { + this.config.audio.device.sampleRate = deviceConfig.sampleRate; + this.config.audio.sampleRate = deviceConfig.sampleRate; // Sync avec config globale + } + if (deviceConfig.bufferSize !== undefined) { + this.config.audio.device.bufferSize = deviceConfig.bufferSize; + } + + this.save(this.config); + + // Émettre événement spécifique + this.emit('audio-device-updated', this.config.audio.device); + + return this.config.audio.device; + } + + /** + * Met à jour la configuration audio globale + */ + updateAudioConfig(audioConfig) { + if (!this.config.audio) { + this.config.audio = {}; + } + + if (audioConfig.sampleRate !== undefined) { + this.config.audio.sampleRate = audioConfig.sampleRate; + } + if (audioConfig.defaultBitrate !== undefined) { + this.config.audio.defaultBitrate = audioConfig.defaultBitrate; + } + if (audioConfig.jitterBufferMs !== undefined) { + this.config.audio.jitterBufferMs = audioConfig.jitterBufferMs; + } + + this.save(this.config); + + return this.config.audio; + } +} + +// Singleton +const configManager = new ConfigManager(); + +export default configManager; diff --git a/server/index.js b/server/index.js index 066f575..7e6d4f1 100644 --- a/server/index.js +++ b/server/index.js @@ -10,45 +10,15 @@ import { networkInterfaces } from 'os'; import YAML from 'yaml'; import { AccessToken } from 'livekit-server-sdk'; import adminRouter, { registerUser, addLog } from './api/admin.js'; +import configManager from './config/ConfigManager.js'; +import audioBridgeManager from './bridge/AudioBridgeManager.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); -// Chargement configuration -const configPath = join(__dirname, 'config', 'config.yaml'); -const configFile = readFileSync(configPath, 'utf8'); -const config = YAML.parse(configFile); +// Chargement configuration via ConfigManager +const config = configManager.get(); -/** - * Génère un ID slug à partir d'un nom - * Ex: "Équipe Production" -> "equipe-production" - */ -function slugify(text) { - return text - .toString() - .normalize('NFD') - .replace(/[\u0300-\u036f]/g, '') // Retire les accents - .toLowerCase() - .trim() - .replace(/\s+/g, '-') - .replace(/[^\w-]+/g, '') - .replace(/--+/g, '-'); -} - -// Générer les IDs pour les groupes et canaux s'ils n'existent pas -config.groups = config.groups.map(group => { - if (!group.id) { - group.id = slugify(group.name); - } - if (group.channels) { - group.channels = group.channels.map(channel => { - if (!channel.id) { - channel.id = `${group.id}-${slugify(channel.name)}`; - } - return channel; - }); - } - return group; -}); +// Note: Les IDs sont maintenant générés automatiquement par le ConfigManager /** * Détecte l'IP réseau locale (WiFi/Ethernet) @@ -388,6 +358,12 @@ async function start() { log('info', `Groupes configurés: ${config.groups.map(g => g.name).join(', ')}`); }); + // 3. Démarrer Audio Bridge Manager (Phase 2.5) + log('info', ''); + log('info', '🎵 Démarrage Audio Bridge Manager...'); + await audioBridgeManager.start(); + log('info', '✓ Audio Bridge Manager prêt (mode placeholder)'); + // Gérer erreur port déjà utilisé server.on('error', (error) => { if (error.code === 'EADDRINUSE') { @@ -407,9 +383,15 @@ async function start() { // ========== Cleanup ========== -function cleanup() { +async function cleanup() { log('info', 'Arrêt du serveur...'); + // Arrêter l'audio bridge + if (audioBridgeManager) { + log('info', 'Arrêt Audio Bridge Manager...'); + await audioBridgeManager.stop(); + } + if (livekitProcess) { log('info', 'Arrêt LiveKit Server...'); livekitProcess.kill('SIGTERM');