Merge pull request 'macos' (#1) from macos into main

Reviewed-on: #1
This commit was merged in pull request #1.
This commit is contained in:
2026-06-18 16:20:05 +02:00
11 changed files with 304 additions and 155 deletions
+1 -1
View File
@@ -81,7 +81,7 @@ define(['./workbox-290dd570'], (function (workbox) { 'use strict';
"revision": "3ca0b8505b4bec776b69afdba2768812"
}, {
"url": "index.html",
"revision": "0.881sreuemg"
"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
+5 -2
View File
@@ -108,17 +108,20 @@ function Admin() {
};
const loadAudioDevices = async () => {
const [devicesRes, currentDeviceRes, channelNamesRes] = await Promise.all([
const [devicesRes, currentDeviceRes, channelNamesRes, groupsRes] = await Promise.all([
fetch(`${API_URL}/admin/audio/devices`),
fetch(`${API_URL}/admin/audio/device`),
fetch(`${API_URL}/admin/audio/channels/names`)
fetch(`${API_URL}/admin/audio/channels/names`),
fetch(`${API_URL}/admin/groups`)
]);
const devicesData = await devicesRes.json();
const currentData = await currentDeviceRes.json();
const channelNamesData = await channelNamesRes.json();
const groupsData = await groupsRes.json();
setAudioDevices(devicesData.devices || []);
setGroups(groupsData.groups || []);
const device = currentData.device || { inputChannels: 8, outputChannels: 8 };
setCurrentDevice(device);
+12 -56
View File
@@ -4,9 +4,6 @@
*/
import { Router } from 'express';
import { readFileSync, writeFileSync } from 'fs';
import { join } from 'path';
import YAML from 'yaml';
import { fileURLToPath } from 'url';
import { dirname } from 'path';
import { CoreAudioBackend } from '../bridge/backends/CoreAudioBackend.js';
@@ -41,47 +38,6 @@ const stats = {
logs: []
};
// Configuration file path
const configPath = join(__dirname, '..', 'config', 'config.yaml');
/**
* Charge la configuration depuis le fichier YAML
* et génère les IDs à partir des noms
*/
function loadConfig() {
const configFile = readFileSync(configPath, 'utf8');
const config = YAML.parse(configFile);
// Générer les IDs pour les groupes
config.groups = config.groups.map(group => {
const groupId = slugify(group.name);
return {
...group,
id: groupId
};
});
return config;
}
/**
* Sauvegarde la configuration dans le fichier YAML
* Ne sauvegarde PAS les IDs (ils sont générés dynamiquement)
*/
function saveConfig(config) {
// Nettoyer les IDs avant de sauvegarder
const cleanConfig = {
...config,
groups: config.groups.map(group => {
const { id, ...groupWithoutId } = group;
return groupWithoutId;
})
};
const yamlContent = YAML.stringify(cleanConfig);
writeFileSync(configPath, yamlContent, 'utf8');
}
/**
* Ajoute un log au système
*/
@@ -166,7 +122,7 @@ export function addAudioStats(data) {
*/
router.get('/groups', (req, res) => {
try {
const config = loadConfig();
const config = configManager.get();
res.json({
groups: config.groups
});
@@ -192,7 +148,7 @@ router.post('/groups', (req, res) => {
});
}
const config = loadConfig();
const config = configManager.get();
// Générer l'ID à partir du nom
const id = slugify(name);
@@ -211,7 +167,7 @@ router.post('/groups', (req, res) => {
};
config.groups.push(newGroup);
saveConfig(config);
configManager.save(config);
addLog('info', `Group created: ${name}`, { id });
@@ -237,7 +193,7 @@ router.put('/groups/:id', (req, res) => {
const { id } = req.params;
const { name, audioBitrate } = req.body;
const config = loadConfig();
const config = configManager.get();
// Chercher le groupe par son nom (qui correspond à l'ID slugifié)
const groupIndex = config.groups.findIndex(g => slugify(g.name) === id);
@@ -252,12 +208,12 @@ router.put('/groups/:id', (req, res) => {
if (name !== undefined) config.groups[groupIndex].name = name;
if (audioBitrate !== undefined) config.groups[groupIndex].audioBitrate = audioBitrate;
saveConfig(config);
configManager.save(config);
addLog('info', `Group updated: ${config.groups[groupIndex].name}`, { id });
// Recharger pour obtenir les IDs générés
const updatedConfig = loadConfig();
// Récupérer la config à jour avec les IDs générés
const updatedConfig = configManager.get();
const updatedGroupIndex = updatedConfig.groups.findIndex(g => slugify(g.name) === id || slugify(g.name) === slugify(name));
const updatedGroup = updatedGroupIndex !== -1 ? updatedConfig.groups[updatedGroupIndex] : null;
@@ -281,7 +237,7 @@ router.delete('/groups/:id', (req, res) => {
try {
const { id } = req.params;
const config = loadConfig();
const config = configManager.get();
const groupIndex = config.groups.findIndex(g => slugify(g.name) === id);
if (groupIndex === -1) {
@@ -292,7 +248,7 @@ router.delete('/groups/:id', (req, res) => {
const groupName = config.groups[groupIndex].name;
config.groups.splice(groupIndex, 1);
saveConfig(config);
configManager.save(config);
addLog('info', `Group deleted: ${groupName}`, { id });
@@ -412,7 +368,7 @@ router.get('/logs', (req, res) => {
*/
router.get('/config', (req, res) => {
try {
const config = loadConfig();
const config = configManager.get();
res.json(config);
} catch (error) {
console.error('Erreur GET /admin/config:', error);
@@ -429,13 +385,13 @@ router.put('/config/audio', (req, res) => {
try {
const { sampleRate, defaultBitrate, jitterBufferMs } = req.body;
const config = loadConfig();
const config = configManager.get();
if (sampleRate !== undefined) config.audio.sampleRate = sampleRate;
if (defaultBitrate !== undefined) config.audio.defaultBitrate = defaultBitrate;
if (jitterBufferMs !== undefined) config.audio.jitterBufferMs = jitterBufferMs;
saveConfig(config);
configManager.save(config);
addLog('info', 'Audio config updated', { sampleRate, defaultBitrate, jitterBufferMs });
+128 -34
View File
@@ -399,10 +399,30 @@ export class AudioBridge extends EventEmitter {
// Convertir PCM Buffer → Float32Array (pour GroupAudioRouter)
const float32Data = this._bufferToFloat32(pcmData);
// Pour l'instant, on assume que l'audio vient du canal 0
// TODO: Supporter multi-canaux depuis la carte son
const channelId = this.options.inputDeviceChannel || 0;
this.inputChannelBuffers.set(channelId, float32Data);
// Séparer les canaux si audio multi-canaux (entrelacé)
const numChannels = this.options.channels || 1;
if (numChannels === 1) {
// Mono : un seul canal
const channelId = this.options.inputDeviceChannel || 0;
this.inputChannelBuffers.set(channelId, float32Data);
} else {
// Multi-canaux : dé-entrelacer les samples
// Format entrelacé : [L0, R0, L1, R1, ...] → [L0, L1, ...] et [R0, R1, ...]
const samplesPerChannel = float32Data.length / numChannels;
for (let ch = 0; ch < numChannels; ch++) {
const channelBuffer = new Float32Array(samplesPerChannel);
for (let i = 0; i < samplesPerChannel; i++) {
channelBuffer[i] = float32Data[i * numChannels + ch];
}
// Mapper canal hardware → canal logique (peut être configuré)
const logicalChannelId = this.options.channelMapping?.[ch] ?? ch;
this.inputChannelBuffers.set(logicalChannelId, channelBuffer);
}
}
// ÉTAPE 1 : Inputs physiques → Groupes (via GroupAudioRouter)
const groupBuffers = this.groupAudioRouter.processInputsToGroups(
@@ -410,39 +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) => {
// Convertir Float32Array → PCM Buffer
const pcmBuffer = this._float32ToBuffer(groupBuffer);
// Les groupes sont MONO (Float32Array de N samples)
// Mais la config globale peut être STÉRÉO (channels=2)
// → Adapter selon la configuration
// Encoder en Opus
const opusData = this.opusEncoder.encode(pcmBuffer);
let pcmBuffer;
const configChannels = this.options.channels || 1;
if (opusData) {
this.stats.bytesEncoded += opusData.length;
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);
// 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`);
}
} 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)
@@ -453,16 +499,53 @@ export class AudioBridge extends EventEmitter {
}
// ÉTAPE 4 : Envoyer chaque output à la carte son
outputBuffers.forEach((outputBuffer, channelId) => {
const pcmBuffer = this._float32ToBuffer(outputBuffer);
const numOutputChannels = this.options.channels || 1;
// Envoyer à la carte son
if (numOutputChannels === 1) {
// Mono : un seul output
if (outputBuffers.size > 0) {
const [firstChannelId, outputBuffer] = outputBuffers.entries().next().value;
const pcmBuffer = this._float32ToBuffer(outputBuffer);
this.audioBackend.queueAudio(pcmBuffer);
if (this.stats.framesCapture % 100 === 0) {
console.log(`[AudioBridge] → Output mono (canal ${firstChannelId}): ${pcmBuffer.length} bytes`);
}
}
} else {
// Multi-canaux : entrelacer les samples
// Récupérer les buffers dans l'ordre des canaux hardware
const channelBuffers = [];
const samplesPerChannel = this.options.frameSize;
for (let ch = 0; ch < numOutputChannels; ch++) {
const logicalChannelId = this.options.channelMapping?.[ch] ?? ch;
const buffer = outputBuffers.get(logicalChannelId);
if (buffer && buffer.length === samplesPerChannel) {
channelBuffers.push(buffer);
} else {
// Canal absent ou taille incorrecte : silence
channelBuffers.push(new Float32Array(samplesPerChannel));
}
}
// Entrelacer : [L0, L1, ...] et [R0, R1, ...] → [L0, R0, L1, R1, ...]
const interleavedBuffer = new Float32Array(samplesPerChannel * numOutputChannels);
for (let i = 0; i < samplesPerChannel; i++) {
for (let ch = 0; ch < numOutputChannels; ch++) {
interleavedBuffer[i * numOutputChannels + ch] = channelBuffers[ch][i];
}
}
const pcmBuffer = this._float32ToBuffer(interleavedBuffer);
this.audioBackend.queueAudio(pcmBuffer);
if (this.stats.framesCapture % 100 === 0) {
console.log(`[AudioBridge] → Output ${channelId}: ${pcmBuffer.length} bytes`);
console.log(`[AudioBridge] → Output multi-canaux (${numOutputChannels}ch): ${pcmBuffer.length} bytes`);
}
});
}
this.stats.framesCapture++;
this.stats.framesPlayback++;
@@ -491,12 +574,23 @@ export class AudioBridge extends EventEmitter {
const accumulator = this.liveKitFrameAccumulators.get(groupName);
// Vérifier que le buffer ne débordera pas
const availableSpace = 960 - accumulator.offset;
const samplesToCopy = Math.min(samplesReceived, availableSpace);
// Copier les samples dans l'accumulateur
accumulator.buffer.set(float32Data, accumulator.offset);
accumulator.offset += samplesReceived;
if (samplesToCopy > 0) {
accumulator.buffer.set(float32Data.subarray(0, samplesToCopy), accumulator.offset);
accumulator.offset += samplesToCopy;
}
// Si on a accumulé assez de samples (960), router vers les outputs
if (accumulator.offset >= 960) {
// Vérifier que le backend est toujours actif (évite crash pendant shutdown)
if (!this.audioBackend) {
return;
}
// Stocker le buffer complet pour le routing
this.groupBuffersFromLiveKit.set(groupName, accumulator.buffer);
+20 -2
View File
@@ -216,6 +216,21 @@ export class GroupAudioRouter extends EventEmitter {
this.groupBuffers.set(groupId, new Float32Array(this.config.frameSize));
});
// Compter le nombre de sources par groupe pour normalisation
const groupSourceCount = new Map();
inputChannelsData.forEach((_, channelId) => {
const key = `in_${channelId}`;
const routes = this.inputToGroupRoutes.get(key);
if (routes) {
routes.forEach(route => {
groupSourceCount.set(
route.destination,
(groupSourceCount.get(route.destination) || 0) + 1
);
});
}
});
// Pour chaque canal d'entrée
inputChannelsData.forEach((pcmData, channelId) => {
const key = `in_${channelId}`;
@@ -234,9 +249,12 @@ export class GroupAudioRouter extends EventEmitter {
return;
}
// Mixage avec gain
// Mixage avec gain + atténuation par nombre de sources
const sourceCount = groupSourceCount.get(route.destination) || 1;
const mixGain = route.linearGain / sourceCount;
for (let i = 0; i < pcmData.length && i < groupBuffer.length; i++) {
groupBuffer[i] += pcmData[i] * route.linearGain;
groupBuffer[i] += pcmData[i] * mixGain;
}
});
});
+18 -4
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
@@ -349,7 +356,14 @@ export class LiveKitClient extends EventEmitter {
if (this.room) {
// Unpublish track
if (this.localAudioTrack) {
await this.room.localParticipant.unpublishTrack(this.localAudioTrack.sid);
try {
await this.room.localParticipant.unpublishTrack(this.localAudioTrack.sid);
} catch (error) {
// Ignorer l'erreur si le track n'existe plus (shutdown rapide)
if (!error.message?.includes('track not found')) {
console.warn('⚠️ Erreur unpublish track:', error.message);
}
}
this.localAudioTrack = null;
}
+67 -39
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 = [];
@@ -184,35 +189,45 @@ export class CoreAudioBackend extends EventEmitter {
}
try {
// Commande sox pour capturer audio
// rec : enregistrer depuis input par défaut
// -t raw : format raw PCM
// -b 16 : 16-bit
// -e signed-integer : signed PCM
// -c 1 : mono (ou nombre de canaux)
// -r 48000 : sample rate
// - : sortie vers stdout
const args = [
'-t', 'coreaudio', // Driver CoreAudio
'default', // Device par défaut (ou spécifier nom)
'-t', 'raw',
'-b', '16',
'-e', 'signed-integer',
`-c`, String(this.options.channels),
`-r`, String(this.options.sampleRate),
'-' // Stdout
];
// Commande sox pour capturer audio sur macOS
// Sur macOS, sox utilise CoreAudio par défaut via 'rec' (alias de sox -d)
// Format: sox -d [options] output
// -d = default input device OU -t coreaudio "Device Name"
// Si device spécifié
const args = [];
// Spécifier le device d'entrée (CoreAudio capture en 32-bit natif)
if (this.options.inputDeviceName) {
args[2] = this.options.inputDeviceName; // Index 2 = device name
args.push('-t', 'coreaudio', this.options.inputDeviceName);
} else {
args.push('-d');
}
// Format de sortie (stdout) - convertir 32→16 bit
args.push(
'-t', 'raw', // Format sortie raw PCM
'-b', '16', // Convertir vers 16-bit
'-e', 'signed-integer',
'-c', String(this.options.channels),
'-r', String(this.options.sampleRate),
'-' // Stdout
);
console.log(`🎤 Démarrage capture sox: ${args.join(' ')}`);
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) => {
@@ -248,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');
}
}
@@ -265,27 +281,31 @@ export class CoreAudioBackend extends EventEmitter {
}
try {
// Commande sox pour lecture audio
// play : lire vers output par défaut
// -t raw : format raw PCM depuis stdin
// --buffer : taille du buffer interne sox (en bytes)
// Commande sox pour lecture audio sur macOS
// Format: sox [options] input output
// Input = stdin (-)
// 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',
`-c`, String(this.options.channels),
`-r`, String(this.options.sampleRate),
'-', // Stdin
'-t', 'coreaudio',
'default' // Device par défaut
'-c', String(this.options.channels),
'-r', String(this.options.sampleRate),
'-' // Input = stdin
];
// Si device spécifié
// Spécifier le device de sortie
if (this.options.outputDeviceName) {
args[args.length - 1] = this.options.outputDeviceName;
// Utiliser le device spécifié par son nom
args.push('-t', 'coreaudio', this.options.outputDeviceName);
} else {
// Device par défaut
args.push('-d');
}
console.log(`🔊 Démarrage playback sox: ${args.join(' ')}`);
this.playbackProcess = spawn('sox', args, {
stdio: ['pipe', 'ignore', 'pipe'] // stdin=pipe, stdout=ignore, stderr=pipe
});
@@ -313,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);
}
});
@@ -438,6 +465,7 @@ export class CoreAudioBackend extends EventEmitter {
* Arrête tous les streams
*/
destroy() {
this.shuttingDown = true;
this.stopCapture();
this.stopPlayback();
this.removeAllListeners();
+19 -2
View File
@@ -30,6 +30,12 @@ export class JACKBackend extends EventEmitter {
this.jackProcess = null;
this.isCapturing = false;
this.isPlaying = false;
this.shuttingDown = false;
// Buffer d'accumulation pour la capture (JACK peut envoyer des chunks de taille variable)
this.captureAccumulator = Buffer.alloc(0);
this.targetCaptureBytes = this.options.framesPerBuffer * 2 * this.options.channels; // 2 bytes per sample
this.playbackBuffer = [];
this.maxBufferSize = 10;
@@ -213,8 +219,17 @@ export class JACKBackend extends EventEmitter {
]);
this.jackProcess.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.jackProcess.stderr.on('data', (data) => {
@@ -248,6 +263,7 @@ export class JACKBackend extends EventEmitter {
this.jackProcess.kill('SIGTERM');
this.jackProcess = null;
this.isCapturing = false;
this.captureAccumulator = Buffer.alloc(0); // Reset accumulator
console.log('✓ Capture JACK arrêtée');
}
}
@@ -359,6 +375,7 @@ export class JACKBackend extends EventEmitter {
* Arrête tous les streams
*/
destroy() {
this.shuttingDown = true;
this.stopCapture();
this.stopPlayback();
this.removeAllListeners();
+19 -1
View File
@@ -33,6 +33,12 @@ export class PipeWireBackend extends EventEmitter {
this.playbackProcess = null;
this.isCapturing = false;
this.isPlaying = false;
this.shuttingDown = false;
// Buffer d'accumulation pour la capture (pw-cat envoie des chunks de taille variable)
this.captureAccumulator = Buffer.alloc(0);
this.targetCaptureBytes = this.options.framesPerBuffer * 2 * this.options.channels; // 2 bytes per sample
this.playbackBuffer = [];
this.maxBufferSize = 10;
}
@@ -194,7 +200,17 @@ export class PipeWireBackend extends EventEmitter {
this.captureProcess = spawn('pw-cat', args);
this.captureProcess.stdout.on('data', (audioData) => {
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) => {
@@ -231,6 +247,7 @@ export class PipeWireBackend extends EventEmitter {
this.captureProcess.kill('SIGTERM');
this.captureProcess = null;
this.isCapturing = false;
this.captureAccumulator = Buffer.alloc(0); // Reset accumulator
console.log('✓ Capture PipeWire arrêtée');
}
}
@@ -360,6 +377,7 @@ export class PipeWireBackend extends EventEmitter {
* Arrête tous les streams
*/
destroy() {
this.shuttingDown = true;
this.stopCapture();
this.stopPlayback();
this.removeAllListeners();
+14 -13
View File
@@ -1,18 +1,17 @@
audio:
sampleRate: 48000
channels: 2
frameSize: 20
defaultBitrate: 96
jitterBufferMs: 40
device:
# Laissez null pour auto-détection du device par défaut
# Ou spécifiez le nom exact via l'interface /admin
inputDeviceId: alsa_input.pci-0000_00_01.0.analog-stereo
outputDeviceId: alsa_output.pci-0000_00_01.0.analog-stereo
inputDeviceId: Loopback Audio 4
outputDeviceId: Haut-parleurs MacBook Pro
sampleRate: 48000
routing:
inputToGroup:
"0":
- production
- default
"1": []
"2": []
"4":
@@ -25,35 +24,37 @@ audio:
production:
- "0"
- "1"
default:
- "0"
gains: {}
channelNames:
inputs:
"0": iphone
"0": Mac
"1": Talkback FOH
"2": Retour Console
"3": Liaison Scène
"4": Monitor Mix
"5": Spare 1
outputs:
"0": Sortie Principale
"1": Retour Scène
"0": L
"1": R
"2": Talkback Console
groups:
- name: Default
audioBitrate: 96
channels: []
- name: Production
audioBitrate: 96
channels: []
- name: Technique
audioBitrate: 96
channels: []
- name: Sonorisation
audioBitrate: 128
channels: []
server:
host: 0.0.0.0
port: 3000
livekit:
url: AUTO # AUTO = détection automatique IP réseau | ou ws://IP:7880 pour manuel
url: AUTO
logging:
level: debug # Changez à 'debug' pour voir plus de détails
level: debug
logLatency: false
logAudioStats: false