diff --git a/client/dev-dist/sw.js b/client/dev-dist/sw.js
index 593a4ec..75e85ea 100644
--- a/client/dev-dist/sw.js
+++ b/client/dev-dist/sw.js
@@ -81,7 +81,7 @@ define(['./workbox-290dd570'], (function (workbox) { 'use strict';
"revision": "3ca0b8505b4bec776b69afdba2768812"
}, {
"url": "index.html",
- "revision": "0.oebo7b1mt4g"
+ "revision": "0.su9rr59m8gg"
}], {});
workbox.cleanupOutdatedCaches();
workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), {
diff --git a/client/dev-dist/sw.js.map b/client/dev-dist/sw.js.map
index 46f99c8..6000e1b 100644
--- a/client/dev-dist/sw.js.map
+++ b/client/dev-dist/sw.js.map
@@ -1 +1 @@
-{"version":3,"file":"sw.js","sources":["../../../../../../private/var/folders/lc/87d3wzj544l4skb096m6003m0000gn/T/4b219ed9b0688aace970cf943e14dea9/sw.js"],"sourcesContent":["import {registerRoute as workbox_routing_registerRoute} from '/Users/benoit/Documents/code/PTT Live/client/node_modules/workbox-routing/registerRoute.mjs';\nimport {ExpirationPlugin as workbox_expiration_ExpirationPlugin} from '/Users/benoit/Documents/code/PTT Live/client/node_modules/workbox-expiration/ExpirationPlugin.mjs';\nimport {NetworkFirst as workbox_strategies_NetworkFirst} from '/Users/benoit/Documents/code/PTT Live/client/node_modules/workbox-strategies/NetworkFirst.mjs';\nimport {clientsClaim as workbox_core_clientsClaim} from '/Users/benoit/Documents/code/PTT Live/client/node_modules/workbox-core/clientsClaim.mjs';\nimport {precacheAndRoute as workbox_precaching_precacheAndRoute} from '/Users/benoit/Documents/code/PTT Live/client/node_modules/workbox-precaching/precacheAndRoute.mjs';\nimport {cleanupOutdatedCaches as workbox_precaching_cleanupOutdatedCaches} from '/Users/benoit/Documents/code/PTT Live/client/node_modules/workbox-precaching/cleanupOutdatedCaches.mjs';\nimport {NavigationRoute as workbox_routing_NavigationRoute} from '/Users/benoit/Documents/code/PTT Live/client/node_modules/workbox-routing/NavigationRoute.mjs';\nimport {createHandlerBoundToURL as workbox_precaching_createHandlerBoundToURL} from '/Users/benoit/Documents/code/PTT Live/client/node_modules/workbox-precaching/createHandlerBoundToURL.mjs';/**\n * Welcome to your Workbox-powered service worker!\n *\n * You'll need to register this file in your web app.\n * See https://goo.gl/nhQhGp\n *\n * The rest of the code is auto-generated. Please don't update this file\n * directly; instead, make changes to your Workbox build configuration\n * and re-run your build process.\n * See https://goo.gl/2aRDsh\n */\n\n\n\n\nself.skipWaiting();\nworkbox_core_clientsClaim();\n/**\n * The precacheAndRoute() method efficiently caches and responds to\n * requests for URLs in the manifest.\n * See https://goo.gl/S9QRab\n */\nworkbox_precaching_precacheAndRoute([\n {\n \"url\": \"registerSW.js\",\n \"revision\": \"3ca0b8505b4bec776b69afdba2768812\"\n },\n {\n \"url\": \"index.html\",\n \"revision\": \"0.oebo7b1mt4g\"\n }\n], {});\nworkbox_precaching_cleanupOutdatedCaches();workbox_routing_registerRoute(new workbox_routing_NavigationRoute(workbox_precaching_createHandlerBoundToURL(\"index.html\"), {\n allowlist: [/^\\/$/], }));\nworkbox_routing_registerRoute(/^https:\\/\\/.*\\.livekit\\.cloud\\/.*/i, new workbox_strategies_NetworkFirst({ \"cacheName\":\"livekit-cache\", plugins: [new workbox_expiration_ExpirationPlugin({ maxEntries: 10, maxAgeSeconds: 86400 })] }), 'GET');\n\n\n"],"names":["self","skipWaiting","workbox_core_clientsClaim","workbox_precaching_precacheAndRoute","workbox_precaching_cleanupOutdatedCaches","workbox_routing_registerRoute","workbox_routing_NavigationRoute","workbox_precaching_createHandlerBoundToURL","allowlist","workbox_strategies_NetworkFirst","plugins","workbox_expiration_ExpirationPlugin","maxEntries","maxAgeSeconds"],"mappings":"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"}
\ No newline at end of file
+{"version":3,"file":"sw.js","sources":["../../../../../../private/var/folders/lc/87d3wzj544l4skb096m6003m0000gn/T/3a76fc28175050c4b8053cd8cd369ac3/sw.js"],"sourcesContent":["import {registerRoute as workbox_routing_registerRoute} from '/Users/benoit/Documents/code/PTT Live/client/node_modules/workbox-routing/registerRoute.mjs';\nimport {ExpirationPlugin as workbox_expiration_ExpirationPlugin} from '/Users/benoit/Documents/code/PTT Live/client/node_modules/workbox-expiration/ExpirationPlugin.mjs';\nimport {NetworkFirst as workbox_strategies_NetworkFirst} from '/Users/benoit/Documents/code/PTT Live/client/node_modules/workbox-strategies/NetworkFirst.mjs';\nimport {clientsClaim as workbox_core_clientsClaim} from '/Users/benoit/Documents/code/PTT Live/client/node_modules/workbox-core/clientsClaim.mjs';\nimport {precacheAndRoute as workbox_precaching_precacheAndRoute} from '/Users/benoit/Documents/code/PTT Live/client/node_modules/workbox-precaching/precacheAndRoute.mjs';\nimport {cleanupOutdatedCaches as workbox_precaching_cleanupOutdatedCaches} from '/Users/benoit/Documents/code/PTT Live/client/node_modules/workbox-precaching/cleanupOutdatedCaches.mjs';\nimport {NavigationRoute as workbox_routing_NavigationRoute} from '/Users/benoit/Documents/code/PTT Live/client/node_modules/workbox-routing/NavigationRoute.mjs';\nimport {createHandlerBoundToURL as workbox_precaching_createHandlerBoundToURL} from '/Users/benoit/Documents/code/PTT Live/client/node_modules/workbox-precaching/createHandlerBoundToURL.mjs';/**\n * Welcome to your Workbox-powered service worker!\n *\n * You'll need to register this file in your web app.\n * See https://goo.gl/nhQhGp\n *\n * The rest of the code is auto-generated. Please don't update this file\n * directly; instead, make changes to your Workbox build configuration\n * and re-run your build process.\n * See https://goo.gl/2aRDsh\n */\n\n\n\n\nself.skipWaiting();\nworkbox_core_clientsClaim();\n/**\n * The precacheAndRoute() method efficiently caches and responds to\n * requests for URLs in the manifest.\n * See https://goo.gl/S9QRab\n */\nworkbox_precaching_precacheAndRoute([\n {\n \"url\": \"registerSW.js\",\n \"revision\": \"3ca0b8505b4bec776b69afdba2768812\"\n },\n {\n \"url\": \"index.html\",\n \"revision\": \"0.su9rr59m8gg\"\n }\n], {});\nworkbox_precaching_cleanupOutdatedCaches();workbox_routing_registerRoute(new workbox_routing_NavigationRoute(workbox_precaching_createHandlerBoundToURL(\"index.html\"), {\n allowlist: [/^\\/$/], }));\nworkbox_routing_registerRoute(/^https:\\/\\/.*\\.livekit\\.cloud\\/.*/i, new workbox_strategies_NetworkFirst({ \"cacheName\":\"livekit-cache\", plugins: [new workbox_expiration_ExpirationPlugin({ maxEntries: 10, maxAgeSeconds: 86400 })] }), 'GET');\n\n\n"],"names":["self","skipWaiting","workbox_core_clientsClaim","workbox_precaching_precacheAndRoute","workbox_precaching_cleanupOutdatedCaches","workbox_routing_registerRoute","workbox_routing_NavigationRoute","workbox_precaching_createHandlerBoundToURL","allowlist","workbox_strategies_NetworkFirst","plugins","workbox_expiration_ExpirationPlugin","maxEntries","maxAgeSeconds"],"mappings":"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"}
\ No newline at end of file
diff --git a/client/src/Admin.jsx b/client/src/Admin.jsx
index d03ea09..def1f6f 100644
--- a/client/src/Admin.jsx
+++ b/client/src/Admin.jsx
@@ -15,7 +15,7 @@ function Admin() {
// Audio devices (Phase 2.5)
const [audioDevices, setAudioDevices] = useState([]);
- const [currentDevice, setCurrentDevice] = useState(null);
+ const [currentDevice, setCurrentDevice] = useState({ inputChannels: 8, outputChannels: 8 });
const [selectedInputDevice, setSelectedInputDevice] = useState(null);
const [selectedOutputDevice, setSelectedOutputDevice] = useState(null);
const [selectedSampleRate, setSelectedSampleRate] = useState(48000);
@@ -30,8 +30,7 @@ function Admin() {
const [editingGroup, setEditingGroup] = useState(null);
const [groupForm, setGroupForm] = useState({
name: '',
- audioBitrate: 96,
- channels: []
+ audioBitrate: 96
});
// Rafraîchissement automatique
@@ -102,14 +101,16 @@ function Admin() {
const channelNamesData = await channelNamesRes.json();
setAudioDevices(devicesData.devices || []);
- setCurrentDevice(currentData.device || {});
+
+ const device = currentData.device || { inputChannels: 8, outputChannels: 8 };
+ setCurrentDevice(device);
setChannelNames(channelNamesData.channelNames || { inputs: {}, outputs: {} });
// Ne réinitialiser les sélections que si l'utilisateur n'est pas en train d'éditer
if (!isEditingAudio) {
- setSelectedInputDevice(currentData.device?.inputDeviceId ?? null);
- setSelectedOutputDevice(currentData.device?.outputDeviceId ?? null);
- setSelectedSampleRate(currentData.device?.sampleRate || 48000);
+ setSelectedInputDevice(device.inputDeviceId ?? null);
+ setSelectedOutputDevice(device.outputDeviceId ?? null);
+ setSelectedSampleRate(device.sampleRate || 48000);
}
};
@@ -189,8 +190,7 @@ function Admin() {
setEditingGroup(group.id);
setGroupForm({
name: group.name,
- audioBitrate: group.audioBitrate || 96,
- channels: group.channels || []
+ audioBitrate: group.audioBitrate || 96
});
setShowGroupForm(true);
};
@@ -198,38 +198,12 @@ function Admin() {
const resetGroupForm = () => {
setGroupForm({
name: '',
- audioBitrate: 96,
- channels: []
+ audioBitrate: 96
});
setShowGroupForm(false);
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) ==========
const handleSaveChannelNames = async () => {
@@ -270,8 +244,8 @@ function Admin() {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
- inputDeviceId: selectedInputDevice !== null ? parseInt(selectedInputDevice) : undefined,
- outputDeviceId: selectedOutputDevice !== null ? parseInt(selectedOutputDevice) : undefined,
+ inputDeviceId: selectedInputDevice || undefined,
+ outputDeviceId: selectedOutputDevice || undefined,
sampleRate: parseInt(selectedSampleRate)
})
});
@@ -415,43 +389,9 @@ function Admin() {
-
+
+ Le routing audio se configure dans l'onglet "Audio" via la matrice de routing.
+
))}
@@ -514,22 +443,22 @@ function Admin() {
value={selectedInputDevice ?? ''}
onChange={(e) => {
setIsEditingAudio(true);
- setSelectedInputDevice(e.target.value === '' ? null : parseInt(e.target.value));
+ setSelectedInputDevice(e.target.value === '' ? null : e.target.value);
}}
className="device-select"
>
{audioDevices
.filter(d => d.maxInputChannels > 0)
- .map(device => (
-
))}
- {selectedInputDevice !== null && (
-
- Device ID {selectedInputDevice} sélectionné
+ {selectedInputDevice !== null && selectedInputDevice !== '' && (
+
+ Device ID: {selectedInputDevice}
)}
@@ -540,22 +469,22 @@ function Admin() {
value={selectedOutputDevice ?? ''}
onChange={(e) => {
setIsEditingAudio(true);
- setSelectedOutputDevice(e.target.value === '' ? null : parseInt(e.target.value));
+ setSelectedOutputDevice(e.target.value === '' ? null : e.target.value);
}}
className="device-select"
>
{audioDevices
.filter(d => d.maxOutputChannels > 0)
- .map(device => (
-
))}
- {selectedOutputDevice !== null && (
-
- Device ID {selectedOutputDevice} sélectionné
+ {selectedOutputDevice !== null && selectedOutputDevice !== '' && (
+
+ Device ID: {selectedOutputDevice}
)}
@@ -603,9 +532,11 @@ function Admin() {
-
Entrées (Inputs)
+
+ Entrées (Inputs) - {currentDevice.inputChannels || 0} canaux disponibles
+
- {Array.from({length: 8}, (_, i) => (
+ {Array.from({length: currentDevice.inputChannels || 8}, (_, i) => (
{i}
-
Sorties (Outputs)
+
+ Sorties (Outputs) - {currentDevice.outputChannels || 0} canaux disponibles
+
- {Array.from({length: 8}, (_, i) => (
+ {Array.from({length: currentDevice.outputChannels || 8}, (_, i) => (
{i}
- {currentDevice && Object.keys(currentDevice).length > 0 && (
+ {currentDevice && currentDevice.inputDeviceId && (
Configuration actuelle
-
Input Device ID: {currentDevice.inputDeviceId ?? 'Non configuré'}
-
Output Device ID: {currentDevice.outputDeviceId ?? 'Non configuré'}
+
Input Device: {currentDevice.inputDeviceName || currentDevice.inputDeviceId}
+
Output Device: {currentDevice.outputDeviceName || currentDevice.outputDeviceId}
Sample Rate: {currentDevice.sampleRate ?? 48000} Hz
+
Canaux: {currentDevice.inputChannels} entrées / {currentDevice.outputChannels} sorties
)}
@@ -683,9 +617,9 @@ function Admin() {
- {audioDevices.map(device => (
-
- | {device.id} |
+ {audioDevices.map((device, index) => (
+
+ | {device.id} |
{device.name} |
{device.maxInputChannels} |
{device.maxOutputChannels} |
diff --git a/client/src/components/AudioRoutingMatrix.jsx b/client/src/components/AudioRoutingMatrix.jsx
index 0c70749..57dea72 100644
--- a/client/src/components/AudioRoutingMatrix.jsx
+++ b/client/src/components/AudioRoutingMatrix.jsx
@@ -10,9 +10,11 @@ function AudioRoutingMatrix({ groups, channelNames }) {
const [routing, setRouting] = useState({ inputToGroup: {}, groupToOutput: {}, gains: {} });
const [loading, setLoading] = useState(true);
const [showOnlyNamedChannels, setShowOnlyNamedChannels] = useState(false);
+ const [audioDevice, setAudioDevice] = useState({ inputChannels: 8, outputChannels: 8 });
useEffect(() => {
loadRouting();
+ loadAudioDevice();
}, []);
const loadRouting = async () => {
@@ -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 () => {
try {
const res = await fetch(`${API_URL}/admin/audio/routing`, {
@@ -146,7 +163,7 @@ function AudioRoutingMatrix({ groups, channelNames }) {
};
const getVisibleInputChannels = () => {
- const allInputs = Array.from({length: 8}, (_, i) => i);
+ const allInputs = Array.from({length: audioDevice.inputChannels}, (_, i) => i);
if (showOnlyNamedChannels) {
return allInputs.filter(i => hasCustomName('inputs', i));
}
@@ -154,7 +171,7 @@ function AudioRoutingMatrix({ groups, channelNames }) {
};
const getVisibleOutputChannels = () => {
- const allOutputs = Array.from({length: 8}, (_, i) => i);
+ const allOutputs = Array.from({length: audioDevice.outputChannels}, (_, i) => i);
if (showOnlyNamedChannels) {
return allOutputs.filter(i => hasCustomName('outputs', i));
}
diff --git a/client/vite.config.js b/client/vite.config.js
index b4c7e9a..903a966 100644
--- a/client/vite.config.js
+++ b/client/vite.config.js
@@ -68,7 +68,7 @@ export default defineConfig({
},
proxy: {
'/api': {
- target: 'http://localhost:3000',
+ target: 'http://192.168.0.146:3000',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, '')
},
diff --git a/server/api/admin.js b/server/api/admin.js
index eff8113..06833a9 100644
--- a/server/api/admin.js
+++ b/server/api/admin.js
@@ -179,7 +179,7 @@ router.get('/groups', (req, res) => {
/**
* POST /admin/groups
* Crée un nouveau groupe
- * Body: { name, audioBitrate?, channels }
+ * Body: { name, audioBitrate? }
* L'ID est généré automatiquement à partir du nom
*/
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 = {
name,
...(audioBitrate && { audioBitrate })
@@ -482,8 +482,28 @@ router.get('/audio/device', (req, res) => {
const config = configManager.get();
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({
- device: audioDevice
+ device: deviceInfo
});
} catch (error) {
console.error('Erreur GET /admin/audio/device:', error);
diff --git a/server/bridge/AudioBridge.js b/server/bridge/AudioBridge.js
index e703395..0e1e32e 100644
--- a/server/bridge/AudioBridge.js
+++ b/server/bridge/AudioBridge.js
@@ -196,13 +196,31 @@ export class AudioBridge extends EventEmitter {
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é
this.audioBackend = new BackendClass({
sampleRate: this.options.sampleRate,
channels: this.options.channels,
framesPerBuffer: this.options.frameSize,
inputDeviceId: this.options.inputDeviceId,
+ inputDeviceName: inputDeviceName,
outputDeviceId: this.options.outputDeviceId,
+ outputDeviceName: outputDeviceName,
// Options spécifiques PipeWire
latency: this.options.latency || 20
});
@@ -366,6 +384,10 @@ export class AudioBridge extends EventEmitter {
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
groupBuffers.forEach((groupBuffer, groupName) => {
// Convertir Float32Array → PCM Buffer
@@ -375,13 +397,19 @@ export class AudioBridge extends EventEmitter {
const opusData = this.opusEncoder.encode(pcmBuffer);
if (opusData) {
- this.stats.framesCapture++;
this.stats.bytesEncoded += opusData.length;
// Envoi vers LiveKit via sendAudioData (prend du PCM, pas de l'Opus)
// 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);
+ 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
@@ -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.framesPlayback++;
} catch (error) {
console.error('Erreur routing capture:', error);
this.stats.errors.capture++;
diff --git a/server/bridge/AudioBridgeManager.js b/server/bridge/AudioBridgeManager.js
index c2985ed..79f9002 100644
--- a/server/bridge/AudioBridgeManager.js
+++ b/server/bridge/AudioBridgeManager.js
@@ -79,6 +79,10 @@ class AudioBridgeManager extends EventEmitter {
if (audioConfig.defaultBitrate) audioConfig.defaultBitrate = parseInt(audioConfig.defaultBitrate, 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
this.bridge = new AudioBridge({
...audioConfig,
@@ -90,7 +94,10 @@ class AudioBridgeManager extends EventEmitter {
routing: config.audio?.routing || {},
groups: config.groups || [],
maxInputChannels: 32,
- maxOutputChannels: 32
+ maxOutputChannels: 32,
+ // Device IDs extraits
+ inputDeviceId,
+ outputDeviceId
});
// Démarrer le bridge
diff --git a/server/bridge/GroupAudioRouter.js b/server/bridge/GroupAudioRouter.js
index d320281..5eb4a0f 100644
--- a/server/bridge/GroupAudioRouter.js
+++ b/server/bridge/GroupAudioRouter.js
@@ -10,6 +10,9 @@
*/
import { EventEmitter } from 'events';
+import { getLogger } from '../utils/Logger.js';
+
+const logger = getLogger('Routing');
/**
* Représente une route audio avec gain
@@ -76,7 +79,10 @@ export class GroupAudioRouter extends EventEmitter {
* Configure le routing depuis la config YAML
*/
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
this.inputToGroupRoutes.clear();
@@ -104,7 +110,7 @@ export class GroupAudioRouter extends EventEmitter {
}
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);
}
@@ -128,7 +134,7 @@ export class GroupAudioRouter extends EventEmitter {
const route = new AudioRoute(inputChannel, groupName, gainDb);
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();
}
@@ -145,7 +151,7 @@ export class GroupAudioRouter extends EventEmitter {
const route = new AudioRoute(groupName, outputChannel, gainDb);
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();
}
@@ -205,7 +211,9 @@ export class GroupAudioRouter extends EventEmitter {
// Réinitialise les buffers de groupe
this.groupBuffers.clear();
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
@@ -221,7 +229,10 @@ export class GroupAudioRouter extends EventEmitter {
// Applique chaque route (mixage additif vers les groupes)
routes.forEach(route => {
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
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++) {
if (Math.abs(buffer[i]) > 1.0) {
this.stats.clippingEvents++;
+ if (this.stats.clippingEvents % 1000 === 1) {
+ logger.warn(`Clipping détecté sur groupe "${groupName}" (${this.stats.clippingEvents} événements)`);
+ }
buffer[i] = Math.sign(buffer[i]) * 1.0; // Hard clipping
}
}
@@ -376,7 +390,7 @@ export class GroupAudioRouter extends EventEmitter {
this.groupBuffers.clear();
this.outputBuffers.clear();
this.removeAllListeners();
- console.log('GroupAudioRouter détruit');
+ logger.info('GroupAudioRouter détruit');
}
}
diff --git a/server/bridge/LiveKitClient.js b/server/bridge/LiveKitClient.js
index e03ea0c..4029be2 100644
--- a/server/bridge/LiveKitClient.js
+++ b/server/bridge/LiveKitClient.js
@@ -223,6 +223,11 @@ export class LiveKitClient extends EventEmitter {
return;
}
+ if (!this.isConnected || !this.localAudioTrack) {
+ // Silently drop frames si pas encore connecté
+ return;
+ }
+
try {
// Création d'un AudioFrame (conversion en int32 explicite)
const samplesPerChannel = Math.floor(pcmData.length / 2 / this.options.channels);
@@ -238,7 +243,10 @@ export class LiveKitClient extends EventEmitter {
await this.audioSource.captureFrame(frame);
} catch (error) {
- console.error('Erreur envoi audio:', error);
+ // Ne logger que les erreurs non-InvalidState pour éviter le spam
+ if (!error.message.includes('InvalidState')) {
+ console.error('Erreur envoi audio:', error);
+ }
}
}
diff --git a/server/bridge/backends/CoreAudioBackend.js b/server/bridge/backends/CoreAudioBackend.js
index 0ce6947..5983895 100644
--- a/server/bridge/backends/CoreAudioBackend.js
+++ b/server/bridge/backends/CoreAudioBackend.js
@@ -48,7 +48,6 @@ export class CoreAudioBackend extends EventEmitter {
const data = JSON.parse(output);
const devices = [];
- let id = 0;
// Parse audio devices
if (data.SPAudioDataType) {
@@ -62,13 +61,16 @@ export class CoreAudioBackend extends EventEmitter {
const outputChannels = parseInt(device.coreaudio_device_output) || 0;
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
if (inputChannels === 0 && outputChannels === 0) {
return;
}
devices.push({
- id: id++,
+ id: deviceUID,
name: name,
maxInputChannels: inputChannels,
maxOutputChannels: outputChannels,
@@ -90,7 +92,7 @@ export class CoreAudioBackend extends EventEmitter {
if (devices.length === 0) {
devices.push(
{
- id: 0,
+ id: 'builtin-mic',
name: 'Built-in Microphone',
maxInputChannels: 1,
maxOutputChannels: 0,
@@ -98,7 +100,7 @@ export class CoreAudioBackend extends EventEmitter {
hostAPIName: 'Core Audio'
},
{
- id: 1,
+ id: 'builtin-output',
name: 'Built-in Output',
maxInputChannels: 0,
maxOutputChannels: 2,
@@ -116,7 +118,7 @@ export class CoreAudioBackend extends EventEmitter {
// Fallback : devices par défaut
return [
{
- id: 0,
+ id: 'builtin-mic',
name: 'Built-in Microphone',
maxInputChannels: 1,
maxOutputChannels: 0,
@@ -124,7 +126,7 @@ export class CoreAudioBackend extends EventEmitter {
hostAPIName: 'Core Audio'
},
{
- id: 1,
+ id: 'builtin-output',
name: 'Built-in Output',
maxInputChannels: 0,
maxOutputChannels: 2,
@@ -203,7 +205,7 @@ export class CoreAudioBackend extends EventEmitter {
// Si device spécifié
if (this.options.inputDeviceName) {
- args[1] = this.options.inputDeviceName;
+ args[2] = this.options.inputDeviceName; // Index 2 = device name
}
this.captureProcess = spawn('sox', args);
@@ -255,8 +257,10 @@ export class CoreAudioBackend extends EventEmitter {
* @returns {Promise}
*/
async startPlayback() {
+ console.log('🔊 Démarrage playback sox...');
+
if (this.isPlaying) {
- console.warn('Lecture déjà active');
+ console.warn('⚠️ Lecture déjà active');
return;
}
@@ -264,7 +268,9 @@ export class CoreAudioBackend extends EventEmitter {
// Commande sox pour lecture audio
// play : lire vers output par défaut
// -t raw : format raw PCM depuis stdin
+ // --buffer : taille du buffer interne sox (en bytes)
const args = [
+ '--buffer', '8192', // Buffer interne sox
'-t', 'raw',
'-b', '16',
'-e', 'signed-integer',
@@ -280,7 +286,9 @@ export class CoreAudioBackend extends EventEmitter {
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)
this.playbackProcess.stdin.on('error', (error) => {
@@ -305,13 +313,28 @@ export class CoreAudioBackend extends EventEmitter {
});
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;
+
+ // 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._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`);
} catch (error) {
console.error('Erreur démarrage lecture:', error);
@@ -323,6 +346,11 @@ export class CoreAudioBackend extends EventEmitter {
* Arrête la lecture audio
*/
stopPlayback() {
+ if (this.playbackInterval) {
+ clearInterval(this.playbackInterval);
+ this.playbackInterval = null;
+ }
+
if (this.playbackProcess && this.isPlaying) {
this.playbackProcess.kill('SIGTERM');
this.playbackProcess = null;
@@ -338,10 +366,16 @@ export class CoreAudioBackend extends EventEmitter {
*/
queueAudio(audioData) {
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;
}
+ this.playbackInactiveWarned = false;
+
// Limite la taille du buffer pour éviter la latence excessive
if (this.playbackBuffer.length < this.maxBufferSize) {
this.playbackBuffer.push(audioData);
@@ -356,42 +390,48 @@ export class CoreAudioBackend extends EventEmitter {
* @private
*/
_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.playbackInterval) {
+ clearInterval(this.playbackInterval);
+ this.playbackInterval = null;
+ }
return;
}
+ let chunk;
if (this.playbackBuffer.length > 0) {
- const chunk = this.playbackBuffer.shift();
- try {
- if (this.playbackProcess.stdin.writable) {
- this.playbackProcess.stdin.write(chunk);
- }
- } catch (error) {
- console.error('Erreur écriture stdin sox:', error);
- this.isPlaying = false;
- return;
- }
+ chunk = this.playbackBuffer.shift();
} else {
- // Buffer vide : underrun (silence)
- const silenceBuffer = Buffer.alloc(this.options.framesPerBuffer * 2 * this.options.channels);
- try {
- if (this.playbackProcess.stdin.writable) {
- this.playbackProcess.stdin.write(silenceBuffer);
- }
- } catch (error) {
- // Ignore si process fermé
- this.isPlaying = false;
- return;
- }
- this.emit('bufferUnderrun');
+ // Buffer vide : underrun (envoyer du silence)
+ chunk = Buffer.alloc(this.options.framesPerBuffer * 2 * this.options.channels);
}
- const intervalMs = (this.options.framesPerBuffer / this.options.sampleRate) * 1000;
- setTimeout(playNextChunk, intervalMs);
- };
-
- playNextChunk();
+ // Toujours écrire quelque chose pour garder sox actif
+ try {
+ if (this.playbackProcess.stdin.writable) {
+ this.playbackProcess.stdin.write(chunk);
+ } else {
+ console.warn('⚠️ Sox stdin non writable, arrêt boucle');
+ this.isPlaying = false;
+ clearInterval(this.playbackInterval);
+ this.playbackInterval = null;
+ }
+ } catch (error) {
+ if (error.code !== 'EPIPE') {
+ console.error('Erreur écriture stdin sox:', error);
+ }
+ this.isPlaying = false;
+ clearInterval(this.playbackInterval);
+ this.playbackInterval = null;
+ }
+ }, intervalMs);
}
/**
diff --git a/server/config/config.yaml b/server/config/config.yaml
index fdef42d..05a651f 100644
--- a/server/config/config.yaml
+++ b/server/config/config.yaml
@@ -4,30 +4,29 @@ audio:
defaultBitrate: 96
jitterBufferMs: 40
device:
- # inputDeviceId et outputDeviceId : laisser vide pour auto-détection du device par défaut
- # ou spécifier un ID numérique pour forcer un device spécifique
- inputDeviceId: null
- outputDeviceId: null
+ inputDeviceId: Microphone MacBook Pro
+ outputDeviceId: Haut-parleurs MacBook Pro
sampleRate: 48000
routing:
inputToGroup:
"0":
- - technique
- "1":
- - technique
- "2":
- - technique
+ - production
+ "1": []
+ "2": []
"4":
- technique
"5":
- technique
groupToOutput:
technique:
+ - "1"
+ production:
- "0"
+ - "1"
gains: {}
channelNames:
inputs:
- "0": Micro Régisseur
+ "0": iphone
"1": Talkback FOH
"2": Retour Console
"3": Liaison Scène
@@ -42,6 +41,7 @@ groups:
audioBitrate: 96
channels: []
- name: Technique
+ audioBitrate: 96
channels: []
- name: Sonorisation
audioBitrate: 128
@@ -50,8 +50,8 @@ server:
host: 0.0.0.0
port: 3000
livekit:
- url: ws://localhost:7880
+ url: ws://192.168.0.146:7880
logging:
- level: debug
- logLatency: true
- logAudioStats: true
+ level: info # Changez à 'debug' pour voir plus de détails
+ logLatency: false
+ logAudioStats: false
diff --git a/server/index.js b/server/index.js
index d1d2c37..b528f74 100644
--- a/server/index.js
+++ b/server/index.js
@@ -13,12 +13,18 @@ import adminRouter, { registerUser, addLog } from './api/admin.js';
import configManager from './config/ConfigManager.js';
import audioBridgeManager from './bridge/AudioBridgeManager.js';
import AudioLevelsServer from './websocket/AudioLevelsServer.js';
+import { setGlobalLogLevel } from './utils/Logger.js';
const __dirname = dirname(fileURLToPath(import.meta.url));
// Chargement configuration via ConfigManager
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
/**
diff --git a/server/utils/Logger.js b/server/utils/Logger.js
new file mode 100644
index 0000000..68d44a9
--- /dev/null
+++ b/server/utils/Logger.js
@@ -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 };