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" "revision": "3ca0b8505b4bec776b69afdba2768812"
}, { }, {
"url": "index.html", "url": "index.html",
"revision": "0.rol69f8gtdg" "revision": "0.t6h2k1g9avg"
}], {}); }], {});
workbox.cleanupOutdatedCaches(); workbox.cleanupOutdatedCaches();
workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), { 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) { 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 // ÉTAPE 2 : Pour chaque groupe, envoyer vers le LiveKitClient correspondant
groupBuffers.forEach((groupBuffer, groupName) => { groupBuffers.forEach((groupBuffer, groupName) => {
// Les groupes sont MONO (Float32Array de N samples) // Les groupes sont MONO (Float32Array de N samples)
// Mais LiveKit attend du STÉRÉO (2 canaux) // Mais la config globale peut être STÉRÉO (channels=2)
// → Dupliquer le canal mono pour créer du faux stéréo // → Adapter selon la configuration
const samplesPerChannel = groupBuffer.length; let pcmBuffer;
const stereoBuffer = new Float32Array(samplesPerChannel * 2); const configChannels = this.options.channels || 1;
// Entrelacer : [M0, M1, M2, ...] → [M0, M0, M1, M1, M2, M2, ...] if (configChannels === 1) {
for (let i = 0; i < samplesPerChannel; i++) { // Config MONO : envoyer directement
stereoBuffer[i * 2] = groupBuffer[i]; // Canal gauche pcmBuffer = this._float32ToBuffer(groupBuffer);
stereoBuffer[i * 2 + 1] = groupBuffer[i]; // Canal droit (dupliqué) } 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 // Entrelacer : [M0, M1, M2, ...] → [M0, M0, M1, M1, M2, M2, ...]
const pcmBuffer = this._float32ToBuffer(stereoBuffer); for (let i = 0; i < samplesPerChannel; i++) {
stereoBuffer[i * 2] = groupBuffer[i]; // Canal gauche
// Encoder en Opus (maintenant en stéréo) stereoBuffer[i * 2 + 1] = groupBuffer[i]; // Canal droit (dupliqué)
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é`);
}
} }
// Émettre aussi pour monitoring/debug pcmBuffer = this._float32ToBuffer(stereoBuffer);
this.emit('groupAudioOut', { groupName, opusData, pcmBuffer }); } 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) // É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 { try {
// Création d'un AudioFrame (conversion en int32 explicite) // AudioFrame attend Int16Array, pas Buffer
const samplesPerChannel = Math.floor(pcmData.length / 2 / this.options.channels); // 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( const frame = new AudioFrame(
pcmData, int16Array,
parseInt(this.options.sampleRate, 10), parseInt(this.options.sampleRate, 10),
parseInt(this.options.channels, 10), parseInt(this.options.channels, 10),
samplesPerChannel samplesPerChannel
+31 -8
View File
@@ -32,6 +32,11 @@ export class CoreAudioBackend extends EventEmitter {
this.playbackProcess = null; this.playbackProcess = null;
this.isCapturing = false; this.isCapturing = false;
this.isPlaying = 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 // Buffer circulaire pour la lecture
this.playbackBuffer = []; this.playbackBuffer = [];
@@ -212,8 +217,17 @@ export class CoreAudioBackend extends EventEmitter {
this.captureProcess = spawn('sox', args); this.captureProcess = spawn('sox', args);
this.captureProcess.stdout.on('data', (audioData) => { this.captureProcess.stdout.on('data', (audioData) => {
// Émet les données audio capturées (Buffer PCM 16-bit) // Accumuler les données jusqu'à avoir un frame complet
this.emit('audioData', audioData); 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) => { this.captureProcess.stderr.on('data', (data) => {
@@ -249,6 +263,7 @@ export class CoreAudioBackend extends EventEmitter {
this.captureProcess.kill('SIGTERM'); this.captureProcess.kill('SIGTERM');
this.captureProcess = null; this.captureProcess = null;
this.isCapturing = false; this.isCapturing = false;
this.captureAccumulator = Buffer.alloc(0); // Reset accumulator
console.log('✓ Capture audio arrêtée'); console.log('✓ Capture audio arrêtée');
} }
} }
@@ -272,7 +287,7 @@ export class CoreAudioBackend extends EventEmitter {
// Output = -d (default) OU -t coreaudio "Device Name" // Output = -d (default) OU -t coreaudio "Device Name"
const args = [ const args = [
'--buffer', '8192', // Buffer interne sox '--buffer', '65536', // Buffer 64k (évite EOF prématuré)
'-t', 'raw', '-t', 'raw',
'-b', '16', '-b', '16',
'-e', 'signed-integer', '-e', 'signed-integer',
@@ -318,13 +333,20 @@ export class CoreAudioBackend extends EventEmitter {
}); });
this.playbackProcess.on('close', (code) => { 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; this.isPlaying = false;
// Tenter de redémarrer si c'était inattendu // Redémarrer automatiquement (sox se ferme quand le buffer stdin se vide)
if (code !== 0) { if (!this.shuttingDown) {
console.log('🔄 Tentative de redémarrage du playback...'); console.log('🔄 Redémarrage automatique du playback...');
setTimeout(() => this.startPlayback(), 1000); 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 * Arrête tous les streams
*/ */
destroy() { destroy() {
this.shuttingDown = true;
this.stopCapture(); this.stopCapture();
this.stopPlayback(); this.stopPlayback();
this.removeAllListeners(); this.removeAllListeners();
+1 -3
View File
@@ -11,10 +11,8 @@ audio:
routing: routing:
inputToGroup: inputToGroup:
"0": "0":
- production
- default
"1":
- default - default
"1": []
"2": [] "2": []
"4": "4":
- technique - technique