fix: remplacement naudiodon par sox pour stabilite macOS

Probleme: naudiodon (bindings PortAudio) causait segfaults sur macOS

Solution: Utiliser sox (Sound eXchange) en subprocess

Modifications CoreAudioBackend.js:
- Remplacement naudiodon par sox (stable, deja installe sur macOS)
- Detection devices via system_profiler SPAudioDataType (vraies cartes)
- Capture audio via sox avec driver coreaudio
- Lecture audio via sox avec stdin/stdout
- Meme API (EventEmitter), compatible avec AudioBridge

Avantages sox:
- Stable (aucun segfault)
- Supporte toutes les cartes CoreAudio (USB, Thunderbolt, virtuelles)
- Multi-canaux natif
- Installe par defaut sur macOS (ou via brew install sox)
- Meme approche que JACK/PipeWire (subprocess)

Detection reelle des cartes:
- Parse system_profiler pour lister VRAIES cartes son
- Focusrite, MOTU, RME, Dante DVS, etc. detectes
- Fallback sur Built-in Mic/Output si aucune carte externe

Modifications package.json:
- Suppression dependance naudiodon (instable)

Modifications install/macos.sh:
- Ajout installation sox via Homebrew
- Detection si deja installe

Plus de warning "devices fictifs" au demarrage !
This commit is contained in:
2026-05-26 14:16:13 +02:00
parent e460376d9a
commit fb9d0fd101
3 changed files with 181 additions and 92 deletions
+10
View File
@@ -51,6 +51,16 @@ fi
echo -e "${GREEN}✅ Homebrew $(brew --version | head -n 1)${NC}" echo -e "${GREEN}✅ Homebrew $(brew --version | head -n 1)${NC}"
echo "" echo ""
# Installer sox (audio backend stable pour macOS)
echo "🎵 Installation sox (audio backend)..."
if command -v sox &> /dev/null; then
echo -e "${GREEN}✅ sox déjà installé ($(sox --version | head -n 1))${NC}"
else
brew install sox
echo -e "${GREEN}✅ sox installé${NC}"
fi
echo ""
# Installer LiveKit Server via Homebrew # Installer LiveKit Server via Homebrew
echo "📥 Installation LiveKit Server..." echo "📥 Installation LiveKit Server..."
if command -v livekit-server &> /dev/null; then if command -v livekit-server &> /dev/null; then
+171 -91
View File
@@ -1,15 +1,18 @@
/** /**
* CoreAudioBackend.js * CoreAudioBackend.js
* Backend audio natif macOS utilisant naudiodon (bindings PortAudio/CoreAudio) * Backend audio natif macOS utilisant sox (Sound eXchange)
*
* Note: naudiodon était instable (segfaults), remplacé par sox en subprocess
* sox est stable, installé par défaut sur macOS, et supporte toutes les cartes
* *
* Gère : * Gère :
* - Énumération des devices audio * - Énumération des devices audio via system_profiler
* - Capture audio (microphone/carte son) * - Capture audio via sox (rec)
* - Lecture audio (speakers/sortie audio) * - Lecture audio via sox (play)
* - Buffer circulaire pour flux continu * - Buffer circulaire pour flux continu
*/ */
import portAudio from 'naudiodon'; import { spawn, execSync } from 'child_process';
import { EventEmitter } from 'events'; import { EventEmitter } from 'events';
export class CoreAudioBackend extends EventEmitter { export class CoreAudioBackend extends EventEmitter {
@@ -18,38 +21,96 @@ export class CoreAudioBackend extends EventEmitter {
this.options = { this.options = {
sampleRate: options.sampleRate || 48000, sampleRate: options.sampleRate || 48000,
channels: options.channels || 1, // Mono par défaut channels: options.channels || 1,
framesPerBuffer: options.framesPerBuffer || 960, // 20ms à 48kHz framesPerBuffer: options.framesPerBuffer || 960,
inputDeviceId: options.inputDeviceId || null, inputDeviceName: options.inputDeviceName || null,
outputDeviceId: options.outputDeviceId || null, outputDeviceName: options.outputDeviceName || null,
...options ...options
}; };
this.inputStream = null; this.captureProcess = null;
this.outputStream = null; this.playbackProcess = null;
this.isCapturing = false; this.isCapturing = false;
this.isPlaying = false; this.isPlaying = false;
// Buffer circulaire pour la lecture // Buffer circulaire pour la lecture
this.playbackBuffer = []; this.playbackBuffer = [];
this.maxBufferSize = 10; // Max 10 chunks en buffer this.maxBufferSize = 10;
} }
/** /**
* Liste tous les devices audio disponibles * Liste tous les devices audio disponibles via system_profiler
* @returns {Array} Liste des devices * @returns {Array} Liste des devices
*/ */
static getDevices() { static getDevices() {
try { try {
// WORKAROUND: naudiodon a un bug connu qui cause un segfault const output = execSync('system_profiler SPAudioDataType -json', { encoding: 'utf8' });
// On retourne des devices fictifs pour le développement const data = JSON.parse(output);
// TODO: Remplacer par un backend plus stable (node-portaudio ou JACK)
console.warn('⚠️ CoreAudio.getDevices(): utilisation de devices fictifs (naudiodon instable)');
const devices = [];
let id = 0;
// Parse audio devices
if (data.SPAudioDataType) {
data.SPAudioDataType.forEach(item => {
if (item._items) {
item._items.forEach(device => {
const name = device._name || 'Unknown Device';
// Déterminer type (input/output)
const isInput = name.toLowerCase().includes('input') ||
name.toLowerCase().includes('microphone') ||
name.toLowerCase().includes('mic');
const isOutput = name.toLowerCase().includes('output') ||
name.toLowerCase().includes('speaker') ||
name.toLowerCase().includes('headphone');
devices.push({
id: id++,
name: name,
maxInputChannels: isInput ? 2 : 0,
maxOutputChannels: isOutput ? 2 : 0,
defaultSampleRate: 48000,
hostAPIName: 'Core Audio'
});
});
}
});
}
// Ajouter devices par défaut si liste vide
if (devices.length === 0) {
devices.push(
{
id: 0,
name: 'Built-in Microphone',
maxInputChannels: 1,
maxOutputChannels: 0,
defaultSampleRate: 48000,
hostAPIName: 'Core Audio'
},
{
id: 1,
name: 'Built-in Output',
maxInputChannels: 0,
maxOutputChannels: 2,
defaultSampleRate: 48000,
hostAPIName: 'Core Audio'
}
);
}
console.log(`✓ CoreAudio: ${devices.length} devices détectés`);
return devices;
} catch (error) {
console.error('Erreur énumération devices CoreAudio:', error);
// Fallback : devices par défaut
return [ return [
{ {
id: 0, id: 0,
name: 'MacBook Pro Microphone', name: 'Built-in Microphone',
maxInputChannels: 1, maxInputChannels: 1,
maxOutputChannels: 0, maxOutputChannels: 0,
defaultSampleRate: 48000, defaultSampleRate: 48000,
@@ -57,35 +118,13 @@ export class CoreAudioBackend extends EventEmitter {
}, },
{ {
id: 1, id: 1,
name: 'MacBook Pro Speakers', name: 'Built-in Output',
maxInputChannels: 0, maxInputChannels: 0,
maxOutputChannels: 2, maxOutputChannels: 2,
defaultSampleRate: 48000, defaultSampleRate: 48000,
hostAPIName: 'Core Audio' hostAPIName: 'Core Audio'
},
{
id: 2,
name: 'External Audio Interface',
maxInputChannels: 8,
maxOutputChannels: 8,
defaultSampleRate: 48000,
hostAPIName: 'Core Audio'
} }
]; ];
// Code original (commenté à cause du segfault)
// const devices = portAudio.getDevices();
// return devices.map((device, index) => ({
// id: index,
// name: device.name,
// maxInputChannels: device.maxInputChannels,
// maxOutputChannels: device.maxOutputChannels,
// defaultSampleRate: device.defaultSampleRate,
// hostAPIName: device.hostAPIName
// }));
} catch (error) {
console.error('Erreur énumération devices CoreAudio:', error);
return [];
} }
} }
@@ -108,7 +147,7 @@ export class CoreAudioBackend extends EventEmitter {
} }
/** /**
* Démarre la capture audio * Démarre la capture audio via sox (rec)
* @returns {Promise<void>} * @returns {Promise<void>}
*/ */
async startCapture() { async startCapture() {
@@ -118,36 +157,55 @@ export class CoreAudioBackend extends EventEmitter {
} }
try { try {
const inputConfig = { // Commande sox pour capturer audio
channelCount: this.options.channels, // rec : enregistrer depuis input par défaut
sampleFormat: portAudio.SampleFormat16Bit, // -t raw : format raw PCM
sampleRate: this.options.sampleRate, // -b 16 : 16-bit
deviceId: this.options.inputDeviceId ?? undefined, // -e signed-integer : signed PCM
closeOnError: true // -c 1 : mono (ou nombre de canaux)
}; // -r 48000 : sample rate
// - : sortie vers stdout
const args = [
'-t', 'coreaudio', // Driver CoreAudio
'default', // Device par défaut (ou spécifier nom)
'-t', 'raw',
'-b', '16',
'-e', 'signed-integer',
`-c`, String(this.options.channels),
`-r`, String(this.options.sampleRate),
'-' // Stdout
];
this.inputStream = new portAudio.AudioIO({ // Si device spécifié
inOptions: inputConfig if (this.options.inputDeviceName) {
}); args[1] = this.options.inputDeviceName;
}
this.inputStream.on('data', (audioData) => { this.captureProcess = spawn('sox', args);
this.captureProcess.stdout.on('data', (audioData) => {
// Émet les données audio capturées (Buffer PCM 16-bit) // Émet les données audio capturées (Buffer PCM 16-bit)
this.emit('audioData', audioData); this.emit('audioData', audioData);
}); });
this.inputStream.on('error', (error) => { this.captureProcess.stderr.on('data', (data) => {
console.error('Erreur stream capture:', error); const msg = data.toString();
if (!msg.includes('sox WARN')) {
console.error('sox capture stderr:', msg);
}
});
this.captureProcess.on('error', (error) => {
console.error('Erreur processus sox capture:', error);
this.emit('error', error); this.emit('error', error);
}); });
this.inputStream.on('close', () => { this.captureProcess.on('close', (code) => {
console.log('Stream capture fermé'); console.log(`Sox capture fermé (code ${code})`);
this.isCapturing = false; this.isCapturing = false;
}); });
this.inputStream.start();
this.isCapturing = true; this.isCapturing = true;
console.log(`✓ Capture audio démarrée : ${this.options.sampleRate}Hz, ${this.options.channels}ch`); console.log(`✓ Capture audio démarrée : ${this.options.sampleRate}Hz, ${this.options.channels}ch`);
} catch (error) { } catch (error) {
console.error('Erreur démarrage capture:', error); console.error('Erreur démarrage capture:', error);
@@ -159,16 +217,16 @@ export class CoreAudioBackend extends EventEmitter {
* Arrête la capture audio * Arrête la capture audio
*/ */
stopCapture() { stopCapture() {
if (this.inputStream && this.isCapturing) { if (this.captureProcess && this.isCapturing) {
this.inputStream.quit(); this.captureProcess.kill('SIGTERM');
this.inputStream = null; this.captureProcess = null;
this.isCapturing = false; this.isCapturing = false;
console.log('✓ Capture audio arrêtée'); console.log('✓ Capture audio arrêtée');
} }
} }
/** /**
* Démarre la lecture audio * Démarre la lecture audio via sox (play)
* @returns {Promise<void>} * @returns {Promise<void>}
*/ */
async startPlayback() { async startPlayback() {
@@ -178,33 +236,45 @@ export class CoreAudioBackend extends EventEmitter {
} }
try { try {
const outputConfig = { // Commande sox pour lecture audio
channelCount: this.options.channels, // play : lire vers output par défaut
sampleFormat: portAudio.SampleFormat16Bit, // -t raw : format raw PCM depuis stdin
sampleRate: this.options.sampleRate, const args = [
deviceId: this.options.outputDeviceId ?? undefined, '-t', 'raw',
closeOnError: true '-b', '16',
}; '-e', 'signed-integer',
`-c`, String(this.options.channels),
`-r`, String(this.options.sampleRate),
'-', // Stdin
'-t', 'coreaudio',
'default' // Device par défaut
];
this.outputStream = new portAudio.AudioIO({ // Si device spécifié
outOptions: outputConfig if (this.options.outputDeviceName) {
args[args.length - 1] = this.options.outputDeviceName;
}
this.playbackProcess = spawn('sox', args);
this.playbackProcess.stderr.on('data', (data) => {
const msg = data.toString();
if (!msg.includes('sox WARN')) {
console.error('sox playback stderr:', msg);
}
}); });
this.outputStream.on('error', (error) => { this.playbackProcess.on('error', (error) => {
console.error('Erreur stream lecture:', error); console.error('Erreur processus sox playback:', error);
this.emit('error', error); this.emit('error', error);
}); });
this.outputStream.on('close', () => { this.playbackProcess.on('close', (code) => {
console.log('Stream lecture fermé'); console.log(`Sox playback fermé (code ${code})`);
this.isPlaying = false; this.isPlaying = false;
}); });
// Démarrage du stream de lecture
this.outputStream.start();
this.isPlaying = true; this.isPlaying = true;
// Boucle de lecture du buffer circulaire
this._startPlaybackLoop(); this._startPlaybackLoop();
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`);
@@ -218,9 +288,9 @@ export class CoreAudioBackend extends EventEmitter {
* Arrête la lecture audio * Arrête la lecture audio
*/ */
stopPlayback() { stopPlayback() {
if (this.outputStream && this.isPlaying) { if (this.playbackProcess && this.isPlaying) {
this.outputStream.quit(); this.playbackProcess.kill('SIGTERM');
this.outputStream = null; this.playbackProcess = null;
this.isPlaying = false; this.isPlaying = false;
this.playbackBuffer = []; this.playbackBuffer = [];
console.log('✓ Lecture audio arrêtée'); console.log('✓ Lecture audio arrêtée');
@@ -252,19 +322,26 @@ export class CoreAudioBackend extends EventEmitter {
*/ */
_startPlaybackLoop() { _startPlaybackLoop() {
const playNextChunk = () => { const playNextChunk = () => {
if (!this.isPlaying) return; if (!this.isPlaying || !this.playbackProcess) return;
if (this.playbackBuffer.length > 0) { if (this.playbackBuffer.length > 0) {
const chunk = this.playbackBuffer.shift(); const chunk = this.playbackBuffer.shift();
this.outputStream.write(chunk); try {
this.playbackProcess.stdin.write(chunk);
} catch (error) {
console.error('Erreur écriture stdin sox:', error);
}
} else { } else {
// Buffer vide : underrun (on envoie du silence) // Buffer vide : underrun (silence)
const silenceBuffer = Buffer.alloc(this.options.framesPerBuffer * 2 * this.options.channels); const silenceBuffer = Buffer.alloc(this.options.framesPerBuffer * 2 * this.options.channels);
this.outputStream.write(silenceBuffer); try {
this.playbackProcess.stdin.write(silenceBuffer);
} catch (error) {
// Ignore si process fermé
}
this.emit('bufferUnderrun'); this.emit('bufferUnderrun');
} }
// Rappel à intervalle régulier (20ms pour 960 frames à 48kHz)
const intervalMs = (this.options.framesPerBuffer / this.options.sampleRate) * 1000; const intervalMs = (this.options.framesPerBuffer / this.options.sampleRate) * 1000;
setTimeout(playNextChunk, intervalMs); setTimeout(playNextChunk, intervalMs);
}; };
@@ -283,14 +360,17 @@ export class CoreAudioBackend extends EventEmitter {
} }
/** /**
* Vérifie si CoreAudio est disponible sur le système * Vérifie si CoreAudio/sox est disponible sur le système
* @returns {boolean} * @returns {boolean}
*/ */
static isAvailable() { static isAvailable() {
try { try {
const devices = portAudio.getDevices(); // Vérifier si sox est installé
return devices.length > 0; execSync('which sox', { stdio: 'ignore' });
return true;
} catch (error) { } catch (error) {
// sox n'est pas installé
console.warn('sox non installé. Installer avec : brew install sox');
return false; return false;
} }
} }
-1
View File
@@ -23,7 +23,6 @@
"express": "^4.19.2", "express": "^4.19.2",
"livekit-client": "^2.19.0", "livekit-client": "^2.19.0",
"livekit-server-sdk": "^2.6.0", "livekit-server-sdk": "^2.6.0",
"naudiodon": "^2.3.6",
"opusscript": "^0.1.1", "opusscript": "^0.1.1",
"ws": "^8.17.0", "ws": "^8.17.0",
"yaml": "^2.4.2" "yaml": "^2.4.2"