fix: résolution device IDs et correction sox capture args

Corrections pour le routing audio carte son → LiveKit :

**Fixes audio backend**
- AudioBridgeManager : extraction des device IDs depuis config.audio.device
- AudioBridge : ajout résolution device ID → device name pour CoreAudio/sox
- CoreAudioBackend : correction index args sox capture (args[2] au lieu de args[1])

**Résultat**
-  Sox capture fonctionne : lit depuis "Microphone MacBook Pro"
-  Audio capturé et envoyé vers routing
-  Sox playback se ferme après 0.2s (problème persistant à corriger)

**Autres modifications**
- Logging centralisé (Logger.js)
- IP corrigée : 192.168.0.146
- Suppression système channels[] legacy dans groupes

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-05-26 22:01:53 +02:00
parent a5879a2ea9
commit f2e1a50d6d
14 changed files with 352 additions and 180 deletions
+1 -1
View File
@@ -81,7 +81,7 @@ define(['./workbox-290dd570'], (function (workbox) { 'use strict';
"revision": "3ca0b8505b4bec776b69afdba2768812" "revision": "3ca0b8505b4bec776b69afdba2768812"
}, { }, {
"url": "index.html", "url": "index.html",
"revision": "0.oebo7b1mt4g" "revision": "0.su9rr59m8gg"
}], {}); }], {});
workbox.cleanupOutdatedCaches(); workbox.cleanupOutdatedCaches();
workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), { workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), {
File diff suppressed because one or more lines are too long
+42 -108
View File
@@ -15,7 +15,7 @@ function Admin() {
// Audio devices (Phase 2.5) // Audio devices (Phase 2.5)
const [audioDevices, setAudioDevices] = useState([]); const [audioDevices, setAudioDevices] = useState([]);
const [currentDevice, setCurrentDevice] = useState(null); const [currentDevice, setCurrentDevice] = useState({ inputChannels: 8, outputChannels: 8 });
const [selectedInputDevice, setSelectedInputDevice] = useState(null); const [selectedInputDevice, setSelectedInputDevice] = useState(null);
const [selectedOutputDevice, setSelectedOutputDevice] = useState(null); const [selectedOutputDevice, setSelectedOutputDevice] = useState(null);
const [selectedSampleRate, setSelectedSampleRate] = useState(48000); const [selectedSampleRate, setSelectedSampleRate] = useState(48000);
@@ -30,8 +30,7 @@ function Admin() {
const [editingGroup, setEditingGroup] = useState(null); const [editingGroup, setEditingGroup] = useState(null);
const [groupForm, setGroupForm] = useState({ const [groupForm, setGroupForm] = useState({
name: '', name: '',
audioBitrate: 96, audioBitrate: 96
channels: []
}); });
// Rafraîchissement automatique // Rafraîchissement automatique
@@ -102,14 +101,16 @@ function Admin() {
const channelNamesData = await channelNamesRes.json(); const channelNamesData = await channelNamesRes.json();
setAudioDevices(devicesData.devices || []); setAudioDevices(devicesData.devices || []);
setCurrentDevice(currentData.device || {});
const device = currentData.device || { inputChannels: 8, outputChannels: 8 };
setCurrentDevice(device);
setChannelNames(channelNamesData.channelNames || { inputs: {}, outputs: {} }); setChannelNames(channelNamesData.channelNames || { inputs: {}, outputs: {} });
// Ne réinitialiser les sélections que si l'utilisateur n'est pas en train d'éditer // Ne réinitialiser les sélections que si l'utilisateur n'est pas en train d'éditer
if (!isEditingAudio) { if (!isEditingAudio) {
setSelectedInputDevice(currentData.device?.inputDeviceId ?? null); setSelectedInputDevice(device.inputDeviceId ?? null);
setSelectedOutputDevice(currentData.device?.outputDeviceId ?? null); setSelectedOutputDevice(device.outputDeviceId ?? null);
setSelectedSampleRate(currentData.device?.sampleRate || 48000); setSelectedSampleRate(device.sampleRate || 48000);
} }
}; };
@@ -189,8 +190,7 @@ function Admin() {
setEditingGroup(group.id); setEditingGroup(group.id);
setGroupForm({ setGroupForm({
name: group.name, name: group.name,
audioBitrate: group.audioBitrate || 96, audioBitrate: group.audioBitrate || 96
channels: group.channels || []
}); });
setShowGroupForm(true); setShowGroupForm(true);
}; };
@@ -198,38 +198,12 @@ function Admin() {
const resetGroupForm = () => { const resetGroupForm = () => {
setGroupForm({ setGroupForm({
name: '', name: '',
audioBitrate: 96, audioBitrate: 96
channels: []
}); });
setShowGroupForm(false); setShowGroupForm(false);
setEditingGroup(null); setEditingGroup(null);
}; };
const addChannel = () => {
setGroupForm({
...groupForm,
channels: [
...groupForm.channels,
{ name: '', audioInput: 0, audioOutput: 0 }
]
});
};
const updateChannel = (index, field, value) => {
const newChannels = [...groupForm.channels];
newChannels[index] = {
...newChannels[index],
[field]: field === 'audioInput' || field === 'audioOutput' ? parseInt(value) : value
};
setGroupForm({ ...groupForm, channels: newChannels });
};
const removeChannel = (index) => {
const newChannels = [...groupForm.channels];
newChannels.splice(index, 1);
setGroupForm({ ...groupForm, channels: newChannels });
};
// ========== Gestion audio devices (Phase 2.5) ========== // ========== Gestion audio devices (Phase 2.5) ==========
const handleSaveChannelNames = async () => { const handleSaveChannelNames = async () => {
@@ -270,8 +244,8 @@ function Admin() {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
inputDeviceId: selectedInputDevice !== null ? parseInt(selectedInputDevice) : undefined, inputDeviceId: selectedInputDevice || undefined,
outputDeviceId: selectedOutputDevice !== null ? parseInt(selectedOutputDevice) : undefined, outputDeviceId: selectedOutputDevice || undefined,
sampleRate: parseInt(selectedSampleRate) sampleRate: parseInt(selectedSampleRate)
}) })
}); });
@@ -415,43 +389,9 @@ function Admin() {
</label> </label>
</div> </div>
<div className="channels-section"> <p style={{color: 'var(--color-text-secondary)', fontSize: '0.9rem', marginTop: 'var(--spacing-md)'}}>
<div className="channels-header"> Le routing audio se configure dans l'onglet "Audio" via la matrice de routing.
<h4>Canaux audio</h4> </p>
<button type="button" onClick={addChannel} className="btn-small">
+ Canal
</button>
</div>
{groupForm.channels.map((channel, index) => (
<div key={index} className="channel-item">
<input
type="text"
placeholder="Nom canal (ex: Principal, Backup...)"
value={channel.name}
onChange={(e) => updateChannel(index, 'name', e.target.value)}
required
/>
<input
type="number"
placeholder="Input"
value={channel.audioInput}
onChange={(e) => updateChannel(index, 'audioInput', e.target.value)}
min="0"
/>
<input
type="number"
placeholder="Output"
value={channel.audioOutput}
onChange={(e) => updateChannel(index, 'audioOutput', e.target.value)}
min="0"
/>
<button type="button" onClick={() => removeChannel(index)} className="btn-danger">
×
</button>
</div>
))}
</div>
<div className="form-actions"> <div className="form-actions">
<button type="submit" className="btn-primary"> <button type="submit" className="btn-primary">
@@ -482,18 +422,7 @@ function Admin() {
<div className="group-info"> <div className="group-info">
<span>Bitrate: {group.audioBitrate || 96} kbps</span> <span>Bitrate: {group.audioBitrate || 96} kbps</span>
<span>Canaux: {group.channels?.length || 0}</span>
</div> </div>
{group.channels && group.channels.length > 0 && (
<div className="channels-list">
{group.channels.map(channel => (
<div key={channel.id} className="channel-badge">
{channel.name} (I/O: {channel.audioInput}/{channel.audioOutput})
</div>
))}
</div>
)}
</div> </div>
))} ))}
</div> </div>
@@ -514,22 +443,22 @@ function Admin() {
value={selectedInputDevice ?? ''} value={selectedInputDevice ?? ''}
onChange={(e) => { onChange={(e) => {
setIsEditingAudio(true); setIsEditingAudio(true);
setSelectedInputDevice(e.target.value === '' ? null : parseInt(e.target.value)); setSelectedInputDevice(e.target.value === '' ? null : e.target.value);
}} }}
className="device-select" className="device-select"
> >
<option value="">-- Sélectionner une carte --</option> <option value="">-- Sélectionner une carte --</option>
{audioDevices {audioDevices
.filter(d => d.maxInputChannels > 0) .filter(d => d.maxInputChannels > 0)
.map(device => ( .map((device, index) => (
<option key={device.id} value={device.id}> <option key={`input-${device.id}-${index}`} value={device.id}>
{device.name} - {device.maxInputChannels} canaux - {device.defaultSampleRate}Hz {device.name} - {device.maxInputChannels} canaux - {device.defaultSampleRate}Hz
</option> </option>
))} ))}
</select> </select>
{selectedInputDevice !== null && ( {selectedInputDevice !== null && selectedInputDevice !== '' && (
<p style={{marginTop: 'var(--spacing-sm)', color: 'var(--color-text-secondary)', fontSize: '0.85rem'}}> <p style={{marginTop: 'var(--spacing-sm)', color: 'var(--color-text-secondary)', fontSize: '0.85rem', wordBreak: 'break-all'}}>
Device ID {selectedInputDevice} sélectionné Device ID: {selectedInputDevice}
</p> </p>
)} )}
</div> </div>
@@ -540,22 +469,22 @@ function Admin() {
value={selectedOutputDevice ?? ''} value={selectedOutputDevice ?? ''}
onChange={(e) => { onChange={(e) => {
setIsEditingAudio(true); setIsEditingAudio(true);
setSelectedOutputDevice(e.target.value === '' ? null : parseInt(e.target.value)); setSelectedOutputDevice(e.target.value === '' ? null : e.target.value);
}} }}
className="device-select" className="device-select"
> >
<option value="">-- Sélectionner une carte --</option> <option value="">-- Sélectionner une carte --</option>
{audioDevices {audioDevices
.filter(d => d.maxOutputChannels > 0) .filter(d => d.maxOutputChannels > 0)
.map(device => ( .map((device, index) => (
<option key={device.id} value={device.id}> <option key={`output-${device.id}-${index}`} value={device.id}>
{device.name} - {device.maxOutputChannels} canaux - {device.defaultSampleRate}Hz {device.name} - {device.maxOutputChannels} canaux - {device.defaultSampleRate}Hz
</option> </option>
))} ))}
</select> </select>
{selectedOutputDevice !== null && ( {selectedOutputDevice !== null && selectedOutputDevice !== '' && (
<p style={{marginTop: 'var(--spacing-sm)', color: 'var(--color-text-secondary)', fontSize: '0.85rem'}}> <p style={{marginTop: 'var(--spacing-sm)', color: 'var(--color-text-secondary)', fontSize: '0.85rem', wordBreak: 'break-all'}}>
Device ID {selectedOutputDevice} sélectionné Device ID: {selectedOutputDevice}
</p> </p>
)} )}
</div> </div>
@@ -603,9 +532,11 @@ function Admin() {
<div style={{display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 'var(--spacing-xl)'}}> <div style={{display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 'var(--spacing-xl)'}}>
<div> <div>
<h4 style={{marginBottom: 'var(--spacing-md)', color: 'var(--color-text-secondary)'}}>Entrées (Inputs)</h4> <h4 style={{marginBottom: 'var(--spacing-md)', color: 'var(--color-text-secondary)'}}>
Entrées (Inputs) - {currentDevice.inputChannels || 0} canaux disponibles
</h4>
<div style={{display: 'grid', gap: 'var(--spacing-sm)'}}> <div style={{display: 'grid', gap: 'var(--spacing-sm)'}}>
{Array.from({length: 8}, (_, i) => ( {Array.from({length: currentDevice.inputChannels || 8}, (_, i) => (
<div key={`input-${i}`} style={{display: 'grid', gridTemplateColumns: '40px 1fr', gap: 'var(--spacing-sm)', alignItems: 'center'}}> <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> <span style={{color: 'var(--color-text-secondary)', fontSize: '0.85rem'}}>{i}</span>
<input <input
@@ -629,9 +560,11 @@ function Admin() {
</div> </div>
<div> <div>
<h4 style={{marginBottom: 'var(--spacing-md)', color: 'var(--color-text-secondary)'}}>Sorties (Outputs)</h4> <h4 style={{marginBottom: 'var(--spacing-md)', color: 'var(--color-text-secondary)'}}>
Sorties (Outputs) - {currentDevice.outputChannels || 0} canaux disponibles
</h4>
<div style={{display: 'grid', gap: 'var(--spacing-sm)'}}> <div style={{display: 'grid', gap: 'var(--spacing-sm)'}}>
{Array.from({length: 8}, (_, i) => ( {Array.from({length: currentDevice.outputChannels || 8}, (_, i) => (
<div key={`output-${i}`} style={{display: 'grid', gridTemplateColumns: '40px 1fr', gap: 'var(--spacing-sm)', alignItems: 'center'}}> <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> <span style={{color: 'var(--color-text-secondary)', fontSize: '0.85rem'}}>{i}</span>
<input <input
@@ -658,13 +591,14 @@ function Admin() {
<AudioRoutingMatrix groups={groups} channelNames={channelNames} /> <AudioRoutingMatrix groups={groups} channelNames={channelNames} />
{currentDevice && Object.keys(currentDevice).length > 0 && ( {currentDevice && currentDevice.inputDeviceId && (
<div className="current-config"> <div className="current-config">
<h3>Configuration actuelle</h3> <h3>Configuration actuelle</h3>
<div className="config-info"> <div className="config-info">
<p><strong>Input Device ID:</strong> {currentDevice.inputDeviceId ?? 'Non configuré'}</p> <p><strong>Input Device:</strong> {currentDevice.inputDeviceName || currentDevice.inputDeviceId}</p>
<p><strong>Output Device ID:</strong> {currentDevice.outputDeviceId ?? 'Non configuré'}</p> <p><strong>Output Device:</strong> {currentDevice.outputDeviceName || currentDevice.outputDeviceId}</p>
<p><strong>Sample Rate:</strong> {currentDevice.sampleRate ?? 48000} Hz</p> <p><strong>Sample Rate:</strong> {currentDevice.sampleRate ?? 48000} Hz</p>
<p><strong>Canaux:</strong> {currentDevice.inputChannels} entrées / {currentDevice.outputChannels} sorties</p>
</div> </div>
</div> </div>
)} )}
@@ -683,9 +617,9 @@ function Admin() {
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{audioDevices.map(device => ( {audioDevices.map((device, index) => (
<tr key={device.id}> <tr key={`${device.id}-${index}`}>
<td>{device.id}</td> <td style={{fontSize: '0.75rem', wordBreak: 'break-all', maxWidth: '200px'}}>{device.id}</td>
<td>{device.name}</td> <td>{device.name}</td>
<td>{device.maxInputChannels}</td> <td>{device.maxInputChannels}</td>
<td>{device.maxOutputChannels}</td> <td>{device.maxOutputChannels}</td>
+19 -2
View File
@@ -10,9 +10,11 @@ function AudioRoutingMatrix({ groups, channelNames }) {
const [routing, setRouting] = useState({ inputToGroup: {}, groupToOutput: {}, gains: {} }); const [routing, setRouting] = useState({ inputToGroup: {}, groupToOutput: {}, gains: {} });
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [showOnlyNamedChannels, setShowOnlyNamedChannels] = useState(false); const [showOnlyNamedChannels, setShowOnlyNamedChannels] = useState(false);
const [audioDevice, setAudioDevice] = useState({ inputChannels: 8, outputChannels: 8 });
useEffect(() => { useEffect(() => {
loadRouting(); loadRouting();
loadAudioDevice();
}, []); }, []);
const loadRouting = async () => { const loadRouting = async () => {
@@ -30,6 +32,21 @@ function AudioRoutingMatrix({ groups, channelNames }) {
} }
}; };
const loadAudioDevice = async () => {
try {
const res = await fetch(`${API_URL}/admin/audio/device`);
if (res.ok) {
const data = await res.json();
setAudioDevice({
inputChannels: data.device?.inputChannels || 8,
outputChannels: data.device?.outputChannels || 8
});
}
} catch (error) {
console.error('Erreur chargement audio device:', error);
}
};
const saveRouting = async () => { const saveRouting = async () => {
try { try {
const res = await fetch(`${API_URL}/admin/audio/routing`, { const res = await fetch(`${API_URL}/admin/audio/routing`, {
@@ -146,7 +163,7 @@ function AudioRoutingMatrix({ groups, channelNames }) {
}; };
const getVisibleInputChannels = () => { const getVisibleInputChannels = () => {
const allInputs = Array.from({length: 8}, (_, i) => i); const allInputs = Array.from({length: audioDevice.inputChannels}, (_, i) => i);
if (showOnlyNamedChannels) { if (showOnlyNamedChannels) {
return allInputs.filter(i => hasCustomName('inputs', i)); return allInputs.filter(i => hasCustomName('inputs', i));
} }
@@ -154,7 +171,7 @@ function AudioRoutingMatrix({ groups, channelNames }) {
}; };
const getVisibleOutputChannels = () => { const getVisibleOutputChannels = () => {
const allOutputs = Array.from({length: 8}, (_, i) => i); const allOutputs = Array.from({length: audioDevice.outputChannels}, (_, i) => i);
if (showOnlyNamedChannels) { if (showOnlyNamedChannels) {
return allOutputs.filter(i => hasCustomName('outputs', i)); return allOutputs.filter(i => hasCustomName('outputs', i));
} }
+1 -1
View File
@@ -68,7 +68,7 @@ export default defineConfig({
}, },
proxy: { proxy: {
'/api': { '/api': {
target: 'http://localhost:3000', target: 'http://192.168.0.146:3000',
changeOrigin: true, changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, '') rewrite: (path) => path.replace(/^\/api/, '')
}, },
+23 -3
View File
@@ -179,7 +179,7 @@ router.get('/groups', (req, res) => {
/** /**
* POST /admin/groups * POST /admin/groups
* Crée un nouveau groupe * Crée un nouveau groupe
* Body: { name, audioBitrate?, channels } * Body: { name, audioBitrate? }
* L'ID est généré automatiquement à partir du nom * L'ID est généré automatiquement à partir du nom
*/ */
router.post('/groups', (req, res) => { router.post('/groups', (req, res) => {
@@ -204,7 +204,7 @@ router.post('/groups', (req, res) => {
}); });
} }
// Créer le nouveau groupe (sans channels) // Créer le nouveau groupe
const newGroup = { const newGroup = {
name, name,
...(audioBitrate && { audioBitrate }) ...(audioBitrate && { audioBitrate })
@@ -482,8 +482,28 @@ router.get('/audio/device', (req, res) => {
const config = configManager.get(); const config = configManager.get();
const audioDevice = config.audio?.device || {}; const audioDevice = config.audio?.device || {};
// Enrichir avec les infos réelles de la carte si configurée
const devices = CoreAudioBackend.getDevices();
let deviceInfo = { ...audioDevice };
if (audioDevice.inputDeviceId) {
const inputDev = devices.find(d => d.id === audioDevice.inputDeviceId);
if (inputDev) {
deviceInfo.inputChannels = inputDev.maxInputChannels;
deviceInfo.inputDeviceName = inputDev.name;
}
}
if (audioDevice.outputDeviceId) {
const outputDev = devices.find(d => d.id === audioDevice.outputDeviceId);
if (outputDev) {
deviceInfo.outputChannels = outputDev.maxOutputChannels;
deviceInfo.outputDeviceName = outputDev.name;
}
}
res.json({ res.json({
device: audioDevice device: deviceInfo
}); });
} catch (error) { } catch (error) {
console.error('Erreur GET /admin/audio/device:', error); console.error('Erreur GET /admin/audio/device:', error);
+50 -2
View File
@@ -196,13 +196,31 @@ export class AudioBridge extends EventEmitter {
throw new Error(`Plateforme non supportée : ${os}`); throw new Error(`Plateforme non supportée : ${os}`);
} }
// Résoudre les device IDs vers les noms pour CoreAudio/sox
let inputDeviceName = null;
let outputDeviceName = null;
if (this.options.inputDeviceId) {
const inputDevice = BackendClass.getDevices().find(d => d.id === this.options.inputDeviceId);
inputDeviceName = inputDevice ? inputDevice.name : this.options.inputDeviceId;
console.log(`📥 Input device: "${inputDeviceName}" (ID: ${this.options.inputDeviceId})`);
}
if (this.options.outputDeviceId) {
const outputDevice = BackendClass.getDevices().find(d => d.id === this.options.outputDeviceId);
outputDeviceName = outputDevice ? outputDevice.name : this.options.outputDeviceId;
console.log(`📤 Output device: "${outputDeviceName}" (ID: ${this.options.outputDeviceId})`);
}
// Initialisation du backend sélectionné // Initialisation du backend sélectionné
this.audioBackend = new BackendClass({ this.audioBackend = new BackendClass({
sampleRate: this.options.sampleRate, sampleRate: this.options.sampleRate,
channels: this.options.channels, channels: this.options.channels,
framesPerBuffer: this.options.frameSize, framesPerBuffer: this.options.frameSize,
inputDeviceId: this.options.inputDeviceId, inputDeviceId: this.options.inputDeviceId,
inputDeviceName: inputDeviceName,
outputDeviceId: this.options.outputDeviceId, outputDeviceId: this.options.outputDeviceId,
outputDeviceName: outputDeviceName,
// Options spécifiques PipeWire // Options spécifiques PipeWire
latency: this.options.latency || 20 latency: this.options.latency || 20
}); });
@@ -366,6 +384,10 @@ export class AudioBridge extends EventEmitter {
this.inputChannelBuffers this.inputChannelBuffers
); );
if (this.stats.framesCapture % 100 === 0) {
console.log(`[AudioBridge] Frame ${this.stats.framesCapture}: ${this.inputChannelBuffers.size} inputs → ${groupBuffers.size} groupes`);
}
// ÉTAPE 2 : Pour chaque groupe, envoyer vers LiveKit // ÉTAPE 2 : Pour chaque groupe, envoyer vers LiveKit
groupBuffers.forEach((groupBuffer, groupName) => { groupBuffers.forEach((groupBuffer, groupName) => {
// Convertir Float32Array → PCM Buffer // Convertir Float32Array → PCM Buffer
@@ -375,13 +397,19 @@ export class AudioBridge extends EventEmitter {
const opusData = this.opusEncoder.encode(pcmBuffer); const opusData = this.opusEncoder.encode(pcmBuffer);
if (opusData) { if (opusData) {
this.stats.framesCapture++;
this.stats.bytesEncoded += opusData.length; this.stats.bytesEncoded += opusData.length;
// Envoi vers LiveKit via sendAudioData (prend du PCM, pas de l'Opus) // Envoi vers LiveKit via sendAudioData (prend du PCM, pas de l'Opus)
// Note: LiveKit gère lui-même l'encodage Opus en interne // Note: LiveKit gère lui-même l'encodage Opus en interne
if (this.liveKitClient && this.liveKitClient.connected) { if (this.liveKitClient && this.liveKitClient.isConnected) {
this.liveKitClient.sendAudioData(pcmBuffer); this.liveKitClient.sendAudioData(pcmBuffer);
if (this.stats.framesCapture % 100 === 0) {
console.log(`[AudioBridge] → LiveKit groupe "${groupName}": ${pcmBuffer.length} bytes`);
}
} else {
if (this.stats.framesCapture % 100 === 0) {
console.log(`[AudioBridge] ⚠️ LiveKit non connecté, audio non envoyé`);
}
} }
// Émettre aussi pour monitoring/debug // Émettre aussi pour monitoring/debug
@@ -389,7 +417,27 @@ export class AudioBridge extends EventEmitter {
} }
}); });
// ÉTAPE 3 : Loopback local - Groupes → Outputs physiques (sans passer par LiveKit)
const outputBuffers = this.groupAudioRouter.processGroupsToOutputs(groupBuffers);
if (this.stats.framesCapture % 100 === 0) {
console.log(`[AudioBridge] Loopback local: ${groupBuffers.size} groupes → ${outputBuffers.size} outputs`);
}
// ÉTAPE 4 : Envoyer chaque output à la carte son
outputBuffers.forEach((outputBuffer, channelId) => {
const pcmBuffer = this._float32ToBuffer(outputBuffer);
// Envoyer à la carte son
this.audioBackend.queueAudio(pcmBuffer);
if (this.stats.framesCapture % 100 === 0) {
console.log(`[AudioBridge] → Output ${channelId}: ${pcmBuffer.length} bytes`);
}
});
this.stats.framesCapture++; this.stats.framesCapture++;
this.stats.framesPlayback++;
} catch (error) { } catch (error) {
console.error('Erreur routing capture:', error); console.error('Erreur routing capture:', error);
this.stats.errors.capture++; this.stats.errors.capture++;
+8 -1
View File
@@ -79,6 +79,10 @@ class AudioBridgeManager extends EventEmitter {
if (audioConfig.defaultBitrate) audioConfig.defaultBitrate = parseInt(audioConfig.defaultBitrate, 10); if (audioConfig.defaultBitrate) audioConfig.defaultBitrate = parseInt(audioConfig.defaultBitrate, 10);
if (audioConfig.customOpusBitrate) audioConfig.customOpusBitrate = parseInt(audioConfig.customOpusBitrate, 10); if (audioConfig.customOpusBitrate) audioConfig.customOpusBitrate = parseInt(audioConfig.customOpusBitrate, 10);
// Extraire les device IDs depuis le sous-objet device
const inputDeviceId = audioConfig.device?.inputDeviceId || null;
const outputDeviceId = audioConfig.device?.outputDeviceId || null;
// Créer l'instance avec la config // Créer l'instance avec la config
this.bridge = new AudioBridge({ this.bridge = new AudioBridge({
...audioConfig, ...audioConfig,
@@ -90,7 +94,10 @@ class AudioBridgeManager extends EventEmitter {
routing: config.audio?.routing || {}, routing: config.audio?.routing || {},
groups: config.groups || [], groups: config.groups || [],
maxInputChannels: 32, maxInputChannels: 32,
maxOutputChannels: 32 maxOutputChannels: 32,
// Device IDs extraits
inputDeviceId,
outputDeviceId
}); });
// Démarrer le bridge // Démarrer le bridge
+21 -7
View File
@@ -10,6 +10,9 @@
*/ */
import { EventEmitter } from 'events'; import { EventEmitter } from 'events';
import { getLogger } from '../utils/Logger.js';
const logger = getLogger('Routing');
/** /**
* Représente une route audio avec gain * Représente une route audio avec gain
@@ -76,7 +79,10 @@ export class GroupAudioRouter extends EventEmitter {
* Configure le routing depuis la config YAML * Configure le routing depuis la config YAML
*/ */
configure(routingConfig) { configure(routingConfig) {
console.log('Configuration du routing audio...'); logger.info('Configuration du routing audio...');
logger.debug(' Groupes disponibles:', this.config.groups.map(g => `${g.name || g} (id: ${g.id || g})`).join(', '));
logger.debug(' inputToGroup:', JSON.stringify(routingConfig.inputToGroup || {}));
logger.debug(' groupToOutput:', JSON.stringify(routingConfig.groupToOutput || {}));
// Réinitialise les routes // Réinitialise les routes
this.inputToGroupRoutes.clear(); this.inputToGroupRoutes.clear();
@@ -104,7 +110,7 @@ export class GroupAudioRouter extends EventEmitter {
} }
this._updateStatsActiveRoutes(); this._updateStatsActiveRoutes();
console.log(`Routing configuré : ${this.stats.routesActive} routes actives`); logger.success(`Routing configuré : ${this.stats.routesActive} routes actives`);
this.emit('configured', this.stats); this.emit('configured', this.stats);
} }
@@ -128,7 +134,7 @@ export class GroupAudioRouter extends EventEmitter {
const route = new AudioRoute(inputChannel, groupName, gainDb); const route = new AudioRoute(inputChannel, groupName, gainDb);
this.inputToGroupRoutes.get(key).push(route); this.inputToGroupRoutes.get(key).push(route);
console.log(`Route ajoutée : Input ${inputChannel} -> Group "${groupName}" (${gainDb}dB)`); logger.info(`Input ${inputChannel} Group "${groupName}" (${gainDb}dB)`);
this._updateStatsActiveRoutes(); this._updateStatsActiveRoutes();
} }
@@ -145,7 +151,7 @@ export class GroupAudioRouter extends EventEmitter {
const route = new AudioRoute(groupName, outputChannel, gainDb); const route = new AudioRoute(groupName, outputChannel, gainDb);
this.groupToOutputRoutes.get(key).push(route); this.groupToOutputRoutes.get(key).push(route);
console.log(`Route ajoutée : Group "${groupName}" -> Output ${outputChannel} (${gainDb}dB)`); logger.info(`Group "${groupName}" Output ${outputChannel} (${gainDb}dB)`);
this._updateStatsActiveRoutes(); this._updateStatsActiveRoutes();
} }
@@ -205,7 +211,9 @@ export class GroupAudioRouter extends EventEmitter {
// Réinitialise les buffers de groupe // Réinitialise les buffers de groupe
this.groupBuffers.clear(); this.groupBuffers.clear();
this.config.groups.forEach(group => { this.config.groups.forEach(group => {
this.groupBuffers.set(group.name, new Float32Array(this.config.frameSize)); // Utiliser l'ID (slugifié) plutôt que le nom pour correspondre au routing
const groupId = group.id || group.name || group;
this.groupBuffers.set(groupId, new Float32Array(this.config.frameSize));
}); });
// Pour chaque canal d'entrée // Pour chaque canal d'entrée
@@ -221,7 +229,10 @@ export class GroupAudioRouter extends EventEmitter {
// Applique chaque route (mixage additif vers les groupes) // Applique chaque route (mixage additif vers les groupes)
routes.forEach(route => { routes.forEach(route => {
const groupBuffer = this.groupBuffers.get(route.destination); const groupBuffer = this.groupBuffers.get(route.destination);
if (!groupBuffer) return; if (!groupBuffer) {
logger.warn(`Buffer groupe "${route.destination}" introuvable pour routing depuis Input ${channelId}`);
return;
}
// Mixage avec gain // Mixage avec gain
for (let i = 0; i < pcmData.length && i < groupBuffer.length; i++) { for (let i = 0; i < pcmData.length && i < groupBuffer.length; i++) {
@@ -235,6 +246,9 @@ export class GroupAudioRouter extends EventEmitter {
for (let i = 0; i < buffer.length; i++) { for (let i = 0; i < buffer.length; i++) {
if (Math.abs(buffer[i]) > 1.0) { if (Math.abs(buffer[i]) > 1.0) {
this.stats.clippingEvents++; this.stats.clippingEvents++;
if (this.stats.clippingEvents % 1000 === 1) {
logger.warn(`Clipping détecté sur groupe "${groupName}" (${this.stats.clippingEvents} événements)`);
}
buffer[i] = Math.sign(buffer[i]) * 1.0; // Hard clipping buffer[i] = Math.sign(buffer[i]) * 1.0; // Hard clipping
} }
} }
@@ -376,7 +390,7 @@ export class GroupAudioRouter extends EventEmitter {
this.groupBuffers.clear(); this.groupBuffers.clear();
this.outputBuffers.clear(); this.outputBuffers.clear();
this.removeAllListeners(); this.removeAllListeners();
console.log('GroupAudioRouter détruit'); logger.info('GroupAudioRouter détruit');
} }
} }
+8
View File
@@ -223,6 +223,11 @@ export class LiveKitClient extends EventEmitter {
return; return;
} }
if (!this.isConnected || !this.localAudioTrack) {
// Silently drop frames si pas encore connecté
return;
}
try { try {
// Création d'un AudioFrame (conversion en int32 explicite) // Création d'un AudioFrame (conversion en int32 explicite)
const samplesPerChannel = Math.floor(pcmData.length / 2 / this.options.channels); const samplesPerChannel = Math.floor(pcmData.length / 2 / this.options.channels);
@@ -238,9 +243,12 @@ export class LiveKitClient extends EventEmitter {
await this.audioSource.captureFrame(frame); await this.audioSource.captureFrame(frame);
} catch (error) { } catch (error) {
// Ne logger que les erreurs non-InvalidState pour éviter le spam
if (!error.message.includes('InvalidState')) {
console.error('Erreur envoi audio:', error); console.error('Erreur envoi audio:', error);
} }
} }
}
/** /**
* Récupère tous les tracks audio distants actifs * Récupère tous les tracks audio distants actifs
+74 -34
View File
@@ -48,7 +48,6 @@ export class CoreAudioBackend extends EventEmitter {
const data = JSON.parse(output); const data = JSON.parse(output);
const devices = []; const devices = [];
let id = 0;
// Parse audio devices // Parse audio devices
if (data.SPAudioDataType) { if (data.SPAudioDataType) {
@@ -62,13 +61,16 @@ export class CoreAudioBackend extends EventEmitter {
const outputChannels = parseInt(device.coreaudio_device_output) || 0; const outputChannels = parseInt(device.coreaudio_device_output) || 0;
const sampleRate = parseInt(device.coreaudio_device_srate) || 48000; const sampleRate = parseInt(device.coreaudio_device_srate) || 48000;
// Utiliser le UID CoreAudio comme ID (unique et stable)
const deviceUID = device._uniqueID || device.coreaudio_device_uid || name;
// Ignorer les devices sans input ni output // Ignorer les devices sans input ni output
if (inputChannels === 0 && outputChannels === 0) { if (inputChannels === 0 && outputChannels === 0) {
return; return;
} }
devices.push({ devices.push({
id: id++, id: deviceUID,
name: name, name: name,
maxInputChannels: inputChannels, maxInputChannels: inputChannels,
maxOutputChannels: outputChannels, maxOutputChannels: outputChannels,
@@ -90,7 +92,7 @@ export class CoreAudioBackend extends EventEmitter {
if (devices.length === 0) { if (devices.length === 0) {
devices.push( devices.push(
{ {
id: 0, id: 'builtin-mic',
name: 'Built-in Microphone', name: 'Built-in Microphone',
maxInputChannels: 1, maxInputChannels: 1,
maxOutputChannels: 0, maxOutputChannels: 0,
@@ -98,7 +100,7 @@ export class CoreAudioBackend extends EventEmitter {
hostAPIName: 'Core Audio' hostAPIName: 'Core Audio'
}, },
{ {
id: 1, id: 'builtin-output',
name: 'Built-in Output', name: 'Built-in Output',
maxInputChannels: 0, maxInputChannels: 0,
maxOutputChannels: 2, maxOutputChannels: 2,
@@ -116,7 +118,7 @@ export class CoreAudioBackend extends EventEmitter {
// Fallback : devices par défaut // Fallback : devices par défaut
return [ return [
{ {
id: 0, id: 'builtin-mic',
name: 'Built-in Microphone', name: 'Built-in Microphone',
maxInputChannels: 1, maxInputChannels: 1,
maxOutputChannels: 0, maxOutputChannels: 0,
@@ -124,7 +126,7 @@ export class CoreAudioBackend extends EventEmitter {
hostAPIName: 'Core Audio' hostAPIName: 'Core Audio'
}, },
{ {
id: 1, id: 'builtin-output',
name: 'Built-in Output', name: 'Built-in Output',
maxInputChannels: 0, maxInputChannels: 0,
maxOutputChannels: 2, maxOutputChannels: 2,
@@ -203,7 +205,7 @@ export class CoreAudioBackend extends EventEmitter {
// Si device spécifié // Si device spécifié
if (this.options.inputDeviceName) { if (this.options.inputDeviceName) {
args[1] = this.options.inputDeviceName; args[2] = this.options.inputDeviceName; // Index 2 = device name
} }
this.captureProcess = spawn('sox', args); this.captureProcess = spawn('sox', args);
@@ -255,8 +257,10 @@ export class CoreAudioBackend extends EventEmitter {
* @returns {Promise<void>} * @returns {Promise<void>}
*/ */
async startPlayback() { async startPlayback() {
console.log('🔊 Démarrage playback sox...');
if (this.isPlaying) { if (this.isPlaying) {
console.warn('Lecture déjà active'); console.warn('⚠️ Lecture déjà active');
return; return;
} }
@@ -264,7 +268,9 @@ export class CoreAudioBackend extends EventEmitter {
// Commande sox pour lecture audio // Commande sox pour lecture audio
// play : lire vers output par défaut // play : lire vers output par défaut
// -t raw : format raw PCM depuis stdin // -t raw : format raw PCM depuis stdin
// --buffer : taille du buffer interne sox (en bytes)
const args = [ const args = [
'--buffer', '8192', // Buffer interne sox
'-t', 'raw', '-t', 'raw',
'-b', '16', '-b', '16',
'-e', 'signed-integer', '-e', 'signed-integer',
@@ -280,7 +286,9 @@ export class CoreAudioBackend extends EventEmitter {
args[args.length - 1] = this.options.outputDeviceName; args[args.length - 1] = this.options.outputDeviceName;
} }
this.playbackProcess = spawn('sox', args); this.playbackProcess = spawn('sox', args, {
stdio: ['pipe', 'ignore', 'pipe'] // stdin=pipe, stdout=ignore, stderr=pipe
});
// Gérer l'erreur EPIPE sur stdin (si processus se ferme) // Gérer l'erreur EPIPE sur stdin (si processus se ferme)
this.playbackProcess.stdin.on('error', (error) => { this.playbackProcess.stdin.on('error', (error) => {
@@ -305,13 +313,28 @@ export class CoreAudioBackend extends EventEmitter {
}); });
this.playbackProcess.on('close', (code) => { this.playbackProcess.on('close', (code) => {
console.log(`Sox playback fermé (code ${code})`); console.log(`⚠️ Sox playback fermé (code ${code}) après ${((Date.now() - this.playbackStartTime) / 1000).toFixed(1)}s`);
this.isPlaying = false; this.isPlaying = false;
// Tenter de redémarrer si c'était inattendu
if (code !== 0) {
console.log('🔄 Tentative de redémarrage du playback...');
setTimeout(() => this.startPlayback(), 1000);
}
}); });
this.playbackStartTime = Date.now();
this.isPlaying = true; this.isPlaying = true;
this._startPlaybackLoop(); this._startPlaybackLoop();
// Envoyer immédiatement du silence pour démarrer sox
const silenceBuffer = Buffer.alloc(this.options.framesPerBuffer * 2 * this.options.channels);
for (let i = 0; i < 10; i++) {
if (this.playbackProcess.stdin.writable) {
this.playbackProcess.stdin.write(silenceBuffer);
}
}
console.log(`✓ Lecture audio démarrée : ${this.options.sampleRate}Hz, ${this.options.channels}ch`); console.log(`✓ Lecture audio démarrée : ${this.options.sampleRate}Hz, ${this.options.channels}ch`);
} catch (error) { } catch (error) {
console.error('Erreur démarrage lecture:', error); console.error('Erreur démarrage lecture:', error);
@@ -323,6 +346,11 @@ export class CoreAudioBackend extends EventEmitter {
* Arrête la lecture audio * Arrête la lecture audio
*/ */
stopPlayback() { stopPlayback() {
if (this.playbackInterval) {
clearInterval(this.playbackInterval);
this.playbackInterval = null;
}
if (this.playbackProcess && this.isPlaying) { if (this.playbackProcess && this.isPlaying) {
this.playbackProcess.kill('SIGTERM'); this.playbackProcess.kill('SIGTERM');
this.playbackProcess = null; this.playbackProcess = null;
@@ -338,10 +366,16 @@ export class CoreAudioBackend extends EventEmitter {
*/ */
queueAudio(audioData) { queueAudio(audioData) {
if (!this.isPlaying) { if (!this.isPlaying) {
console.warn('Tentative ajout audio alors que lecture inactive'); // Ne logger qu'une fois pour éviter le spam
if (!this.playbackInactiveWarned) {
console.warn('⚠️ Tentative ajout audio alors que lecture inactive (message unique)');
this.playbackInactiveWarned = true;
}
return; return;
} }
this.playbackInactiveWarned = false;
// Limite la taille du buffer pour éviter la latence excessive // Limite la taille du buffer pour éviter la latence excessive
if (this.playbackBuffer.length < this.maxBufferSize) { if (this.playbackBuffer.length < this.maxBufferSize) {
this.playbackBuffer.push(audioData); this.playbackBuffer.push(audioData);
@@ -356,42 +390,48 @@ export class CoreAudioBackend extends EventEmitter {
* @private * @private
*/ */
_startPlaybackLoop() { _startPlaybackLoop() {
const playNextChunk = () => { // Calculer l'intervalle en ms (ex: 960 frames à 48kHz = 20ms)
const intervalMs = (this.options.framesPerBuffer / this.options.sampleRate) * 1000;
console.log(`🔁 Boucle playback démarrée (intervalle: ${intervalMs}ms)`);
// Utiliser setInterval pour garantir un flux continu
this.playbackInterval = setInterval(() => {
if (!this.isPlaying || !this.playbackProcess || !this.playbackProcess.stdin) { if (!this.isPlaying || !this.playbackProcess || !this.playbackProcess.stdin) {
if (this.playbackInterval) {
clearInterval(this.playbackInterval);
this.playbackInterval = null;
}
return; return;
} }
let chunk;
if (this.playbackBuffer.length > 0) { if (this.playbackBuffer.length > 0) {
const chunk = this.playbackBuffer.shift(); chunk = this.playbackBuffer.shift();
} else {
// Buffer vide : underrun (envoyer du silence)
chunk = Buffer.alloc(this.options.framesPerBuffer * 2 * this.options.channels);
}
// Toujours écrire quelque chose pour garder sox actif
try { try {
if (this.playbackProcess.stdin.writable) { if (this.playbackProcess.stdin.writable) {
this.playbackProcess.stdin.write(chunk); this.playbackProcess.stdin.write(chunk);
}
} catch (error) {
console.error('Erreur écriture stdin sox:', error);
this.isPlaying = false;
return;
}
} else { } else {
// Buffer vide : underrun (silence) console.warn('⚠️ Sox stdin non writable, arrêt boucle');
const silenceBuffer = Buffer.alloc(this.options.framesPerBuffer * 2 * this.options.channels); this.isPlaying = false;
try { clearInterval(this.playbackInterval);
if (this.playbackProcess.stdin.writable) { this.playbackInterval = null;
this.playbackProcess.stdin.write(silenceBuffer);
} }
} catch (error) { } catch (error) {
// Ignore si process fermé if (error.code !== 'EPIPE') {
console.error('Erreur écriture stdin sox:', error);
}
this.isPlaying = false; this.isPlaying = false;
return; clearInterval(this.playbackInterval);
this.playbackInterval = null;
} }
this.emit('bufferUnderrun'); }, intervalMs);
}
const intervalMs = (this.options.framesPerBuffer / this.options.sampleRate) * 1000;
setTimeout(playNextChunk, intervalMs);
};
playNextChunk();
} }
/** /**
+14 -14
View File
@@ -4,30 +4,29 @@ audio:
defaultBitrate: 96 defaultBitrate: 96
jitterBufferMs: 40 jitterBufferMs: 40
device: device:
# inputDeviceId et outputDeviceId : laisser vide pour auto-détection du device par défaut inputDeviceId: Microphone MacBook Pro
# ou spécifier un ID numérique pour forcer un device spécifique outputDeviceId: Haut-parleurs MacBook Pro
inputDeviceId: null
outputDeviceId: null
sampleRate: 48000 sampleRate: 48000
routing: routing:
inputToGroup: inputToGroup:
"0": "0":
- technique - production
"1": "1": []
- technique "2": []
"2":
- technique
"4": "4":
- technique - technique
"5": "5":
- technique - technique
groupToOutput: groupToOutput:
technique: technique:
- "1"
production:
- "0" - "0"
- "1"
gains: {} gains: {}
channelNames: channelNames:
inputs: inputs:
"0": Micro Régisseur "0": iphone
"1": Talkback FOH "1": Talkback FOH
"2": Retour Console "2": Retour Console
"3": Liaison Scène "3": Liaison Scène
@@ -42,6 +41,7 @@ groups:
audioBitrate: 96 audioBitrate: 96
channels: [] channels: []
- name: Technique - name: Technique
audioBitrate: 96
channels: [] channels: []
- name: Sonorisation - name: Sonorisation
audioBitrate: 128 audioBitrate: 128
@@ -50,8 +50,8 @@ server:
host: 0.0.0.0 host: 0.0.0.0
port: 3000 port: 3000
livekit: livekit:
url: ws://localhost:7880 url: ws://192.168.0.146:7880
logging: logging:
level: debug level: info # Changez à 'debug' pour voir plus de détails
logLatency: true logLatency: false
logAudioStats: true logAudioStats: false
+6
View File
@@ -13,12 +13,18 @@ import adminRouter, { registerUser, addLog } from './api/admin.js';
import configManager from './config/ConfigManager.js'; import configManager from './config/ConfigManager.js';
import audioBridgeManager from './bridge/AudioBridgeManager.js'; import audioBridgeManager from './bridge/AudioBridgeManager.js';
import AudioLevelsServer from './websocket/AudioLevelsServer.js'; import AudioLevelsServer from './websocket/AudioLevelsServer.js';
import { setGlobalLogLevel } from './utils/Logger.js';
const __dirname = dirname(fileURLToPath(import.meta.url)); const __dirname = dirname(fileURLToPath(import.meta.url));
// Chargement configuration via ConfigManager // Chargement configuration via ConfigManager
const config = configManager.get(); const config = configManager.get();
// Configure le niveau de log
const logLevel = config.logging?.level?.toUpperCase() || 'INFO';
setGlobalLogLevel(logLevel);
console.log(`📊 Niveau de log: ${logLevel}`);
// Note: Les IDs sont maintenant générés automatiquement par le ConfigManager // Note: Les IDs sont maintenant générés automatiquement par le ConfigManager
/** /**
+78
View File
@@ -0,0 +1,78 @@
/**
* Logger.js
* Système de logging centralisé avec niveaux configurables
*/
const LOG_LEVELS = {
ERROR: 0,
WARN: 1,
INFO: 2,
DEBUG: 3,
TRACE: 4
};
class Logger {
constructor(category = 'default', level = 'INFO') {
this.category = category;
this.level = LOG_LEVELS[level] ?? LOG_LEVELS.INFO;
}
setLevel(level) {
this.level = LOG_LEVELS[level] ?? LOG_LEVELS.INFO;
}
error(message, ...args) {
if (this.level >= LOG_LEVELS.ERROR) {
console.error(`[${this.category}] ❌`, message, ...args);
}
}
warn(message, ...args) {
if (this.level >= LOG_LEVELS.WARN) {
console.warn(`[${this.category}] ⚠️ `, message, ...args);
}
}
info(message, ...args) {
if (this.level >= LOG_LEVELS.INFO) {
console.log(`[${this.category}] `, message, ...args);
}
}
success(message, ...args) {
if (this.level >= LOG_LEVELS.INFO) {
console.log(`[${this.category}] ✓`, message, ...args);
}
}
debug(message, ...args) {
if (this.level >= LOG_LEVELS.DEBUG) {
console.log(`[${this.category}] 🔍`, message, ...args);
}
}
trace(message, ...args) {
if (this.level >= LOG_LEVELS.TRACE) {
console.log(`[${this.category}] 🔬`, message, ...args);
}
}
}
// Configuration globale depuis env ou config
const globalLevel = process.env.LOG_LEVEL || 'INFO';
// Loggers par catégorie
const loggers = new Map();
export function getLogger(category) {
if (!loggers.has(category)) {
loggers.set(category, new Logger(category, globalLevel));
}
return loggers.get(category);
}
export function setGlobalLogLevel(level) {
loggers.forEach(logger => logger.setLevel(level));
}
export default { getLogger, setGlobalLogLevel };