fix: routing audio macOS avec support multi-canaux et LiveKit

Corrections majeures pour le support audio sous macOS :

- CoreAudioBackend : syntaxe sox correcte avec `-t coreaudio "Device Name"`
- AudioBridge : dé-entrelacement stéréo → canaux séparés (ligne 410-424)
- AudioBridge : entrelacement canaux → stéréo pour sortie (ligne 490-522)
- AudioBridge : duplication mono → stéréo pour LiveKit (ligne 438-449)
- config.yaml : ajout `channels: 2` pour capture stéréo
- config.yaml : ajout groupes "Production" et "Technique"

Résultat :
- Capture stéréo fonctionnelle depuis Loopback Audio 4
- Routing : 2 inputs → 3 groupes → LiveKit + 2 outputs
- Format audio correct pour LiveKit (mono dupliqué en stéréo)
- Pas d'erreur "Taille frame incorrecte"

Problème restant : sox playback se ferme après 0.4s (EPIPE)
This commit is contained in:
2026-06-02 00:33:26 +02:00
parent 36e1799ec5
commit a803250f9f
3 changed files with 133 additions and 51 deletions
+81 -11
View File
@@ -399,10 +399,30 @@ export class AudioBridge extends EventEmitter {
// Convertir PCM Buffer → Float32Array (pour GroupAudioRouter) // Convertir PCM Buffer → Float32Array (pour GroupAudioRouter)
const float32Data = this._bufferToFloat32(pcmData); const float32Data = this._bufferToFloat32(pcmData);
// Pour l'instant, on assume que l'audio vient du canal 0 // Séparer les canaux si audio multi-canaux (entrelacé)
// TODO: Supporter multi-canaux depuis la carte son const numChannels = this.options.channels || 1;
if (numChannels === 1) {
// Mono : un seul canal
const channelId = this.options.inputDeviceChannel || 0; const channelId = this.options.inputDeviceChannel || 0;
this.inputChannelBuffers.set(channelId, float32Data); this.inputChannelBuffers.set(channelId, float32Data);
} else {
// Multi-canaux : dé-entrelacer les samples
// Format entrelacé : [L0, R0, L1, R1, ...] → [L0, L1, ...] et [R0, R1, ...]
const samplesPerChannel = float32Data.length / numChannels;
for (let ch = 0; ch < numChannels; ch++) {
const channelBuffer = new Float32Array(samplesPerChannel);
for (let i = 0; i < samplesPerChannel; i++) {
channelBuffer[i] = float32Data[i * numChannels + ch];
}
// Mapper canal hardware → canal logique (peut être configuré)
const logicalChannelId = this.options.channelMapping?.[ch] ?? ch;
this.inputChannelBuffers.set(logicalChannelId, channelBuffer);
}
}
// ÉTAPE 1 : Inputs physiques → Groupes (via GroupAudioRouter) // ÉTAPE 1 : Inputs physiques → Groupes (via GroupAudioRouter)
const groupBuffers = this.groupAudioRouter.processInputsToGroups( const groupBuffers = this.groupAudioRouter.processInputsToGroups(
@@ -415,10 +435,23 @@ export class AudioBridge extends EventEmitter {
// ÉTAPE 2 : Pour chaque groupe, envoyer vers le LiveKitClient correspondant // ÉTAPE 2 : Pour chaque groupe, envoyer vers le LiveKitClient correspondant
groupBuffers.forEach((groupBuffer, groupName) => { groupBuffers.forEach((groupBuffer, groupName) => {
// Convertir Float32Array → PCM Buffer // Les groupes sont MONO (Float32Array de N samples)
const pcmBuffer = this._float32ToBuffer(groupBuffer); // Mais LiveKit attend du STÉRÉO (2 canaux)
// → Dupliquer le canal mono pour créer du faux stéréo
// Encoder en Opus const samplesPerChannel = groupBuffer.length;
const stereoBuffer = new Float32Array(samplesPerChannel * 2);
// Entrelacer : [M0, M1, M2, ...] → [M0, M0, M1, M1, M2, M2, ...]
for (let i = 0; i < samplesPerChannel; i++) {
stereoBuffer[i * 2] = groupBuffer[i]; // Canal gauche
stereoBuffer[i * 2 + 1] = groupBuffer[i]; // Canal droit (dupliqué)
}
// Convertir Float32Array stéréo → PCM Buffer
const pcmBuffer = this._float32ToBuffer(stereoBuffer);
// Encoder en Opus (maintenant en stéréo)
const opusData = this.opusEncoder.encode(pcmBuffer); const opusData = this.opusEncoder.encode(pcmBuffer);
if (opusData) { if (opusData) {
@@ -432,7 +465,7 @@ export class AudioBridge extends EventEmitter {
if (client && client.isConnected) { if (client && client.isConnected) {
client.sendAudioData(pcmBuffer); client.sendAudioData(pcmBuffer);
if (this.stats.framesCapture % 100 === 0) { if (this.stats.framesCapture % 100 === 0) {
console.log(`[AudioBridge] → LiveKit groupe "${groupName}": ${pcmBuffer.length} bytes`); console.log(`[AudioBridge] → LiveKit groupe "${groupName}": ${pcmBuffer.length} bytes (mono→stéréo)`);
} }
} else { } else {
if (this.stats.framesCapture % 100 === 0) { if (this.stats.framesCapture % 100 === 0) {
@@ -453,16 +486,53 @@ export class AudioBridge extends EventEmitter {
} }
// ÉTAPE 4 : Envoyer chaque output à la carte son // ÉTAPE 4 : Envoyer chaque output à la carte son
outputBuffers.forEach((outputBuffer, channelId) => { const numOutputChannels = this.options.channels || 1;
const pcmBuffer = this._float32ToBuffer(outputBuffer);
// Envoyer à la carte son if (numOutputChannels === 1) {
// Mono : un seul output
if (outputBuffers.size > 0) {
const [firstChannelId, outputBuffer] = outputBuffers.entries().next().value;
const pcmBuffer = this._float32ToBuffer(outputBuffer);
this.audioBackend.queueAudio(pcmBuffer); this.audioBackend.queueAudio(pcmBuffer);
if (this.stats.framesCapture % 100 === 0) { if (this.stats.framesCapture % 100 === 0) {
console.log(`[AudioBridge] → Output ${channelId}: ${pcmBuffer.length} bytes`); console.log(`[AudioBridge] → Output mono (canal ${firstChannelId}): ${pcmBuffer.length} bytes`);
}
}
} else {
// Multi-canaux : entrelacer les samples
// Récupérer les buffers dans l'ordre des canaux hardware
const channelBuffers = [];
const samplesPerChannel = this.options.frameSize;
for (let ch = 0; ch < numOutputChannels; ch++) {
const logicalChannelId = this.options.channelMapping?.[ch] ?? ch;
const buffer = outputBuffers.get(logicalChannelId);
if (buffer && buffer.length === samplesPerChannel) {
channelBuffers.push(buffer);
} else {
// Canal absent ou taille incorrecte : silence
channelBuffers.push(new Float32Array(samplesPerChannel));
}
}
// Entrelacer : [L0, L1, ...] et [R0, R1, ...] → [L0, R0, L1, R1, ...]
const interleavedBuffer = new Float32Array(samplesPerChannel * numOutputChannels);
for (let i = 0; i < samplesPerChannel; i++) {
for (let ch = 0; ch < numOutputChannels; ch++) {
interleavedBuffer[i * numOutputChannels + ch] = channelBuffers[ch][i];
}
}
const pcmBuffer = this._float32ToBuffer(interleavedBuffer);
this.audioBackend.queueAudio(pcmBuffer);
if (this.stats.framesCapture % 100 === 0) {
console.log(`[AudioBridge] → Output multi-canaux (${numOutputChannels}ch): ${pcmBuffer.length} bytes`);
}
} }
});
this.stats.framesCapture++; this.stats.framesCapture++;
this.stats.framesPlayback++; this.stats.framesPlayback++;
+37 -30
View File
@@ -184,30 +184,33 @@ export class CoreAudioBackend extends EventEmitter {
} }
try { try {
// Commande sox pour capturer audio // Commande sox pour capturer audio sur macOS
// rec : enregistrer depuis input par défaut // Sur macOS, sox utilise CoreAudio par défaut via 'rec' (alias de sox -d)
// -t raw : format raw PCM // Format: sox -d [options] output
// -b 16 : 16-bit // -d = default input device OU -t coreaudio "Device Name"
// -e signed-integer : signed PCM
// -c 1 : mono (ou nombre de canaux) const args = [];
// -r 48000 : sample rate
// - : sortie vers stdout // Spécifier le device d'entrée
const args = [ if (this.options.inputDeviceName) {
'-t', 'coreaudio', // Driver CoreAudio // Utiliser le device spécifié par son nom
'default', // Device par défaut (ou spécifier nom) args.push('-t', 'coreaudio', this.options.inputDeviceName);
} else {
// Device par défaut
args.push('-d');
}
// Format de sortie (stdout)
args.push(
'-t', 'raw', '-t', 'raw',
'-b', '16', '-b', '16',
'-e', 'signed-integer', '-e', 'signed-integer',
`-c`, String(this.options.channels), '-c', String(this.options.channels),
`-r`, String(this.options.sampleRate), '-r', String(this.options.sampleRate),
'-' // Stdout '-' // Stdout
]; );
// Si device spécifié
if (this.options.inputDeviceName) {
args[2] = this.options.inputDeviceName; // Index 2 = device name
}
console.log(`🎤 Démarrage capture sox: ${args.join(' ')}`);
this.captureProcess = spawn('sox', args); this.captureProcess = spawn('sox', args);
this.captureProcess.stdout.on('data', (audioData) => { this.captureProcess.stdout.on('data', (audioData) => {
@@ -265,27 +268,31 @@ export class CoreAudioBackend extends EventEmitter {
} }
try { try {
// Commande sox pour lecture audio // Commande sox pour lecture audio sur macOS
// play : lire vers output par défaut // Format: sox [options] input output
// -t raw : format raw PCM depuis stdin // Input = stdin (-)
// --buffer : taille du buffer interne sox (en bytes) // Output = -d (default) OU -t coreaudio "Device Name"
const args = [ const args = [
'--buffer', '8192', // Buffer interne sox '--buffer', '8192', // Buffer interne sox
'-t', 'raw', '-t', 'raw',
'-b', '16', '-b', '16',
'-e', 'signed-integer', '-e', 'signed-integer',
`-c`, String(this.options.channels), '-c', String(this.options.channels),
`-r`, String(this.options.sampleRate), '-r', String(this.options.sampleRate),
'-', // Stdin '-' // Input = stdin
'-t', 'coreaudio',
'default' // Device par défaut
]; ];
// Si device spécifié // Spécifier le device de sortie
if (this.options.outputDeviceName) { if (this.options.outputDeviceName) {
args[args.length - 1] = this.options.outputDeviceName; // Utiliser le device spécifié par son nom
args.push('-t', 'coreaudio', this.options.outputDeviceName);
} else {
// Device par défaut
args.push('-d');
} }
console.log(`🔊 Démarrage playback sox: ${args.join(' ')}`);
this.playbackProcess = spawn('sox', args, { this.playbackProcess = spawn('sox', args, {
stdio: ['pipe', 'ignore', 'pipe'] // stdin=pipe, stdout=ignore, stderr=pipe stdio: ['pipe', 'ignore', 'pipe'] // stdin=pipe, stdout=ignore, stderr=pipe
}); });
+13 -8
View File
@@ -1,17 +1,20 @@
audio: audio:
sampleRate: 48000 sampleRate: 48000
channels: 2
frameSize: 20 frameSize: 20
defaultBitrate: 96 defaultBitrate: 96
jitterBufferMs: 40 jitterBufferMs: 40
device: device:
inputDeviceId: Microphone MacBook Pro inputDeviceId: Loopback Audio 4
outputDeviceId: Haut-parleurs MacBook Pro outputDeviceId: Haut-parleurs MacBook Pro
sampleRate: 48000 sampleRate: 48000
routing: routing:
inputToGroup: inputToGroup:
"0": "0":
- production - production
"1": [] - default
"1":
- default
"2": [] "2": []
"4": "4":
- technique - technique
@@ -23,29 +26,31 @@ audio:
production: production:
- "0" - "0"
- "1" - "1"
default:
- "0"
gains: {} gains: {}
channelNames: channelNames:
inputs: inputs:
"0": iphone "0": Mac
"1": Talkback FOH "1": Talkback FOH
"2": Retour Console "2": Retour Console
"3": Liaison Scène "3": Liaison Scène
"4": Monitor Mix "4": Monitor Mix
"5": Spare 1 "5": Spare 1
outputs: outputs:
"0": Sortie Principale "0": L
"1": Retour Scène "1": R
"2": Talkback Console "2": Talkback Console
groups: groups:
- name: Default
audioBitrate: 96
channels: []
- name: Production - name: Production
audioBitrate: 96 audioBitrate: 96
channels: [] channels: []
- name: Technique - name: Technique
audioBitrate: 96 audioBitrate: 96
channels: [] channels: []
- name: Sonorisation
audioBitrate: 128
channels: []
server: server:
host: 0.0.0.0 host: 0.0.0.0
port: 3000 port: 3000