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
+50 -2
View File
@@ -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++;
+8 -1
View File
@@ -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
+21 -7
View File
@@ -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');
}
}
+9 -1
View File
@@ -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);
}
}
}
+79 -39
View File
@@ -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<void>}
*/
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);
}
/**