efd697a9d3
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>
324 lines
7.7 KiB
JavaScript
324 lines
7.7 KiB
JavaScript
/**
|
|
* 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;
|