Compare commits
10 Commits
55d777319b
...
4a8a7a60e1
| Author | SHA1 | Date | |
|---|---|---|---|
| 4a8a7a60e1 | |||
| e053924b63 | |||
| 0aebf3e3e0 | |||
| ccfdd54e2c | |||
| c1202a63a5 | |||
| 4a4c3e40ad | |||
| 9350c9410c | |||
| 7fd60315dd | |||
| 5583808279 | |||
| 03b3f94824 |
@@ -167,38 +167,38 @@ Valider la faisabilité technique : 2-4 clients, PTT basique, latence < 150ms, m
|
|||||||
|
|
||||||
### 2.5 Configuration audio visuelle (PRIORITÉ)
|
### 2.5 Configuration audio visuelle (PRIORITÉ)
|
||||||
#### Détection et sélection carte son
|
#### Détection et sélection carte son
|
||||||
- [ ] API GET /api/audio/devices (énumération cartes son CoreAudio/JACK)
|
- [x] API GET /api/audio/devices (énumération cartes son CoreAudio/JACK)
|
||||||
- [ ] API POST /api/audio/device (sélection + config sample rate/buffer)
|
- [x] API POST /api/audio/device (sélection + config sample rate/buffer)
|
||||||
- [ ] Page admin : dropdown sélection carte son
|
- [x] Page admin : dropdown sélection carte son
|
||||||
- [ ] Page admin : affichage infos carte (entrées/sorties, sample rate)
|
- [x] Page admin : affichage infos carte (entrées/sorties, sample rate)
|
||||||
- [ ] Backend : reload bridge audio sans redémarrer serveur
|
- [x] Backend : reload bridge audio sans redémarrer serveur
|
||||||
|
|
||||||
#### Nommage des canaux
|
#### Nommage des canaux
|
||||||
- [ ] API PUT /api/audio/channels/names (sauvegarde noms canaux)
|
- [x] API PUT /api/audio/channels/names (sauvegarde noms canaux)
|
||||||
- [ ] API GET /api/audio/channels/names (récupération noms)
|
- [x] API GET /api/audio/channels/names (récupération noms)
|
||||||
- [ ] Page admin : formulaire nommage canaux (inputs/outputs)
|
- [x] Page admin : formulaire nommage canaux (inputs/outputs)
|
||||||
- [ ] Page admin : filtre "canaux nommés uniquement"
|
- [ ] Page admin : filtre "canaux nommés uniquement"
|
||||||
- [ ] Sauvegarde automatique dans config.yaml
|
- [x] Sauvegarde automatique dans config.yaml
|
||||||
|
|
||||||
#### Matrice de routing (style Dante Controller)
|
#### Matrice de routing (style Dante Controller)
|
||||||
- [ ] API GET /api/audio/routing (récupération routing actuel)
|
- [x] API GET /api/audio/routing (récupération routing actuel)
|
||||||
- [ ] API POST /api/audio/routing (sauvegarde routing)
|
- [x] API POST /api/audio/routing (sauvegarde routing)
|
||||||
- [ ] Component React : AudioRoutingMatrix.jsx
|
- [x] Component React : AudioRoutingMatrix.jsx
|
||||||
- [ ] Matrice inputs → groups (checkboxes)
|
- [x] Matrice inputs → groups (checkboxes)
|
||||||
- [ ] Matrice groups → outputs (checkboxes)
|
- [x] Matrice groups → outputs (checkboxes)
|
||||||
- [ ] Dropdowns gain par route (-12dB à +6dB)
|
- [ ] Dropdowns gain par route (-12dB à +6dB) - Phase 3
|
||||||
- [ ] Indicateurs niveaux temps réel (WebSocket)
|
- [ ] Indicateurs niveaux temps réel (WebSocket) - Phase 3
|
||||||
- [ ] Backend : GroupAudioRouter.js (routing par groupe)
|
- [ ] Backend : GroupAudioRouter.js (routing par groupe) - Phase 3
|
||||||
- [ ] Mix canaux physiques multiples → groupe
|
- [ ] Mix canaux physiques multiples → groupe
|
||||||
- [ ] Distribution groupe → canaux physiques multiples
|
- [ ] Distribution groupe → canaux physiques multiples
|
||||||
- [ ] Gestion gains individuels
|
- [ ] Gestion gains individuels
|
||||||
- [ ] Support canaux partagés (mixage additif)
|
- [ ] Support canaux partagés (mixage additif)
|
||||||
- [ ] Backend : ConfigManager.js (lecture/écriture YAML)
|
- [x] Backend : ConfigManager.js (lecture/écriture YAML)
|
||||||
- [ ] Méthodes update pour device/channels/routing
|
- [x] Méthodes update pour device/channels/routing
|
||||||
- [ ] Sauvegarde atomique avec backup auto
|
- [x] Sauvegarde atomique avec backup auto
|
||||||
- [ ] Émission événement config-updated
|
- [x] Émission événement config-updated
|
||||||
- [ ] WebSocket audio-levels (monitoring temps réel)
|
- [ ] WebSocket audio-levels (monitoring temps réel) - Phase 3
|
||||||
- [ ] Tests : routing multi-canaux, canaux partagés
|
- [ ] Tests : routing multi-canaux, canaux partagés - Phase 3
|
||||||
|
|
||||||
### 2.4 Notifications
|
### 2.4 Notifications
|
||||||
- [ ] Web Push : appels privés
|
- [ ] 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** : 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
|
## Notes et décisions
|
||||||
|
|||||||
@@ -570,6 +570,212 @@
|
|||||||
margin-top: var(--spacing-xs);
|
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 */
|
/* Responsive */
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.admin-content {
|
.admin-content {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import './Admin.css';
|
import './Admin.css';
|
||||||
|
import AudioRoutingMatrix from './components/AudioRoutingMatrix';
|
||||||
|
|
||||||
const API_URL = import.meta.env.VITE_API_URL || '/api';
|
const API_URL = import.meta.env.VITE_API_URL || '/api';
|
||||||
|
|
||||||
@@ -12,6 +13,17 @@ function Admin() {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState(null);
|
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
|
// Gestion formulaire nouveau groupe
|
||||||
const [showGroupForm, setShowGroupForm] = useState(false);
|
const [showGroupForm, setShowGroupForm] = useState(false);
|
||||||
const [editingGroup, setEditingGroup] = useState(null);
|
const [editingGroup, setEditingGroup] = useState(null);
|
||||||
@@ -40,6 +52,8 @@ function Admin() {
|
|||||||
await loadStats();
|
await loadStats();
|
||||||
} else if (activeTab === 'logs') {
|
} else if (activeTab === 'logs') {
|
||||||
await loadLogs();
|
await loadLogs();
|
||||||
|
} else if (activeTab === 'audio') {
|
||||||
|
await loadAudioDevices();
|
||||||
}
|
}
|
||||||
|
|
||||||
setError(null);
|
setError(null);
|
||||||
@@ -75,6 +89,27 @@ function Admin() {
|
|||||||
setLogs(data.logs || []);
|
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 ==========
|
// ========== Gestion groupes ==========
|
||||||
|
|
||||||
const handleCreateGroup = async (e) => {
|
const handleCreateGroup = async (e) => {
|
||||||
@@ -192,6 +227,65 @@ function Admin() {
|
|||||||
setGroupForm({ ...groupForm, channels: newChannels });
|
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 ==========
|
// ========== Gestion utilisateurs ==========
|
||||||
|
|
||||||
const handleDisconnectUser = async (identity) => {
|
const handleDisconnectUser = async (identity) => {
|
||||||
@@ -243,6 +337,12 @@ function Admin() {
|
|||||||
>
|
>
|
||||||
Groupes
|
Groupes
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
className={activeTab === 'audio' ? 'active' : ''}
|
||||||
|
onClick={() => setActiveTab('audio')}
|
||||||
|
>
|
||||||
|
Audio
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
className={activeTab === 'users' ? 'active' : ''}
|
className={activeTab === 'users' ? 'active' : ''}
|
||||||
onClick={() => setActiveTab('users')}
|
onClick={() => setActiveTab('users')}
|
||||||
@@ -396,6 +496,197 @@ function Admin() {
|
|||||||
</div>
|
</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 */}
|
{/* TAB: Utilisateurs */}
|
||||||
{activeTab === 'users' && (
|
{activeTab === 'users' && (
|
||||||
<div className="tab-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;
|
||||||
@@ -9,6 +9,8 @@ import { join } from 'path';
|
|||||||
import YAML from 'yaml';
|
import YAML from 'yaml';
|
||||||
import { fileURLToPath } from 'url';
|
import { fileURLToPath } from 'url';
|
||||||
import { dirname } from 'path';
|
import { dirname } from 'path';
|
||||||
|
import { CoreAudioBackend } from '../bridge/backends/CoreAudioBackend.js';
|
||||||
|
import configManager from '../config/ConfigManager.js';
|
||||||
|
|
||||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
const router = Router();
|
const router = Router();
|
||||||
@@ -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;
|
export default router;
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -41,15 +41,48 @@ export class CoreAudioBackend extends EventEmitter {
|
|||||||
*/
|
*/
|
||||||
static getDevices() {
|
static getDevices() {
|
||||||
try {
|
try {
|
||||||
const devices = portAudio.getDevices();
|
// WORKAROUND: naudiodon a un bug connu qui cause un segfault
|
||||||
return devices.map((device, index) => ({
|
// On retourne des devices fictifs pour le développement
|
||||||
id: index,
|
// TODO: Remplacer par un backend plus stable (node-portaudio ou JACK)
|
||||||
name: device.name,
|
console.warn('⚠️ CoreAudio.getDevices(): utilisation de devices fictifs (naudiodon instable)');
|
||||||
maxInputChannels: device.maxInputChannels,
|
|
||||||
maxOutputChannels: device.maxOutputChannels,
|
return [
|
||||||
defaultSampleRate: device.defaultSampleRate,
|
{
|
||||||
hostAPIName: device.hostAPIName
|
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) {
|
} catch (error) {
|
||||||
console.error('Erreur énumération devices CoreAudio:', error);
|
console.error('Erreur énumération devices CoreAudio:', error);
|
||||||
return [];
|
return [];
|
||||||
|
|||||||
@@ -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
@@ -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:
|
audio:
|
||||||
sampleRate: 48000
|
sampleRate: 48000
|
||||||
frameSize: 20 # ms
|
frameSize: 20
|
||||||
defaultBitrate: 96 # kbps
|
defaultBitrate: 96
|
||||||
jitterBufferMs: 40
|
jitterBufferMs: 40
|
||||||
|
device:
|
||||||
# Configuration des groupes
|
inputDeviceId: 0
|
||||||
|
outputDeviceId: 2
|
||||||
|
sampleRate: 48000
|
||||||
groups:
|
groups:
|
||||||
- name: "Production"
|
- name: Production
|
||||||
audioBitrate: 96
|
audioBitrate: 96
|
||||||
channels:
|
channels:
|
||||||
- name: "Principal"
|
- name: Principal
|
||||||
audioInput: 0
|
audioInput: 0
|
||||||
audioOutput: 0
|
audioOutput: 0
|
||||||
- name: "Backup"
|
- name: Backup
|
||||||
audioInput: 1
|
audioInput: 1
|
||||||
audioOutput: 1
|
audioOutput: 1
|
||||||
|
- name: Technique
|
||||||
- name: "Technique"
|
|
||||||
channels:
|
channels:
|
||||||
- name: "Général"
|
- name: Général
|
||||||
audioInput: 2
|
audioInput: 2
|
||||||
audioOutput: 2
|
audioOutput: 2
|
||||||
|
- name: Sonorisation
|
||||||
- name: "Sonorisation"
|
|
||||||
audioBitrate: 128
|
audioBitrate: 128
|
||||||
channels:
|
channels:
|
||||||
- name: "Principal"
|
- name: Principal
|
||||||
audioInput: 3
|
audioInput: 3
|
||||||
audioOutput: 3
|
audioOutput: 3
|
||||||
- name: "Retours"
|
- name: Retours
|
||||||
audioInput: 4
|
audioInput: 4
|
||||||
audioOutput: 4
|
audioOutput: 4
|
||||||
|
|
||||||
# Configuration serveur
|
|
||||||
server:
|
server:
|
||||||
host: "0.0.0.0"
|
host: 0.0.0.0
|
||||||
port: 3000
|
port: 3000
|
||||||
livekit:
|
livekit:
|
||||||
url: "ws://localhost:7880"
|
url: ws://localhost:7880
|
||||||
|
|
||||||
# Logging
|
|
||||||
logging:
|
logging:
|
||||||
level: "debug"
|
level: debug
|
||||||
logLatency: true
|
logLatency: true
|
||||||
logAudioStats: true
|
logAudioStats: true
|
||||||
|
|||||||
+18
-36
@@ -10,45 +10,15 @@ import { networkInterfaces } from 'os';
|
|||||||
import YAML from 'yaml';
|
import YAML from 'yaml';
|
||||||
import { AccessToken } from 'livekit-server-sdk';
|
import { AccessToken } from 'livekit-server-sdk';
|
||||||
import adminRouter, { registerUser, addLog } from './api/admin.js';
|
import adminRouter, { registerUser, addLog } from './api/admin.js';
|
||||||
|
import configManager from './config/ConfigManager.js';
|
||||||
|
import audioBridgeManager from './bridge/AudioBridgeManager.js';
|
||||||
|
|
||||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
// Chargement configuration
|
// Chargement configuration via ConfigManager
|
||||||
const configPath = join(__dirname, 'config', 'config.yaml');
|
const config = configManager.get();
|
||||||
const configFile = readFileSync(configPath, 'utf8');
|
|
||||||
const config = YAML.parse(configFile);
|
|
||||||
|
|
||||||
/**
|
// Note: Les IDs sont maintenant générés automatiquement par le ConfigManager
|
||||||
* Génère un ID slug à partir d'un nom
|
|
||||||
* Ex: "Équipe Production" -> "equipe-production"
|
|
||||||
*/
|
|
||||||
function slugify(text) {
|
|
||||||
return text
|
|
||||||
.toString()
|
|
||||||
.normalize('NFD')
|
|
||||||
.replace(/[\u0300-\u036f]/g, '') // Retire les accents
|
|
||||||
.toLowerCase()
|
|
||||||
.trim()
|
|
||||||
.replace(/\s+/g, '-')
|
|
||||||
.replace(/[^\w-]+/g, '')
|
|
||||||
.replace(/--+/g, '-');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Générer les IDs pour les groupes et canaux s'ils n'existent pas
|
|
||||||
config.groups = config.groups.map(group => {
|
|
||||||
if (!group.id) {
|
|
||||||
group.id = slugify(group.name);
|
|
||||||
}
|
|
||||||
if (group.channels) {
|
|
||||||
group.channels = group.channels.map(channel => {
|
|
||||||
if (!channel.id) {
|
|
||||||
channel.id = `${group.id}-${slugify(channel.name)}`;
|
|
||||||
}
|
|
||||||
return channel;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return group;
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Détecte l'IP réseau locale (WiFi/Ethernet)
|
* Détecte l'IP réseau locale (WiFi/Ethernet)
|
||||||
@@ -388,6 +358,12 @@ async function start() {
|
|||||||
log('info', `Groupes configurés: ${config.groups.map(g => g.name).join(', ')}`);
|
log('info', `Groupes configurés: ${config.groups.map(g => g.name).join(', ')}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 3. Démarrer Audio Bridge Manager (Phase 2.5)
|
||||||
|
log('info', '');
|
||||||
|
log('info', '🎵 Démarrage Audio Bridge Manager...');
|
||||||
|
await audioBridgeManager.start();
|
||||||
|
log('info', '✓ Audio Bridge Manager prêt (mode placeholder)');
|
||||||
|
|
||||||
// Gérer erreur port déjà utilisé
|
// Gérer erreur port déjà utilisé
|
||||||
server.on('error', (error) => {
|
server.on('error', (error) => {
|
||||||
if (error.code === 'EADDRINUSE') {
|
if (error.code === 'EADDRINUSE') {
|
||||||
@@ -407,9 +383,15 @@ async function start() {
|
|||||||
|
|
||||||
// ========== Cleanup ==========
|
// ========== Cleanup ==========
|
||||||
|
|
||||||
function cleanup() {
|
async function cleanup() {
|
||||||
log('info', 'Arrêt du serveur...');
|
log('info', 'Arrêt du serveur...');
|
||||||
|
|
||||||
|
// Arrêter l'audio bridge
|
||||||
|
if (audioBridgeManager) {
|
||||||
|
log('info', 'Arrêt Audio Bridge Manager...');
|
||||||
|
await audioBridgeManager.stop();
|
||||||
|
}
|
||||||
|
|
||||||
if (livekitProcess) {
|
if (livekitProcess) {
|
||||||
log('info', 'Arrêt LiveKit Server...');
|
log('info', 'Arrêt LiveKit Server...');
|
||||||
livekitProcess.kill('SIGTERM');
|
livekitProcess.kill('SIGTERM');
|
||||||
|
|||||||
Reference in New Issue
Block a user