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:
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user