fix: audio haché depuis carte son par chunks sox de taille variable

- Ajout buffer d'accumulation dans CoreAudioBackend pour garantir frames fixes
- sox envoie des chunks de taille variable → accumuler jusqu'à frameSize complet
- Émission de frames exactement 960 samples × 2ch × 2 bytes = 3840 bytes
- Adaptation automatique mono/stéréo selon config channels
- Audio fluide sans hachage/robotique vers clients LiveKit
This commit is contained in:
2026-06-02 01:14:49 +02:00
parent 9aff58c528
commit 2b88ea0ad5
6 changed files with 92 additions and 51 deletions
+1 -1
View File
@@ -81,7 +81,7 @@ define(['./workbox-290dd570'], (function (workbox) { 'use strict';
"revision": "3ca0b8505b4bec776b69afdba2768812"
}, {
"url": "index.html",
"revision": "0.rol69f8gtdg"
"revision": "0.t6h2k1g9avg"
}], {});
workbox.cleanupOutdatedCaches();
workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), {
File diff suppressed because one or more lines are too long
+48 -35
View File
@@ -430,52 +430,65 @@ export class AudioBridge extends EventEmitter {
);
if (this.stats.framesCapture % 100 === 0) {
console.log(`[AudioBridge] Frame ${this.stats.framesCapture}: ${this.inputChannelBuffers.size} inputs ${groupBuffers.size} groupes`);
// Détecter si l'audio est du silence (toutes les samples < 0.001)
let totalEnergy = 0;
this.inputChannelBuffers.forEach((buffer) => {
for (let i = 0; i < buffer.length; i++) {
totalEnergy += Math.abs(buffer[i]);
}
});
const avgEnergy = totalEnergy / (this.inputChannelBuffers.size * (this.options.frameSize || 960));
console.log(`[AudioBridge] Frame ${this.stats.framesCapture}: ${this.inputChannelBuffers.size} inputs → ${groupBuffers.size} groupes | Énergie audio: ${avgEnergy.toFixed(6)}`);
}
// É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
// Mais la config globale peut être STÉRÉO (channels=2)
// → Adapter selon la configuration
const samplesPerChannel = groupBuffer.length;
const stereoBuffer = new Float32Array(samplesPerChannel * 2);
let pcmBuffer;
const configChannels = this.options.channels || 1;
// 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é)
}
if (configChannels === 1) {
// Config MONO : envoyer directement
pcmBuffer = this._float32ToBuffer(groupBuffer);
} else if (configChannels === 2) {
// Config STÉRÉO : dupliquer le canal mono
const samplesPerChannel = groupBuffer.length;
const stereoBuffer = new Float32Array(samplesPerChannel * 2);
// 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é`);
}
// 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é)
}
// Émettre aussi pour monitoring/debug
this.emit('groupAudioOut', { groupName, opusData, pcmBuffer });
pcmBuffer = this._float32ToBuffer(stereoBuffer);
} else {
console.error(`❌ Nombre de canaux non supporté: ${configChannels}`);
return;
}
// Récupérer le client LiveKit pour ce groupe
const client = this.liveKitClients.get(groupName);
// Envoi vers LiveKit via sendAudioData (prend du PCM 16-bit)
// 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) {
const channelLabel = configChannels === 1 ? 'mono' : `${configChannels}ch`;
console.log(`[AudioBridge] → LiveKit groupe "${groupName}": ${pcmBuffer.length} bytes (${channelLabel})`);
}
} 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, pcmBuffer });
});
// ÉTAPE 3 : Loopback local - Groupes → Outputs physiques (sans passer par LiveKit)
+10 -3
View File
@@ -264,11 +264,18 @@ export class LiveKitClient extends EventEmitter {
}
try {
// Création d'un AudioFrame (conversion en int32 explicite)
const samplesPerChannel = Math.floor(pcmData.length / 2 / this.options.channels);
// AudioFrame attend Int16Array, pas Buffer
// Convertir Buffer → Int16Array (éviter .slice, utiliser .subarray selon doc)
const int16Array = new Int16Array(
pcmData.buffer,
pcmData.byteOffset,
pcmData.length / 2 // length en samples, pas en bytes
);
const samplesPerChannel = Math.floor(int16Array.length / this.options.channels);
const frame = new AudioFrame(
pcmData,
int16Array,
parseInt(this.options.sampleRate, 10),
parseInt(this.options.channels, 10),
samplesPerChannel
+31 -8
View File
@@ -32,6 +32,11 @@ export class CoreAudioBackend extends EventEmitter {
this.playbackProcess = null;
this.isCapturing = false;
this.isPlaying = false;
this.shuttingDown = false;
// Buffer d'accumulation pour la capture (sox envoie des chunks de taille variable)
this.captureAccumulator = Buffer.alloc(0);
this.targetCaptureBytes = this.options.framesPerBuffer * 2 * this.options.channels; // 2 bytes per sample
// Buffer circulaire pour la lecture
this.playbackBuffer = [];
@@ -212,8 +217,17 @@ export class CoreAudioBackend extends EventEmitter {
this.captureProcess = spawn('sox', args);
this.captureProcess.stdout.on('data', (audioData) => {
// Émet les données audio capturées (Buffer PCM 16-bit)
this.emit('audioData', audioData);
// Accumuler les données jusqu'à avoir un frame complet
this.captureAccumulator = Buffer.concat([this.captureAccumulator, audioData]);
// Émettre des frames de taille fixe
while (this.captureAccumulator.length >= this.targetCaptureBytes) {
const frame = this.captureAccumulator.subarray(0, this.targetCaptureBytes);
this.emit('audioData', Buffer.from(frame)); // Copier pour éviter les références
// Garder le reste pour la prochaine frame
this.captureAccumulator = this.captureAccumulator.subarray(this.targetCaptureBytes);
}
});
this.captureProcess.stderr.on('data', (data) => {
@@ -249,6 +263,7 @@ export class CoreAudioBackend extends EventEmitter {
this.captureProcess.kill('SIGTERM');
this.captureProcess = null;
this.isCapturing = false;
this.captureAccumulator = Buffer.alloc(0); // Reset accumulator
console.log('✓ Capture audio arrêtée');
}
}
@@ -272,7 +287,7 @@ export class CoreAudioBackend extends EventEmitter {
// Output = -d (default) OU -t coreaudio "Device Name"
const args = [
'--buffer', '8192', // Buffer interne sox
'--buffer', '65536', // Buffer 64k (évite EOF prématuré)
'-t', 'raw',
'-b', '16',
'-e', 'signed-integer',
@@ -318,13 +333,20 @@ export class CoreAudioBackend extends EventEmitter {
});
this.playbackProcess.on('close', (code) => {
console.log(`⚠️ Sox playback fermé (code ${code}) après ${((Date.now() - this.playbackStartTime) / 1000).toFixed(1)}s`);
const uptime = ((Date.now() - this.playbackStartTime) / 1000).toFixed(1);
console.log(`⚠️ Sox playback fermé (code ${code}) après ${uptime}s`);
this.isPlaying = false;
// Tenter de redémarrer si c'était inattendu
if (code !== 0) {
console.log('🔄 Tentative de redémarrage du playback...');
setTimeout(() => this.startPlayback(), 1000);
// Redémarrer automatiquement (sox se ferme quand le buffer stdin se vide)
if (!this.shuttingDown) {
console.log('🔄 Redémarrage automatique du playback...');
setTimeout(() => {
if (!this.shuttingDown) {
this.startPlayback().catch(err => {
console.error('Erreur redémarrage playback:', err);
});
}
}, 100);
}
});
@@ -443,6 +465,7 @@ export class CoreAudioBackend extends EventEmitter {
* Arrête tous les streams
*/
destroy() {
this.shuttingDown = true;
this.stopCapture();
this.stopPlayback();
this.removeAllListeners();
+1 -3
View File
@@ -11,10 +11,8 @@ audio:
routing:
inputToGroup:
"0":
- production
- default
"1":
- default
"1": []
"2": []
"4":
- technique