From 37205f0409c3cdf5499b0afd589ddbef21364f6f Mon Sep 17 00:00:00 2001 From: Benoit Date: Tue, 26 May 2026 13:37:18 +0200 Subject: [PATCH] feat: ajout support Linux avec backends JACK et PipeWire Phase 3.1 - Support Linux professionnel Nouveaux backends audio: - JACKBackend.js : support JACK Audio Connection Kit pour audio pro - PipeWireBackend.js : support PipeWire (standard moderne Linux) - Detection automatique dans AudioBridge (PipeWire > JACK > erreur) Script installation: - install/linux.sh pour Ubuntu/Debian/Arch/Fedora - Installation automatique dependencies (Node.js, PipeWire/JACK) - Telechargement LiveKit Server pour Linux (amd64/arm64) Fonctionnalites: - Detection serveur audio (PipeWire/JACK) - Enumeration devices audio via pactl/jack_lsp - Capture et lecture audio basse latence (pw-cat, jack_rec/play) - Messages d'erreur detailles pour troubleshooting - Compatibilite Ubuntu 22.04+, Debian 11+, Arch Linux, Fedora TODO.md mis a jour: Phase 3.1 en cours --- TODO.md | 22 +- install/linux.sh | 293 ++++++++++++++++ server/bridge/AudioBridge.js | 60 +++- server/bridge/backends/JACKBackend.js | 404 +++++++++++++++++++++ server/bridge/backends/PipeWireBackend.js | 407 ++++++++++++++++++++++ 5 files changed, 1166 insertions(+), 20 deletions(-) create mode 100755 install/linux.sh create mode 100644 server/bridge/backends/JACKBackend.js create mode 100644 server/bridge/backends/PipeWireBackend.js diff --git a/TODO.md b/TODO.md index eac233e..31f43fe 100644 --- a/TODO.md +++ b/TODO.md @@ -1,7 +1,7 @@ # TODO.md - Plan de développement PTT Live -**Dernière mise à jour** : 2026-05-25 -**Phase actuelle** : PHASE 2 - Fonctionnalités professionnelles (Phase 2.5 TERMINÉE - Configuration audio visuelle complète) +**Dernière mise à jour** : 2026-05-26 +**Phase actuelle** : PHASE 3 - Intégrations audio pro (Phase 3.1 EN COURS - Support Linux) --- @@ -215,9 +215,9 @@ Valider la faisabilité technique : 2-4 clients, PTT basique, latence < 150ms, m ## PHASE 3 — Intégrations audio pro ### 3.1 Support Linux -- [ ] Backend JACK (server/bridge/backends/JACKBackend.js) -- [ ] Backend PipeWire (server/bridge/backends/PipeWireBackend.js) -- [ ] Script install/linux.sh +- [x] Backend JACK (server/bridge/backends/JACKBackend.js) +- [x] Backend PipeWire (server/bridge/backends/PipeWireBackend.js) +- [x] Script install/linux.sh - [ ] Tests Ubuntu 22.04 LTS + Arch Linux ### 3.2 Dante @@ -255,10 +255,14 @@ Valider la faisabilité technique : 2-4 clients, PTT basique, latence < 150ms, m 5. ✅ Préférences utilisateur pour mode PTT par défaut (2.2) 6. ✅ Web Push notifications pour appels privés (2.4) -### Phase 3 - Préparation -- Support Linux (JACK/PipeWire backends) -- Intégration Dante/AES67 -- Tests charge 30+ clients +### Phase 3 - EN COURS +1. ✅ Backend JACK pour Linux (3.1) +2. ✅ Backend PipeWire pour Linux moderne (3.1) +3. ✅ Détection automatique backend Linux dans AudioBridge (3.1) +4. ✅ Script d'installation Linux (Ubuntu/Debian/Arch) (3.1) +5. ⏳ Tests sur Ubuntu 22.04 LTS et Arch Linux (3.1) +6. Documentation setup Dante/AES67 (3.2/3.3) +7. Tests charge 30+ clients (3.4) --- diff --git a/install/linux.sh b/install/linux.sh new file mode 100755 index 0000000..8ab1890 --- /dev/null +++ b/install/linux.sh @@ -0,0 +1,293 @@ +#!/bin/bash + +############################################################################### +# PTT Live - Script d'installation Linux +# Supporte : Ubuntu 22.04+, Debian 11+, Arch Linux +############################################################################### + +set -e # Arrête en cas d'erreur + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(dirname "$SCRIPT_DIR")" + +echo "" +echo "========================================" +echo " PTT Live - Installation Linux" +echo "========================================" +echo "" + +# Détection de la distribution +detect_distro() { + if [ -f /etc/os-release ]; then + . /etc/os-release + DISTRO=$ID + VERSION=$VERSION_ID + else + echo "Erreur : impossible de détecter la distribution Linux" + exit 1 + fi + + echo "Distribution détectée : $DISTRO $VERSION" +} + +# Installation des dépendances système +install_system_deps() { + echo "" + echo "Installation des dépendances système..." + + case $DISTRO in + ubuntu|debian) + echo "Distribution : Debian/Ubuntu" + + # Mise à jour des paquets + sudo apt update + + # Dépendances de base + sudo apt install -y \ + curl \ + git \ + build-essential \ + pkg-config + + # Node.js (via NodeSource si pas déjà installé) + if ! command -v node &> /dev/null; then + echo "Installation de Node.js 20.x..." + curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash - + sudo apt install -y nodejs + else + echo "Node.js déjà installé : $(node --version)" + fi + + # Backend audio : PipeWire (recommandé pour Ubuntu 22.04+) + if [ "${VERSION%%.*}" -ge 22 ]; then + echo "Installation de PipeWire (backend audio moderne)..." + sudo apt install -y \ + pipewire \ + pipewire-pulse \ + pipewire-jack \ + wireplumber \ + pipewire-audio-client-libraries + + # Outils PipeWire + sudo apt install -y \ + pipewire-bin \ + libspa-0.2-jack + + # Démarrage automatique + systemctl --user enable --now pipewire pipewire-pulse wireplumber + echo "PipeWire démarré et activé au démarrage" + else + echo "Version Ubuntu < 22.04 : installation de JACK..." + install_jack_debian + fi + + # Outils JACK optionnels (compatibilité) + sudo apt install -y \ + jack-tools \ + qjackctl || true + + echo "Dépendances système installées !" + ;; + + arch|manjaro) + echo "Distribution : Arch Linux" + + # Mise à jour des paquets + sudo pacman -Syu --noconfirm + + # Dépendances de base + sudo pacman -S --needed --noconfirm \ + base-devel \ + git \ + curl \ + nodejs \ + npm + + # PipeWire (installé par défaut sur Arch moderne) + sudo pacman -S --needed --noconfirm \ + pipewire \ + pipewire-pulse \ + pipewire-jack \ + wireplumber \ + pipewire-alsa + + # Outils audio + sudo pacman -S --needed --noconfirm \ + jack2 \ + qjackctl || true + + # Activation PipeWire + systemctl --user enable --now pipewire pipewire-pulse wireplumber + echo "PipeWire démarré et activé au démarrage" + + echo "Dépendances système installées !" + ;; + + fedora) + echo "Distribution : Fedora" + + sudo dnf install -y \ + nodejs \ + npm \ + gcc-c++ \ + make \ + pipewire \ + pipewire-jack-audio-connection-kit \ + pipewire-pulseaudio \ + wireplumber + + systemctl --user enable --now pipewire pipewire-pulse wireplumber + echo "Dépendances système installées !" + ;; + + *) + echo "Distribution non supportée automatiquement : $DISTRO" + echo "Installez manuellement :" + echo " - Node.js 18+" + echo " - PipeWire ou JACK" + exit 1 + ;; + esac +} + +# Installation de JACK (fallback pour anciennes versions) +install_jack_debian() { + echo "Installation de JACK Audio Connection Kit..." + sudo apt install -y \ + jackd2 \ + jack-tools \ + qjackctl + + # Configuration JACK pour basse latence + sudo usermod -a -G audio $USER + echo "JACK installé. Vous devrez peut-être redémarrer pour appliquer les permissions audio." +} + +# Téléchargement de LiveKit Server +install_livekit_server() { + echo "" + echo "Téléchargement de LiveKit Server..." + + LIVEKIT_VERSION="v1.5.2" + LIVEKIT_DIR="$PROJECT_ROOT/server/bin" + LIVEKIT_BINARY="$LIVEKIT_DIR/livekit-server" + + mkdir -p "$LIVEKIT_DIR" + + # Détection de l'architecture + ARCH=$(uname -m) + case $ARCH in + x86_64) + LIVEKIT_ARCH="amd64" + ;; + aarch64|arm64) + LIVEKIT_ARCH="arm64" + ;; + *) + echo "Architecture non supportée : $ARCH" + exit 1 + ;; + esac + + LIVEKIT_URL="https://github.com/livekit/livekit/releases/download/${LIVEKIT_VERSION}/livekit_${LIVEKIT_VERSION}_linux_${LIVEKIT_ARCH}.tar.gz" + + echo "Téléchargement depuis : $LIVEKIT_URL" + + cd "$LIVEKIT_DIR" + curl -L -o livekit.tar.gz "$LIVEKIT_URL" + tar -xzf livekit.tar.gz + rm livekit.tar.gz + + chmod +x livekit-server + + echo "LiveKit Server installé : $LIVEKIT_BINARY" + echo "Version : $($LIVEKIT_BINARY --version)" +} + +# Installation des dépendances Node.js +install_node_deps() { + echo "" + echo "Installation des dépendances Node.js..." + + # Serveur + echo "Serveur..." + cd "$PROJECT_ROOT/server" + npm install + + # Client + echo "Client..." + cd "$PROJECT_ROOT/client" + npm install + + echo "Dépendances Node.js installées !" +} + +# Configuration audio +configure_audio() { + echo "" + echo "========================================" + echo " Configuration audio" + echo "========================================" + + # Vérification PipeWire + if systemctl --user is-active --quiet pipewire; then + echo "PipeWire : ACTIF" + pw-cli info 0 | head -n 5 + else + echo "PipeWire : INACTIF" + echo "Démarrez-le : systemctl --user start pipewire pipewire-pulse" + fi + + # Vérification JACK (si installé) + if command -v jack_lsp &> /dev/null; then + echo "" + echo "JACK : Installé" + if jack_lsp &> /dev/null; then + echo "Serveur JACK : ACTIF" + else + echo "Serveur JACK : INACTIF" + echo "Démarrez-le : jackd -d alsa -r 48000" + fi + fi + + echo "" + echo "Backend audio recommandé : PipeWire" + echo "Pour démarrer le serveur PTT Live, voir README.md" +} + +# Résumé final +print_summary() { + echo "" + echo "========================================" + echo " Installation terminée !" + echo "========================================" + echo "" + echo "Prochaines étapes :" + echo "" + echo "1. Démarrer le serveur :" + echo " cd $PROJECT_ROOT/server" + echo " npm run dev" + echo "" + echo "2. Démarrer le client (autre terminal) :" + echo " cd $PROJECT_ROOT/client" + echo " npm run dev" + echo "" + echo "3. Accéder à l'interface :" + echo " http://localhost:5173" + echo "" + echo "Documentation : $PROJECT_ROOT/README.md" + echo "========================================" + echo "" +} + +# Script principal +main() { + detect_distro + install_system_deps + install_livekit_server + install_node_deps + configure_audio + print_summary +} + +main "$@" diff --git a/server/bridge/AudioBridge.js b/server/bridge/AudioBridge.js index 3b75d39..d7562e9 100644 --- a/server/bridge/AudioBridge.js +++ b/server/bridge/AudioBridge.js @@ -13,6 +13,8 @@ import { EventEmitter } from 'events'; import { platform } from 'os'; import CoreAudioBackend from './backends/CoreAudioBackend.js'; +import JACKBackend from './backends/JACKBackend.js'; +import PipeWireBackend from './backends/PipeWireBackend.js'; import OpusCodec, { OpusPresets } from './OpusCodec.js'; import JitterBuffer, { JitterBufferPresets } from './JitterBuffer.js'; import LiveKitClient from './LiveKitClient.js'; @@ -123,27 +125,52 @@ export class AudioBridge extends EventEmitter { */ async _initAudioBackend() { const os = platform(); + let BackendClass = null; + let devices = []; // macOS : CoreAudio prioritaire if (os === 'darwin') { if (CoreAudioBackend.isAvailable()) { this.backendType = 'CoreAudio'; - this.audioBackend = new CoreAudioBackend({ - sampleRate: this.options.sampleRate, - channels: this.options.channels, - framesPerBuffer: this.options.frameSize, - inputDeviceId: this.options.inputDeviceId, - outputDeviceId: this.options.outputDeviceId - }); - + BackendClass = CoreAudioBackend; console.log('✓ Backend audio : CoreAudio (macOS natif)'); } else { throw new Error('CoreAudio non disponible sur ce système'); } } - // Linux : JACK ou PipeWire (Phase 3) + // Linux : PipeWire > JACK (ordre de préférence) else if (os === 'linux') { - throw new Error('Support Linux non encore implémenté (Phase 3)'); + // Détection automatique : préfère PipeWire (moderne) puis JACK (pro) + if (PipeWireBackend.isAvailable() && PipeWireBackend.isServerRunning()) { + this.backendType = 'PipeWire'; + BackendClass = PipeWireBackend; + console.log('✓ Backend audio : PipeWire (Linux moderne)'); + } else if (JACKBackend.isAvailable() && JACKBackend.isServerRunning()) { + this.backendType = 'JACK'; + BackendClass = JACKBackend; + console.log('✓ Backend audio : JACK (Linux professionnel)'); + } else { + // Aucun backend disponible + const pipewireInstalled = PipeWireBackend.isAvailable(); + const jackInstalled = JACKBackend.isAvailable(); + + let errorMsg = 'Aucun backend audio disponible sur Linux.\n'; + + if (!pipewireInstalled && !jackInstalled) { + errorMsg += 'Installez PipeWire (recommandé) ou JACK :\n'; + errorMsg += ' Ubuntu/Debian : sudo apt install pipewire pipewire-pulse\n'; + errorMsg += ' Arch Linux : sudo pacman -S pipewire pipewire-pulse\n'; + errorMsg += ' JACK : sudo apt install jackd2 jack-tools'; + } else if (pipewireInstalled && !PipeWireBackend.isServerRunning()) { + errorMsg += 'PipeWire installé mais non démarré.\n'; + errorMsg += 'Démarrez-le : systemctl --user start pipewire pipewire-pulse'; + } else if (jackInstalled && !JACKBackend.isServerRunning()) { + errorMsg += 'JACK installé mais serveur non démarré.\n'; + errorMsg += 'Démarrez-le : jackd -d alsa -r 48000'; + } + + throw new Error(errorMsg); + } } // Windows : WASAPI (futur) else if (os === 'win32') { @@ -153,8 +180,19 @@ export class AudioBridge extends EventEmitter { throw new Error(`Plateforme non supportée : ${os}`); } + // Initialisation du backend sélectionné + this.audioBackend = new BackendClass({ + sampleRate: this.options.sampleRate, + channels: this.options.channels, + framesPerBuffer: this.options.frameSize, + inputDeviceId: this.options.inputDeviceId, + outputDeviceId: this.options.outputDeviceId, + // Options spécifiques PipeWire + latency: this.options.latency || 20 + }); + // Liste des devices disponibles - const devices = CoreAudioBackend.getDevices(); + devices = BackendClass.getDevices(); console.log(`📻 Devices audio détectés : ${devices.length}`); devices.forEach(d => { console.log(` - ${d.name} (in:${d.maxInputChannels}, out:${d.maxOutputChannels})`); diff --git a/server/bridge/backends/JACKBackend.js b/server/bridge/backends/JACKBackend.js new file mode 100644 index 0000000..f223fba --- /dev/null +++ b/server/bridge/backends/JACKBackend.js @@ -0,0 +1,404 @@ +/** + * JACKBackend.js + * Backend audio pour Linux utilisant JACK Audio Connection Kit + * + * Gère : + * - Connexion au serveur JACK + * - Ports audio input/output + * - Capture et lecture audio temps réel + * - Détection automatique du serveur JACK + */ + +import { spawn, execSync } from 'child_process'; +import { EventEmitter } from 'events'; + +export class JACKBackend extends EventEmitter { + constructor(options = {}) { + super(); + + this.options = { + sampleRate: options.sampleRate || 48000, + channels: options.channels || 1, + framesPerBuffer: options.framesPerBuffer || 960, // 20ms à 48kHz + clientName: options.clientName || 'PTTLive', + autoConnect: options.autoConnect !== false, + inputPorts: options.inputPorts || [], + outputPorts: options.outputPorts || [], + ...options + }; + + this.jackProcess = null; + this.isCapturing = false; + this.isPlaying = false; + this.playbackBuffer = []; + this.maxBufferSize = 10; + + // Ports JACK créés + this.capturePort = null; + this.playbackPort = null; + } + + /** + * Vérifie si JACK est installé et disponible + * @returns {boolean} + */ + static isAvailable() { + try { + execSync('which jackd', { stdio: 'ignore' }); + return true; + } catch (error) { + return false; + } + } + + /** + * Vérifie si le serveur JACK est en cours d'exécution + * @returns {boolean} + */ + static isServerRunning() { + try { + execSync('jack_lsp', { stdio: 'ignore' }); + return true; + } catch (error) { + return false; + } + } + + /** + * Liste tous les ports JACK disponibles + * @returns {Array} Liste des ports + */ + static getPorts() { + try { + const output = execSync('jack_lsp', { encoding: 'utf8' }); + const ports = output.trim().split('\n').filter(p => p.length > 0); + + return ports.map(port => { + const isOutput = port.includes('capture') || port.includes('output'); + const isInput = port.includes('playback') || port.includes('input'); + + return { + name: port, + type: isOutput ? 'output' : (isInput ? 'input' : 'unknown'), + isPhysical: port.includes('system:') + }; + }); + } catch (error) { + console.error('Erreur listage ports JACK:', error); + return []; + } + } + + /** + * Liste les devices audio via JACK (ports système) + * @returns {Array} Liste des devices + */ + static getDevices() { + if (!this.isServerRunning()) { + console.warn('Serveur JACK non démarré'); + return []; + } + + try { + const ports = this.getPorts(); + const systemPorts = ports.filter(p => p.isPhysical); + + // Grouper par device (system:capture_*, system:playback_*) + const devices = []; + + // Ports d'entrée (capture) + const capturePorts = systemPorts.filter(p => p.name.includes('capture')); + if (capturePorts.length > 0) { + devices.push({ + id: 'jack-input', + name: 'JACK System Capture', + maxInputChannels: capturePorts.length, + maxOutputChannels: 0, + defaultSampleRate: this._getServerSampleRate(), + hostAPIName: 'JACK', + ports: capturePorts.map(p => p.name) + }); + } + + // Ports de sortie (playback) + const playbackPorts = systemPorts.filter(p => p.name.includes('playback')); + if (playbackPorts.length > 0) { + devices.push({ + id: 'jack-output', + name: 'JACK System Playback', + maxInputChannels: 0, + maxOutputChannels: playbackPorts.length, + defaultSampleRate: this._getServerSampleRate(), + hostAPIName: 'JACK', + ports: playbackPorts.map(p => p.name) + }); + } + + return devices; + } catch (error) { + console.error('Erreur énumération devices JACK:', error); + return []; + } + } + + /** + * Récupère le sample rate du serveur JACK + * @returns {number} + * @private + */ + static _getServerSampleRate() { + try { + const output = execSync('jack_samplerate', { encoding: 'utf8' }); + return parseInt(output.trim()) || 48000; + } catch (error) { + return 48000; + } + } + + /** + * Récupère la taille du buffer du serveur JACK + * @returns {number} + * @private + */ + static _getServerBufferSize() { + try { + const output = execSync('jack_bufsize', { encoding: 'utf8' }); + return parseInt(output.trim()) || 1024; + } catch (error) { + return 1024; + } + } + + /** + * Trouve le device par défaut pour l'entrée + * @returns {Object|null} + */ + 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} + */ + 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 JACK déjà active'); + return; + } + + if (!JACKBackend.isServerRunning()) { + throw new Error('Serveur JACK non démarré. Lancez jackd avant de continuer.'); + } + + try { + // Utilisation de jack_rec pour capturer l'audio + const portName = this.options.inputPorts[0] || 'system:capture_1'; + + this.jackProcess = spawn('jack_rec', [ + '-f', '-', // Sortie vers stdout + '-d', String(this.options.framesPerBuffer), + '-b', '16', // 16-bit PCM + portName + ]); + + this.jackProcess.stdout.on('data', (audioData) => { + // Émet les données audio capturées (Buffer PCM 16-bit) + this.emit('audioData', audioData); + }); + + this.jackProcess.stderr.on('data', (data) => { + console.error('JACK stderr:', data.toString()); + }); + + this.jackProcess.on('error', (error) => { + console.error('Erreur processus JACK:', error); + this.emit('error', error); + }); + + this.jackProcess.on('close', () => { + console.log('Processus JACK capture fermé'); + this.isCapturing = false; + }); + + this.isCapturing = true; + console.log(`✓ Capture JACK démarrée : ${this.options.sampleRate}Hz, ${this.options.channels}ch`); + console.log(` Port: ${portName}`); + } catch (error) { + console.error('Erreur démarrage capture JACK:', error); + throw error; + } + } + + /** + * Arrête la capture audio + */ + stopCapture() { + if (this.jackProcess && this.isCapturing) { + this.jackProcess.kill('SIGTERM'); + this.jackProcess = null; + this.isCapturing = false; + console.log('✓ Capture JACK arrêtée'); + } + } + + /** + * Démarre la lecture audio + * @returns {Promise} + */ + async startPlayback() { + if (this.isPlaying) { + console.warn('Lecture JACK déjà active'); + return; + } + + if (!JACKBackend.isServerRunning()) { + throw new Error('Serveur JACK non démarré'); + } + + try { + const portName = this.options.outputPorts[0] || 'system:playback_1'; + + this.playbackProcess = spawn('jack_play', [ + '-f', '-', // Lecture depuis stdin + '-b', '16', // 16-bit PCM + portName + ]); + + this.playbackProcess.on('error', (error) => { + console.error('Erreur processus JACK playback:', error); + this.emit('error', error); + }); + + this.playbackProcess.stderr.on('data', (data) => { + console.error('JACK playback stderr:', data.toString()); + }); + + this.playbackProcess.on('close', () => { + console.log('Processus JACK playback fermé'); + this.isPlaying = false; + }); + + this.isPlaying = true; + this._startPlaybackLoop(); + + console.log(`✓ Lecture JACK démarrée : ${this.options.sampleRate}Hz, ${this.options.channels}ch`); + console.log(` Port: ${portName}`); + } catch (error) { + console.error('Erreur démarrage lecture JACK:', error); + throw error; + } + } + + /** + * Arrête la lecture audio + */ + stopPlayback() { + if (this.playbackProcess && this.isPlaying) { + this.playbackProcess.kill('SIGTERM'); + this.playbackProcess = null; + this.isPlaying = false; + this.playbackBuffer = []; + console.log('✓ Lecture JACK 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 JACK inactive'); + return; + } + + if (this.playbackBuffer.length < this.maxBufferSize) { + this.playbackBuffer.push(audioData); + } else { + 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.playbackProcess.stdin.write(chunk); + } else { + // Buffer vide : underrun (silence) + const silenceBuffer = Buffer.alloc(this.options.framesPerBuffer * 2 * this.options.channels); + this.playbackProcess.stdin.write(silenceBuffer); + this.emit('bufferUnderrun'); + } + + 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('✓ JACKBackend détruit'); + } + + /** + * 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, + jackServerRunning: JACKBackend.isServerRunning(), + jackSampleRate: JACKBackend._getServerSampleRate(), + jackBufferSize: JACKBackend._getServerBufferSize() + }; + } + + /** + * Obtient les informations du serveur JACK + * @returns {Object} + */ + static getServerInfo() { + if (!this.isServerRunning()) { + return { running: false }; + } + + return { + running: true, + sampleRate: this._getServerSampleRate(), + bufferSize: this._getServerBufferSize(), + ports: this.getPorts().length + }; + } +} + +export default JACKBackend; diff --git a/server/bridge/backends/PipeWireBackend.js b/server/bridge/backends/PipeWireBackend.js new file mode 100644 index 0000000..4fa680c --- /dev/null +++ b/server/bridge/backends/PipeWireBackend.js @@ -0,0 +1,407 @@ +/** + * PipeWireBackend.js + * Backend audio pour Linux moderne utilisant PipeWire + * + * PipeWire est le nouveau standard audio sur Linux (remplace PulseAudio + JACK) + * Compatible avec : Fedora 34+, Ubuntu 22.10+, Arch Linux + * + * Gère : + * - Connexion au serveur PipeWire + * - Capture et lecture audio via pw-cat + * - Détection automatique des devices + * - Mode basse latence (compatible JACK) + */ + +import { spawn, execSync } from 'child_process'; +import { EventEmitter } from 'events'; + +export class PipeWireBackend extends EventEmitter { + constructor(options = {}) { + super(); + + this.options = { + sampleRate: options.sampleRate || 48000, + channels: options.channels || 1, + framesPerBuffer: options.framesPerBuffer || 960, + targetDevice: options.targetDevice || null, + latency: options.latency || 20, // ms + ...options + }; + + this.captureProcess = null; + this.playbackProcess = null; + this.isCapturing = false; + this.isPlaying = false; + this.playbackBuffer = []; + this.maxBufferSize = 10; + } + + /** + * Vérifie si PipeWire est installé et disponible + * @returns {boolean} + */ + static isAvailable() { + try { + execSync('which pw-cat', { stdio: 'ignore' }); + return true; + } catch (error) { + return false; + } + } + + /** + * Vérifie si le serveur PipeWire est en cours d'exécution + * @returns {boolean} + */ + static isServerRunning() { + try { + execSync('pw-cli info 0', { stdio: 'ignore' }); + return true; + } catch (error) { + return false; + } + } + + /** + * Liste tous les devices audio PipeWire + * @returns {Array} Liste des devices + */ + static getDevices() { + if (!this.isServerRunning()) { + console.warn('Serveur PipeWire non démarré'); + return []; + } + + try { + // Utilise pactl (compatible PipeWire) pour lister les devices + const sourcesOutput = execSync('pactl list sources short', { encoding: 'utf8' }); + const sinksOutput = execSync('pactl list sinks short', { encoding: 'utf8' }); + + const devices = []; + + // Parse sources (entrées) + const sources = sourcesOutput.trim().split('\n').filter(l => l.length > 0); + sources.forEach(line => { + const parts = line.split(/\s+/); + if (parts.length >= 2) { + devices.push({ + id: `pw-input-${parts[0]}`, + name: parts[1], + maxInputChannels: 2, // Assume stéréo par défaut + maxOutputChannels: 0, + defaultSampleRate: 48000, + hostAPIName: 'PipeWire', + type: 'source' + }); + } + }); + + // Parse sinks (sorties) + const sinks = sinksOutput.trim().split('\n').filter(l => l.length > 0); + sinks.forEach(line => { + const parts = line.split(/\s+/); + if (parts.length >= 2) { + devices.push({ + id: `pw-output-${parts[0]}`, + name: parts[1], + maxInputChannels: 0, + maxOutputChannels: 2, + defaultSampleRate: 48000, + hostAPIName: 'PipeWire', + type: 'sink' + }); + } + }); + + return devices; + } catch (error) { + console.error('Erreur énumération devices PipeWire:', error); + return []; + } + } + + /** + * Trouve le device par défaut pour l'entrée + * @returns {Object|null} + */ + static getDefaultInputDevice() { + try { + const output = execSync('pactl get-default-source', { encoding: 'utf8' }); + const defaultName = output.trim(); + + const devices = this.getDevices(); + return devices.find(d => d.name === defaultName && d.maxInputChannels > 0) || + devices.find(d => d.maxInputChannels > 0); + } catch (error) { + const devices = this.getDevices(); + return devices.find(d => d.maxInputChannels > 0) || null; + } + } + + /** + * Trouve le device par défaut pour la sortie + * @returns {Object|null} + */ + static getDefaultOutputDevice() { + try { + const output = execSync('pactl get-default-sink', { encoding: 'utf8' }); + const defaultName = output.trim(); + + const devices = this.getDevices(); + return devices.find(d => d.name === defaultName && d.maxOutputChannels > 0) || + devices.find(d => d.maxOutputChannels > 0); + } catch (error) { + 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 PipeWire déjà active'); + return; + } + + if (!PipeWireBackend.isServerRunning()) { + throw new Error('Serveur PipeWire non démarré'); + } + + try { + // Utilise pw-cat pour capturer l'audio + const args = [ + '--record', + '--format=s16', // 16-bit signed PCM + `--rate=${this.options.sampleRate}`, + `--channels=${this.options.channels}`, + `--latency=${this.options.latency}ms`, + '-' // Sortie vers stdout + ]; + + // Ajoute le device cible si spécifié + if (this.options.targetDevice) { + args.push(`--target=${this.options.targetDevice}`); + } + + this.captureProcess = spawn('pw-cat', args); + + this.captureProcess.stdout.on('data', (audioData) => { + this.emit('audioData', audioData); + }); + + this.captureProcess.stderr.on('data', (data) => { + const msg = data.toString(); + if (!msg.includes('stream state changed')) { + console.error('PipeWire capture stderr:', msg); + } + }); + + this.captureProcess.on('error', (error) => { + console.error('Erreur processus PipeWire capture:', error); + this.emit('error', error); + }); + + this.captureProcess.on('close', (code) => { + console.log(`Processus PipeWire capture fermé (code ${code})`); + this.isCapturing = false; + }); + + this.isCapturing = true; + console.log(`✓ Capture PipeWire démarrée : ${this.options.sampleRate}Hz, ${this.options.channels}ch`); + console.log(` Latence: ${this.options.latency}ms`); + } catch (error) { + console.error('Erreur démarrage capture PipeWire:', error); + throw error; + } + } + + /** + * Arrête la capture audio + */ + stopCapture() { + if (this.captureProcess && this.isCapturing) { + this.captureProcess.kill('SIGTERM'); + this.captureProcess = null; + this.isCapturing = false; + console.log('✓ Capture PipeWire arrêtée'); + } + } + + /** + * Démarre la lecture audio + * @returns {Promise} + */ + async startPlayback() { + if (this.isPlaying) { + console.warn('Lecture PipeWire déjà active'); + return; + } + + if (!PipeWireBackend.isServerRunning()) { + throw new Error('Serveur PipeWire non démarré'); + } + + try { + const args = [ + '--playback', + '--format=s16', + `--rate=${this.options.sampleRate}`, + `--channels=${this.options.channels}`, + `--latency=${this.options.latency}ms`, + '-' // Lecture depuis stdin + ]; + + if (this.options.targetDevice) { + args.push(`--target=${this.options.targetDevice}`); + } + + this.playbackProcess = spawn('pw-cat', args); + + this.playbackProcess.stderr.on('data', (data) => { + const msg = data.toString(); + if (!msg.includes('stream state changed')) { + console.error('PipeWire playback stderr:', msg); + } + }); + + this.playbackProcess.on('error', (error) => { + console.error('Erreur processus PipeWire playback:', error); + this.emit('error', error); + }); + + this.playbackProcess.on('close', (code) => { + console.log(`Processus PipeWire playback fermé (code ${code})`); + this.isPlaying = false; + }); + + this.isPlaying = true; + this._startPlaybackLoop(); + + console.log(`✓ Lecture PipeWire démarrée : ${this.options.sampleRate}Hz, ${this.options.channels}ch`); + console.log(` Latence: ${this.options.latency}ms`); + } catch (error) { + console.error('Erreur démarrage lecture PipeWire:', error); + throw error; + } + } + + /** + * Arrête la lecture audio + */ + stopPlayback() { + if (this.playbackProcess && this.isPlaying) { + this.playbackProcess.kill('SIGTERM'); + this.playbackProcess = null; + this.isPlaying = false; + this.playbackBuffer = []; + console.log('✓ Lecture PipeWire 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 PipeWire inactive'); + return; + } + + if (this.playbackBuffer.length < this.maxBufferSize) { + this.playbackBuffer.push(audioData); + } else { + this.emit('bufferOverrun'); + } + } + + /** + * Boucle de lecture du buffer circulaire + * @private + */ + _startPlaybackLoop() { + const playNextChunk = () => { + if (!this.isPlaying || !this.playbackProcess) return; + + if (this.playbackBuffer.length > 0) { + const chunk = this.playbackBuffer.shift(); + try { + this.playbackProcess.stdin.write(chunk); + } catch (error) { + console.error('Erreur écriture stdin PipeWire:', error); + } + } else { + // Buffer vide : underrun (silence) + const silenceBuffer = Buffer.alloc(this.options.framesPerBuffer * 2 * this.options.channels); + try { + this.playbackProcess.stdin.write(silenceBuffer); + } catch (error) { + // Ignore si le process est fermé + } + this.emit('bufferUnderrun'); + } + + 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('✓ PipeWireBackend détruit'); + } + + /** + * 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, + latency: this.options.latency, + pipewireServerRunning: PipeWireBackend.isServerRunning() + }; + } + + /** + * Obtient les informations du serveur PipeWire + * @returns {Object} + */ + static getServerInfo() { + if (!this.isServerRunning()) { + return { running: false }; + } + + try { + const output = execSync('pw-cli info 0', { encoding: 'utf8' }); + + // Parse basique des infos + const versionMatch = output.match(/version:\s*"([^"]+)"/); + + return { + running: true, + version: versionMatch ? versionMatch[1] : 'unknown', + devices: this.getDevices().length + }; + } catch (error) { + return { running: true }; + } + } +} + +export default PipeWireBackend;