Compare commits

...
4 Commits
Author SHA1 Message Date
benoit 06cb6a7dd1 refactor: simplifier AudioBridge, filtrer bridge dans PWA, option aucune sortie
- 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)
2026-07-03 14:55:55 +02:00
benoit bf960f49bb refactor: supprimer complètement les matrices de routing inputToGroup/groupToOutput
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.
2026-07-03 14:55:37 +02:00
benoit 9a2bec6d2f feat: page Routing complète + gestion server_audio_users dans Config
- 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)
2026-07-01 14:27:35 +02:00
benoit 87a1370ad4 feat: ajouter les server audio users (participants LiveKit côté serveur avec I/O physique)
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é).
2026-07-01 14:14:26 +02:00
17 changed files with 1083 additions and 1469 deletions
+1
View File
@@ -9,6 +9,7 @@ pnpm-lock.yaml
.env.local .env.local
.env.*.local .env.*.local
server/.env server/.env
server/config/config.yaml
client/.env client/.env
# Keep .env.example files (templates) # Keep .env.example files (templates)
-5
View File
@@ -1,6 +1,5 @@
import { useState, useEffect, useRef } from 'react'; import { useState, useEffect, useRef } from 'react';
import './Admin.css'; import './Admin.css';
import AudioRoutingMatrix from './components/AudioRoutingMatrix';
const API_URL = import.meta.env.VITE_API_URL || '/api'; const API_URL = import.meta.env.VITE_API_URL || '/api';
@@ -409,9 +408,6 @@ function Admin() {
</label> </label>
</div> </div>
<p style={{color: 'var(--color-text-secondary)', fontSize: '0.9rem', marginTop: 'var(--spacing-md)'}}>
Le routing audio se configure dans l'onglet "Audio" via la matrice de routing.
</p>
<div className="form-actions"> <div className="form-actions">
<button type="submit" className="btn-primary"> <button type="submit" className="btn-primary">
@@ -609,7 +605,6 @@ function Admin() {
</div> </div>
</div> </div>
<AudioRoutingMatrix groups={groups} channelNames={channelNames} />
{currentDevice && currentDevice.inputDeviceId && ( {currentDevice && currentDevice.inputDeviceId && (
<div className="current-config"> <div className="current-config">
@@ -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;
}
}
@@ -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 <div style={{padding: 'var(--spacing-xl)', textAlign: 'center'}}>Chargement...</div>;
}
return (
<div className="routing-matrix-container">
<div className="routing-matrix-header">
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
<h3>Matrice de routing audio</h3>
<span
className={`ws-status ${wsConnected ? 'connected' : 'disconnected'}`}
title={wsConnected ? 'Monitoring temps réel actif' : 'Monitoring temps réel déconnecté'}
>
{wsConnected ? '● Live' : '○ Offline'}
</span>
</div>
<label style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', cursor: 'pointer' }}>
<input
type="checkbox"
checked={showOnlyNamedChannels}
onChange={(e) => setShowOnlyNamedChannels(e.target.checked)}
/>
<span>Afficher uniquement les canaux nommés</span>
</label>
</div>
<div className="routing-section">
<h4>Inputs vers Groupes</h4>
<p className="routing-description">
Sélectionnez quels inputs audio alimentent chaque groupe
</p>
<div className="routing-matrix" style={{gridTemplateColumns: `120px repeat(${groups.length}, minmax(60px, 1fr))`}}>
<div className="matrix-corner"></div>
{groups.map(group => (
<div key={group.id} className="matrix-header-cell">
{group.name}
</div>
))}
{getVisibleInputChannels().map(i => (
<React.Fragment key={`input-row-${i}`}>
<div className="matrix-label-cell">
<div className="label-content">
<span className="label-text">{getChannelName('inputs', i)}</span>
{wsConnected && levels.inputs[i] && (
<VUMeter level={levels.inputs[i]} size="mini" />
)}
</div>
</div>
{groups.map(group => {
const isRouted = isInputRoutedToGroup(String(i), group.id);
const gain = getGainForInputToGroup(String(i), group.id);
return (
<div
key={`${i}-${group.id}`}
className={`matrix-cell ${isRouted ? 'active' : ''}`}
>
<div
className="cell-checkbox"
onClick={() => toggleInputToGroup(String(i), group.id)}
>
{isRouted && <span className="checkmark"></span>}
</div>
{isRouted && (
<select
className="gain-select"
value={gain}
onChange={(e) => setGainForInputToGroup(String(i), group.id, e.target.value)}
onClick={(e) => e.stopPropagation()}
>
<option value="-12">-12dB</option>
<option value="-6">-6dB</option>
<option value="-3">-3dB</option>
<option value="0">0dB</option>
<option value="3">+3dB</option>
<option value="6">+6dB</option>
</select>
)}
</div>
);
})}
</React.Fragment>
))}
</div>
</div>
<div className="routing-section">
<h4>Groupes vers Outputs</h4>
<p className="routing-description">
Sélectionnez vers quels outputs chaque groupe envoie son audio
</p>
<div className="routing-matrix" style={{gridTemplateColumns: `120px repeat(${getVisibleOutputChannels().length}, minmax(60px, 1fr))`}}>
<div className="matrix-corner"></div>
{getVisibleOutputChannels().map(i => (
<div key={`output-header-${i}`} className="matrix-header-cell">
<div className="header-content">
<span className="header-text">{getChannelName('outputs', i)}</span>
{wsConnected && levels.outputs[i] && (
<VUMeter level={levels.outputs[i]} size="mini" />
)}
</div>
</div>
))}
{groups.map(group => (
<React.Fragment key={`group-row-${group.id}`}>
<div className="matrix-label-cell">
<div className="label-content">
<span className="label-text">{group.name}</span>
{wsConnected && levels.groups[group.id] && (
<VUMeter level={levels.groups[group.id]} size="mini" />
)}
</div>
</div>
{getVisibleOutputChannels().map(i => {
const isRouted = isGroupRoutedToOutput(group.id, String(i));
const gain = getGainForGroupToOutput(group.id, String(i));
return (
<div
key={`${group.id}-${i}`}
className={`matrix-cell ${isRouted ? 'active' : ''}`}
>
<div
className="cell-checkbox"
onClick={() => toggleGroupToOutput(group.id, String(i))}
>
{isRouted && <span className="checkmark"></span>}
</div>
{isRouted && (
<select
className="gain-select"
value={gain}
onChange={(e) => setGainForGroupToOutput(group.id, String(i), e.target.value)}
onClick={(e) => e.stopPropagation()}
>
<option value="-12">-12dB</option>
<option value="-6">-6dB</option>
<option value="-3">-3dB</option>
<option value="0">0dB</option>
<option value="3">+3dB</option>
<option value="6">+6dB</option>
</select>
)}
</div>
);
})}
</React.Fragment>
))}
</div>
</div>
<div className="routing-actions">
<button onClick={saveRouting} className="btn-primary">
Sauvegarder le routing audio
</button>
</div>
</div>
);
}
export default AudioRoutingMatrix;
+10 -1
View File
@@ -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) => { 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 audioTracks = participant.audioTracks ? Array.from(participant.audioTracks.values()) : [];
const audioPublication = audioTracks[0]; const audioPublication = audioTracks[0];
const isSpeaking = room.activeSpeakers.some(s => s.identity === participant.identity); const isSpeaking = room.activeSpeakers.some(s => s.identity === participant.identity);
+123
View File
@@ -436,6 +436,129 @@ 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: output_channel !== null && output_channel !== '' ? parseInt(output_channel) : null };
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: output_channel !== null && output_channel !== '' ? parseInt(output_channel) : null };
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 {
channelNames: config.audio?.channelNames || { inputs: {}, outputs: {} },
groups: config.groups || [],
serverAudioUsers: config.server_audio_users || []
};
} catch (error) {
return { error: error.message };
}
});
// ========== 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.channelNames = channelNames;
writeConfig(config);
return { success: true };
} catch (error) {
return { success: false, error: error.message };
}
});
ipcMain.handle('config:export', async () => { ipcMain.handle('config:export', async () => {
const configPath = path.join(__dirname, '..', 'server', 'config', 'config.yaml'); const configPath = path.join(__dirname, '..', 'server', 'config', 'config.yaml');
+19
View File
@@ -51,6 +51,25 @@ contextBridge.exposeInMainWorld('electronAPI', {
delete: (data) => ipcRenderer.invoke('groups:delete', data) 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)
},
// Découverte canaux physiques de la carte son sélectionnée
devices: {
getChannels: () => ipcRenderer.invoke('devices:getChannels')
},
// Helpers // Helpers
platform: process.platform, platform: process.platform,
version: process.env.npm_package_version || '0.3.0' version: process.env.npm_package_version || '0.3.0'
+334 -18
View File
@@ -9,6 +9,8 @@ let serverRunning = false;
let statsInterval = null; let statsInterval = null;
let logsBuffer = []; let logsBuffer = [];
let audioLevelsWS = null; let audioLevelsWS = null;
let routingData = null;
let deviceChannels = null;
let audioLevelsData = { let audioLevelsData = {
inputs: {}, inputs: {},
groups: {}, groups: {},
@@ -572,12 +574,25 @@ async function loadInitialData() {
} }
async function loadViewData(view) { 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') { if (view === 'groups') {
await fetchGroups(); await fetchGroups();
return; return;
} }
if (view === 'routing') {
await fetchRouting();
return;
}
if (view === 'config') {
if (serverRunning) {
await fetchDevices();
await fetchConfig();
}
return;
}
if (!serverRunning) return; if (!serverRunning) return;
switch (view) { switch (view) {
@@ -586,10 +601,6 @@ async function loadViewData(view) {
await fetchUsers(); await fetchUsers();
await generateQRCode(); await generateQRCode();
break; break;
case 'config':
await fetchDevices();
await fetchConfig();
break;
case 'monitoring': case 'monitoring':
renderVUMeters(); renderVUMeters();
break; break;
@@ -778,8 +789,302 @@ document.addEventListener('DOMContentLoaded', () => {
} }
}); });
} }
// Boutons routing
document.getElementById('btn-save-routing')?.addEventListener('click', saveRouting);
document.getElementById('btn-reload-routing')?.addEventListener('click', fetchRouting);
document.getElementById('btn-refresh-channels')?.addEventListener('click', fetchRouting);
document.getElementById('btn-add-server-audio-user')?.addEventListener('click', addServerAudioUser);
// 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;
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 (dans la vue Routing) ==========
function renderServerAudioUsers() {
const container = document.getElementById('server-audio-users-list');
if (!container) return;
const users = routingData?.serverAudioUsers || [];
const inputs = routingData?.channelNames?.inputs || {};
const outputs = routingData?.channelNames?.outputs || {};
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 (users.length === 0) {
container.innerHTML = '<p class="empty-state" style="margin-top:1rem">Aucun participant serveur configuré</p>';
return;
}
container.innerHTML = `
<table class="sau-table">
<thead>
<tr><th>Nom</th><th>Groupe</th><th>Entrée</th><th>Sortie</th><th></th></tr>
</thead>
<tbody>
${users.map(u => `
<tr>
<td class="sau-name">${escapeHtml(u.name)}</td>
<td><span class="ch-badge ch-badge-group">${escapeHtml(u.group)}</span></td>
<td><span class="ch-badge">${chLabel(u.input_channel, 'input')}</span></td>
<td><span class="ch-badge">${chLabel(u.output_channel, 'output')}</span></td>
<td class="sau-actions">
<button class="btn btn-small btn-secondary"
data-sau-action="edit"
data-sau-name="${escapeHtml(u.name)}"
data-sau-group="${escapeHtml(u.group)}"
data-sau-input="${u.input_channel}"
data-sau-output="${u.output_channel}">Éditer</button>
<button class="btn btn-small btn-danger"
data-sau-action="delete"
data-sau-name="${escapeHtml(u.name)}">✕</button>
</td>
</tr>`).join('')}
</tbody>
</table>`;
}
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 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: 'Nouveau participant serveur',
fields: [
{ name: 'name', label: 'Nom (identifiant unique, ex: foh)' },
{ name: 'group', label: 'Groupe', type: 'select', options: groupOptions, default: defaultGroup },
inputField,
outputField
],
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: result.output_channel !== '' ? parseInt(result.output_channel) : null
});
if (res.success) {
showNotification('Participant serveur ajouté', 'success');
await fetchRouting();
} 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 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 },
inputField,
outputField
],
confirmLabel: 'Modifier'
});
if (!result) return;
const res = await window.electronAPI.serverAudioUsers.update({
name,
group: result.group,
input_channel: parseInt(result.input_channel),
output_channel: result.output_channel !== '' ? parseInt(result.output_channel) : null
});
if (res.success) {
showNotification('Participant serveur modifié', 'success');
await fetchRouting();
} else {
showNotification('Erreur: ' + (res.error || 'Modification échouée'), 'error');
}
}
async function deleteServerAudioUser(name) {
const confirmed = await showModal({
title: 'Supprimer le participant 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('Participant serveur supprimé', 'success');
await fetchRouting();
} else {
showNotification('Erreur: ' + (res.error || 'Suppression échouée'), 'error');
}
}
// ========== Routing ==========
async function fetchRouting() {
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;
}
deviceChannels = devResult;
routingData = { ...routingResult, deviceChannels: devResult };
renderRoutingView();
}
function renderRoutingView() {
if (!routingData) return;
renderDeviceBanner();
renderChannelLabels();
renderServerAudioUsers();
}
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 = `<span class="device-unset">Aucun device configuré — sélectionnez une carte son dans <strong>Configuration</strong></span>`;
return;
}
const { inputDevice, outputDevice } = dc;
el.innerHTML = `
<span class="device-entry"><strong>Entrée :</strong> ${escapeHtml(inputDevice.name)}
<span class="device-ch-badge">${inputDevice.channels} ch</span></span>
<span class="device-sep">·</span>
<span class="device-entry"><strong>Sortie :</strong> ${escapeHtml(outputDevice.name)}
<span class="device-ch-badge">${outputDevice.channels} ch</span></span>`;
}
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 inputsEl = document.getElementById('channel-names-inputs');
const outputsEl = document.getElementById('channel-names-outputs');
if (inputsEl) {
inputsEl.innerHTML = inputCount > 0
? Array.from({ length: inputCount }, (_, i) => channelLabelRow('input', i, inputs[i] || '')).join('')
: '<p class="config-note">Aucun device d\'entrée détecté.</p>';
}
if (outputsEl) {
outputsEl.innerHTML = outputCount > 0
? Array.from({ length: outputCount }, (_, i) => channelLabelRow('output', i, outputs[i] || '')).join('')
: '<p class="config-note">Aucun device de sortie détecté.</p>';
}
}
function channelLabelRow(dir, ch, name) {
return `
<div class="channel-name-row" data-channel="${ch}" data-dir="${dir}">
<span class="channel-index">Ch ${ch}</span>
<input type="text" class="form-control form-control-small channel-name-input"
data-dir="${dir}" data-channel="${ch}"
value="${escapeHtml(name)}" placeholder="Label canal ${ch}">
</div>`;
}
async function saveRouting() {
if (!routingData) return;
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;
});
const result = await window.electronAPI.routing.save({ channelNames: newChannelNames });
if (result.success) {
routingData.channelNames = newChannelNames;
renderRoutingView();
showNotification('Noms de canaux sauvegardés', 'success');
} else {
showNotification('Erreur: ' + (result.error || 'Sauvegarde échouée'), 'error');
}
}
// ========== Helpers ========== // ========== Helpers ==========
/** /**
@@ -802,19 +1107,30 @@ function showModal({ title, fields = [], confirmLabel = 'Confirmer', confirmClas
if (message) { if (message) {
bodyEl.innerHTML = `<p class="modal-message">${escapeHtml(message)}</p>`; bodyEl.innerHTML = `<p class="modal-message">${escapeHtml(message)}</p>`;
} else { } else {
bodyEl.innerHTML = fields.map(field => ` bodyEl.innerHTML = fields.map(field => {
<div class="form-group"> if (field.type === 'select') {
<label>${escapeHtml(field.label)}</label> const optionsHtml = (field.options || []).map(opt =>
<input `<option value="${escapeHtml(opt.value)}" ${opt.value === field.default ? 'selected' : ''}>${escapeHtml(opt.label)}</option>`
type="${field.type || 'text'}" ).join('');
id="modal-field-${field.name}" return `
class="form-control" <div class="form-group">
value="${escapeHtml(String(field.default ?? ''))}" <label>${escapeHtml(field.label)}</label>
${field.min !== undefined ? `min="${field.min}"` : ''} <select id="modal-field-${field.name}" class="form-control">${optionsHtml}</select>
${field.max !== undefined ? `max="${field.max}"` : ''} </div>`;
${field.step !== undefined ? `step="${field.step}"` : ''}> }
</div> return `
`).join(''); <div class="form-group">
<label>${escapeHtml(field.label)}</label>
<input
type="${field.type || 'text'}"
id="modal-field-${field.name}"
class="form-control"
value="${escapeHtml(String(field.default ?? ''))}"
${field.min !== undefined ? `min="${field.min}"` : ''}
${field.max !== undefined ? `max="${field.max}"` : ''}
${field.step !== undefined ? `step="${field.step}"` : ''}>
</div>`;
}).join('');
} }
overlay.classList.remove('hidden'); overlay.classList.remove('hidden');
+50
View File
@@ -40,6 +40,9 @@
<button class="nav-item" data-view="groups"> <button class="nav-item" data-view="groups">
👥 Groupes 👥 Groupes
</button> </button>
<button class="nav-item" data-view="routing">
🔀 Routing
</button>
<button class="nav-item" data-view="monitoring"> <button class="nav-item" data-view="monitoring">
📈 Monitoring 📈 Monitoring
</button> </button>
@@ -163,6 +166,53 @@
</div> </div>
</div> </div>
<!-- Routing View -->
<div id="view-routing" class="view">
<h2>Routing Audio</h2>
<!-- Device info -->
<div class="section">
<div class="routing-device-banner">
<div class="routing-device-info" id="routing-device-info">Chargement...</div>
<button class="btn btn-small btn-secondary" id="btn-refresh-channels">🔄 Actualiser</button>
</div>
</div>
<!-- Labels canaux physiques -->
<div class="section">
<h3>🏷️ Canaux Physiques</h3>
<p class="config-note">Labels des canaux de votre carte son. La liste est déterminée par le device sélectionné en <strong>Configuration</strong>.</p>
<div class="channel-names-grid">
<div class="channel-names-col">
<h4>Entrées</h4>
<div id="channel-names-inputs" class="channel-names-list">
<p class="empty-state">Chargement...</p>
</div>
</div>
<div class="channel-names-col">
<h4>Sorties</h4>
<div id="channel-names-outputs" class="channel-names-list">
<p class="empty-state">Chargement...</p>
</div>
</div>
</div>
</div>
<!-- Participants Serveur -->
<div class="section">
<h3>🎙️ Participants Serveur</h3>
<p class="config-note">Participants LiveKit côté serveur — chaque ligne branche des canaux physiques d'E/S sur un groupe.</p>
<button class="btn btn-primary btn-small" id="btn-add-server-audio-user"> Ajouter</button>
<div id="server-audio-users-list" class="sau-list"></div>
</div>
<!-- Actions -->
<div class="routing-actions">
<button class="btn btn-primary" id="btn-save-routing">Sauvegarder noms de canaux</button>
<button class="btn btn-secondary" id="btn-reload-routing">Recharger</button>
</div>
</div>
<!-- Monitoring View --> <!-- Monitoring View -->
<div id="view-monitoring" class="view"> <div id="view-monitoring" class="view">
<h2>Monitoring Audio</h2> <h2>Monitoring Audio</h2>
+260
View File
@@ -800,3 +800,263 @@ body {
opacity: 0.3; 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);
}
/* 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;
grid-template-columns: 1fr 1fr;
gap: 2rem;
}
.channel-names-col h4 {
font-size: 0.8125rem;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.06em;
margin: 0 0 0.75rem;
padding-bottom: 0.5rem;
border-bottom: 1px solid var(--border-color);
}
.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: 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 {
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;
}
-60
View File
@@ -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 * POST /admin/audio/device
+59 -300
View File
@@ -17,8 +17,7 @@ import JACKBackend from './backends/JACKBackend.js';
import PipeWireBackend from './backends/PipeWireBackend.js'; import PipeWireBackend from './backends/PipeWireBackend.js';
import OpusCodec, { OpusPresets } from './OpusCodec.js'; import OpusCodec, { OpusPresets } from './OpusCodec.js';
import JitterBuffer, { JitterBufferPresets } from './JitterBuffer.js'; import JitterBuffer, { JitterBufferPresets } from './JitterBuffer.js';
import LiveKitClient from './LiveKitClient.js'; import ServerAudioUser from './ServerAudioUser.js';
import GroupAudioRouter from './GroupAudioRouter.js';
export class AudioBridge extends EventEmitter { export class AudioBridge extends EventEmitter {
constructor(options = {}) { constructor(options = {}) {
@@ -54,8 +53,6 @@ export class AudioBridge extends EventEmitter {
this.opusEncoder = null; this.opusEncoder = null;
this.opusDecoder = null; this.opusDecoder = null;
this.jitterBuffer = null; this.jitterBuffer = null;
this.liveKitClients = new Map(); // Map<groupName, LiveKitClient> - un client par groupe
this.groupAudioRouter = null;
// État // État
this.isRunning = false; this.isRunning = false;
@@ -63,10 +60,9 @@ export class AudioBridge extends EventEmitter {
// Buffers pour routing multi-canaux // Buffers pour routing multi-canaux
this.inputChannelBuffers = new Map(); // Map<channelId, Float32Array> this.inputChannelBuffers = new Map(); // Map<channelId, Float32Array>
this.groupBuffersFromLiveKit = new Map(); // Map<groupName, Float32Array>
// Frame accumulators pour LiveKit (240 samples → 960 samples) // Utilisateurs audio gérés côté serveur (participants LiveKit avec I/O physique dédiés)
this.liveKitFrameAccumulators = new Map(); // Map<groupName, { buffer: Float32Array, offset: number }> this.serverAudioUsers = new Map(); // Map<name, ServerAudioUser>
// Pool de buffers pré-alloués pour éviter allocations répétées // Pool de buffers pré-alloués pour éviter allocations répétées
this.bufferPool = { this.bufferPool = {
@@ -114,13 +110,10 @@ export class AudioBridge extends EventEmitter {
// 3. Initialisation du jitter buffer // 3. Initialisation du jitter buffer
this._initJitterBuffer(); this._initJitterBuffer();
// 4. Initialisation du GroupAudioRouter // 4. Initialisation des server audio users
this._initGroupAudioRouter(); await this._initServerAudioUsers();
// 5. Connexion à LiveKit // 5. Démarrage du routing audio
await this._initLiveKit();
// 6. Démarrage du routing audio
await this._startAudioRouting(); await this._startAudioRouting();
this.isRunning = true; this.isRunning = true;
@@ -301,329 +294,101 @@ export class AudioBridge extends EventEmitter {
} }
/** /**
* Initialise le GroupAudioRouter pour le routing multi-canaux * Initialise les utilisateurs audio serveur (participants LiveKit avec I/O physique)
* @private * @private
*/ */
_initGroupAudioRouter() { async _initServerAudioUsers() {
this.groupAudioRouter = new GroupAudioRouter({ const users = this.options.serverAudioUsers;
sampleRate: this.options.sampleRate, if (!users || users.length === 0) return;
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 console.log(`🎤 Initialisation ${users.length} server audio user(s)...`);
if (this.options.routing) {
this.groupAudioRouter.configure(this.options.routing);
}
// Events du router for (const userConfig of users) {
this.groupAudioRouter.on('configured', (stats) => { const user = new ServerAudioUser({
console.log(`✓ GroupAudioRouter configuré : ${stats.routesActive} routes`); name: userConfig.name,
}); groupId: userConfig.groupId,
inputChannel: userConfig.inputChannel,
console.log('✓ GroupAudioRouter initialisé'); outputChannel: userConfig.outputChannel,
} liveKitUrl: this.options.liveKitUrl,
token: userConfig.token,
/**
* 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, sampleRate: this.options.sampleRate,
channels: this.options.channels, frameSize: this.options.frameSize
audioBitrate: this.opusEncoder.options.bitrate
}); });
// Events LiveKit pour ce groupe // Quand une frame de mix est prête, l'envoyer vers le canal physique de sortie
client.on('connected', () => { const outputCh = userConfig.outputChannel;
console.log(`✓ LiveKit connecté pour groupe "${groupName}" (room: ${roomName})`); 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);
}
}); });
client.on('disconnected', (data) => { await user.start();
const reason = data?.reason || 'unknown'; this.serverAudioUsers.set(userConfig.name, user);
console.warn(`⚠️ LiveKit déconnecté pour groupe "${groupName}":`, reason); console.log(`✓ Server audio user "${userConfig.name}" démarré (entrée canal ${userConfig.inputChannel} → sortie canal ${userConfig.outputChannel}, room: ${userConfig.groupId})`);
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`); console.log(`${this.serverAudioUsers.size} server audio user(s) initialisés`);
} }
/** /**
* Démarre le routing audio bidirectionnel complet * Démarre le routing audio : capture physique → server audio users
* @private * @private
*/ */
async _startAudioRouting() { 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) => { this.audioBackend.on('audioData', (pcmData) => {
try { try {
// Convertir PCM Buffer → Float32Array (pour GroupAudioRouter)
const float32Data = this._bufferToFloat32(pcmData); const float32Data = this._bufferToFloat32(pcmData);
// Séparer les canaux si audio multi-canaux (entrelacé)
const numChannels = this.options.channels || 1; const numChannels = this.options.channels || 1;
if (numChannels === 1) { if (numChannels === 1) {
// Mono : un seul canal this.inputChannelBuffers.set(0, float32Data);
const channelId = this.options.inputDeviceChannel || 0;
this.inputChannelBuffers.set(channelId, float32Data);
} else { } else {
// Multi-canaux : dé-entrelacer les samples
// Format entrelacé : [L0, R0, L1, R1, ...] → [L0, L1, ...] et [R0, R1, ...]
const samplesPerChannel = float32Data.length / numChannels; const samplesPerChannel = float32Data.length / numChannels;
for (let ch = 0; ch < numChannels; ch++) { for (let ch = 0; ch < numChannels; ch++) {
const channelBuffer = new Float32Array(samplesPerChannel); const channelBuffer = new Float32Array(samplesPerChannel);
for (let i = 0; i < samplesPerChannel; i++) { for (let i = 0; i < samplesPerChannel; i++) {
channelBuffer[i] = float32Data[i * numChannels + ch]; channelBuffer[i] = float32Data[i * numChannels + ch];
} }
this.inputChannelBuffers.set(ch, channelBuffer);
// Mapper canal hardware → canal logique (peut être configuré)
const logicalChannelId = this.options.channelMapping?.[ch] ?? ch;
this.inputChannelBuffers.set(logicalChannelId, channelBuffer);
} }
} }
// ÉTAPE 1 : Inputs physiques → Groupes (via GroupAudioRouter) // Alimenter chaque server audio user avec son canal d'entrée
const groupBuffers = this.groupAudioRouter.processInputsToGroups( for (const [, user] of this.serverAudioUsers) {
this.inputChannelBuffers const channelData = this.inputChannelBuffers.get(user.inputChannel);
); if (channelData) {
user.sendAudio(channelData);
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.framesCapture++;
this.stats.framesPlayback++;
} catch (error) { } catch (error) {
console.error('Erreur routing capture:', error); console.error('Erreur routing capture:', error);
this.stats.errors.capture++; 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.startCapture();
await this.audioBackend.startPlayback(); await this.audioBackend.startPlayback();
console.log('✓ Routing audio bidirectionnel actif'); console.log('✓ Routing audio actif');
console.log(' → Carte Son → GroupRouter → LiveKit → Clients'); console.log(' → Carte Son → Server Audio Users → LiveKit → Clients');
console.log(' ← Carte Son ← GroupRouter ← LiveKit ← Clients');
} }
/** /**
@@ -753,17 +518,12 @@ export class AudioBridge extends EventEmitter {
this.audioBackend = null; this.audioBackend = null;
} }
// Déconnecter tous les clients LiveKit // Arrêter les server audio users
for (const [groupName, client] of this.liveKitClients.entries()) { for (const [name, user] of this.serverAudioUsers.entries()) {
console.log(`🔌 Déconnexion LiveKit groupe "${groupName}"...`); console.log(`🔌 Arrêt server audio user "${name}"...`);
await client.destroy(); await user.stop();
}
this.liveKitClients.clear();
if (this.groupAudioRouter) {
this.groupAudioRouter.destroy();
this.groupAudioRouter = null;
} }
this.serverAudioUsers.clear();
if (this.jitterBuffer) { if (this.jitterBuffer) {
this.jitterBuffer.destroy(); this.jitterBuffer.destroy();
@@ -782,7 +542,6 @@ export class AudioBridge extends EventEmitter {
// Nettoyer les buffers // Nettoyer les buffers
this.inputChannelBuffers.clear(); this.inputChannelBuffers.clear();
this.groupBuffersFromLiveKit.clear();
// Nettoyer le pool de buffers // Nettoyer le pool de buffers
this.bufferPool.float32 = []; this.bufferPool.float32 = [];
+22 -26
View File
@@ -34,15 +34,12 @@ class AudioBridgeManager extends EventEmitter {
const config = configManager.get(); const config = configManager.get();
console.log('🎵 Démarrage AudioBridge avec configuration:', config.audio); console.log('🎵 Démarrage AudioBridge avec configuration:', config.audio);
// Générer un token JWT par groupe
const liveKitTokens = [];
// Fonction pour slugifier le nom (identique à admin.js) // Fonction pour slugifier le nom (identique à admin.js)
const slugify = (text) => { const slugify = (text) => {
return text return text
.toString() .toString()
.normalize('NFD') .normalize('NFD')
.replace(/[\u0300-\u036f]/g, '') .replace(/[̀-ͯ]/g, '')
.toLowerCase() .toLowerCase()
.trim() .trim()
.replace(/\s+/g, '-') .replace(/\s+/g, '-')
@@ -50,27 +47,27 @@ class AudioBridgeManager extends EventEmitter {
.replace(/--+/g, '-'); .replace(/--+/g, '-');
}; };
for (const group of config.groups || []) { // Générer un token JWT par server audio user
const groupId = slugify(group.name); const serverAudioUsers = [];
const groupName = group.name;
for (const user of config.server_audio_users || []) {
const groupId = slugify(user.group);
const token = new AccessToken( const token = new AccessToken(
config.server?.livekit?.apiKey || 'devkey', config.server?.livekit?.apiKey || 'devkey',
config.server?.livekit?.apiSecret || 'secret', config.server?.livekit?.apiSecret || 'secret',
{ {
identity: `AudioBridge-${groupId}`, identity: `server-${user.name}`,
name: `Audio Bridge - ${groupName}`, name: `Server Audio - ${user.name}`,
metadata: JSON.stringify({ metadata: JSON.stringify({
role: 'bridge', role: 'server-audio-user',
group: groupId, group: groupId
capabilities: ['audio-routing', 'monitoring']
}) })
} }
); );
// Permissions complètes pour ce groupe
token.addGrant({ token.addGrant({
room: groupId, // Chaque groupe a sa propre room room: groupId,
roomJoin: true, roomJoin: true,
canPublish: true, canPublish: true,
canSubscribe: true, canSubscribe: true,
@@ -78,15 +75,18 @@ class AudioBridgeManager extends EventEmitter {
}); });
const jwt = await token.toJwt(); const jwt = await token.toJwt();
liveKitTokens.push({ groupName, groupId, token: jwt });
console.log(`✓ Token JWT généré pour groupe "${groupName}" (room: ${groupId})`); const outputChannel = user.output_channel ?? user.outputChannel;
}
if (liveKitTokens.length === 0) { serverAudioUsers.push({
console.warn('⚠️ Aucun groupe configuré, AudioBridge ne pourra pas démarrer'); name: user.name,
this.isRunning = false; groupId,
return; inputChannel: user.input_channel ?? user.inputChannel ?? 0,
outputChannel: outputChannel !== null && outputChannel !== undefined ? outputChannel : null,
token: jwt
});
console.log(`✓ Token JWT généré pour server audio user "${user.name}" (room: ${groupId})`);
} }
// Import dynamique du AudioBridge // Import dynamique du AudioBridge
@@ -120,15 +120,11 @@ class AudioBridgeManager extends EventEmitter {
// Créer l'instance avec la config // Créer l'instance avec la config
this.bridge = new AudioBridge({ this.bridge = new AudioBridge({
...audioConfig, ...audioConfig,
// Options LiveKit (multi-rooms)
liveKitUrl, liveKitUrl,
liveKitTokens, // Tableau de { groupName, groupId, token } serverAudioUsers,
// Options de routing
routing: config.audio?.routing || {},
groups: config.groups || [], groups: config.groups || [],
maxInputChannels: 32, maxInputChannels: 32,
maxOutputChannels: 32, maxOutputChannels: 32,
// Device IDs extraits
inputDeviceId, inputDeviceId,
outputDeviceId outputDeviceId
}); });
-417
View File
@@ -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<string, AudioRoute[]>
// Routes : group -> output
this.groupToOutputRoutes = new Map(); // Map<string, AudioRoute[]>
// Buffers audio
this.inputBuffers = new Map(); // Map<number, Float32Array>
this.groupBuffers = new Map(); // Map<string, Float32Array>
this.outputBuffers = new Map(); // Map<number, Float32Array>
// 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<number, Float32Array>} 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<string, Float32Array>} groupBuffersData - Données PCM par groupe (depuis LiveKit)
* @returns {Map<number, Float32Array>} 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;
+192
View File
@@ -0,0 +1,192 @@
/**
* 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 = (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;
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<participantSid, { buffer: Float32Array, offset: number }>
// 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 ?? 'aucune'})`);
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;
if (this.outputChannel !== null) {
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;
+12 -29
View File
@@ -6,39 +6,22 @@ audio:
jitterBufferMs: 40 jitterBufferMs: 40
device: device:
inputDeviceId: Loopback Audio 4 inputDeviceId: Loopback Audio 4
outputDeviceId: Haut-parleurs MacBook Pro outputDeviceId: Périphérique agrégé
sampleRate: 48000 sampleRate: 48000
routing:
inputToGroup:
"0":
- default
"1": []
"2": []
"4":
- technique
"5":
- technique
groupToOutput:
technique:
- "1"
production:
- "0"
- "1"
default:
- "0"
gains: {}
channelNames: channelNames:
inputs: inputs:
"0": Mac "0": Loopback L
"1": Talkback FOH "1": Loopback R
"2": Retour Console
"3": Liaison Scène
"4": Monitor Mix
"5": Spare 1
outputs: outputs:
"0": L "0": Casque L
"1": R "1": Casque R
"2": Talkback Console "2": Mac L
"3": Mac R
server_audio_users:
- name: Utilisateur Serveur
group: default
input_channel: 0
output_channel: 0
groups: groups:
- name: Default - name: Default
audioBitrate: 96 audioBitrate: 96
+1 -19
View File
@@ -334,30 +334,12 @@ apiRouter.post('/token', async (req, res) => {
// Enregistrer l'utilisateur dans le système admin // Enregistrer l'utilisateur dans le système admin
registerUser(participantIdentity, username, groupId, roomName); 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({ res.json({
token, token,
url: LIVEKIT_URL, url: LIVEKIT_URL,
roomName, roomName,
participantIdentity, participantIdentity,
virtualChannels virtualChannels: []
}); });
} catch (error) { } catch (error) {