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:
@@ -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++;
|
||||||
|
|||||||
@@ -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
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user