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,293 @@
|
||||
/**
|
||||
* 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;
|
||||
Reference in New Issue
Block a user