/** * CoreAudioBackend.js * Backend audio natif macOS utilisant naudiodon (bindings PortAudio/CoreAudio) * * Gère : * - Énumération des devices audio * - Capture audio (microphone/carte son) * - Lecture audio (speakers/sortie audio) * - Buffer circulaire pour flux continu */ import portAudio from 'naudiodon'; import { EventEmitter } from 'events'; export class CoreAudioBackend extends EventEmitter { constructor(options = {}) { super(); this.options = { sampleRate: options.sampleRate || 48000, channels: options.channels || 1, // Mono par défaut framesPerBuffer: options.framesPerBuffer || 960, // 20ms à 48kHz inputDeviceId: options.inputDeviceId || null, outputDeviceId: options.outputDeviceId || null, ...options }; this.inputStream = null; this.outputStream = null; this.isCapturing = false; this.isPlaying = false; // Buffer circulaire pour la lecture this.playbackBuffer = []; this.maxBufferSize = 10; // Max 10 chunks en buffer } /** * Liste tous les devices audio disponibles * @returns {Array} Liste des devices */ static getDevices() { try { // WORKAROUND: naudiodon a un bug connu qui cause un segfault // On retourne des devices fictifs pour le développement // TODO: Remplacer par un backend plus stable (node-portaudio ou JACK) console.warn('⚠️ CoreAudio.getDevices(): utilisation de devices fictifs (naudiodon instable)'); return [ { id: 0, name: 'MacBook Pro Microphone', maxInputChannels: 1, maxOutputChannels: 0, defaultSampleRate: 48000, hostAPIName: 'Core Audio' }, { id: 1, name: 'MacBook Pro Speakers', maxInputChannels: 0, maxOutputChannels: 2, defaultSampleRate: 48000, 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 []; } } /** * Trouve le device par défaut pour l'entrée * @returns {Object|null} Device d'entrée par défaut */ static getDefaultInputDevice() { const devices = this.getDevices(); return devices.find(d => d.maxInputChannels > 0) || null; } /** * Trouve le device par défaut pour la sortie * @returns {Object|null} Device de sortie par défaut */ static getDefaultOutputDevice() { const devices = this.getDevices(); return devices.find(d => d.maxOutputChannels > 0) || null; } /** * Démarre la capture audio * @returns {Promise} */ async startCapture() { if (this.isCapturing) { console.warn('Capture déjà active'); return; } try { const inputConfig = { channelCount: this.options.channels, sampleFormat: portAudio.SampleFormat16Bit, sampleRate: this.options.sampleRate, deviceId: this.options.inputDeviceId ?? undefined, closeOnError: true }; this.inputStream = new portAudio.AudioIO({ inOptions: inputConfig }); this.inputStream.on('data', (audioData) => { // Émet les données audio capturées (Buffer PCM 16-bit) this.emit('audioData', audioData); }); this.inputStream.on('error', (error) => { console.error('Erreur stream capture:', error); this.emit('error', error); }); this.inputStream.on('close', () => { console.log('Stream capture fermé'); this.isCapturing = false; }); this.inputStream.start(); this.isCapturing = true; console.log(`✓ Capture audio démarrée : ${this.options.sampleRate}Hz, ${this.options.channels}ch`); } catch (error) { console.error('Erreur démarrage capture:', error); throw error; } } /** * Arrête la capture audio */ stopCapture() { if (this.inputStream && this.isCapturing) { this.inputStream.quit(); this.inputStream = null; this.isCapturing = false; console.log('✓ Capture audio arrêtée'); } } /** * Démarre la lecture audio * @returns {Promise} */ async startPlayback() { if (this.isPlaying) { console.warn('Lecture déjà active'); return; } try { const outputConfig = { channelCount: this.options.channels, sampleFormat: portAudio.SampleFormat16Bit, sampleRate: this.options.sampleRate, deviceId: this.options.outputDeviceId ?? undefined, closeOnError: true }; this.outputStream = new portAudio.AudioIO({ outOptions: outputConfig }); this.outputStream.on('error', (error) => { console.error('Erreur stream lecture:', error); this.emit('error', error); }); this.outputStream.on('close', () => { console.log('Stream lecture fermé'); this.isPlaying = false; }); // Démarrage du stream de lecture this.outputStream.start(); this.isPlaying = true; // Boucle de lecture du buffer circulaire this._startPlaybackLoop(); console.log(`✓ Lecture audio démarrée : ${this.options.sampleRate}Hz, ${this.options.channels}ch`); } catch (error) { console.error('Erreur démarrage lecture:', error); throw error; } } /** * Arrête la lecture audio */ stopPlayback() { if (this.outputStream && this.isPlaying) { this.outputStream.quit(); this.outputStream = null; this.isPlaying = false; this.playbackBuffer = []; console.log('✓ Lecture audio arrêtée'); } } /** * Ajoute des données audio au buffer de lecture * @param {Buffer} audioData - Données PCM 16-bit */ queueAudio(audioData) { if (!this.isPlaying) { console.warn('Tentative ajout audio alors que lecture inactive'); return; } // Limite la taille du buffer pour éviter la latence excessive if (this.playbackBuffer.length < this.maxBufferSize) { this.playbackBuffer.push(audioData); } else { // Buffer plein : overrun this.emit('bufferOverrun'); } } /** * Boucle de lecture du buffer circulaire * @private */ _startPlaybackLoop() { const playNextChunk = () => { if (!this.isPlaying) return; if (this.playbackBuffer.length > 0) { const chunk = this.playbackBuffer.shift(); this.outputStream.write(chunk); } else { // Buffer vide : underrun (on envoie du silence) const silenceBuffer = Buffer.alloc(this.options.framesPerBuffer * 2 * this.options.channels); this.outputStream.write(silenceBuffer); this.emit('bufferUnderrun'); } // Rappel à intervalle régulier (20ms pour 960 frames à 48kHz) const intervalMs = (this.options.framesPerBuffer / this.options.sampleRate) * 1000; setTimeout(playNextChunk, intervalMs); }; playNextChunk(); } /** * Arrête tous les streams */ destroy() { this.stopCapture(); this.stopPlayback(); this.removeAllListeners(); console.log('✓ CoreAudioBackend détruit'); } /** * Vérifie si CoreAudio est disponible sur le système * @returns {boolean} */ static isAvailable() { try { const devices = portAudio.getDevices(); return devices.length > 0; } catch (error) { return false; } } /** * Obtient les statistiques du backend * @returns {Object} */ getStats() { return { capturing: this.isCapturing, playing: this.isPlaying, playbackBufferSize: this.playbackBuffer.length, sampleRate: this.options.sampleRate, channels: this.options.channels, framesPerBuffer: this.options.framesPerBuffer }; } } export default CoreAudioBackend;