Files
PTT-Live/server/bridge/AudioBridge.js
T
benoit a803250f9f fix: routing audio macOS avec support multi-canaux et LiveKit
Corrections majeures pour le support audio sous macOS :

- CoreAudioBackend : syntaxe sox correcte avec `-t coreaudio "Device Name"`
- AudioBridge : dé-entrelacement stéréo → canaux séparés (ligne 410-424)
- AudioBridge : entrelacement canaux → stéréo pour sortie (ligne 490-522)
- AudioBridge : duplication mono → stéréo pour LiveKit (ligne 438-449)
- config.yaml : ajout `channels: 2` pour capture stéréo
- config.yaml : ajout groupes "Production" et "Technique"

Résultat :
- Capture stéréo fonctionnelle depuis Loopback Audio 4
- Routing : 2 inputs → 3 groupes → LiveKit + 2 outputs
- Format audio correct pour LiveKit (mono dupliqué en stéréo)
- Pas d'erreur "Taille frame incorrecte"

Problème restant : sox playback se ferme après 0.4s (EPIPE)
2026-06-02 00:33:26 +02:00

841 lines
28 KiB
JavaScript

/**
* AudioBridge.js
* Classe principale du bridge audio serveur
*
* Orchestre :
* - Détection et initialisation du backend audio (CoreAudio/JACK/etc.)
* - Routing : CoreAudio → Opus → LiveKit
* - Routing : LiveKit → Opus → CoreAudio
* - Jitter buffer pour flux entrants
* - Logs détaillés et statistiques
*/
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';
import GroupAudioRouter from './GroupAudioRouter.js';
export class AudioBridge extends EventEmitter {
constructor(options = {}) {
super();
this.options = {
// Configuration audio
sampleRate: options.sampleRate || 48000,
channels: options.channels || 1,
frameSize: options.frameSize || 960, // 20ms à 48kHz
// Configuration Opus
opusPreset: options.opusPreset || 'VOICE_STANDARD',
customOpusBitrate: options.customOpusBitrate || null,
// Configuration JitterBuffer
jitterBufferPreset: options.jitterBufferPreset || 'LOW_LATENCY',
// Configuration LiveKit
liveKitUrl: options.liveKitUrl || 'ws://localhost:7880',
liveKitToken: options.liveKitToken || null,
roomName: options.roomName || 'main',
// Configuration backend
inputDeviceId: options.inputDeviceId || null,
outputDeviceId: options.outputDeviceId || null,
...options
};
// Composants
this.audioBackend = null;
this.opusEncoder = null;
this.opusDecoder = null;
this.jitterBuffer = null;
this.liveKitClients = new Map(); // Map<groupName, LiveKitClient> - un client par groupe
this.groupAudioRouter = null;
// État
this.isRunning = false;
this.backendType = null;
// Buffers pour routing multi-canaux
this.inputChannelBuffers = new Map(); // Map<channelId, Float32Array>
this.groupBuffersFromLiveKit = new Map(); // Map<groupName, Float32Array>
// Frame accumulators pour LiveKit (240 samples → 960 samples)
this.liveKitFrameAccumulators = new Map(); // Map<groupName, { buffer: Float32Array, offset: number }>
// Pool de buffers pré-alloués pour éviter allocations répétées
this.bufferPool = {
float32: [], // Pool de Float32Array réutilisables
pcm: [] // Pool de Buffer PCM réutilisables
};
this.maxPoolSize = 50; // Limite du pool (adapté pour 30+ clients)
// Statistiques
this.stats = {
startTime: null,
framesCapture: 0,
framesPlayback: 0,
bytesEncoded: 0,
bytesDecoded: 0,
errors: {
capture: 0,
playback: 0,
encode: 0,
decode: 0,
network: 0
}
};
}
/**
* Initialise et démarre le bridge audio
* @returns {Promise<void>}
*/
async start() {
if (this.isRunning) {
console.warn('Bridge audio déjà démarré');
return;
}
console.log('🚀 Démarrage AudioBridge...');
try {
// 1. Détection et initialisation du backend audio
await this._initAudioBackend();
// 2. Initialisation des codecs Opus
this._initOpusCodecs();
// 3. Initialisation du jitter buffer
this._initJitterBuffer();
// 4. Initialisation du GroupAudioRouter
this._initGroupAudioRouter();
// 5. Connexion à LiveKit
await this._initLiveKit();
// 6. Démarrage du routing audio
await this._startAudioRouting();
this.isRunning = true;
this.stats.startTime = Date.now();
console.log('✅ AudioBridge démarré avec succès');
this.emit('started');
// Logs périodiques
this._startStatsLogger();
} catch (error) {
console.error('❌ Erreur démarrage AudioBridge:', error);
await this.stop();
throw error;
}
}
/**
* Détecte et initialise le backend audio approprié
* @private
*/
async _initAudioBackend() {
const os = platform();
let BackendClass = null;
let devices = [];
// macOS : CoreAudio prioritaire
if (os === 'darwin') {
if (CoreAudioBackend.isAvailable()) {
this.backendType = 'CoreAudio';
BackendClass = CoreAudioBackend;
console.log('✓ Backend audio : CoreAudio (macOS natif)');
} else {
throw new Error('CoreAudio non disponible sur ce système');
}
}
// Linux : PipeWire > JACK (ordre de préférence)
else if (os === 'linux') {
// 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') {
throw new Error('Support Windows non encore implémenté');
}
else {
throw new Error(`Plateforme non supportée : ${os}`);
}
// Résoudre les device IDs vers les noms pour CoreAudio/sox
let inputDeviceName = null;
let outputDeviceName = null;
if (this.options.inputDeviceId) {
const inputDevice = BackendClass.getDevices().find(d => d.id === this.options.inputDeviceId);
inputDeviceName = inputDevice ? inputDevice.name : this.options.inputDeviceId;
console.log(`📥 Input device: "${inputDeviceName}" (ID: ${this.options.inputDeviceId})`);
}
if (this.options.outputDeviceId) {
const outputDevice = BackendClass.getDevices().find(d => d.id === this.options.outputDeviceId);
outputDeviceName = outputDevice ? outputDevice.name : this.options.outputDeviceId;
console.log(`📤 Output device: "${outputDeviceName}" (ID: ${this.options.outputDeviceId})`);
}
// Initialisation du backend sélectionné
const backendOptions = {
sampleRate: this.options.sampleRate,
channels: this.options.channels,
framesPerBuffer: this.options.frameSize,
latency: this.options.latency || 20
};
// PipeWire utilise targetDevice, CoreAudio utilise inputDeviceName/outputDeviceName
if (this.backendType === 'PipeWire') {
// Pour PipeWire, on utilise inputDeviceId directement comme targetDevice
// (startCapture et startPlayback peuvent avoir des targets différents)
backendOptions.inputTargetDevice = this.options.inputDeviceId;
backendOptions.outputTargetDevice = this.options.outputDeviceId;
} else {
// CoreAudio et autres backends
backendOptions.inputDeviceId = this.options.inputDeviceId;
backendOptions.inputDeviceName = inputDeviceName;
backendOptions.outputDeviceId = this.options.outputDeviceId;
backendOptions.outputDeviceName = outputDeviceName;
}
this.audioBackend = new BackendClass(backendOptions);
// Liste des devices disponibles
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})`);
});
}
/**
* Initialise les codecs Opus (encoder et decoder)
* @private
*/
_initOpusCodecs() {
// Configuration Opus depuis preset ou custom
let opusConfig = OpusPresets[this.options.opusPreset] || OpusPresets.VOICE_STANDARD;
if (this.options.customOpusBitrate) {
opusConfig = { ...opusConfig, bitrate: this.options.customOpusBitrate };
}
const codecOptions = {
sampleRate: this.options.sampleRate,
channels: this.options.channels,
frameSize: this.options.frameSize,
...opusConfig
};
// Encoder pour capture (CoreAudio → Opus → LiveKit)
this.opusEncoder = new OpusCodec(codecOptions);
// Decoder pour lecture (LiveKit → Opus → CoreAudio)
this.opusDecoder = new OpusCodec(codecOptions);
console.log(`✓ Codecs Opus : ${opusConfig.bitrate / 1000}kbps, ${this.options.sampleRate}Hz`);
}
/**
* Initialise le jitter buffer
* @private
*/
_initJitterBuffer() {
const bufferConfig = JitterBufferPresets[this.options.jitterBufferPreset] || JitterBufferPresets.LOW_LATENCY;
this.jitterBuffer = new JitterBuffer(bufferConfig);
// Events du jitter buffer
this.jitterBuffer.on('underrun', () => {
console.warn('⚠️ Jitter buffer underrun');
});
this.jitterBuffer.on('overrun', () => {
console.warn('⚠️ Jitter buffer overrun');
});
this.jitterBuffer.on('adapted', ({ newTargetSize, reason }) => {
console.log(`🔧 Jitter buffer adapté : ${newTargetSize} frames (raison: ${reason})`);
});
console.log(`✓ Jitter buffer : cible ${bufferConfig.targetSize} frames`);
}
/**
* Initialise le GroupAudioRouter pour le routing multi-canaux
* @private
*/
_initGroupAudioRouter() {
this.groupAudioRouter = new GroupAudioRouter({
sampleRate: this.options.sampleRate,
frameSize: this.options.frameSize,
maxInputChannels: this.options.maxInputChannels || 32,
maxOutputChannels: this.options.maxOutputChannels || 32,
groups: this.options.groups || []
});
// Charger la configuration de routing depuis les options
if (this.options.routing) {
this.groupAudioRouter.configure(this.options.routing);
}
// Events du router
this.groupAudioRouter.on('configured', (stats) => {
console.log(`✓ GroupAudioRouter configuré : ${stats.routesActive} routes`);
});
console.log('✓ GroupAudioRouter initialisé');
}
/**
* Initialise les connexions LiveKit (une par groupe)
* @private
*/
async _initLiveKit() {
if (!this.options.liveKitTokens || !Array.isArray(this.options.liveKitTokens)) {
throw new Error('liveKitTokens requis (tableau d\'objets { groupName, groupId, token })');
}
console.log(`🔌 Initialisation ${this.options.liveKitTokens.length} connexions LiveKit (une par groupe)...`);
// Créer un LiveKitClient pour chaque groupe
for (const { groupName, groupId, token } of this.options.liveKitTokens) {
const roomName = groupId; // La room porte le nom du groupId (slugifié)
const client = new LiveKitClient({
url: this.options.liveKitUrl,
token,
roomName,
participantName: `AudioBridge-${groupId}`,
sampleRate: this.options.sampleRate,
channels: this.options.channels,
audioBitrate: this.opusEncoder.options.bitrate
});
// Events LiveKit pour ce groupe
client.on('connected', () => {
console.log(`✓ LiveKit connecté pour groupe "${groupName}" (room: ${roomName})`);
});
client.on('disconnected', (data) => {
const reason = data?.reason || 'unknown';
console.warn(`⚠️ LiveKit déconnecté pour groupe "${groupName}":`, reason);
this.stats.errors.network++;
});
client.on('reconnecting', () => {
console.log(`🔄 LiveKit reconnexion pour groupe "${groupName}"...`);
});
client.on('audioTrackSubscribed', ({ track, participant }) => {
console.log(`🎵 Nouveau track audio dans groupe "${groupName}": ${participant.identity}`);
});
// Réception audio depuis les clients LiveKit de ce groupe
client.on('audioData', ({ participantName, pcmData, sampleRate, channels }) => {
// Router vers le bon groupe
this.emit('groupAudioIn', { groupName: groupId, pcmBuffer: pcmData });
});
// Connexion
await client.connect();
// Stocker le client par groupId
this.liveKitClients.set(groupId, client);
}
console.log(`${this.liveKitClients.size} connexions LiveKit établies`);
}
/**
* Démarre le routing audio bidirectionnel complet
* @private
*/
async _startAudioRouting() {
console.log('🔄 Démarrage routing audio bidirectionnel...');
// ===== FLUX 1 : CAPTURE (Carte Son → Groupes → LiveKit → Clients) =====
this.audioBackend.on('audioData', (pcmData) => {
try {
// Convertir PCM Buffer → Float32Array (pour GroupAudioRouter)
const float32Data = this._bufferToFloat32(pcmData);
// Séparer les canaux si audio multi-canaux (entrelacé)
const numChannels = this.options.channels || 1;
if (numChannels === 1) {
// Mono : un seul canal
const channelId = this.options.inputDeviceChannel || 0;
this.inputChannelBuffers.set(channelId, float32Data);
} else {
// Multi-canaux : dé-entrelacer les samples
// Format entrelacé : [L0, R0, L1, R1, ...] → [L0, L1, ...] et [R0, R1, ...]
const samplesPerChannel = float32Data.length / numChannels;
for (let ch = 0; ch < numChannels; ch++) {
const channelBuffer = new Float32Array(samplesPerChannel);
for (let i = 0; i < samplesPerChannel; i++) {
channelBuffer[i] = float32Data[i * numChannels + ch];
}
// Mapper canal hardware → canal logique (peut être configuré)
const logicalChannelId = this.options.channelMapping?.[ch] ?? ch;
this.inputChannelBuffers.set(logicalChannelId, channelBuffer);
}
}
// ÉTAPE 1 : Inputs physiques → Groupes (via GroupAudioRouter)
const groupBuffers = this.groupAudioRouter.processInputsToGroups(
this.inputChannelBuffers
);
if (this.stats.framesCapture % 100 === 0) {
console.log(`[AudioBridge] Frame ${this.stats.framesCapture}: ${this.inputChannelBuffers.size} inputs → ${groupBuffers.size} groupes`);
}
// ÉTAPE 2 : Pour chaque groupe, envoyer vers le LiveKitClient correspondant
groupBuffers.forEach((groupBuffer, groupName) => {
// Les groupes sont MONO (Float32Array de N samples)
// Mais LiveKit attend du STÉRÉO (2 canaux)
// → Dupliquer le canal mono pour créer du faux stéréo
const samplesPerChannel = groupBuffer.length;
const stereoBuffer = new Float32Array(samplesPerChannel * 2);
// Entrelacer : [M0, M1, M2, ...] → [M0, M0, M1, M1, M2, M2, ...]
for (let i = 0; i < samplesPerChannel; i++) {
stereoBuffer[i * 2] = groupBuffer[i]; // Canal gauche
stereoBuffer[i * 2 + 1] = groupBuffer[i]; // Canal droit (dupliqué)
}
// Convertir Float32Array stéréo → PCM Buffer
const pcmBuffer = this._float32ToBuffer(stereoBuffer);
// Encoder en Opus (maintenant en stéréo)
const opusData = this.opusEncoder.encode(pcmBuffer);
if (opusData) {
this.stats.bytesEncoded += opusData.length;
// Récupérer le client LiveKit pour ce groupe
const client = this.liveKitClients.get(groupName);
// Envoi vers LiveKit via sendAudioData (prend du PCM, pas de l'Opus)
// Note: LiveKit gère lui-même l'encodage Opus en interne
if (client && client.isConnected) {
client.sendAudioData(pcmBuffer);
if (this.stats.framesCapture % 100 === 0) {
console.log(`[AudioBridge] → LiveKit groupe "${groupName}": ${pcmBuffer.length} bytes (mono→stéréo)`);
}
} else {
if (this.stats.framesCapture % 100 === 0) {
console.log(`[AudioBridge] ⚠️ LiveKit non connecté pour groupe "${groupName}", audio non envoyé`);
}
}
// Émettre aussi pour monitoring/debug
this.emit('groupAudioOut', { groupName, opusData, pcmBuffer });
}
});
// ÉTAPE 3 : Loopback local - Groupes → Outputs physiques (sans passer par LiveKit)
const outputBuffers = this.groupAudioRouter.processGroupsToOutputs(groupBuffers);
if (this.stats.framesCapture % 100 === 0) {
console.log(`[AudioBridge] Loopback local: ${groupBuffers.size} groupes → ${outputBuffers.size} outputs`);
}
// ÉTAPE 4 : Envoyer chaque output à la carte son
const numOutputChannels = this.options.channels || 1;
if (numOutputChannels === 1) {
// Mono : un seul output
if (outputBuffers.size > 0) {
const [firstChannelId, outputBuffer] = outputBuffers.entries().next().value;
const pcmBuffer = this._float32ToBuffer(outputBuffer);
this.audioBackend.queueAudio(pcmBuffer);
if (this.stats.framesCapture % 100 === 0) {
console.log(`[AudioBridge] → Output mono (canal ${firstChannelId}): ${pcmBuffer.length} bytes`);
}
}
} else {
// Multi-canaux : entrelacer les samples
// Récupérer les buffers dans l'ordre des canaux hardware
const channelBuffers = [];
const samplesPerChannel = this.options.frameSize;
for (let ch = 0; ch < numOutputChannels; ch++) {
const logicalChannelId = this.options.channelMapping?.[ch] ?? ch;
const buffer = outputBuffers.get(logicalChannelId);
if (buffer && buffer.length === samplesPerChannel) {
channelBuffers.push(buffer);
} else {
// Canal absent ou taille incorrecte : silence
channelBuffers.push(new Float32Array(samplesPerChannel));
}
}
// Entrelacer : [L0, L1, ...] et [R0, R1, ...] → [L0, R0, L1, R1, ...]
const interleavedBuffer = new Float32Array(samplesPerChannel * numOutputChannels);
for (let i = 0; i < samplesPerChannel; i++) {
for (let ch = 0; ch < numOutputChannels; ch++) {
interleavedBuffer[i * numOutputChannels + ch] = channelBuffers[ch][i];
}
}
const pcmBuffer = this._float32ToBuffer(interleavedBuffer);
this.audioBackend.queueAudio(pcmBuffer);
if (this.stats.framesCapture % 100 === 0) {
console.log(`[AudioBridge] → Output multi-canaux (${numOutputChannels}ch): ${pcmBuffer.length} bytes`);
}
}
this.stats.framesCapture++;
this.stats.framesPlayback++;
} catch (error) {
console.error('Erreur routing capture:', error);
this.stats.errors.capture++;
}
});
// ===== FLUX 2 : LECTURE (Clients → LiveKit → Groupes → Carte Son) =====
// Écouter l'audio entrant de LiveKit (sera connecté par LiveKitServerBridge)
this.on('groupAudioIn', ({ groupName, pcmBuffer }) => {
try {
// Convertir PCM Buffer → Float32Array
const float32Data = this._bufferToFloat32(pcmBuffer);
const samplesReceived = float32Data.length;
// Initialiser l'accumulateur pour ce groupe si nécessaire
if (!this.liveKitFrameAccumulators.has(groupName)) {
this.liveKitFrameAccumulators.set(groupName, {
buffer: new Float32Array(960), // Frame size attendu par GroupRouter
offset: 0
});
}
const accumulator = this.liveKitFrameAccumulators.get(groupName);
// Vérifier que le buffer ne débordera pas
const availableSpace = 960 - accumulator.offset;
const samplesToCopy = Math.min(samplesReceived, availableSpace);
// Copier les samples dans l'accumulateur
if (samplesToCopy > 0) {
accumulator.buffer.set(float32Data.subarray(0, samplesToCopy), accumulator.offset);
accumulator.offset += samplesToCopy;
}
// Si on a accumulé assez de samples (960), router vers les outputs
if (accumulator.offset >= 960) {
// Vérifier que le backend est toujours actif (évite crash pendant shutdown)
if (!this.audioBackend) {
return;
}
// Stocker le buffer complet pour le routing
this.groupBuffersFromLiveKit.set(groupName, accumulator.buffer);
// ÉTAPE 3 : Groupes → Outputs physiques (via GroupAudioRouter)
const outputBuffers = this.groupAudioRouter.processGroupsToOutputs(
this.groupBuffersFromLiveKit
);
// ÉTAPE 4 : Envoyer chaque output à la carte son
outputBuffers.forEach((outputBuffer, channelId) => {
const pcmBuffer = this._float32ToBuffer(outputBuffer);
this.audioBackend.queueAudio(pcmBuffer);
});
// Réinitialiser l'accumulateur
accumulator.offset = 0;
accumulator.buffer.fill(0);
this.stats.framesPlayback++;
}
} catch (error) {
console.error('Erreur routing lecture:', error);
this.stats.errors.playback++;
}
});
// Démarrage des streams audio
await this.audioBackend.startCapture();
await this.audioBackend.startPlayback();
console.log('✓ Routing audio bidirectionnel actif');
console.log(' → Carte Son → GroupRouter → LiveKit → Clients');
console.log(' ← Carte Son ← GroupRouter ← LiveKit ← Clients');
}
/**
* Acquiert un Float32Array depuis le pool ou en crée un nouveau
* @param {number} size - Taille du buffer
* @returns {Float32Array}
* @private
*/
_acquireFloat32Buffer(size) {
const pooled = this.bufferPool.float32.find(b => b.length === size);
if (pooled) {
this.bufferPool.float32.splice(this.bufferPool.float32.indexOf(pooled), 1);
return pooled;
}
return new Float32Array(size);
}
/**
* Retourne un Float32Array au pool pour réutilisation
* @param {Float32Array} buffer
* @private
*/
_releaseFloat32Buffer(buffer) {
if (this.bufferPool.float32.length < this.maxPoolSize) {
this.bufferPool.float32.push(buffer);
}
}
/**
* Acquiert un Buffer PCM depuis le pool ou en crée un nouveau
* @param {number} size - Taille du buffer
* @returns {Buffer}
* @private
*/
_acquirePcmBuffer(size) {
const pooled = this.bufferPool.pcm.find(b => b.length === size);
if (pooled) {
this.bufferPool.pcm.splice(this.bufferPool.pcm.indexOf(pooled), 1);
return pooled;
}
return Buffer.alloc(size);
}
/**
* Retourne un Buffer PCM au pool pour réutilisation
* @param {Buffer} buffer
* @private
*/
_releasePcmBuffer(buffer) {
if (this.bufferPool.pcm.length < this.maxPoolSize) {
this.bufferPool.pcm.push(buffer);
}
}
/**
* Convertit Buffer/Int16Array PCM 16-bit → Float32Array [-1.0, 1.0]
* @param {Buffer|Int16Array|Uint8Array} buffer - Buffer PCM 16-bit signed
* @returns {Float32Array}
* @private
*/
_bufferToFloat32(buffer) {
let samples;
let float32;
// Cas 1 : Int16Array (LiveKit Node SDK format)
if (buffer instanceof Int16Array) {
samples = buffer.length;
float32 = this._acquireFloat32Buffer(samples);
for (let i = 0; i < samples; i++) {
float32[i] = buffer[i] / 32768.0;
}
return float32;
}
// Cas 2 : Buffer/Uint8Array (format classique)
if (!(buffer instanceof Buffer)) {
buffer = Buffer.from(buffer);
}
samples = buffer.length / 2; // 2 bytes per sample (16-bit)
float32 = this._acquireFloat32Buffer(samples);
for (let i = 0; i < samples; i++) {
// Lire 16-bit signed little-endian
const int16 = buffer.readInt16LE(i * 2);
// Normaliser vers [-1.0, 1.0]
float32[i] = int16 / 32768.0;
}
return float32;
}
/**
* Convertit Float32Array [-1.0, 1.0] → Buffer PCM 16-bit
* @param {Float32Array} float32 - Données audio normalisées
* @returns {Buffer}
* @private
*/
_float32ToBuffer(float32) {
const buffer = this._acquirePcmBuffer(float32.length * 2); // 2 bytes per sample
for (let i = 0; i < float32.length; i++) {
// Clamping [-1.0, 1.0]
const clamped = Math.max(-1.0, Math.min(1.0, float32[i]));
// Convertir vers 16-bit signed
const int16 = Math.round(clamped * 32767);
buffer.writeInt16LE(int16, i * 2);
}
return buffer;
}
/**
* Arrête le bridge audio
*/
async stop() {
if (!this.isRunning) {
return;
}
console.log('🛑 Arrêt AudioBridge...');
// Arrêt des composants
if (this.audioBackend) {
this.audioBackend.destroy();
this.audioBackend = null;
}
// Déconnecter tous les clients LiveKit
for (const [groupName, client] of this.liveKitClients.entries()) {
console.log(`🔌 Déconnexion LiveKit groupe "${groupName}"...`);
await client.destroy();
}
this.liveKitClients.clear();
if (this.groupAudioRouter) {
this.groupAudioRouter.destroy();
this.groupAudioRouter = null;
}
if (this.jitterBuffer) {
this.jitterBuffer.destroy();
this.jitterBuffer = null;
}
if (this.opusEncoder) {
this.opusEncoder.destroy();
this.opusEncoder = null;
}
if (this.opusDecoder) {
this.opusDecoder.destroy();
this.opusDecoder = null;
}
// Nettoyer les buffers
this.inputChannelBuffers.clear();
this.groupBuffersFromLiveKit.clear();
// Nettoyer le pool de buffers
this.bufferPool.float32 = [];
this.bufferPool.pcm = [];
this.isRunning = false;
console.log('✓ AudioBridge arrêté');
this.emit('stopped');
}
/**
* Logger de statistiques périodiques
* @private
*/
_startStatsLogger() {
const logInterval = 10000; // 10s
const logger = setInterval(() => {
if (!this.isRunning) {
clearInterval(logger);
return;
}
const stats = this.getStats();
console.log('📊 Statistiques AudioBridge:');
console.log(` Uptime: ${Math.floor(stats.uptimeSeconds)}s`);
console.log(` Capture: ${stats.framesCapture} frames (${stats.errors.capture} erreurs)`);
console.log(` Playback: ${stats.framesPlayback} frames (${stats.errors.playback} erreurs)`);
console.log(` Jitter buffer: ${stats.jitterBuffer.currentBufferSize}/${stats.jitterBuffer.maxSize} (santé: ${stats.jitterBuffer.health.toFixed(1)}%)`);
console.log(` Codec: enc=${stats.codec.encoded}, dec=${stats.codec.decoded}`);
}, logInterval);
}
/**
* Récupère les statistiques complètes
* @returns {Object}
*/
getStats() {
const uptime = this.stats.startTime ? (Date.now() - this.stats.startTime) / 1000 : 0;
return {
running: this.isRunning,
backendType: this.backendType,
uptimeSeconds: uptime,
framesCapture: this.stats.framesCapture,
framesPlayback: this.stats.framesPlayback,
bytesEncoded: this.stats.bytesEncoded,
bytesDecoded: this.stats.bytesDecoded,
errors: { ...this.stats.errors },
audioBackend: this.audioBackend ? this.audioBackend.getStats() : null,
codec: this.opusEncoder ? this.opusEncoder.getStats() : null,
jitterBuffer: this.jitterBuffer ? this.jitterBuffer.getStats() : null,
liveKit: this.liveKitClient ? this.liveKitClient.getStats() : null
};
}
/**
* Détruit le bridge et libère toutes les ressources
*/
async destroy() {
await this.stop();
this.removeAllListeners();
console.log('✓ AudioBridge détruit');
}
}
export default AudioBridge;