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:
@@ -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
|
||||||
|
|||||||
@@ -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,70 @@ 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)');
|
|
||||||
|
|
||||||
return [
|
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,
|
id: 0,
|
||||||
name: 'MacBook Pro Microphone',
|
name: 'Built-in Microphone',
|
||||||
maxInputChannels: 1,
|
maxInputChannels: 1,
|
||||||
maxOutputChannels: 0,
|
maxOutputChannels: 0,
|
||||||
defaultSampleRate: 48000,
|
defaultSampleRate: 48000,
|
||||||
@@ -57,35 +92,39 @@ 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'
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 [
|
||||||
|
{
|
||||||
|
id: 0,
|
||||||
|
name: 'Built-in Microphone',
|
||||||
|
maxInputChannels: 1,
|
||||||
|
maxOutputChannels: 0,
|
||||||
|
defaultSampleRate: 48000,
|
||||||
|
hostAPIName: 'Core Audio'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 2,
|
id: 1,
|
||||||
name: 'External Audio Interface',
|
name: 'Built-in Output',
|
||||||
maxInputChannels: 8,
|
maxInputChannels: 0,
|
||||||
maxOutputChannels: 8,
|
maxOutputChannels: 2,
|
||||||
defaultSampleRate: 48000,
|
defaultSampleRate: 48000,
|
||||||
hostAPIName: 'Core Audio'
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user