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
This commit is contained in:
@@ -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)
|
||||
|
||||
---
|
||||
|
||||
|
||||
Executable
+293
@@ -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 "$@"
|
||||
@@ -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})`);
|
||||
|
||||
@@ -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<void>}
|
||||
*/
|
||||
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<void>}
|
||||
*/
|
||||
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;
|
||||
@@ -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<void>}
|
||||
*/
|
||||
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<void>}
|
||||
*/
|
||||
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;
|
||||
Reference in New Issue
Block a user