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:
@@ -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;
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user