feat: système hot-reload bridge audio avec ConfigManager (Phase 2.5)

- 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)
This commit is contained in:
2026-05-25 09:45:59 +02:00
parent 7fd60315dd
commit 9350c9410c
4 changed files with 332 additions and 59 deletions
+12 -23
View File
@@ -10,6 +10,7 @@ import YAML from 'yaml';
import { fileURLToPath } from 'url'; import { fileURLToPath } from 'url';
import { dirname } from 'path'; import { dirname } from 'path';
import { CoreAudioBackend } from '../bridge/backends/CoreAudioBackend.js'; import { CoreAudioBackend } from '../bridge/backends/CoreAudioBackend.js';
import configManager from '../config/ConfigManager.js';
const __dirname = dirname(fileURLToPath(import.meta.url)); const __dirname = dirname(fileURLToPath(import.meta.url));
const router = Router(); const router = Router();
@@ -503,8 +504,8 @@ router.get('/audio/devices', (req, res) => {
*/ */
router.get('/audio/device', (req, res) => { router.get('/audio/device', (req, res) => {
try { try {
const config = loadConfig(); const config = configManager.get();
const audioDevice = config.audio.device || {}; const audioDevice = config.audio?.device || {};
res.json({ res.json({
device: audioDevice device: audioDevice
@@ -524,31 +525,19 @@ router.post('/audio/device', (req, res) => {
try { try {
const { inputDeviceId, outputDeviceId, sampleRate, bufferSize } = req.body; const { inputDeviceId, outputDeviceId, sampleRate, bufferSize } = req.body;
const config = loadConfig(); // Utiliser le ConfigManager pour mettre à jour et émettre l'événement
const deviceConfig = configManager.updateAudioDevice({
// Initialiser la section device si elle n'existe pas inputDeviceId,
if (!config.audio.device) { outputDeviceId,
config.audio.device = {}; sampleRate,
} bufferSize
});
// 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);
addLog('info', 'Audio device configured', { 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({ res.json({
message: 'Audio device configured', message: 'Audio device configured (bridge audio sera rechargé)',
device: config.audio.device device: deviceConfig
}); });
} catch (error) { } catch (error) {
+129
View File
@@ -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;
+173
View File
@@ -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;
+18 -36
View File
@@ -10,45 +10,15 @@ import { networkInterfaces } from 'os';
import YAML from 'yaml'; import YAML from 'yaml';
import { AccessToken } from 'livekit-server-sdk'; import { AccessToken } from 'livekit-server-sdk';
import adminRouter, { registerUser, addLog } from './api/admin.js'; 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)); const __dirname = dirname(fileURLToPath(import.meta.url));
// Chargement configuration // Chargement configuration via ConfigManager
const configPath = join(__dirname, 'config', 'config.yaml'); const config = configManager.get();
const configFile = readFileSync(configPath, 'utf8');
const config = YAML.parse(configFile);
/** // Note: Les IDs sont maintenant générés automatiquement par le ConfigManager
* 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;
});
/** /**
* Détecte l'IP réseau locale (WiFi/Ethernet) * 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(', ')}`); 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é // Gérer erreur port déjà utilisé
server.on('error', (error) => { server.on('error', (error) => {
if (error.code === 'EADDRINUSE') { if (error.code === 'EADDRINUSE') {
@@ -407,9 +383,15 @@ async function start() {
// ========== Cleanup ========== // ========== Cleanup ==========
function cleanup() { async function cleanup() {
log('info', 'Arrêt du serveur...'); 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) { if (livekitProcess) {
log('info', 'Arrêt LiveKit Server...'); log('info', 'Arrêt LiveKit Server...');
livekitProcess.kill('SIGTERM'); livekitProcess.kill('SIGTERM');