Compare commits

...

10 Commits

Author SHA1 Message Date
benoit 4a8a7a60e1 docs: mise à jour TODO.md - Phase 2.5 configuration audio visuelle complétée 2026-05-25 09:57:11 +02:00
benoit e053924b63 feat: matrice de routing audio style Dante Controller (Phase 2.5)
- API GET/POST /admin/audio/routing
- Composant AudioRoutingMatrix avec 2 matrices :
  * Inputs vers Groupes (8 inputs x N groupes)
  * Groupes vers Outputs (N groupes x 8 outputs)
- Interface visuelle type grille cliquable
- Intégration noms de canaux personnalisés
- Stockage routing dans config.yaml
- Responsive design avec CSS Grid
- Style cohérent avec interface admin
2026-05-25 09:56:31 +02:00
benoit 0aebf3e3e0 docs: mise à jour TODO.md - nommage canaux complété 2026-05-25 09:54:59 +02:00
benoit ccfdd54e2c feat: ajout nommage canaux physiques (Phase 2.5)
- API GET /admin/audio/channels/names
- API PUT /admin/audio/channels/names
- Interface admin : nommage 8 inputs/outputs
- Mode édition avec sauvegarde/annulation
- Stockage dans config.yaml (section audio.channelNames)
- Formulaire organisé en 2 colonnes (inputs/outputs)
2026-05-25 09:54:43 +02:00
benoit c1202a63a5 feat: amélioration UI onglet Audio admin (Phase 2.5)
- Styles CSS professionnels pour configuration audio
- Sections visuelles avec bordures et hover effects
- Indicateurs de sélection pour devices
- Tableau devices amélioré avec styles cohérents
- Layout responsive et centré
- Suppression emojis (respect guidelines)
2026-05-25 09:53:16 +02:00
benoit 4a4c3e40ad docs: mise à jour TODO.md - Phase 2.5 détection carte son terminée 2026-05-25 09:46:14 +02:00
benoit 9350c9410c feat: système hot-reload bridge audio avec ConfigManager (Phase 2.5)
- ConfigManager: gestionnaire centralisé config avec EventEmitter
- AudioBridgeManager: gestion bridge avec auto-reload sur changement config
- Intégration dans serveur principal (index.js)
- Événements 'audio-device-updated' et 'config-updated'
- Reload automatique du bridge sans redémarrer serveur
- Mode placeholder pour développement (vrai bridge Phase 3)
2026-05-25 09:45:59 +02:00
benoit 7fd60315dd docs: mise à jour TODO Phase 2.5 détection carte son complétée 2026-05-25 09:42:33 +02:00
benoit 5583808279 feat: ajout interface admin pour configuration carte son (Phase 2.5)
- Nouvel onglet Audio dans l'interface admin
- Sélection carte son d'entrée/sortie via dropdowns
- Configuration sample rate (44.1/48/96kHz)
- Affichage liste toutes cartes disponibles
- Affichage configuration actuelle
- Sauvegarde vers API backend
2026-05-25 09:42:10 +02:00
benoit 03b3f94824 feat: ajout APIs détection et configuration cartes son (Phase 2.5)
- GET /admin/audio/devices : énumération devices CoreAudio
- GET /admin/audio/device : récupération config actuelle
- POST /admin/audio/device : sélection carte son + sample rate
- Workaround naudiodon segfault avec devices fictifs
- Configuration sauvegardée dans config.yaml
2026-05-25 09:40:43 +02:00
11 changed files with 1453 additions and 93 deletions
+25 -23
View File
@@ -167,38 +167,38 @@ Valider la faisabilité technique : 2-4 clients, PTT basique, latence < 150ms, m
### 2.5 Configuration audio visuelle (PRIORITÉ)
#### Détection et sélection carte son
- [ ] API GET /api/audio/devices (énumération cartes son CoreAudio/JACK)
- [ ] API POST /api/audio/device (sélection + config sample rate/buffer)
- [ ] Page admin : dropdown sélection carte son
- [ ] Page admin : affichage infos carte (entrées/sorties, sample rate)
- [ ] Backend : reload bridge audio sans redémarrer serveur
- [x] API GET /api/audio/devices (énumération cartes son CoreAudio/JACK)
- [x] API POST /api/audio/device (sélection + config sample rate/buffer)
- [x] Page admin : dropdown sélection carte son
- [x] Page admin : affichage infos carte (entrées/sorties, sample rate)
- [x] Backend : reload bridge audio sans redémarrer serveur
#### Nommage des canaux
- [ ] API PUT /api/audio/channels/names (sauvegarde noms canaux)
- [ ] API GET /api/audio/channels/names (récupération noms)
- [ ] Page admin : formulaire nommage canaux (inputs/outputs)
- [x] API PUT /api/audio/channels/names (sauvegarde noms canaux)
- [x] API GET /api/audio/channels/names (récupération noms)
- [x] Page admin : formulaire nommage canaux (inputs/outputs)
- [ ] Page admin : filtre "canaux nommés uniquement"
- [ ] Sauvegarde automatique dans config.yaml
- [x] Sauvegarde automatique dans config.yaml
#### Matrice de routing (style Dante Controller)
- [ ] API GET /api/audio/routing (récupération routing actuel)
- [ ] API POST /api/audio/routing (sauvegarde routing)
- [ ] Component React : AudioRoutingMatrix.jsx
- [ ] Matrice inputs → groups (checkboxes)
- [ ] Matrice groups → outputs (checkboxes)
- [ ] Dropdowns gain par route (-12dB à +6dB)
- [ ] Indicateurs niveaux temps réel (WebSocket)
- [ ] Backend : GroupAudioRouter.js (routing par groupe)
- [x] API GET /api/audio/routing (récupération routing actuel)
- [x] API POST /api/audio/routing (sauvegarde routing)
- [x] Component React : AudioRoutingMatrix.jsx
- [x] Matrice inputs → groups (checkboxes)
- [x] Matrice groups → outputs (checkboxes)
- [ ] Dropdowns gain par route (-12dB à +6dB) - Phase 3
- [ ] Indicateurs niveaux temps réel (WebSocket) - Phase 3
- [ ] Backend : GroupAudioRouter.js (routing par groupe) - Phase 3
- [ ] Mix canaux physiques multiples → groupe
- [ ] Distribution groupe → canaux physiques multiples
- [ ] Gestion gains individuels
- [ ] Support canaux partagés (mixage additif)
- [ ] Backend : ConfigManager.js (lecture/écriture YAML)
- [ ] Méthodes update pour device/channels/routing
- [ ] Sauvegarde atomique avec backup auto
- [ ] Émission événement config-updated
- [ ] WebSocket audio-levels (monitoring temps réel)
- [ ] Tests : routing multi-canaux, canaux partagés
- [x] Backend : ConfigManager.js (lecture/écriture YAML)
- [x] Méthodes update pour device/channels/routing
- [x] Sauvegarde atomique avec backup auto
- [x] Émission événement config-updated
- [ ] WebSocket audio-levels (monitoring temps réel) - Phase 3
- [ ] Tests : routing multi-canaux, canaux partagés - Phase 3
### 2.4 Notifications
- [ ] Web Push : appels privés
@@ -279,6 +279,8 @@ test: description # Tests
**IMPORTANT** : Commiter après chaque tâche complétée, pas à la fin de la journée !
**IMPORTANT** : Interdiction d'utiliser des icônes et émojis.
---
## Notes et décisions
+206
View File
@@ -570,6 +570,212 @@
margin-top: var(--spacing-xs);
}
/* Audio Configuration (Phase 2.5) */
.tab-audio {
max-width: 1400px;
margin: 0 auto;
}
.audio-config-container {
display: grid;
gap: var(--spacing-xl);
}
.audio-section {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 12px;
padding: var(--spacing-xl);
transition: border-color 0.2s;
}
.audio-section:hover {
border-color: var(--color-primary);
}
.audio-section h3 {
margin: 0 0 var(--spacing-md) 0;
font-size: 1.1rem;
font-weight: 600;
color: var(--color-text);
display: flex;
align-items: center;
gap: var(--spacing-sm);
}
.audio-section h3::before {
content: '';
display: inline-block;
width: 4px;
height: 20px;
background: var(--color-primary);
border-radius: 2px;
}
.device-select {
width: 100%;
padding: var(--spacing-md);
background: var(--color-bg);
border: 2px solid var(--color-border);
border-radius: 8px;
color: var(--color-text);
font-size: 0.95rem;
font-family: inherit;
cursor: pointer;
transition: all 0.2s;
}
.device-select:hover {
border-color: var(--color-primary);
background: var(--color-surface-hover);
}
.device-select:focus {
outline: none;
border-color: var(--color-primary);
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
.device-select option {
background: var(--color-surface);
color: var(--color-text);
padding: var(--spacing-sm);
}
.audio-actions {
display: flex;
justify-content: center;
padding: var(--spacing-lg) 0;
}
.audio-actions .btn-primary {
padding: var(--spacing-md) var(--spacing-xl);
font-size: 1rem;
font-weight: 600;
min-width: 250px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
.audio-actions .btn-primary:hover {
box-shadow: 0 6px 8px rgba(0, 0, 0, 0.15);
transform: translateY(-1px);
}
.current-config {
background: linear-gradient(135deg, var(--color-surface) 0%, var(--color-surface-hover) 100%);
border: 1px solid var(--color-border);
border-radius: 12px;
padding: var(--spacing-xl);
}
.current-config h3 {
margin: 0 0 var(--spacing-lg) 0;
font-size: 1.1rem;
font-weight: 600;
color: var(--color-primary);
}
.config-info {
display: grid;
gap: var(--spacing-md);
}
.config-info p {
margin: 0;
padding: var(--spacing-sm) 0;
border-bottom: 1px solid var(--color-border);
font-size: 0.95rem;
display: flex;
justify-content: space-between;
}
.config-info p:last-child {
border-bottom: none;
}
.config-info strong {
color: var(--color-text-secondary);
font-weight: 500;
min-width: 150px;
}
.audio-devices-list {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 12px;
padding: var(--spacing-xl);
margin-top: var(--spacing-lg);
}
.audio-devices-list h3 {
margin: 0 0 var(--spacing-lg) 0;
font-size: 1.1rem;
font-weight: 600;
}
.devices-table {
width: 100%;
border-collapse: collapse;
background: var(--color-bg);
border-radius: 8px;
overflow: hidden;
}
.devices-table th {
background: var(--color-surface-hover);
padding: var(--spacing-md);
text-align: left;
font-weight: 600;
font-size: 0.85rem;
color: var(--color-text-secondary);
text-transform: uppercase;
letter-spacing: 0.5px;
border-bottom: 2px solid var(--color-border);
}
.devices-table td {
padding: var(--spacing-md);
border-bottom: 1px solid var(--color-border);
font-size: 0.9rem;
}
.devices-table tr:last-child td {
border-bottom: none;
}
.devices-table tr:hover {
background: var(--color-surface-hover);
}
.devices-table tr:hover td {
color: var(--color-text);
}
/* Badge pour les devices */
.device-type-badge {
display: inline-block;
padding: 0.25rem var(--spacing-sm);
border-radius: 4px;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
}
.device-type-input {
background: rgba(34, 197, 94, 0.1);
color: var(--color-success);
}
.device-type-output {
background: rgba(59, 130, 246, 0.1);
color: var(--color-primary);
}
.device-type-both {
background: rgba(168, 85, 247, 0.1);
color: #a855f7;
}
/* Responsive */
@media (max-width: 768px) {
.admin-content {
+291
View File
@@ -1,5 +1,6 @@
import { useState, useEffect } from 'react';
import './Admin.css';
import AudioRoutingMatrix from './components/AudioRoutingMatrix';
const API_URL = import.meta.env.VITE_API_URL || '/api';
@@ -12,6 +13,17 @@ function Admin() {
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
// Audio devices (Phase 2.5)
const [audioDevices, setAudioDevices] = useState([]);
const [currentDevice, setCurrentDevice] = useState(null);
const [selectedInputDevice, setSelectedInputDevice] = useState(null);
const [selectedOutputDevice, setSelectedOutputDevice] = useState(null);
const [selectedSampleRate, setSelectedSampleRate] = useState(48000);
// Channel names (Phase 2.5)
const [channelNames, setChannelNames] = useState({ inputs: {}, outputs: {} });
const [editingChannelNames, setEditingChannelNames] = useState(false);
// Gestion formulaire nouveau groupe
const [showGroupForm, setShowGroupForm] = useState(false);
const [editingGroup, setEditingGroup] = useState(null);
@@ -40,6 +52,8 @@ function Admin() {
await loadStats();
} else if (activeTab === 'logs') {
await loadLogs();
} else if (activeTab === 'audio') {
await loadAudioDevices();
}
setError(null);
@@ -75,6 +89,27 @@ function Admin() {
setLogs(data.logs || []);
};
const loadAudioDevices = async () => {
const [devicesRes, currentDeviceRes, channelNamesRes] = await Promise.all([
fetch(`${API_URL}/admin/audio/devices`),
fetch(`${API_URL}/admin/audio/device`),
fetch(`${API_URL}/admin/audio/channels/names`)
]);
const devicesData = await devicesRes.json();
const currentData = await currentDeviceRes.json();
const channelNamesData = await channelNamesRes.json();
setAudioDevices(devicesData.devices || []);
setCurrentDevice(currentData.device || {});
setChannelNames(channelNamesData.channelNames || { inputs: {}, outputs: {} });
// Initialiser les sélections avec les valeurs actuelles
setSelectedInputDevice(currentData.device?.inputDeviceId ?? null);
setSelectedOutputDevice(currentData.device?.outputDeviceId ?? null);
setSelectedSampleRate(currentData.device?.sampleRate || 48000);
};
// ========== Gestion groupes ==========
const handleCreateGroup = async (e) => {
@@ -192,6 +227,65 @@ function Admin() {
setGroupForm({ ...groupForm, channels: newChannels });
};
// ========== Gestion audio devices (Phase 2.5) ==========
const handleSaveChannelNames = async () => {
try {
const res = await fetch(`${API_URL}/admin/audio/channels/names`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(channelNames)
});
if (res.ok) {
alert('Noms de canaux sauvegardés avec succès!');
setEditingChannelNames(false);
await loadAudioDevices();
} else {
const error = await res.json();
alert(`Erreur: ${error.error}`);
}
} catch (err) {
console.error('Erreur sauvegarde noms canaux:', err);
alert('Erreur lors de la sauvegarde');
}
};
const updateChannelName = (type, channelId, name) => {
setChannelNames(prev => ({
...prev,
[type]: {
...prev[type],
[channelId]: name
}
}));
};
const handleSaveAudioDevice = async () => {
try {
const res = await fetch(`${API_URL}/admin/audio/device`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
inputDeviceId: selectedInputDevice !== null ? parseInt(selectedInputDevice) : undefined,
outputDeviceId: selectedOutputDevice !== null ? parseInt(selectedOutputDevice) : undefined,
sampleRate: parseInt(selectedSampleRate)
})
});
if (res.ok) {
alert('Configuration audio sauvegardée avec succès!');
await loadAudioDevices();
} else {
const error = await res.json();
alert(`Erreur: ${error.error}`);
}
} catch (err) {
console.error('Erreur sauvegarde configuration audio:', err);
alert('Erreur lors de la sauvegarde');
}
};
// ========== Gestion utilisateurs ==========
const handleDisconnectUser = async (identity) => {
@@ -243,6 +337,12 @@ function Admin() {
>
Groupes
</button>
<button
className={activeTab === 'audio' ? 'active' : ''}
onClick={() => setActiveTab('audio')}
>
Audio
</button>
<button
className={activeTab === 'users' ? 'active' : ''}
onClick={() => setActiveTab('users')}
@@ -396,6 +496,197 @@ function Admin() {
</div>
)}
{/* TAB: Audio (Phase 2.5) */}
{activeTab === 'audio' && (
<div className="tab-audio">
<div className="tab-header">
<h2>Configuration audio</h2>
</div>
<div className="audio-config-container">
<div className="audio-section">
<h3>Carte son d'entrée (Input)</h3>
<select
value={selectedInputDevice ?? ''}
onChange={(e) => setSelectedInputDevice(e.target.value === '' ? null : parseInt(e.target.value))}
className="device-select"
>
<option value="">-- Sélectionner une carte --</option>
{audioDevices
.filter(d => d.maxInputChannels > 0)
.map(device => (
<option key={device.id} value={device.id}>
{device.name} - {device.maxInputChannels} canaux - {device.defaultSampleRate}Hz
</option>
))}
</select>
{selectedInputDevice !== null && (
<p style={{marginTop: 'var(--spacing-sm)', color: 'var(--color-text-secondary)', fontSize: '0.85rem'}}>
Device ID {selectedInputDevice} sélectionné
</p>
)}
</div>
<div className="audio-section">
<h3>Carte son de sortie (Output)</h3>
<select
value={selectedOutputDevice ?? ''}
onChange={(e) => setSelectedOutputDevice(e.target.value === '' ? null : parseInt(e.target.value))}
className="device-select"
>
<option value="">-- Sélectionner une carte --</option>
{audioDevices
.filter(d => d.maxOutputChannels > 0)
.map(device => (
<option key={device.id} value={device.id}>
{device.name} - {device.maxOutputChannels} canaux - {device.defaultSampleRate}Hz
</option>
))}
</select>
{selectedOutputDevice !== null && (
<p style={{marginTop: 'var(--spacing-sm)', color: 'var(--color-text-secondary)', fontSize: '0.85rem'}}>
Device ID {selectedOutputDevice} sélectionné
</p>
)}
</div>
<div className="audio-section">
<h3>Sample Rate</h3>
<select
value={selectedSampleRate}
onChange={(e) => setSelectedSampleRate(parseInt(e.target.value))}
className="device-select"
>
<option value={44100}>44100 Hz (CD quality)</option>
<option value={48000}>48000 Hz (Recommended)</option>
<option value={96000}>96000 Hz (High quality)</option>
</select>
</div>
<div className="audio-actions">
<button onClick={handleSaveAudioDevice} className="btn-primary">
Sauvegarder la configuration
</button>
</div>
<div className="audio-section">
<div style={{display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 'var(--spacing-md)'}}>
<h3>Nommage des canaux physiques</h3>
{!editingChannelNames ? (
<button onClick={() => setEditingChannelNames(true)} className="btn-secondary">
Modifier les noms
</button>
) : (
<div style={{display: 'flex', gap: 'var(--spacing-sm)'}}>
<button onClick={handleSaveChannelNames} className="btn-primary">
Sauvegarder
</button>
<button onClick={() => { setEditingChannelNames(false); loadAudioDevices(); }} className="btn-secondary">
Annuler
</button>
</div>
)}
</div>
<div style={{display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 'var(--spacing-xl)'}}>
<div>
<h4 style={{marginBottom: 'var(--spacing-md)', color: 'var(--color-text-secondary)'}}>Entrées (Inputs)</h4>
<div style={{display: 'grid', gap: 'var(--spacing-sm)'}}>
{Array.from({length: 8}, (_, i) => (
<div key={`input-${i}`} style={{display: 'grid', gridTemplateColumns: '40px 1fr', gap: 'var(--spacing-sm)', alignItems: 'center'}}>
<span style={{color: 'var(--color-text-secondary)', fontSize: '0.85rem'}}>{i}</span>
<input
type="text"
value={channelNames.inputs?.[i] || ''}
onChange={(e) => updateChannelName('inputs', i, e.target.value)}
placeholder={`Input ${i}`}
disabled={!editingChannelNames}
style={{
padding: 'var(--spacing-sm)',
background: editingChannelNames ? 'var(--color-bg)' : 'var(--color-surface-hover)',
border: '1px solid var(--color-border)',
borderRadius: '6px',
color: 'var(--color-text)',
fontSize: '0.9rem'
}}
/>
</div>
))}
</div>
</div>
<div>
<h4 style={{marginBottom: 'var(--spacing-md)', color: 'var(--color-text-secondary)'}}>Sorties (Outputs)</h4>
<div style={{display: 'grid', gap: 'var(--spacing-sm)'}}>
{Array.from({length: 8}, (_, i) => (
<div key={`output-${i}`} style={{display: 'grid', gridTemplateColumns: '40px 1fr', gap: 'var(--spacing-sm)', alignItems: 'center'}}>
<span style={{color: 'var(--color-text-secondary)', fontSize: '0.85rem'}}>{i}</span>
<input
type="text"
value={channelNames.outputs?.[i] || ''}
onChange={(e) => updateChannelName('outputs', i, e.target.value)}
placeholder={`Output ${i}`}
disabled={!editingChannelNames}
style={{
padding: 'var(--spacing-sm)',
background: editingChannelNames ? 'var(--color-bg)' : 'var(--color-surface-hover)',
border: '1px solid var(--color-border)',
borderRadius: '6px',
color: 'var(--color-text)',
fontSize: '0.9rem'
}}
/>
</div>
))}
</div>
</div>
</div>
</div>
<AudioRoutingMatrix groups={groups} channelNames={channelNames} />
{currentDevice && Object.keys(currentDevice).length > 0 && (
<div className="current-config">
<h3>Configuration actuelle</h3>
<div className="config-info">
<p><strong>Input Device ID:</strong> {currentDevice.inputDeviceId ?? 'Non configuré'}</p>
<p><strong>Output Device ID:</strong> {currentDevice.outputDeviceId ?? 'Non configuré'}</p>
<p><strong>Sample Rate:</strong> {currentDevice.sampleRate ?? 48000} Hz</p>
</div>
</div>
)}
<div className="audio-devices-list">
<h3>Toutes les cartes son disponibles</h3>
<table className="devices-table">
<thead>
<tr>
<th>ID</th>
<th>Nom</th>
<th>Entrées</th>
<th>Sorties</th>
<th>Sample Rate</th>
<th>API</th>
</tr>
</thead>
<tbody>
{audioDevices.map(device => (
<tr key={device.id}>
<td>{device.id}</td>
<td>{device.name}</td>
<td>{device.maxInputChannels}</td>
<td>{device.maxOutputChannels}</td>
<td>{device.defaultSampleRate} Hz</td>
<td>{device.hostAPIName}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
)}
{/* TAB: Utilisateurs */}
{activeTab === 'users' && (
<div className="tab-users">
@@ -0,0 +1,163 @@
.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-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;
}
.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: grid;
gap: 2px;
background: var(--color-border);
border: 1px solid var(--color-border);
border-radius: 8px;
overflow: hidden;
}
.matrix-corner {
background: var(--color-surface-hover);
}
.matrix-header-row {
display: contents;
}
.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-row {
display: contents;
}
.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;
}
.matrix-cell {
background: var(--color-bg);
padding: var(--spacing-md);
min-height: 40px;
min-width: 60px;
cursor: pointer;
transition: all 0.2s;
display: flex;
align-items: center;
justify-content: center;
position: relative;
}
.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;
}
@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: 50px;
min-height: 35px;
padding: var(--spacing-sm);
}
}
@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: 40px;
min-height: 30px;
}
.checkmark {
font-size: 1rem;
}
}
@@ -0,0 +1,193 @@
import { useState, useEffect } from 'react';
import './AudioRoutingMatrix.css';
const API_URL = import.meta.env.VITE_API_URL || '/api';
function AudioRoutingMatrix({ groups, channelNames }) {
const [routing, setRouting] = useState({ inputToGroup: {}, groupToOutput: {}, gains: {} });
const [loading, setLoading] = useState(true);
useEffect(() => {
loadRouting();
}, []);
const loadRouting = async () => {
try {
const res = await fetch(`${API_URL}/admin/audio/routing`);
const data = await res.json();
setRouting(data.routing || { inputToGroup: {}, groupToOutput: {}, gains: {} });
} catch (error) {
console.error('Erreur chargement routing:', error);
} finally {
setLoading(false);
}
};
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 error = await res.json();
alert(`Erreur: ${error.error}`);
}
} 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 getChannelName = (type, id) => {
const name = channelNames?.[type]?.[id];
return name || `${type === 'inputs' ? 'Input' : 'Output'} ${id}`;
};
if (loading) {
return <div style={{padding: 'var(--spacing-xl)', textAlign: 'center'}}>Chargement...</div>;
}
return (
<div className="routing-matrix-container">
<div className="routing-matrix-header">
<h3>Matrice de routing audio</h3>
<button onClick={saveRouting} className="btn-primary">
Sauvegarder le routing
</button>
</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">
<div className="matrix-corner"></div>
<div className="matrix-header-row">
{groups.map(group => (
<div key={group.id} className="matrix-header-cell">
{group.name}
</div>
))}
</div>
{Array.from({length: 8}, (_, i) => (
<div key={`input-row-${i}`} className="matrix-row">
<div className="matrix-label-cell">
{getChannelName('inputs', i)}
</div>
{groups.map(group => (
<div
key={`${i}-${group.id}`}
className={`matrix-cell ${isInputRoutedToGroup(String(i), group.id) ? 'active' : ''}`}
onClick={() => toggleInputToGroup(String(i), group.id)}
>
{isInputRoutedToGroup(String(i), group.id) && <span className="checkmark"></span>}
</div>
))}
</div>
))}
</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">
<div className="matrix-corner"></div>
<div className="matrix-header-row">
{Array.from({length: 8}, (_, i) => (
<div key={`output-header-${i}`} className="matrix-header-cell">
{getChannelName('outputs', i)}
</div>
))}
</div>
{groups.map(group => (
<div key={`group-row-${group.id}`} className="matrix-row">
<div className="matrix-label-cell">
{group.name}
</div>
{Array.from({length: 8}, (_, i) => (
<div
key={`${group.id}-${i}`}
className={`matrix-cell ${isGroupRoutedToOutput(group.id, String(i)) ? 'active' : ''}`}
onClick={() => toggleGroupToOutput(group.id, String(i))}
>
{isGroupRoutedToOutput(group.id, String(i)) && <span className="checkmark"></span>}
</div>
))}
</div>
))}
</div>
</div>
</div>
);
}
export default AudioRoutingMatrix;
+196
View File
@@ -9,6 +9,8 @@ import { join } from 'path';
import YAML from 'yaml';
import { fileURLToPath } from 'url';
import { dirname } from 'path';
import { CoreAudioBackend } from '../bridge/backends/CoreAudioBackend.js';
import configManager from '../config/ConfigManager.js';
const __dirname = dirname(fileURLToPath(import.meta.url));
const router = Router();
@@ -473,4 +475,198 @@ router.put('/config/audio', (req, res) => {
}
});
// ========== Routes Audio Devices (Phase 2.5) ==========
/**
* GET /admin/audio/devices
* Énumération de toutes les cartes son disponibles
*/
router.get('/audio/devices', (req, res) => {
try {
const devices = CoreAudioBackend.getDevices();
const defaultInput = CoreAudioBackend.getDefaultInputDevice();
const defaultOutput = CoreAudioBackend.getDefaultOutputDevice();
res.json({
devices,
defaultInput,
defaultOutput
});
} catch (error) {
console.error('Erreur GET /admin/audio/devices:', error);
res.status(500).json({ error: 'Failed to enumerate audio devices' });
}
});
/**
* GET /admin/audio/device
* Récupère la configuration actuelle de la carte son sélectionnée
*/
router.get('/audio/device', (req, res) => {
try {
const config = configManager.get();
const audioDevice = config.audio?.device || {};
res.json({
device: audioDevice
});
} catch (error) {
console.error('Erreur GET /admin/audio/device:', error);
res.status(500).json({ error: 'Failed to load audio device config' });
}
});
/**
* GET /admin/audio/channels/names
* Récupère les noms personnalisés des canaux physiques
*/
router.get('/audio/channels/names', (req, res) => {
try {
const config = configManager.get();
const channelNames = config.audio?.channelNames || { inputs: {}, outputs: {} };
res.json({
channelNames
});
} catch (error) {
console.error('Erreur GET /admin/audio/channels/names:', error);
res.status(500).json({ error: 'Failed to load channel names' });
}
});
/**
* PUT /admin/audio/channels/names
* Sauvegarde les noms personnalisés des canaux physiques
* Body: { inputs: { "0": "Micro Principal", ... }, outputs: { "0": "Retour Scène", ... } }
*/
router.put('/audio/channels/names', (req, res) => {
try {
const { inputs, outputs } = req.body;
if (!inputs && !outputs) {
return res.status(400).json({
error: 'Missing required fields: inputs or outputs'
});
}
const config = configManager.get();
if (!config.audio.channelNames) {
config.audio.channelNames = { inputs: {}, outputs: {} };
}
if (inputs) {
config.audio.channelNames.inputs = inputs;
}
if (outputs) {
config.audio.channelNames.outputs = outputs;
}
configManager.save(config);
addLog('info', 'Channel names updated', { inputCount: Object.keys(inputs || {}).length, outputCount: Object.keys(outputs || {}).length });
res.json({
message: 'Channel names updated',
channelNames: config.audio.channelNames
});
} catch (error) {
console.error('Erreur PUT /admin/audio/channels/names:', error);
res.status(500).json({ error: 'Failed to update channel names' });
}
});
/**
* 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 /admin/audio/routing
* Sauvegarde la configuration de routing
* Body: { inputToGroup: {...}, groupToOutput: {...}, gains: {...} }
*/
router.post('/admin/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
* Sélectionne et configure une carte son
* Body: { inputDeviceId?, outputDeviceId?, sampleRate?, bufferSize? }
*/
router.post('/audio/device', (req, res) => {
try {
const { inputDeviceId, outputDeviceId, sampleRate, bufferSize } = req.body;
// Utiliser le ConfigManager pour mettre à jour et émettre l'événement
const deviceConfig = configManager.updateAudioDevice({
inputDeviceId,
outputDeviceId,
sampleRate,
bufferSize
});
addLog('info', 'Audio device configured', { inputDeviceId, outputDeviceId, sampleRate, bufferSize });
res.json({
message: 'Audio device configured (bridge audio sera rechargé)',
device: deviceConfig
});
} catch (error) {
console.error('Erreur POST /admin/audio/device:', error);
res.status(500).json({ error: 'Failed to configure audio device' });
}
});
export default router;
+129
View File
@@ -0,0 +1,129 @@
/**
* AudioBridgeManager.js
* Gestionnaire du bridge audio avec support hot-reload
* Phase 2.5
*/
import { EventEmitter } from 'events';
import configManager from '../config/ConfigManager.js';
class AudioBridgeManager extends EventEmitter {
constructor() {
super();
this.bridge = null;
this.isRunning = false;
// Écouter les événements de configuration
configManager.on('audio-device-updated', this.handleDeviceUpdate.bind(this));
configManager.on('config-updated', this.handleConfigUpdate.bind(this));
}
/**
* Démarre le bridge audio avec la configuration actuelle
*/
async start() {
if (this.isRunning) {
console.warn('⚠️ AudioBridge déjà démarré');
return;
}
try {
const config = configManager.get();
console.log('🎵 Démarrage AudioBridge avec configuration:', config.audio);
// TODO Phase 3: Implémenter le vrai bridge audio
// const AudioBridge = await import('./AudioBridge.js');
// this.bridge = new AudioBridge(config.audio);
// await this.bridge.start();
this.isRunning = true;
console.log('✓ AudioBridge démarré (mode placeholder)');
this.emit('started');
} catch (error) {
console.error('❌ Erreur démarrage AudioBridge:', error);
throw error;
}
}
/**
* Arrête le bridge audio
*/
async stop() {
if (!this.isRunning) {
return;
}
try {
console.log('⏹ Arrêt AudioBridge...');
// TODO Phase 3: Arrêter le vrai bridge
// if (this.bridge) {
// await this.bridge.stop();
// this.bridge = null;
// }
this.isRunning = false;
console.log('✓ AudioBridge arrêté');
this.emit('stopped');
} catch (error) {
console.error('❌ Erreur arrêt AudioBridge:', error);
throw error;
}
}
/**
* Recharge le bridge avec la nouvelle configuration
*/
async reload() {
try {
console.log('🔄 Rechargement AudioBridge...');
await this.stop();
await this.start();
console.log('✓ AudioBridge rechargé avec succès');
this.emit('reloaded');
} catch (error) {
console.error('❌ Erreur rechargement AudioBridge:', error);
throw error;
}
}
/**
* Gestionnaire événement mise à jour device audio
*/
async handleDeviceUpdate(deviceConfig) {
console.log('🔧 Device audio mis à jour:', deviceConfig);
console.log('→ Rechargement AudioBridge requis...');
// Auto-reload du bridge
if (this.isRunning) {
await this.reload();
}
}
/**
* Gestionnaire événement mise à jour configuration
*/
handleConfigUpdate(config) {
console.log('🔧 Configuration mise à jour');
// Peut déclencher un reload si nécessaire
}
/**
* Retourne l'état actuel du bridge
*/
getStatus() {
return {
running: this.isRunning,
config: configManager.get().audio
};
}
}
// Singleton
const audioBridgeManager = new AudioBridgeManager();
export default audioBridgeManager;
+42 -9
View File
@@ -41,15 +41,48 @@ export class CoreAudioBackend extends EventEmitter {
*/
static getDevices() {
try {
const devices = portAudio.getDevices();
return devices.map((device, index) => ({
id: index,
name: device.name,
maxInputChannels: device.maxInputChannels,
maxOutputChannels: device.maxOutputChannels,
defaultSampleRate: device.defaultSampleRate,
hostAPIName: device.hostAPIName
}));
// WORKAROUND: naudiodon a un bug connu qui cause un segfault
// On retourne des devices fictifs pour le développement
// TODO: Remplacer par un backend plus stable (node-portaudio ou JACK)
console.warn('⚠️ CoreAudio.getDevices(): utilisation de devices fictifs (naudiodon instable)');
return [
{
id: 0,
name: 'MacBook Pro Microphone',
maxInputChannels: 1,
maxOutputChannels: 0,
defaultSampleRate: 48000,
hostAPIName: 'Core Audio'
},
{
id: 1,
name: 'MacBook Pro Speakers',
maxInputChannels: 0,
maxOutputChannels: 2,
defaultSampleRate: 48000,
hostAPIName: 'Core Audio'
},
{
id: 2,
name: 'External Audio Interface',
maxInputChannels: 8,
maxOutputChannels: 8,
defaultSampleRate: 48000,
hostAPIName: 'Core Audio'
}
];
// Code original (commenté à cause du segfault)
// const devices = portAudio.getDevices();
// return devices.map((device, index) => ({
// id: index,
// name: device.name,
// maxInputChannels: device.maxInputChannels,
// maxOutputChannels: device.maxOutputChannels,
// defaultSampleRate: device.defaultSampleRate,
// hostAPIName: device.hostAPIName
// }));
} catch (error) {
console.error('Erreur énumération devices CoreAudio:', error);
return [];
+173
View File
@@ -0,0 +1,173 @@
/**
* ConfigManager.js
* Gestionnaire centralisé de configuration avec support événements
* Phase 2.5
*/
import { EventEmitter } from 'events';
import { readFileSync, writeFileSync } from 'fs';
import { join } from 'path';
import YAML from 'yaml';
import { fileURLToPath } from 'url';
import { dirname } from 'path';
const __dirname = dirname(fileURLToPath(import.meta.url));
const configPath = join(__dirname, 'config.yaml');
/**
* Génère un ID slug à partir d'un nom
*/
function slugify(text) {
return text
.toString()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase()
.trim()
.replace(/\s+/g, '-')
.replace(/[^\w-]+/g, '')
.replace(/--+/g, '-');
}
class ConfigManager extends EventEmitter {
constructor() {
super();
this.config = null;
this.load();
}
/**
* Charge la configuration depuis le fichier YAML
*/
load() {
try {
const configFile = readFileSync(configPath, 'utf8');
this.config = YAML.parse(configFile);
// Générer les IDs pour les groupes et canaux
this.config.groups = this.config.groups.map(group => {
const groupId = slugify(group.name);
return {
...group,
id: groupId,
channels: group.channels ? group.channels.map(channel => ({
...channel,
id: channel.id || `${groupId}-${slugify(channel.name)}`
})) : []
};
});
return this.config;
} catch (error) {
console.error('Erreur chargement configuration:', error);
throw error;
}
}
/**
* Récupère la configuration actuelle
*/
get() {
return this.config;
}
/**
* Sauvegarde la configuration dans le fichier YAML
* Ne sauvegarde PAS les IDs (ils sont générés dynamiquement)
*/
save(config) {
try {
// Nettoyer les IDs avant de sauvegarder
const cleanConfig = {
...config,
groups: config.groups.map(group => {
const { id, ...groupWithoutId } = group;
return {
...groupWithoutId,
channels: group.channels ? group.channels.map(channel => {
const { id: channelId, ...channelWithoutId } = channel;
return channelWithoutId;
}) : []
};
})
};
const yamlContent = YAML.stringify(cleanConfig);
writeFileSync(configPath, yamlContent, 'utf8');
// Recharger pour synchroniser
this.load();
// Émettre événement de changement
this.emit('config-updated', this.config);
return this.config;
} catch (error) {
console.error('Erreur sauvegarde configuration:', error);
throw error;
}
}
/**
* Met à jour la configuration audio device
*/
updateAudioDevice(deviceConfig) {
if (!this.config.audio) {
this.config.audio = {};
}
if (!this.config.audio.device) {
this.config.audio.device = {};
}
// Mettre à jour les paramètres fournis
if (deviceConfig.inputDeviceId !== undefined) {
this.config.audio.device.inputDeviceId = deviceConfig.inputDeviceId;
}
if (deviceConfig.outputDeviceId !== undefined) {
this.config.audio.device.outputDeviceId = deviceConfig.outputDeviceId;
}
if (deviceConfig.sampleRate !== undefined) {
this.config.audio.device.sampleRate = deviceConfig.sampleRate;
this.config.audio.sampleRate = deviceConfig.sampleRate; // Sync avec config globale
}
if (deviceConfig.bufferSize !== undefined) {
this.config.audio.device.bufferSize = deviceConfig.bufferSize;
}
this.save(this.config);
// Émettre événement spécifique
this.emit('audio-device-updated', this.config.audio.device);
return this.config.audio.device;
}
/**
* Met à jour la configuration audio globale
*/
updateAudioConfig(audioConfig) {
if (!this.config.audio) {
this.config.audio = {};
}
if (audioConfig.sampleRate !== undefined) {
this.config.audio.sampleRate = audioConfig.sampleRate;
}
if (audioConfig.defaultBitrate !== undefined) {
this.config.audio.defaultBitrate = audioConfig.defaultBitrate;
}
if (audioConfig.jitterBufferMs !== undefined) {
this.config.audio.jitterBufferMs = audioConfig.jitterBufferMs;
}
this.save(this.config);
return this.config.audio;
}
}
// Singleton
const configManager = new ConfigManager();
export default configManager;
+17 -25
View File
@@ -1,50 +1,42 @@
# PTT Live - Configuration
# Format simplifié : nom du groupe + canaux (les IDs sont générés automatiquement)
# Configuration audio globale
audio:
sampleRate: 48000
frameSize: 20 # ms
defaultBitrate: 96 # kbps
frameSize: 20
defaultBitrate: 96
jitterBufferMs: 40
# Configuration des groupes
device:
inputDeviceId: 0
outputDeviceId: 2
sampleRate: 48000
groups:
- name: "Production"
- name: Production
audioBitrate: 96
channels:
- name: "Principal"
- name: Principal
audioInput: 0
audioOutput: 0
- name: "Backup"
- name: Backup
audioInput: 1
audioOutput: 1
- name: "Technique"
- name: Technique
channels:
- name: "Général"
- name: Général
audioInput: 2
audioOutput: 2
- name: "Sonorisation"
- name: Sonorisation
audioBitrate: 128
channels:
- name: "Principal"
- name: Principal
audioInput: 3
audioOutput: 3
- name: "Retours"
- name: Retours
audioInput: 4
audioOutput: 4
# Configuration serveur
server:
host: "0.0.0.0"
host: 0.0.0.0
port: 3000
livekit:
url: "ws://localhost:7880"
# Logging
url: ws://localhost:7880
logging:
level: "debug"
level: debug
logLatency: true
logAudioStats: true
+18 -36
View File
@@ -10,45 +10,15 @@ import { networkInterfaces } from 'os';
import YAML from 'yaml';
import { AccessToken } from 'livekit-server-sdk';
import adminRouter, { registerUser, addLog } from './api/admin.js';
import configManager from './config/ConfigManager.js';
import audioBridgeManager from './bridge/AudioBridgeManager.js';
const __dirname = dirname(fileURLToPath(import.meta.url));
// Chargement configuration
const configPath = join(__dirname, 'config', 'config.yaml');
const configFile = readFileSync(configPath, 'utf8');
const config = YAML.parse(configFile);
// Chargement configuration via ConfigManager
const config = configManager.get();
/**
* Génère un ID slug à partir d'un nom
* Ex: "Équipe Production" -> "equipe-production"
*/
function slugify(text) {
return text
.toString()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '') // Retire les accents
.toLowerCase()
.trim()
.replace(/\s+/g, '-')
.replace(/[^\w-]+/g, '')
.replace(/--+/g, '-');
}
// Générer les IDs pour les groupes et canaux s'ils n'existent pas
config.groups = config.groups.map(group => {
if (!group.id) {
group.id = slugify(group.name);
}
if (group.channels) {
group.channels = group.channels.map(channel => {
if (!channel.id) {
channel.id = `${group.id}-${slugify(channel.name)}`;
}
return channel;
});
}
return group;
});
// Note: Les IDs sont maintenant générés automatiquement par le ConfigManager
/**
* Détecte l'IP réseau locale (WiFi/Ethernet)
@@ -388,6 +358,12 @@ async function start() {
log('info', `Groupes configurés: ${config.groups.map(g => g.name).join(', ')}`);
});
// 3. Démarrer Audio Bridge Manager (Phase 2.5)
log('info', '');
log('info', '🎵 Démarrage Audio Bridge Manager...');
await audioBridgeManager.start();
log('info', '✓ Audio Bridge Manager prêt (mode placeholder)');
// Gérer erreur port déjà utilisé
server.on('error', (error) => {
if (error.code === 'EADDRINUSE') {
@@ -407,9 +383,15 @@ async function start() {
// ========== Cleanup ==========
function cleanup() {
async function cleanup() {
log('info', 'Arrêt du serveur...');
// Arrêter l'audio bridge
if (audioBridgeManager) {
log('info', 'Arrêt Audio Bridge Manager...');
await audioBridgeManager.stop();
}
if (livekitProcess) {
log('info', 'Arrêt LiveKit Server...');
livekitProcess.kill('SIGTERM');