Files
PTT-Live/server/bridge/OpusCodec.js
T
benoit efd697a9d3 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>
2026-05-22 22:18:18 +02:00

294 lines
7.5 KiB
JavaScript

/**
* OpusCodec.js
* Wrapper pour encoder/décoder audio avec Opus
*
* Gère :
* - Encodage PCM 16-bit → Opus
* - Décodage Opus → PCM 16-bit
* - Configuration bitrate (32-320 kbps)
* - Frame size flexible (20ms par défaut)
*/
import OpusScript from 'opusscript';
export class OpusCodec {
constructor(options = {}) {
this.options = {
sampleRate: options.sampleRate || 48000,
channels: options.channels || 1,
bitrate: options.bitrate || 96000, // 96kbps par défaut (voix standard)
frameSize: options.frameSize || 960, // 20ms à 48kHz
application: options.application || 'voip', // 'voip' | 'audio' | 'restricted_lowdelay'
...options
};
// Validation
this._validateOptions();
// Création des encodeurs/décodeurs Opus
this.encoder = null;
this.decoder = null;
this._initCodecs();
// Statistiques
this.stats = {
encoded: 0,
decoded: 0,
encodeErrors: 0,
decodeErrors: 0
};
}
/**
* Valide les options
* @private
*/
_validateOptions() {
const validSampleRates = [8000, 12000, 16000, 24000, 48000];
if (!validSampleRates.includes(this.options.sampleRate)) {
throw new Error(`Sample rate invalide : ${this.options.sampleRate}. Valeurs acceptées : ${validSampleRates.join(', ')}`);
}
if (this.options.channels < 1 || this.options.channels > 2) {
throw new Error(`Nombre de canaux invalide : ${this.options.channels}. Doit être 1 (mono) ou 2 (stereo)`);
}
if (this.options.bitrate < 6000 || this.options.bitrate > 510000) {
throw new Error(`Bitrate invalide : ${this.options.bitrate}. Doit être entre 6000 et 510000 bps`);
}
const validApplications = ['voip', 'audio', 'restricted_lowdelay'];
if (!validApplications.includes(this.options.application)) {
throw new Error(`Application invalide : ${this.options.application}. Valeurs acceptées : ${validApplications.join(', ')}`);
}
}
/**
* Initialise les codecs Opus
* @private
*/
_initCodecs() {
try {
// Mapping des applications
const appMapping = {
'voip': OpusScript.Application.VOIP,
'audio': OpusScript.Application.AUDIO,
'restricted_lowdelay': OpusScript.Application.RESTRICTED_LOWDELAY
};
// Création encoder
this.encoder = new OpusScript(
this.options.sampleRate,
this.options.channels,
appMapping[this.options.application]
);
// Configuration bitrate
this.encoder.setBitrate(this.options.bitrate);
// Création decoder
this.decoder = new OpusScript(
this.options.sampleRate,
this.options.channels,
appMapping[this.options.application]
);
console.log(`✓ Opus codec initialisé : ${this.options.sampleRate}Hz, ${this.options.channels}ch, ${this.options.bitrate / 1000}kbps`);
} catch (error) {
console.error('Erreur initialisation codec Opus:', error);
throw error;
}
}
/**
* Encode des données PCM en Opus
* @param {Buffer} pcmData - Données PCM 16-bit signed
* @returns {Buffer|null} Données Opus encodées ou null en cas d'erreur
*/
encode(pcmData) {
if (!this.encoder) {
console.error('Encoder non initialisé');
return null;
}
try {
// Conversion Buffer → Int16Array pour OpusScript
const pcmInt16 = new Int16Array(
pcmData.buffer,
pcmData.byteOffset,
pcmData.byteLength / 2
);
// Vérification taille frame
const expectedSamples = this.options.frameSize * this.options.channels;
if (pcmInt16.length !== expectedSamples) {
console.warn(`Taille frame incorrecte : ${pcmInt16.length} samples (attendu ${expectedSamples})`);
// Padding ou truncate si nécessaire
const adjusted = new Int16Array(expectedSamples);
adjusted.set(pcmInt16.slice(0, expectedSamples));
const opusData = this.encoder.encode(adjusted, this.options.frameSize);
this.stats.encoded++;
return Buffer.from(opusData);
}
// Encodage
const opusData = this.encoder.encode(pcmInt16, this.options.frameSize);
this.stats.encoded++;
return Buffer.from(opusData);
} catch (error) {
console.error('Erreur encodage Opus:', error);
this.stats.encodeErrors++;
return null;
}
}
/**
* Décode des données Opus en PCM
* @param {Buffer} opusData - Données Opus
* @returns {Buffer|null} Données PCM 16-bit ou null en cas d'erreur
*/
decode(opusData) {
if (!this.decoder) {
console.error('Decoder non initialisé');
return null;
}
try {
// Décodage
const pcmInt16 = this.decoder.decode(opusData, this.options.frameSize);
this.stats.decoded++;
// Conversion Int16Array → Buffer
const pcmBuffer = Buffer.from(pcmInt16.buffer);
return pcmBuffer;
} catch (error) {
console.error('Erreur décodage Opus:', error);
this.stats.decodeErrors++;
return null;
}
}
/**
* Change le bitrate de l'encodeur
* @param {number} bitrate - Nouveau bitrate en bps (6000-510000)
*/
setBitrate(bitrate) {
if (bitrate < 6000 || bitrate > 510000) {
console.error(`Bitrate invalide : ${bitrate}`);
return;
}
if (this.encoder) {
this.encoder.setBitrate(bitrate);
this.options.bitrate = bitrate;
console.log(`✓ Bitrate Opus mis à jour : ${bitrate / 1000}kbps`);
}
}
/**
* Obtient les statistiques du codec
* @returns {Object}
*/
getStats() {
return {
...this.stats,
config: {
sampleRate: this.options.sampleRate,
channels: this.options.channels,
bitrate: this.options.bitrate,
frameSize: this.options.frameSize,
application: this.options.application
}
};
}
/**
* Réinitialise les statistiques
*/
resetStats() {
this.stats = {
encoded: 0,
decoded: 0,
encodeErrors: 0,
decodeErrors: 0
};
}
/**
* Détruit le codec et libère les ressources
*/
destroy() {
this.encoder = null;
this.decoder = null;
console.log('✓ OpusCodec détruit');
}
/**
* Calcule la taille d'une frame en millisecondes
* @returns {number} Durée en ms
*/
getFrameDuration() {
return (this.options.frameSize / this.options.sampleRate) * 1000;
}
/**
* Calcule le nombre de bytes PCM pour une frame
* @returns {number} Taille en bytes
*/
getFrameSizeBytes() {
// PCM 16-bit = 2 bytes par sample
return this.options.frameSize * this.options.channels * 2;
}
}
/**
* Présets de configuration Opus selon le cas d'usage
*/
export const OpusPresets = {
// Voix économique (WiFi limité, faible bande passante)
VOICE_LOW: {
bitrate: 32000, // 32 kbps
application: 'voip'
},
// Voix économique améliorée
VOICE_ECONOMY: {
bitrate: 64000, // 64 kbps
application: 'voip'
},
// Voix standard (défaut, bon compromis)
VOICE_STANDARD: {
bitrate: 96000, // 96 kbps
application: 'voip'
},
// Voix HD (qualité maximale voix)
VOICE_HD: {
bitrate: 128000, // 128 kbps
application: 'voip'
},
// Voix ultra HD
VOICE_ULTRA: {
bitrate: 192000, // 192 kbps
application: 'audio'
},
// Musique/monitoring (si besoin événementiel)
MUSIC: {
bitrate: 256000, // 256 kbps
application: 'audio'
},
// Musique haute qualité
MUSIC_HQ: {
bitrate: 320000, // 320 kbps
application: 'audio'
}
};
export default OpusCodec;