feat: implémentation complète du bridge audio serveur (Phase 1.3)
Composants créés : - CoreAudioBackend.js : Backend audio macOS natif (naudiodon/PortAudio) - Énumération et sélection devices audio - Capture audio 48kHz mono/stereo - Lecture audio avec buffer circulaire - Gestion underrun/overrun - OpusCodec.js : Encodeur/décodeur Opus - Support 32-320 kbps configurable - Présets voix (économique, standard, HD) et musique - Frame 20ms (960 samples à 48kHz) - Statistiques encode/decode - JitterBuffer.js : Buffer FIFO adaptatif - Cible 40ms (2 frames) - Détection underrun/overrun - Mode adaptatif pour conditions réseau variables - Statistiques latence et santé buffer - LiveKitClient.js : Client LiveKit pour bridge - Connexion room en tant que participant "AudioBridge" - Publication/souscription tracks audio - Reconnexion automatique - Gestion événements participants - AudioBridge.js : Classe principale orchestration - Détection automatique backend (CoreAudio macOS) - Routing bidirectionnel CoreAudio ↔ Opus ↔ LiveKit - Configuration via présets ou custom - Logs détaillés et statistiques temps réel Dépendances ajoutées : - opusscript : Codec Opus JavaScript - naudiodon : Bindings natifs PortAudio/CoreAudio - livekit-client : SDK LiveKit côté serveur TODO.md mis à jour avec tâches Phase 1.3 complétées. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,323 @@
|
||||
/**
|
||||
* JitterBuffer.js
|
||||
* Buffer FIFO pour compenser le jitter réseau et garantir lecture fluide
|
||||
*
|
||||
* Gère :
|
||||
* - Buffer circulaire avec cible 40ms
|
||||
* - Détection underrun (buffer vide)
|
||||
* - Détection overrun (buffer plein)
|
||||
* - Statistiques latence et santé buffer
|
||||
*/
|
||||
|
||||
import { EventEmitter } from 'events';
|
||||
|
||||
export class JitterBuffer extends EventEmitter {
|
||||
constructor(options = {}) {
|
||||
super();
|
||||
|
||||
this.options = {
|
||||
targetSize: options.targetSize || 2, // Nombre de frames cible (40ms = 2x 20ms frames)
|
||||
maxSize: options.maxSize || 10, // Taille max buffer (200ms)
|
||||
minSize: options.minSize || 1, // Taille min avant lecture
|
||||
adaptiveMode: options.adaptiveMode !== false, // Adaptation automatique
|
||||
...options
|
||||
};
|
||||
|
||||
// Buffer de frames
|
||||
this.buffer = [];
|
||||
|
||||
// Statistiques
|
||||
this.stats = {
|
||||
received: 0,
|
||||
played: 0,
|
||||
underruns: 0,
|
||||
overruns: 0,
|
||||
dropped: 0,
|
||||
avgBufferSize: 0,
|
||||
currentBufferSize: 0,
|
||||
latencyMs: 0
|
||||
};
|
||||
|
||||
// Historique pour adaptation
|
||||
this.bufferSizeHistory = [];
|
||||
this.historyMaxLength = 100;
|
||||
|
||||
// État
|
||||
this.isReady = false;
|
||||
this.lastUpdateTime = Date.now();
|
||||
}
|
||||
|
||||
/**
|
||||
* Ajoute une frame au buffer
|
||||
* @param {Buffer} frame - Frame audio (Opus ou PCM)
|
||||
* @param {Object} metadata - Métadonnées optionnelles (timestamp, sequence, etc.)
|
||||
* @returns {boolean} True si ajouté, false si buffer plein
|
||||
*/
|
||||
push(frame, metadata = {}) {
|
||||
const now = Date.now();
|
||||
|
||||
// Vérification buffer plein
|
||||
if (this.buffer.length >= this.options.maxSize) {
|
||||
this.stats.overruns++;
|
||||
this.emit('overrun', {
|
||||
bufferSize: this.buffer.length,
|
||||
maxSize: this.options.maxSize
|
||||
});
|
||||
|
||||
// En mode adaptatif, on drop la frame la plus ancienne
|
||||
if (this.options.adaptiveMode) {
|
||||
this.buffer.shift();
|
||||
this.stats.dropped++;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Ajout de la frame avec timestamp
|
||||
this.buffer.push({
|
||||
data: frame,
|
||||
timestamp: now,
|
||||
metadata
|
||||
});
|
||||
|
||||
this.stats.received++;
|
||||
this.stats.currentBufferSize = this.buffer.length;
|
||||
|
||||
// Mise à jour historique
|
||||
this._updateHistory();
|
||||
|
||||
// Vérification si le buffer est prêt pour la lecture
|
||||
if (!this.isReady && this.buffer.length >= this.options.minSize) {
|
||||
this.isReady = true;
|
||||
this.emit('ready', { bufferSize: this.buffer.length });
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère la prochaine frame du buffer
|
||||
* @returns {Buffer|null} Frame audio ou null si buffer vide
|
||||
*/
|
||||
pop() {
|
||||
if (this.buffer.length === 0) {
|
||||
this.stats.underruns++;
|
||||
this.isReady = false;
|
||||
this.emit('underrun', {
|
||||
bufferSize: 0
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
// Récupération de la frame la plus ancienne
|
||||
const item = this.buffer.shift();
|
||||
this.stats.played++;
|
||||
this.stats.currentBufferSize = this.buffer.length;
|
||||
|
||||
// Calcul latence (temps passé dans le buffer)
|
||||
const latency = Date.now() - item.timestamp;
|
||||
this.stats.latencyMs = latency;
|
||||
|
||||
// Mise à jour historique
|
||||
this._updateHistory();
|
||||
|
||||
return item.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère la prochaine frame sans la retirer du buffer
|
||||
* @returns {Buffer|null}
|
||||
*/
|
||||
peek() {
|
||||
if (this.buffer.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return this.buffer[0].data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Vide le buffer
|
||||
*/
|
||||
flush() {
|
||||
const flushedCount = this.buffer.length;
|
||||
this.buffer = [];
|
||||
this.isReady = false;
|
||||
this.stats.currentBufferSize = 0;
|
||||
this.emit('flush', { flushedCount });
|
||||
}
|
||||
|
||||
/**
|
||||
* Mise à jour de l'historique des tailles de buffer
|
||||
* @private
|
||||
*/
|
||||
_updateHistory() {
|
||||
this.bufferSizeHistory.push(this.buffer.length);
|
||||
|
||||
// Limite la taille de l'historique
|
||||
if (this.bufferSizeHistory.length > this.historyMaxLength) {
|
||||
this.bufferSizeHistory.shift();
|
||||
}
|
||||
|
||||
// Calcul moyenne
|
||||
const sum = this.bufferSizeHistory.reduce((a, b) => a + b, 0);
|
||||
this.stats.avgBufferSize = sum / this.bufferSizeHistory.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adaptation automatique de la taille cible du buffer
|
||||
* Appelé périodiquement pour ajuster selon les conditions réseau
|
||||
*/
|
||||
adapt() {
|
||||
if (!this.options.adaptiveMode) return;
|
||||
|
||||
// Analyse de l'historique pour détecter les tendances
|
||||
if (this.bufferSizeHistory.length < 10) return;
|
||||
|
||||
const recent = this.bufferSizeHistory.slice(-10);
|
||||
const avg = recent.reduce((a, b) => a + b, 0) / recent.length;
|
||||
|
||||
// Si le buffer est souvent proche du min, augmenter la cible
|
||||
if (avg < this.options.targetSize * 0.7 && this.options.targetSize < this.options.maxSize / 2) {
|
||||
this.options.targetSize++;
|
||||
this.emit('adapted', {
|
||||
newTargetSize: this.options.targetSize,
|
||||
reason: 'buffer_low',
|
||||
avgSize: avg
|
||||
});
|
||||
}
|
||||
|
||||
// Si le buffer est souvent plein, réduire la cible
|
||||
if (avg > this.options.targetSize * 1.5 && this.options.targetSize > this.options.minSize) {
|
||||
this.options.targetSize--;
|
||||
this.emit('adapted', {
|
||||
newTargetSize: this.options.targetSize,
|
||||
reason: 'buffer_high',
|
||||
avgSize: avg
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtient les statistiques du buffer
|
||||
* @returns {Object}
|
||||
*/
|
||||
getStats() {
|
||||
return {
|
||||
...this.stats,
|
||||
isReady: this.isReady,
|
||||
targetSize: this.options.targetSize,
|
||||
maxSize: this.options.maxSize,
|
||||
fillPercentage: (this.buffer.length / this.options.maxSize) * 100,
|
||||
health: this._getHealthScore()
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Calcule un score de santé du buffer (0-100)
|
||||
* @returns {number}
|
||||
* @private
|
||||
*/
|
||||
_getHealthScore() {
|
||||
let score = 100;
|
||||
|
||||
// Pénalité pour underruns
|
||||
if (this.stats.played > 0) {
|
||||
const underrunRate = this.stats.underruns / this.stats.played;
|
||||
score -= underrunRate * 50;
|
||||
}
|
||||
|
||||
// Pénalité pour overruns
|
||||
if (this.stats.received > 0) {
|
||||
const overrunRate = this.stats.overruns / this.stats.received;
|
||||
score -= overrunRate * 30;
|
||||
}
|
||||
|
||||
// Pénalité si taille actuelle loin de la cible
|
||||
const targetDiff = Math.abs(this.buffer.length - this.options.targetSize);
|
||||
score -= (targetDiff / this.options.maxSize) * 20;
|
||||
|
||||
return Math.max(0, Math.min(100, score));
|
||||
}
|
||||
|
||||
/**
|
||||
* Réinitialise les statistiques
|
||||
*/
|
||||
resetStats() {
|
||||
this.stats = {
|
||||
received: 0,
|
||||
played: 0,
|
||||
underruns: 0,
|
||||
overruns: 0,
|
||||
dropped: 0,
|
||||
avgBufferSize: 0,
|
||||
currentBufferSize: this.buffer.length,
|
||||
latencyMs: 0
|
||||
};
|
||||
this.bufferSizeHistory = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Vérifie si le buffer est prêt pour la lecture
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isReadyToPlay() {
|
||||
return this.isReady && this.buffer.length >= this.options.minSize;
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtient la latence actuelle du buffer en ms
|
||||
* @param {number} frameDurationMs - Durée d'une frame en ms
|
||||
* @returns {number}
|
||||
*/
|
||||
getCurrentLatency(frameDurationMs) {
|
||||
return this.buffer.length * frameDurationMs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Détruit le buffer et libère les ressources
|
||||
*/
|
||||
destroy() {
|
||||
this.flush();
|
||||
this.removeAllListeners();
|
||||
console.log('✓ JitterBuffer détruit');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Présets de configuration JitterBuffer selon le cas d'usage
|
||||
*/
|
||||
export const JitterBufferPresets = {
|
||||
// Très faible latence (réseau local stable)
|
||||
ULTRA_LOW_LATENCY: {
|
||||
targetSize: 1,
|
||||
maxSize: 5,
|
||||
minSize: 1,
|
||||
adaptiveMode: false
|
||||
},
|
||||
|
||||
// Faible latence (WiFi local)
|
||||
LOW_LATENCY: {
|
||||
targetSize: 2,
|
||||
maxSize: 8,
|
||||
minSize: 1,
|
||||
adaptiveMode: true
|
||||
},
|
||||
|
||||
// Latence standard (défaut, bon compromis)
|
||||
STANDARD: {
|
||||
targetSize: 3,
|
||||
maxSize: 10,
|
||||
minSize: 2,
|
||||
adaptiveMode: true
|
||||
},
|
||||
|
||||
// Haute tolérance (réseau instable)
|
||||
HIGH_TOLERANCE: {
|
||||
targetSize: 5,
|
||||
maxSize: 15,
|
||||
minSize: 2,
|
||||
adaptiveMode: true
|
||||
}
|
||||
};
|
||||
|
||||
export default JitterBuffer;
|
||||
Reference in New Issue
Block a user