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.
This commit is contained in:
2026-07-03 14:55:37 +02:00
parent 9a2bec6d2f
commit bf960f49bb
5 changed files with 0 additions and 1076 deletions
-5
View File
@@ -1,6 +1,5 @@
import { useState, useEffect, useRef } from 'react';
import './Admin.css';
import AudioRoutingMatrix from './components/AudioRoutingMatrix';
const API_URL = import.meta.env.VITE_API_URL || '/api';
@@ -409,9 +408,6 @@ function Admin() {
</label>
</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">
<button type="submit" className="btn-primary">
@@ -609,7 +605,6 @@ function Admin() {
</div>
</div>
<AudioRoutingMatrix groups={groups} channelNames={channelNames} />
{currentDevice && currentDevice.inputDeviceId && (
<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;
-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
-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;