Compare commits

18 Commits

Author SHA1 Message Date
benoit 060453fe06 fix: application du buffer d'accumulation aux backends Linux (PipeWire/JACK)
- Ajout buffer d'accumulation dans PipeWireBackend (même pattern que CoreAudio)
- Ajout buffer d'accumulation dans JACKBackend
- pw-cat et jack_rec émettent aussi des chunks de taille variable
- Garantit frames fixes (960 samples) sur tous les backends
- Prévient audio haché/robotique sous Linux
2026-06-02 21:19:45 +02:00
benoit 2b88ea0ad5 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
2026-06-02 01:14:49 +02:00
benoit 9aff58c528 fix: déformation audio par saturation du mixage
Problème:
- Son complètement déformé (clipping massif)
- CoreAudio capture en 32-bit mais traité comme 16-bit
- Mixage additif sans normalisation

Solution:
1. Sox convertit 32→16 bit automatiquement
2. GroupAudioRouter divise gain par nombre de sources
   Exemple: 2 inputs → groupe default = gain × 0.5 chacun

Résultat: Aucun clipping détecté, audio propre
2026-06-02 00:45:41 +02:00
benoit a803250f9f fix: routing audio macOS avec support multi-canaux et LiveKit
Corrections majeures pour le support audio sous macOS :

- CoreAudioBackend : syntaxe sox correcte avec `-t coreaudio "Device Name"`
- AudioBridge : dé-entrelacement stéréo → canaux séparés (ligne 410-424)
- AudioBridge : entrelacement canaux → stéréo pour sortie (ligne 490-522)
- AudioBridge : duplication mono → stéréo pour LiveKit (ligne 438-449)
- config.yaml : ajout `channels: 2` pour capture stéréo
- config.yaml : ajout groupes "Production" et "Technique"

Résultat :
- Capture stéréo fonctionnelle depuis Loopback Audio 4
- Routing : 2 inputs → 3 groupes → LiveKit + 2 outputs
- Format audio correct pour LiveKit (mono dupliqué en stéréo)
- Pas d'erreur "Taille frame incorrecte"

Problème restant : sox playback se ferme après 0.4s (EPIPE)
2026-06-02 00:33:26 +02:00
benoit 36e1799ec5 fix: chargement des groupes dans l'onglet Audio pour matrice de routing
- Ajout fetch groupes dans loadAudioDevices()
- Fix: matrice de routing maintenant éditable (groupes chargés)
- Fix: WebSocket audio-levels connecté (nécessite VITE_WS_AUDIO_LEVELS_URL dans .env)
2026-06-01 23:51:01 +02:00
benoit f302b3f266 fix: persistance des groupes avec ConfigManager
- Conversion des routes groupes (GET/POST/PUT/DELETE) pour utiliser configManager
- Suppression de loadConfig() et saveConfig() locales (redondantes)
- Fix: les modifications de groupes sont maintenant persistées correctement
- Les groupes créés/modifiés/supprimés survivent au redémarrage du serveur
2026-06-01 23:24:38 +02:00
benoit 91d13d1be7 Merge branch 'main' into macos 2026-06-01 23:17:25 +02:00
benoit 77bc36b765 feat: amélioration UX interface admin audio
- Admin : regroupement des 3 dropdowns cartes son dans une seule section
- Admin : suppression du mode édition pour noms de canaux (directement éditables)
- Admin : unification des boutons de sauvegarde en bas de chaque section
- Admin : routing par hash URL pour persistance des onglets (#groups, #audio, etc.)
- AudioRoutingMatrix : bouton sauvegarde déplacé en bas de la matrice
- AudioRoutingMatrix : dropdowns de gain en nuance de bleu (cohérence visuelle)
2026-06-01 23:04:57 +02:00
benoit 58bc91b966 fix: UX interface admin et client
- Settings : suppression paramètres inutiles (mode PTT continu, feedback audio non implémenté)
- Settings : conservation uniquement du paramètre vibrations (fonctionnel)
- PTTButton : suppression init mode continu par défaut (redondant avec geste verrouillage)
- PWAInstallPrompt : ajout fond semi-transparent et couleurs hardcodées pour lisibilité
- Admin : fix dropdowns audio qui se réinitialisaient (useRef au lieu de useState pour édition)
2026-06-01 22:30:51 +02:00
benoit c9ec10dfd9 fix: shutdown propre sans erreurs
Problèmes corrigés pendant l'arrêt serveur :
1. RangeError offset bounds : vérification availableSpace avant .set()
2. audioBackend null : vérification avant queueAudio()
3. LiveKit track not found : try/catch sur unpublishTrack

Shutdown maintenant sans erreurs fatales.
2026-05-28 16:13:35 +02:00
benoit d908cf4ee6 fix: API /admin/devices/list compatible macOS avec CoreAudio
Avant : Utilisait sox (IDs numériques, incomplet)
Après : Utilise CoreAudioBackend.getDevices() (noms devices réels)

- Retourne device.name comme ID (compatible inputDeviceName)
- Affiche channels, sampleRate, isDefault
- Fallback sur built-in devices si erreur
- Cohérent avec résolution AudioBridge (ligne 206-216)

Interface /admin maintenant 100% compatible macOS et Linux.
2026-05-28 16:07:01 +02:00
benoit 522a6255fe fix: API /admin/devices/list retourne vrais IDs PulseAudio/PipeWire
Avant : IDs numériques (0, 1, 2...) incompatibles avec PipeWireBackend
Après : IDs réels (alsa_input.pci-..., alsa_output.pci-...)

- Extraction deviceId depuis pactl (colonne 2)
- Filtrage des monitors (.monitor)
- Descriptions lisibles (Input: pci-..., Output: pci-...)
- Compatible avec config.yaml existant
2026-05-28 16:04:57 +02:00
benoit 5784aa68e1 clean: suppression logs debug audio
L'audio fonctionne correctement maintenant :
- Client PWA → LiveKit → Serveur → Haut-parleurs ✓
- Latence acceptable
- Qualité audio bonne

Fixes appliqués :
- Support Int16Array (LiveKit Node SDK format)
- Accumulation frames 240→960 samples
- Conversion directe Int16Array vers Float32
2026-05-28 15:52:23 +02:00
benoit 05e7f69ffb fix: support Int16Array depuis LiveKit Node SDK
LiveKit renvoie Int16Array directement au lieu de Buffer.
Ajout détection Int16Array dans _bufferToFloat32 avec conversion directe.

Ajout logs RMS/dBFS pour diagnostiquer niveau audio.
2026-05-28 15:31:41 +02:00
benoit 5534a43b0a debug: ajout logs diagnostic format audio LiveKit
Affiche sampleRate, channels, buffer type et premiers bytes
pour diagnostiquer le bruit audio
2026-05-28 15:28:28 +02:00
benoit 1941e9c8a1 fix: accumulation frames LiveKit 240→960 samples avant routing
Problème : LiveKit envoie des frames de 240 samples (5ms @ 48kHz)
mais GroupRouter attend 960 samples (20ms). Cela causait :
- Bruit audio (720 samples de silence ajoutés à chaque frame)
- Latence de plusieurs secondes (buffers qui s'accumulent)

Solution :
- Ajout liveKitFrameAccumulators Map pour chaque groupe
- Accumulation de 4 frames LiveKit (240×4 = 960) avant routing
- Nettoyage logs verbeux dans AudioBridge et GroupAudioRouter

Résultat attendu :
- Audio clair (pas de silence injecté)
- Latence réduite (~20ms au lieu de plusieurs secondes)
2026-05-28 15:21:26 +02:00
benoit cfeb275d18 fix: convert Uint8Array to Buffer in _bufferToFloat32
LiveKit SDK returns audio data as Uint8Array/ArrayBuffer, not Node.js Buffer.
Added Buffer.from() conversion before readInt16LE operations.

Fixes: TypeError: buffer.readInt16LE is not a function
Permet à l'audio reçu des clients d'être converti en Float32 pour lecture.
2026-05-28 15:05:01 +02:00
benoit adadbfeeb7 fix: utilisation de TrackKind.KIND_AUDIO au lieu de string 'audio'
L'API LiveKit Node utilise des enums numériques pour track.kind :
- TrackKind.KIND_AUDIO = 1
- TrackKind.KIND_VIDEO = 2

La comparaison avec 'audio' échouait, maintenant on utilise l'enum correctement.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-05-28 14:52:38 +02:00
17 changed files with 864 additions and 1615 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.lhgefe7plc8" "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
+65 -45
View File
@@ -1,11 +1,17 @@
import { useState, useEffect } from 'react'; import { useState, useEffect, useRef } from 'react';
import './Admin.css'; import './Admin.css';
import AudioRoutingMatrix from './components/AudioRoutingMatrix'; import AudioRoutingMatrix from './components/AudioRoutingMatrix';
const API_URL = import.meta.env.VITE_API_URL || '/api'; const API_URL = import.meta.env.VITE_API_URL || '/api';
function Admin() { function Admin() {
const [activeTab, setActiveTab] = useState('groups'); // Lire l'onglet depuis l'URL hash (ex: #audio) ou utiliser 'groups' par défaut
const getInitialTab = () => {
const hash = window.location.hash.slice(1); // Enlever le #
return ['groups', 'audio', 'users', 'stats', 'logs'].includes(hash) ? hash : 'groups';
};
const [activeTab, setActiveTab] = useState(getInitialTab());
const [groups, setGroups] = useState([]); const [groups, setGroups] = useState([]);
const [users, setUsers] = useState([]); const [users, setUsers] = useState([]);
const [stats, setStats] = useState(null); const [stats, setStats] = useState(null);
@@ -19,11 +25,10 @@ function Admin() {
const [selectedInputDevice, setSelectedInputDevice] = useState(null); const [selectedInputDevice, setSelectedInputDevice] = useState(null);
const [selectedOutputDevice, setSelectedOutputDevice] = useState(null); const [selectedOutputDevice, setSelectedOutputDevice] = useState(null);
const [selectedSampleRate, setSelectedSampleRate] = useState(48000); const [selectedSampleRate, setSelectedSampleRate] = useState(48000);
const [isEditingAudio, setIsEditingAudio] = useState(false); const isEditingAudioRef = useRef(false);
// Channel names (Phase 2.5) // Channel names (Phase 2.5)
const [channelNames, setChannelNames] = useState({ inputs: {}, outputs: {} }); const [channelNames, setChannelNames] = useState({ inputs: {}, outputs: {} });
const [editingChannelNames, setEditingChannelNames] = useState(false);
// Gestion formulaire nouveau groupe // Gestion formulaire nouveau groupe
const [showGroupForm, setShowGroupForm] = useState(false); const [showGroupForm, setShowGroupForm] = useState(false);
@@ -33,6 +38,19 @@ function Admin() {
audioBitrate: 96 audioBitrate: 96
}); });
// Synchroniser l'onglet avec l'URL hash
useEffect(() => {
const handleHashChange = () => {
const hash = window.location.hash.slice(1);
if (['groups', 'audio', 'users', 'stats', 'logs'].includes(hash)) {
setActiveTab(hash);
}
};
window.addEventListener('hashchange', handleHashChange);
return () => window.removeEventListener('hashchange', handleHashChange);
}, []);
// Rafraîchissement automatique // Rafraîchissement automatique
useEffect(() => { useEffect(() => {
loadData(); loadData();
@@ -90,24 +108,27 @@ function Admin() {
}; };
const loadAudioDevices = async () => { 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/devices`),
fetch(`${API_URL}/admin/audio/device`), 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 devicesData = await devicesRes.json();
const currentData = await currentDeviceRes.json(); const currentData = await currentDeviceRes.json();
const channelNamesData = await channelNamesRes.json(); const channelNamesData = await channelNamesRes.json();
const groupsData = await groupsRes.json();
setAudioDevices(devicesData.devices || []); setAudioDevices(devicesData.devices || []);
setGroups(groupsData.groups || []);
const device = currentData.device || { inputChannels: 8, outputChannels: 8 }; const device = currentData.device || { inputChannels: 8, outputChannels: 8 };
setCurrentDevice(device); setCurrentDevice(device);
setChannelNames(channelNamesData.channelNames || { inputs: {}, outputs: {} }); setChannelNames(channelNamesData.channelNames || { inputs: {}, outputs: {} });
// Ne réinitialiser les sélections que si l'utilisateur n'est pas en train d'éditer // Ne réinitialiser les sélections que lors du chargement initial (pas en train d'éditer)
if (!isEditingAudio) { if (!isEditingAudioRef.current) {
setSelectedInputDevice(device.inputDeviceId ?? null); setSelectedInputDevice(device.inputDeviceId ?? null);
setSelectedOutputDevice(device.outputDeviceId ?? null); setSelectedOutputDevice(device.outputDeviceId ?? null);
setSelectedSampleRate(device.sampleRate || 48000); setSelectedSampleRate(device.sampleRate || 48000);
@@ -216,7 +237,6 @@ function Admin() {
if (res.ok) { if (res.ok) {
alert('Noms de canaux sauvegardés avec succès!'); alert('Noms de canaux sauvegardés avec succès!');
setEditingChannelNames(false);
await loadAudioDevices(); await loadAudioDevices();
} else { } else {
const error = await res.json(); const error = await res.json();
@@ -251,7 +271,7 @@ function Admin() {
}); });
if (res.ok) { if (res.ok) {
setIsEditingAudio(false); // Désactiver le mode édition isEditingAudioRef.current = false; // Désactiver le mode édition
alert('Configuration audio sauvegardée avec succès!'); alert('Configuration audio sauvegardée avec succès!');
await loadAudioDevices(); await loadAudioDevices();
} else { } else {
@@ -311,31 +331,31 @@ function Admin() {
<nav className="admin-tabs"> <nav className="admin-tabs">
<button <button
className={activeTab === 'groups' ? 'active' : ''} className={activeTab === 'groups' ? 'active' : ''}
onClick={() => setActiveTab('groups')} onClick={() => { window.location.hash = 'groups'; setActiveTab('groups'); }}
> >
Groupes Groupes
</button> </button>
<button <button
className={activeTab === 'audio' ? 'active' : ''} className={activeTab === 'audio' ? 'active' : ''}
onClick={() => setActiveTab('audio')} onClick={() => { window.location.hash = 'audio'; setActiveTab('audio'); }}
> >
Audio Audio
</button> </button>
<button <button
className={activeTab === 'users' ? 'active' : ''} className={activeTab === 'users' ? 'active' : ''}
onClick={() => setActiveTab('users')} onClick={() => { window.location.hash = 'users'; setActiveTab('users'); }}
> >
Utilisateurs ({users.length}) Utilisateurs ({users.length})
</button> </button>
<button <button
className={activeTab === 'stats' ? 'active' : ''} className={activeTab === 'stats' ? 'active' : ''}
onClick={() => setActiveTab('stats')} onClick={() => { window.location.hash = 'stats'; setActiveTab('stats'); }}
> >
Statistiques Statistiques
</button> </button>
<button <button
className={activeTab === 'logs' ? 'active' : ''} className={activeTab === 'logs' ? 'active' : ''}
onClick={() => setActiveTab('logs')} onClick={() => { window.location.hash = 'logs'; setActiveTab('logs'); }}
> >
Logs Logs
</button> </button>
@@ -438,11 +458,17 @@ function Admin() {
<div className="audio-config-container"> <div className="audio-config-container">
<div className="audio-section"> <div className="audio-section">
<h3>Carte son d'entrée (Input)</h3> <h3>Configuration des cartes son</h3>
<div style={{display: 'grid', gap: 'var(--spacing-lg)', marginTop: 'var(--spacing-md)'}}>
<div>
<label style={{display: 'block', marginBottom: 'var(--spacing-xs)', fontSize: '0.9rem', fontWeight: 600, color: 'var(--color-text-secondary)'}}>
Carte son d'entrée (Input)
</label>
<select <select
value={selectedInputDevice ?? ''} value={selectedInputDevice ?? ''}
onChange={(e) => { onChange={(e) => {
setIsEditingAudio(true); isEditingAudioRef.current = true;
setSelectedInputDevice(e.target.value === '' ? null : e.target.value); setSelectedInputDevice(e.target.value === '' ? null : e.target.value);
}} }}
className="device-select" className="device-select"
@@ -463,12 +489,14 @@ function Admin() {
)} )}
</div> </div>
<div className="audio-section"> <div>
<h3>Carte son de sortie (Output)</h3> <label style={{display: 'block', marginBottom: 'var(--spacing-xs)', fontSize: '0.9rem', fontWeight: 600, color: 'var(--color-text-secondary)'}}>
Carte son de sortie (Output)
</label>
<select <select
value={selectedOutputDevice ?? ''} value={selectedOutputDevice ?? ''}
onChange={(e) => { onChange={(e) => {
setIsEditingAudio(true); isEditingAudioRef.current = true;
setSelectedOutputDevice(e.target.value === '' ? null : e.target.value); setSelectedOutputDevice(e.target.value === '' ? null : e.target.value);
}} }}
className="device-select" className="device-select"
@@ -489,12 +517,14 @@ function Admin() {
)} )}
</div> </div>
<div className="audio-section"> <div>
<h3>Sample Rate</h3> <label style={{display: 'block', marginBottom: 'var(--spacing-xs)', fontSize: '0.9rem', fontWeight: 600, color: 'var(--color-text-secondary)'}}>
Sample Rate
</label>
<select <select
value={selectedSampleRate} value={selectedSampleRate}
onChange={(e) => { onChange={(e) => {
setIsEditingAudio(true); isEditingAudioRef.current = true;
setSelectedSampleRate(parseInt(e.target.value)); setSelectedSampleRate(parseInt(e.target.value));
}} }}
className="device-select" className="device-select"
@@ -504,33 +534,19 @@ function Admin() {
<option value={96000}>96000 Hz (High quality)</option> <option value={96000}>96000 Hz (High quality)</option>
</select> </select>
</div> </div>
</div>
<div className="audio-actions"> <div className="audio-actions">
<button onClick={handleSaveAudioDevice} className="btn-primary"> <button onClick={handleSaveAudioDevice} className="btn-primary">
Sauvegarder la configuration Sauvegarder la configuration audio
</button> </button>
</div> </div>
</div>
<div className="audio-section"> <div className="audio-section">
<div style={{display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 'var(--spacing-md)'}}>
<h3>Nommage des canaux physiques</h3> <h3>Nommage des canaux physiques</h3>
{!editingChannelNames ? (
<button onClick={() => setEditingChannelNames(true)} className="btn-secondary">
Modifier les noms
</button>
) : (
<div style={{display: 'flex', gap: 'var(--spacing-sm)'}}>
<button onClick={handleSaveChannelNames} className="btn-primary">
Sauvegarder
</button>
<button onClick={() => { setEditingChannelNames(false); loadAudioDevices(); }} className="btn-secondary">
Annuler
</button>
</div>
)}
</div>
<div style={{display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 'var(--spacing-xl)'}}> <div style={{display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 'var(--spacing-xl)', marginTop: 'var(--spacing-md)'}}>
<div> <div>
<h4 style={{marginBottom: 'var(--spacing-md)', color: 'var(--color-text-secondary)'}}> <h4 style={{marginBottom: 'var(--spacing-md)', color: 'var(--color-text-secondary)'}}>
Entrées (Inputs) - {currentDevice.inputChannels || 0} canaux disponibles Entrées (Inputs) - {currentDevice.inputChannels || 0} canaux disponibles
@@ -544,10 +560,9 @@ function Admin() {
value={channelNames.inputs?.[i] || ''} value={channelNames.inputs?.[i] || ''}
onChange={(e) => updateChannelName('inputs', i, e.target.value)} onChange={(e) => updateChannelName('inputs', i, e.target.value)}
placeholder={`Input ${i}`} placeholder={`Input ${i}`}
disabled={!editingChannelNames}
style={{ style={{
padding: 'var(--spacing-sm)', padding: 'var(--spacing-sm)',
background: editingChannelNames ? 'var(--color-bg)' : 'var(--color-surface-hover)', background: 'var(--color-bg)',
border: '1px solid var(--color-border)', border: '1px solid var(--color-border)',
borderRadius: '6px', borderRadius: '6px',
color: 'var(--color-text)', color: 'var(--color-text)',
@@ -572,10 +587,9 @@ function Admin() {
value={channelNames.outputs?.[i] || ''} value={channelNames.outputs?.[i] || ''}
onChange={(e) => updateChannelName('outputs', i, e.target.value)} onChange={(e) => updateChannelName('outputs', i, e.target.value)}
placeholder={`Output ${i}`} placeholder={`Output ${i}`}
disabled={!editingChannelNames}
style={{ style={{
padding: 'var(--spacing-sm)', padding: 'var(--spacing-sm)',
background: editingChannelNames ? 'var(--color-bg)' : 'var(--color-surface-hover)', background: 'var(--color-bg)',
border: '1px solid var(--color-border)', border: '1px solid var(--color-border)',
borderRadius: '6px', borderRadius: '6px',
color: 'var(--color-text)', color: 'var(--color-text)',
@@ -587,6 +601,12 @@ function Admin() {
</div> </div>
</div> </div>
</div> </div>
<div className="audio-actions">
<button onClick={handleSaveChannelNames} className="btn-primary">
Sauvegarder les noms des canaux
</button>
</div>
</div> </div>
<AudioRoutingMatrix groups={groups} channelNames={channelNames} /> <AudioRoutingMatrix groups={groups} channelNames={channelNames} />
+13 -5
View File
@@ -6,6 +6,13 @@
margin-top: var(--spacing-lg); margin-top: var(--spacing-lg);
} }
.routing-actions {
margin-top: var(--spacing-xl);
display: flex;
justify-content: flex-start;
gap: var(--spacing-sm);
}
.routing-matrix-header { .routing-matrix-header {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
@@ -169,9 +176,9 @@
width: 100%; width: 100%;
padding: 4px 8px; padding: 4px 8px;
font-size: 0.75rem; font-size: 0.75rem;
background: rgba(255, 255, 255, 0.9); background: rgba(59, 130, 246, 0.2);
color: var(--color-text); color: #ffffff;
border: 1px solid rgba(255, 255, 255, 0.3); border: 1px solid rgba(255, 255, 255, 1);
border-radius: 4px; border-radius: 4px;
cursor: pointer; cursor: pointer;
font-weight: 600; font-weight: 600;
@@ -180,8 +187,9 @@
.gain-select:focus { .gain-select:focus {
outline: none; outline: none;
border-color: rgba(255, 255, 255, 0.6); background: rgba(59, 130, 246, 0.3);
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.2); border-color: rgba(255, 255, 255, 1);
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.2);
} }
@media (max-width: 1024px) { @media (max-width: 1024px) {
+6 -5
View File
@@ -194,7 +194,6 @@ function AudioRoutingMatrix({ groups, channelNames }) {
{wsConnected ? '● Live' : '○ Offline'} {wsConnected ? '● Live' : '○ Offline'}
</span> </span>
</div> </div>
<div style={{ display: 'flex', gap: '1rem', alignItems: 'center' }}>
<label style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', cursor: 'pointer' }}> <label style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', cursor: 'pointer' }}>
<input <input
type="checkbox" type="checkbox"
@@ -203,10 +202,6 @@ function AudioRoutingMatrix({ groups, channelNames }) {
/> />
<span>Afficher uniquement les canaux nommés</span> <span>Afficher uniquement les canaux nommés</span>
</label> </label>
<button onClick={saveRouting} className="btn-primary">
Sauvegarder le routing
</button>
</div>
</div> </div>
<div className="routing-section"> <div className="routing-section">
@@ -341,6 +336,12 @@ function AudioRoutingMatrix({ groups, channelNames }) {
))} ))}
</div> </div>
</div> </div>
<div className="routing-actions">
<button onClick={saveRouting} className="btn-primary">
Sauvegarder le routing audio
</button>
</div>
</div> </div>
); );
} }
-10
View File
@@ -22,16 +22,6 @@ export default function PTTButton({ isTalking, onPressStart, onPressEnd, audioLe
const currentYRef = useRef(null); const currentYRef = useRef(null);
const [dragOffset, setDragOffset] = useState(0); // Offset visuel du drag (en pixels) const [dragOffset, setDragOffset] = useState(0); // Offset visuel du drag (en pixels)
// Initialiser le mode selon les préférences au démarrage
useEffect(() => {
const currentSettings = loadSettings();
if (currentSettings.defaultPTTMode === 'continuous') {
setIsLockMode(true);
isLockModeRef.current = true;
console.log('Mode continu activé par défaut (préférences)');
}
}, []);
useEffect(() => { useEffect(() => {
const button = buttonRef.current; const button = buttonRef.current;
if (!button) return; if (!button) return;
+30 -14
View File
@@ -3,8 +3,22 @@
bottom: 0; bottom: 0;
left: 0; left: 0;
right: 0; right: 0;
top: 0;
z-index: 1001; z-index: 1001;
animation: slideUp 0.3s ease; background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(4px);
display: flex;
align-items: flex-end;
animation: fadeIn 0.3s ease;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
} }
@keyframes slideUp { @keyframes slideUp {
@@ -17,12 +31,14 @@
} }
.pwa-prompt { .pwa-prompt {
background: var(--bg-secondary); width: 100%;
background: #1a1a1a;
border-top-left-radius: 16px; border-top-left-radius: 16px;
border-top-right-radius: 16px; border-top-right-radius: 16px;
box-shadow: 0 -4px 20px rgba(0, 0, 0, 0.3); box-shadow: 0 -4px 20px rgba(0, 0, 0, 0.3);
max-height: 80vh; max-height: 80vh;
overflow-y: auto; overflow-y: auto;
animation: slideUp 0.3s ease;
} }
.pwa-prompt-header { .pwa-prompt-header {
@@ -36,13 +52,13 @@
.pwa-prompt-header h3 { .pwa-prompt-header h3 {
margin: 0; margin: 0;
font-size: 1.2rem; font-size: 1.2rem;
color: var(--text-primary); color: #ffffff;
} }
.pwa-prompt-close { .pwa-prompt-close {
background: none; background: none;
border: none; border: none;
color: var(--text-secondary); color: #9ca3af;
cursor: pointer; cursor: pointer;
padding: 0.5rem; padding: 0.5rem;
border-radius: 8px; border-radius: 8px;
@@ -50,8 +66,8 @@
} }
.pwa-prompt-close:hover { .pwa-prompt-close:hover {
background: var(--bg-hover); background: rgba(255, 255, 255, 0.1);
color: var(--text-primary); color: #ffffff;
} }
.pwa-prompt-content { .pwa-prompt-content {
@@ -59,8 +75,8 @@
} }
.pwa-prompt-content > p { .pwa-prompt-content > p {
margin: 0 0 var(--spacing-lg) 0; margin: 0 0 1.5rem 0;
color: var(--text-secondary); color: #d1d5db;
line-height: 1.6; line-height: 1.6;
} }
@@ -73,9 +89,9 @@
.pwa-prompt-step { .pwa-prompt-step {
display: flex; display: flex;
align-items: center; align-items: center;
gap: var(--spacing-md); gap: 1rem;
padding: var(--spacing-md); padding: 1rem;
background: var(--bg-hover); background: rgba(255, 255, 255, 0.05);
border-radius: 8px; border-radius: 8px;
} }
@@ -85,7 +101,7 @@
justify-content: center; justify-content: center;
width: 32px; width: 32px;
height: 32px; height: 32px;
background: var(--primary-color); background: #3b82f6;
color: white; color: white;
border-radius: 50%; border-radius: 50%;
font-weight: 600; font-weight: 600;
@@ -95,13 +111,13 @@
.pwa-prompt-step p { .pwa-prompt-step p {
flex: 1; flex: 1;
margin: 0; margin: 0;
color: var(--text-primary); color: #ffffff;
font-size: 0.95rem; font-size: 0.95rem;
} }
.pwa-prompt-step svg { .pwa-prompt-step svg {
flex-shrink: 0; flex-shrink: 0;
color: var(--primary-color); color: #3b82f6;
} }
.pwa-prompt-footer { .pwa-prompt-footer {
+2 -49
View File
@@ -4,9 +4,7 @@ import './Settings.css';
const STORAGE_KEY = 'ptt-live-settings'; const STORAGE_KEY = 'ptt-live-settings';
const defaultSettings = { const defaultSettings = {
defaultPTTMode: 'normal', // 'normal' ou 'continuous' vibrationEnabled: true
vibrationEnabled: true,
audioFeedbackEnabled: true
}; };
/** /**
@@ -68,39 +66,6 @@ export default function Settings({ isOpen, onClose }) {
</div> </div>
<div className="settings-content"> <div className="settings-content">
<div className="setting-section">
<h3>Mode PTT</h3>
<p className="setting-description">
Choisissez le mode de fonctionnement par défaut du bouton PTT
</p>
<label className="radio-option">
<input
type="radio"
name="pttMode"
checked={settings.defaultPTTMode === 'normal'}
onChange={() => handleChange('defaultPTTMode', 'normal')}
/>
<div>
<strong>Mode normal (Push-To-Talk)</strong>
<p>Maintenir le bouton pour parler, relâcher pour arrêter</p>
</div>
</label>
<label className="radio-option">
<input
type="radio"
name="pttMode"
checked={settings.defaultPTTMode === 'continuous'}
onChange={() => handleChange('defaultPTTMode', 'continuous')}
/>
<div>
<strong>Mode continu (verrouillé)</strong>
<p>Un appui active le micro en continu, un second appui le désactive</p>
</div>
</label>
</div>
<div className="setting-section"> <div className="setting-section">
<h3>Feedback</h3> <h3>Feedback</h3>
@@ -112,19 +77,7 @@ export default function Settings({ isOpen, onClose }) {
/> />
<div> <div>
<strong>Vibrations</strong> <strong>Vibrations</strong>
<p>Activer le retour haptique (si disponible)</p> <p>Activer le retour haptique lors du verrouillage PTT</p>
</div>
</label>
<label className="checkbox-option">
<input
type="checkbox"
checked={settings.audioFeedbackEnabled}
onChange={(e) => handleChange('audioFeedbackEnabled', e.target.checked)}
/>
<div>
<strong>Feedback audio</strong>
<p>Sons de confirmation pour les actions</p>
</div> </div>
</label> </label>
</div> </div>
+68 -85
View File
@@ -4,9 +4,6 @@
*/ */
import { Router } from 'express'; import { Router } from 'express';
import { readFileSync, writeFileSync } from 'fs';
import { join } from 'path';
import YAML from 'yaml';
import { fileURLToPath } from 'url'; import { fileURLToPath } from 'url';
import { dirname } from 'path'; import { dirname } from 'path';
import { CoreAudioBackend } from '../bridge/backends/CoreAudioBackend.js'; import { CoreAudioBackend } from '../bridge/backends/CoreAudioBackend.js';
@@ -41,47 +38,6 @@ const stats = {
logs: [] 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 * Ajoute un log au système
*/ */
@@ -166,7 +122,7 @@ export function addAudioStats(data) {
*/ */
router.get('/groups', (req, res) => { router.get('/groups', (req, res) => {
try { try {
const config = loadConfig(); const config = configManager.get();
res.json({ res.json({
groups: config.groups 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 // Générer l'ID à partir du nom
const id = slugify(name); const id = slugify(name);
@@ -211,7 +167,7 @@ router.post('/groups', (req, res) => {
}; };
config.groups.push(newGroup); config.groups.push(newGroup);
saveConfig(config); configManager.save(config);
addLog('info', `Group created: ${name}`, { id }); addLog('info', `Group created: ${name}`, { id });
@@ -237,7 +193,7 @@ router.put('/groups/:id', (req, res) => {
const { id } = req.params; const { id } = req.params;
const { name, audioBitrate } = req.body; const { name, audioBitrate } = req.body;
const config = loadConfig(); const config = configManager.get();
// Chercher le groupe par son nom (qui correspond à l'ID slugifié) // Chercher le groupe par son nom (qui correspond à l'ID slugifié)
const groupIndex = config.groups.findIndex(g => slugify(g.name) === id); 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 (name !== undefined) config.groups[groupIndex].name = name;
if (audioBitrate !== undefined) config.groups[groupIndex].audioBitrate = audioBitrate; if (audioBitrate !== undefined) config.groups[groupIndex].audioBitrate = audioBitrate;
saveConfig(config); configManager.save(config);
addLog('info', `Group updated: ${config.groups[groupIndex].name}`, { id }); addLog('info', `Group updated: ${config.groups[groupIndex].name}`, { id });
// Recharger pour obtenir les IDs générés // Récupérer la config à jour avec les IDs générés
const updatedConfig = loadConfig(); const updatedConfig = configManager.get();
const updatedGroupIndex = updatedConfig.groups.findIndex(g => slugify(g.name) === id || slugify(g.name) === slugify(name)); const updatedGroupIndex = updatedConfig.groups.findIndex(g => slugify(g.name) === id || slugify(g.name) === slugify(name));
const updatedGroup = updatedGroupIndex !== -1 ? updatedConfig.groups[updatedGroupIndex] : null; const updatedGroup = updatedGroupIndex !== -1 ? updatedConfig.groups[updatedGroupIndex] : null;
@@ -281,7 +237,7 @@ router.delete('/groups/:id', (req, res) => {
try { try {
const { id } = req.params; const { id } = req.params;
const config = loadConfig(); const config = configManager.get();
const groupIndex = config.groups.findIndex(g => slugify(g.name) === id); const groupIndex = config.groups.findIndex(g => slugify(g.name) === id);
if (groupIndex === -1) { if (groupIndex === -1) {
@@ -292,7 +248,7 @@ router.delete('/groups/:id', (req, res) => {
const groupName = config.groups[groupIndex].name; const groupName = config.groups[groupIndex].name;
config.groups.splice(groupIndex, 1); config.groups.splice(groupIndex, 1);
saveConfig(config); configManager.save(config);
addLog('info', `Group deleted: ${groupName}`, { id }); addLog('info', `Group deleted: ${groupName}`, { id });
@@ -412,7 +368,7 @@ router.get('/logs', (req, res) => {
*/ */
router.get('/config', (req, res) => { router.get('/config', (req, res) => {
try { try {
const config = loadConfig(); const config = configManager.get();
res.json(config); res.json(config);
} catch (error) { } catch (error) {
console.error('Erreur GET /admin/config:', error); console.error('Erreur GET /admin/config:', error);
@@ -429,13 +385,13 @@ router.put('/config/audio', (req, res) => {
try { try {
const { sampleRate, defaultBitrate, jitterBufferMs } = req.body; const { sampleRate, defaultBitrate, jitterBufferMs } = req.body;
const config = loadConfig(); const config = configManager.get();
if (sampleRate !== undefined) config.audio.sampleRate = sampleRate; if (sampleRate !== undefined) config.audio.sampleRate = sampleRate;
if (defaultBitrate !== undefined) config.audio.defaultBitrate = defaultBitrate; if (defaultBitrate !== undefined) config.audio.defaultBitrate = defaultBitrate;
if (jitterBufferMs !== undefined) config.audio.jitterBufferMs = jitterBufferMs; if (jitterBufferMs !== undefined) config.audio.jitterBufferMs = jitterBufferMs;
saveConfig(config); configManager.save(config);
addLog('info', 'Audio config updated', { sampleRate, defaultBitrate, jitterBufferMs }); addLog('info', 'Audio config updated', { sampleRate, defaultBitrate, jitterBufferMs });
@@ -679,39 +635,46 @@ router.get('/devices/list', async (req, res) => {
// Détection selon la plateforme // Détection selon la plateforme
if (process.platform === 'darwin') { if (process.platform === 'darwin') {
// macOS : utiliser CoreAudio via sox // macOS : utiliser CoreAudioBackend.getDevices()
const { exec } = await import('child_process');
const { promisify } = await import('util');
const execPromise = promisify(exec);
try { try {
// Utiliser sox pour lister les devices audio const coreAudioDevices = CoreAudioBackend.getDevices();
const { stdout } = await execPromise('sox -V6 2>&1');
// Parser la sortie sox pour extraire les devices // Séparer inputs et outputs
// Format typique : "Input Device [0]: MacBook Pro Microphone" coreAudioDevices.forEach(device => {
const inputMatches = stdout.matchAll(/Input Device \[(\d+)\]: (.+)/g); if (device.maxInputChannels > 0) {
const outputMatches = stdout.matchAll(/Output Device \[(\d+)\]: (.+)/g);
for (const match of inputMatches) {
devices.inputs.push({ devices.inputs.push({
id: parseInt(match[1], 10), id: device.name, // Utiliser le nom comme ID (compatible avec inputDeviceName)
name: match[2].trim() name: device.name,
channels: device.maxInputChannels,
sampleRate: device.defaultSampleRate,
isDefault: device.isDefault?.input || false
}); });
} }
for (const match of outputMatches) { if (device.maxOutputChannels > 0) {
devices.outputs.push({ devices.outputs.push({
id: parseInt(match[1], 10), id: device.name, // Utiliser le nom comme ID (compatible avec outputDeviceName)
name: match[2].trim() name: device.name,
channels: device.maxOutputChannels,
sampleRate: device.defaultSampleRate,
isDefault: device.isDefault?.output || false
}); });
} }
} catch (soxError) { });
console.warn('⚠️ sox non disponible, devices limités:', soxError.message);
// Fallback si aucun device trouvé
if (devices.inputs.length === 0) {
devices.inputs.push({ id: 'builtin-mic', name: 'Built-in Microphone', isDefault: true });
}
if (devices.outputs.length === 0) {
devices.outputs.push({ id: 'builtin-output', name: 'Built-in Output', isDefault: true });
}
} catch (error) {
console.warn('⚠️ Détection CoreAudio échouée:', error.message);
// Fallback : devices par défaut macOS // Fallback : devices par défaut macOS
devices.inputs.push({ id: 0, name: 'Default Input (Built-in Microphone)', isDefault: true }); devices.inputs.push({ id: 'builtin-mic', name: 'Built-in Microphone', isDefault: true });
devices.outputs.push({ id: 0, name: 'Default Output (Built-in Speakers)', isDefault: true }); devices.outputs.push({ id: 'builtin-output', name: 'Built-in Output', isDefault: true });
} }
} else if (process.platform === 'linux') { } else if (process.platform === 'linux') {
@@ -736,21 +699,41 @@ router.get('/devices/list', async (req, res) => {
} }
}); });
} else { } else {
// Fallback : PipeWire via pactl // Fallback : PipeWire/PulseAudio via pactl
const { stdout: paDevices } = await execPromise('pactl list short sources 2>/dev/null || echo ""'); const { stdout: paDevices } = await execPromise('pactl list short sources 2>/dev/null || echo ""');
const { stdout: paSinks } = await execPromise('pactl list short sinks 2>/dev/null || echo ""'); const { stdout: paSinks } = await execPromise('pactl list short sinks 2>/dev/null || echo ""');
// Helper pour obtenir une description lisible
const getDeviceDescription = (deviceId) => {
// Extraire une description plus lisible du nom technique
if (deviceId.includes('alsa_input')) return deviceId.replace('alsa_input.', 'Input: ');
if (deviceId.includes('alsa_output')) return deviceId.replace('alsa_output.', 'Output: ');
return deviceId;
};
if (paDevices.trim()) { if (paDevices.trim()) {
paDevices.split('\n').filter(Boolean).forEach((line, idx) => { paDevices.split('\n').filter(Boolean).forEach((line) => {
const name = line.split('\t')[1] || `Device ${idx}`; const parts = line.split('\t');
devices.inputs.push({ id: idx, name }); const deviceId = parts[1]; // Nom du device (ex: alsa_input.pci-...)
if (deviceId && !deviceId.includes('.monitor')) { // Ignorer les monitors
devices.inputs.push({
id: deviceId,
name: getDeviceDescription(deviceId)
});
}
}); });
} }
if (paSinks.trim()) { if (paSinks.trim()) {
paSinks.split('\n').filter(Boolean).forEach((line, idx) => { paSinks.split('\n').filter(Boolean).forEach((line) => {
const name = line.split('\t')[1] || `Device ${idx}`; const parts = line.split('\t');
devices.outputs.push({ id: idx, name }); const deviceId = parts[1]; // Nom du device (ex: alsa_output.pci-...)
if (deviceId) {
devices.outputs.push({
id: deviceId,
name: getDeviceDescription(deviceId)
});
}
}); });
} }
} }
+163 -37
View File
@@ -65,6 +65,9 @@ export class AudioBridge extends EventEmitter {
this.inputChannelBuffers = new Map(); // Map<channelId, Float32Array> this.inputChannelBuffers = new Map(); // Map<channelId, Float32Array>
this.groupBuffersFromLiveKit = new Map(); // Map<groupName, Float32Array> this.groupBuffersFromLiveKit = new Map(); // Map<groupName, Float32Array>
// Frame accumulators pour LiveKit (240 samples → 960 samples)
this.liveKitFrameAccumulators = new Map(); // Map<groupName, { buffer: Float32Array, offset: number }>
// Pool de buffers pré-alloués pour éviter allocations répétées // Pool de buffers pré-alloués pour éviter allocations répétées
this.bufferPool = { this.bufferPool = {
float32: [], // Pool de Float32Array réutilisables float32: [], // Pool de Float32Array réutilisables
@@ -369,12 +372,8 @@ export class AudioBridge extends EventEmitter {
// Réception audio depuis les clients LiveKit de ce groupe // Réception audio depuis les clients LiveKit de ce groupe
client.on('audioData', ({ participantName, pcmData, sampleRate, channels }) => { client.on('audioData', ({ participantName, pcmData, sampleRate, channels }) => {
console.log(`[AudioBridge FLUX 2] Audio reçu de ${participantName} (groupe "${groupName}"): ${pcmData.length} bytes`);
// Router vers le bon groupe // Router vers le bon groupe
this.emit('groupAudioIn', { groupName: groupId, pcmBuffer: pcmData }); this.emit('groupAudioIn', { groupName: groupId, pcmBuffer: pcmData });
console.log(`[AudioBridge FLUX 2] Événement groupAudioIn émis pour groupe "${groupId}"`);
}); });
// Connexion // Connexion
@@ -400,10 +399,30 @@ export class AudioBridge extends EventEmitter {
// Convertir PCM Buffer → Float32Array (pour GroupAudioRouter) // Convertir PCM Buffer → Float32Array (pour GroupAudioRouter)
const float32Data = this._bufferToFloat32(pcmData); const float32Data = this._bufferToFloat32(pcmData);
// Pour l'instant, on assume que l'audio vient du canal 0 // Séparer les canaux si audio multi-canaux (entrelacé)
// TODO: Supporter multi-canaux depuis la carte son const numChannels = this.options.channels || 1;
if (numChannels === 1) {
// Mono : un seul canal
const channelId = this.options.inputDeviceChannel || 0; const channelId = this.options.inputDeviceChannel || 0;
this.inputChannelBuffers.set(channelId, float32Data); 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) // ÉTAPE 1 : Inputs physiques → Groupes (via GroupAudioRouter)
const groupBuffers = this.groupAudioRouter.processInputsToGroups( const groupBuffers = this.groupAudioRouter.processInputsToGroups(
@@ -411,29 +430,56 @@ 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) => {
// Convertir Float32Array → PCM Buffer // Les groupes sont MONO (Float32Array de N samples)
const pcmBuffer = this._float32ToBuffer(groupBuffer); // Mais la config globale peut être STÉRÉO (channels=2)
// → Adapter selon la configuration
// Encoder en Opus let pcmBuffer;
const opusData = this.opusEncoder.encode(pcmBuffer); const configChannels = this.options.channels || 1;
if (opusData) { if (configChannels === 1) {
this.stats.bytesEncoded += opusData.length; // 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);
// 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é)
}
pcmBuffer = this._float32ToBuffer(stereoBuffer);
} else {
console.error(`❌ Nombre de canaux non supporté: ${configChannels}`);
return;
}
// Récupérer le client LiveKit pour ce groupe // Récupérer le client LiveKit pour ce groupe
const client = this.liveKitClients.get(groupName); const client = this.liveKitClients.get(groupName);
// Envoi vers LiveKit via sendAudioData (prend du PCM, pas de l'Opus) // Envoi vers LiveKit via sendAudioData (prend du PCM 16-bit)
// Note: LiveKit gère lui-même l'encodage Opus en interne // Note: LiveKit gère lui-même l'encodage Opus en interne
if (client && client.isConnected) { if (client && client.isConnected) {
client.sendAudioData(pcmBuffer); client.sendAudioData(pcmBuffer);
if (this.stats.framesCapture % 100 === 0) { if (this.stats.framesCapture % 100 === 0) {
console.log(`[AudioBridge] → LiveKit groupe "${groupName}": ${pcmBuffer.length} bytes`); const channelLabel = configChannels === 1 ? 'mono' : `${configChannels}ch`;
console.log(`[AudioBridge] → LiveKit groupe "${groupName}": ${pcmBuffer.length} bytes (${channelLabel})`);
} }
} else { } else {
if (this.stats.framesCapture % 100 === 0) { if (this.stats.framesCapture % 100 === 0) {
@@ -442,8 +488,7 @@ export class AudioBridge extends EventEmitter {
} }
// Émettre aussi pour monitoring/debug // Émettre aussi pour monitoring/debug
this.emit('groupAudioOut', { groupName, opusData, pcmBuffer }); 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)
@@ -454,16 +499,53 @@ export class AudioBridge extends EventEmitter {
} }
// ÉTAPE 4 : Envoyer chaque output à la carte son // ÉTAPE 4 : Envoyer chaque output à la carte son
outputBuffers.forEach((outputBuffer, channelId) => { const numOutputChannels = this.options.channels || 1;
const pcmBuffer = this._float32ToBuffer(outputBuffer);
// 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); this.audioBackend.queueAudio(pcmBuffer);
if (this.stats.framesCapture % 100 === 0) { if (this.stats.framesCapture % 100 === 0) {
console.log(`[AudioBridge] → Output ${channelId}: ${pcmBuffer.length} bytes`); 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 multi-canaux (${numOutputChannels}ch): ${pcmBuffer.length} bytes`);
}
} }
});
this.stats.framesCapture++; this.stats.framesCapture++;
this.stats.framesPlayback++; this.stats.framesPlayback++;
@@ -478,32 +560,57 @@ export class AudioBridge extends EventEmitter {
// Écouter l'audio entrant de LiveKit (sera connecté par LiveKitServerBridge) // Écouter l'audio entrant de LiveKit (sera connecté par LiveKitServerBridge)
this.on('groupAudioIn', ({ groupName, pcmBuffer }) => { this.on('groupAudioIn', ({ groupName, pcmBuffer }) => {
try { try {
console.log(`[AudioBridge FLUX 2] Handler groupAudioIn: groupe="${groupName}", buffer=${pcmBuffer.length} bytes`); // Convertir PCM Buffer → Float32Array
// Stocker le buffer du groupe pour le routing
const float32Data = this._bufferToFloat32(pcmBuffer); const float32Data = this._bufferToFloat32(pcmBuffer);
this.groupBuffersFromLiveKit.set(groupName, float32Data); const samplesReceived = float32Data.length;
console.log(`[AudioBridge FLUX 2] Buffer Float32 créé: ${float32Data.length} samples`); // Initialiser l'accumulateur pour ce groupe si nécessaire
if (!this.liveKitFrameAccumulators.has(groupName)) {
this.liveKitFrameAccumulators.set(groupName, {
buffer: new Float32Array(960), // Frame size attendu par GroupRouter
offset: 0
});
}
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
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);
// ÉTAPE 3 : Groupes → Outputs physiques (via GroupAudioRouter) // ÉTAPE 3 : Groupes → Outputs physiques (via GroupAudioRouter)
const outputBuffers = this.groupAudioRouter.processGroupsToOutputs( const outputBuffers = this.groupAudioRouter.processGroupsToOutputs(
this.groupBuffersFromLiveKit this.groupBuffersFromLiveKit
); );
console.log(`[AudioBridge FLUX 2] GroupRouter processGroupsToOutputs: ${this.groupBuffersFromLiveKit.size} groupes → ${outputBuffers.size} outputs`);
// ÉTAPE 4 : Envoyer chaque output à la carte son // ÉTAPE 4 : Envoyer chaque output à la carte son
outputBuffers.forEach((outputBuffer, channelId) => { outputBuffers.forEach((outputBuffer, channelId) => {
const pcmBuffer = this._float32ToBuffer(outputBuffer); const pcmBuffer = this._float32ToBuffer(outputBuffer);
console.log(`[AudioBridge FLUX 2] → Output ${channelId}: ${pcmBuffer.length} bytes vers carte son`);
// Envoyer à la carte son
this.audioBackend.queueAudio(pcmBuffer); this.audioBackend.queueAudio(pcmBuffer);
}); });
// Réinitialiser l'accumulateur
accumulator.offset = 0;
accumulator.buffer.fill(0);
this.stats.framesPlayback++; this.stats.framesPlayback++;
}
} catch (error) { } catch (error) {
console.error('Erreur routing lecture:', error); console.error('Erreur routing lecture:', error);
this.stats.errors.playback++; this.stats.errors.playback++;
@@ -572,14 +679,33 @@ export class AudioBridge extends EventEmitter {
} }
/** /**
* Convertit Buffer PCM 16-bit → Float32Array [-1.0, 1.0] * Convertit Buffer/Int16Array PCM 16-bit → Float32Array [-1.0, 1.0]
* @param {Buffer} buffer - Buffer PCM 16-bit signed * @param {Buffer|Int16Array|Uint8Array} buffer - Buffer PCM 16-bit signed
* @returns {Float32Array} * @returns {Float32Array}
* @private * @private
*/ */
_bufferToFloat32(buffer) { _bufferToFloat32(buffer) {
const samples = buffer.length / 2; // 2 bytes per sample (16-bit) let samples;
const float32 = this._acquireFloat32Buffer(samples); let float32;
// Cas 1 : Int16Array (LiveKit Node SDK format)
if (buffer instanceof Int16Array) {
samples = buffer.length;
float32 = this._acquireFloat32Buffer(samples);
for (let i = 0; i < samples; i++) {
float32[i] = buffer[i] / 32768.0;
}
return float32;
}
// Cas 2 : Buffer/Uint8Array (format classique)
if (!(buffer instanceof Buffer)) {
buffer = Buffer.from(buffer);
}
samples = buffer.length / 2; // 2 bytes per sample (16-bit)
float32 = this._acquireFloat32Buffer(samples);
for (let i = 0; i < samples; i++) { for (let i = 0; i < samples; i++) {
// Lire 16-bit signed little-endian // Lire 16-bit signed little-endian
+20 -8
View File
@@ -216,6 +216,21 @@ export class GroupAudioRouter extends EventEmitter {
this.groupBuffers.set(groupId, new Float32Array(this.config.frameSize)); 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 // Pour chaque canal d'entrée
inputChannelsData.forEach((pcmData, channelId) => { inputChannelsData.forEach((pcmData, channelId) => {
const key = `in_${channelId}`; const key = `in_${channelId}`;
@@ -234,9 +249,12 @@ export class GroupAudioRouter extends EventEmitter {
return; 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++) { for (let i = 0; i < pcmData.length && i < groupBuffer.length; i++) {
groupBuffer[i] += pcmData[i] * route.linearGain; groupBuffer[i] += pcmData[i] * mixGain;
} }
}); });
}); });
@@ -270,17 +288,11 @@ export class GroupAudioRouter extends EventEmitter {
// Réinitialise les buffers de sortie // Réinitialise les buffers de sortie
this.outputBuffers.clear(); this.outputBuffers.clear();
logger.debug(`[GroupRouter] processGroupsToOutputs: ${groupBuffersData.size} groupes en entrée`);
logger.debug(`[GroupRouter] Routes disponibles: ${JSON.stringify([...this.groupToOutputRoutes.keys()])}`);
// Pour chaque groupe // Pour chaque groupe
groupBuffersData.forEach((pcmData, groupName) => { groupBuffersData.forEach((pcmData, groupName) => {
const routes = this.groupToOutputRoutes.get(groupName); const routes = this.groupToOutputRoutes.get(groupName);
logger.debug(`[GroupRouter] Groupe "${groupName}": ${routes ? routes.length : 0} routes trouvées`);
if (!routes || routes.length === 0) { if (!routes || routes.length === 0) {
logger.warn(`[GroupRouter] Aucune route de sortie configurée pour groupe "${groupName}" - audio ignoré`);
return; return;
} }
+23 -9
View File
@@ -10,7 +10,7 @@
* - Reconnexion automatique * - Reconnexion automatique
*/ */
import { Room, RoomEvent, AudioSource, AudioFrame, LocalAudioTrack, TrackSource, AudioStream } from '@livekit/rtc-node'; import { Room, RoomEvent, AudioSource, AudioFrame, LocalAudioTrack, TrackSource, AudioStream, TrackKind } from '@livekit/rtc-node';
import { EventEmitter } from 'events'; import { EventEmitter } from 'events';
export class LiveKitClient extends EventEmitter { export class LiveKitClient extends EventEmitter {
@@ -143,7 +143,7 @@ export class LiveKitClient extends EventEmitter {
for (const [trackSid, publication] of participant.trackPublications) { for (const [trackSid, publication] of participant.trackPublications) {
console.log(` 📝 Track disponible: ${publication.kind} (${trackSid}), muted: ${publication.muted}`); console.log(` 📝 Track disponible: ${publication.kind} (${trackSid}), muted: ${publication.muted}`);
if (publication.kind === 'audio' && publication.track) { if (publication.kind === TrackKind.KIND_AUDIO && publication.track) {
console.log(` ⚡ Souscription manuelle au track audio ${trackSid}...`); console.log(` ⚡ Souscription manuelle au track audio ${trackSid}...`);
await this._handleAudioTrack(publication.track, publication, participant); await this._handleAudioTrack(publication.track, publication, participant);
} }
@@ -163,10 +163,10 @@ export class LiveKitClient extends EventEmitter {
console.log(`📢 Track publié par ${participant.identity}: ${publication.kind} (${publication.sid}), muted: ${publication.muted}`); console.log(`📢 Track publié par ${participant.identity}: ${publication.kind} (${publication.sid}), muted: ${publication.muted}`);
// Si c'est un track audio, s'y abonner immédiatement // Si c'est un track audio, s'y abonner immédiatement
if (publication.kind === 'audio' && publication.track) { if (publication.kind === TrackKind.KIND_AUDIO && publication.track) {
console.log(` ⚡ Track audio détecté, souscription...`); console.log(` ⚡ Track audio détecté, souscription...`);
await this._handleAudioTrack(publication.track, publication, participant); await this._handleAudioTrack(publication.track, publication, participant);
} else if (publication.kind === 'audio' && !publication.track) { } else if (publication.kind === TrackKind.KIND_AUDIO && !publication.track) {
console.log(` ⚠️ Track audio publié mais track object non disponible encore`); console.log(` ⚠️ Track audio publié mais track object non disponible encore`);
} }
}); });
@@ -174,14 +174,14 @@ export class LiveKitClient extends EventEmitter {
this.room.on(RoomEvent.TrackSubscribed, async (track, publication, participant) => { this.room.on(RoomEvent.TrackSubscribed, async (track, publication, participant) => {
console.log(`🎵 Track souscrit de ${participant.identity}: ${track.kind} (${publication.sid})`); console.log(`🎵 Track souscrit de ${participant.identity}: ${track.kind} (${publication.sid})`);
if (track.kind === 'audio') { if (track.kind === TrackKind.KIND_AUDIO) {
console.log(`🎵 Track AUDIO souscrit de ${participant.identity} (événement TrackSubscribed)`); console.log(`🎵 Track AUDIO souscrit de ${participant.identity} (événement TrackSubscribed)`);
await this._handleAudioTrack(track, publication, participant); await this._handleAudioTrack(track, publication, participant);
} }
}); });
this.room.on(RoomEvent.TrackUnsubscribed, (track, publication, participant) => { this.room.on(RoomEvent.TrackUnsubscribed, (track, publication, participant) => {
if (track.kind === 'audio') { if (track.kind === TrackKind.KIND_AUDIO) {
console.log(`🔇 Track audio désouscrit de ${participant.identity}`); console.log(`🔇 Track audio désouscrit de ${participant.identity}`);
this.remoteParticipants.delete(participant.sid); this.remoteParticipants.delete(participant.sid);
this.emit('audioTrackUnsubscribed', { track, participant }); this.emit('audioTrackUnsubscribed', { track, participant });
@@ -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
@@ -349,7 +356,14 @@ export class LiveKitClient extends EventEmitter {
if (this.room) { if (this.room) {
// Unpublish track // Unpublish track
if (this.localAudioTrack) { if (this.localAudioTrack) {
try {
await this.room.localParticipant.unpublishTrack(this.localAudioTrack.sid); 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; this.localAudioTrack = null;
} }
+67 -39
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 = [];
@@ -184,35 +189,45 @@ export class CoreAudioBackend extends EventEmitter {
} }
try { try {
// Commande sox pour capturer audio // Commande sox pour capturer audio sur macOS
// rec : enregistrer depuis input par défaut // Sur macOS, sox utilise CoreAudio par défaut via 'rec' (alias de sox -d)
// -t raw : format raw PCM // Format: sox -d [options] output
// -b 16 : 16-bit // -d = default input device OU -t coreaudio "Device Name"
// -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
];
// Si device spécifié const args = [];
// Spécifier le device d'entrée (CoreAudio capture en 32-bit natif)
if (this.options.inputDeviceName) { 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 = 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) => {
@@ -248,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');
} }
} }
@@ -265,27 +281,31 @@ export class CoreAudioBackend extends EventEmitter {
} }
try { try {
// Commande sox pour lecture audio // Commande sox pour lecture audio sur macOS
// play : lire vers output par défaut // Format: sox [options] input output
// -t raw : format raw PCM depuis stdin // Input = stdin (-)
// --buffer : taille du buffer interne sox (en bytes) // 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',
`-c`, String(this.options.channels), '-c', String(this.options.channels),
`-r`, String(this.options.sampleRate), '-r', String(this.options.sampleRate),
'-', // Stdin '-' // Input = stdin
'-t', 'coreaudio',
'default' // Device par défaut
]; ];
// Si device spécifié // Spécifier le device de sortie
if (this.options.outputDeviceName) { 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, { this.playbackProcess = spawn('sox', args, {
stdio: ['pipe', 'ignore', 'pipe'] // stdin=pipe, stdout=ignore, stderr=pipe stdio: ['pipe', 'ignore', 'pipe'] // stdin=pipe, stdout=ignore, stderr=pipe
}); });
@@ -313,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);
} }
}); });
@@ -438,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();
+19 -2
View File
@@ -30,6 +30,12 @@ export class JACKBackend extends EventEmitter {
this.jackProcess = null; this.jackProcess = null;
this.isCapturing = false; this.isCapturing = false;
this.isPlaying = 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.playbackBuffer = [];
this.maxBufferSize = 10; this.maxBufferSize = 10;
@@ -213,8 +219,17 @@ export class JACKBackend extends EventEmitter {
]); ]);
this.jackProcess.stdout.on('data', (audioData) => { this.jackProcess.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.jackProcess.stderr.on('data', (data) => { this.jackProcess.stderr.on('data', (data) => {
@@ -248,6 +263,7 @@ export class JACKBackend extends EventEmitter {
this.jackProcess.kill('SIGTERM'); this.jackProcess.kill('SIGTERM');
this.jackProcess = null; this.jackProcess = null;
this.isCapturing = false; this.isCapturing = false;
this.captureAccumulator = Buffer.alloc(0); // Reset accumulator
console.log('✓ Capture JACK arrêtée'); console.log('✓ Capture JACK arrêtée');
} }
} }
@@ -359,6 +375,7 @@ export class JACKBackend 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();
+19 -1
View File
@@ -33,6 +33,12 @@ export class PipeWireBackend 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 (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.playbackBuffer = [];
this.maxBufferSize = 10; this.maxBufferSize = 10;
} }
@@ -194,7 +200,17 @@ export class PipeWireBackend extends EventEmitter {
this.captureProcess = spawn('pw-cat', args); this.captureProcess = spawn('pw-cat', args);
this.captureProcess.stdout.on('data', (audioData) => { 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) => { this.captureProcess.stderr.on('data', (data) => {
@@ -231,6 +247,7 @@ export class PipeWireBackend 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 PipeWire arrêtée'); console.log('✓ Capture PipeWire arrêtée');
} }
} }
@@ -360,6 +377,7 @@ export class PipeWireBackend 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();
File diff suppressed because one or more lines are too long
+14 -13
View File
@@ -1,18 +1,17 @@
audio: audio:
sampleRate: 48000 sampleRate: 48000
channels: 2
frameSize: 20 frameSize: 20
defaultBitrate: 96 defaultBitrate: 96
jitterBufferMs: 40 jitterBufferMs: 40
device: device:
# Laissez null pour auto-détection du device par défaut inputDeviceId: Loopback Audio 4
# Ou spécifiez le nom exact via l'interface /admin outputDeviceId: Haut-parleurs MacBook Pro
inputDeviceId: alsa_input.pci-0000_00_01.0.analog-stereo
outputDeviceId: alsa_output.pci-0000_00_01.0.analog-stereo
sampleRate: 48000 sampleRate: 48000
routing: routing:
inputToGroup: inputToGroup:
"0": "0":
- production - default
"1": [] "1": []
"2": [] "2": []
"4": "4":
@@ -25,35 +24,37 @@ audio:
production: production:
- "0" - "0"
- "1" - "1"
default:
- "0"
gains: {} gains: {}
channelNames: channelNames:
inputs: inputs:
"0": iphone "0": Mac
"1": Talkback FOH "1": Talkback FOH
"2": Retour Console "2": Retour Console
"3": Liaison Scène "3": Liaison Scène
"4": Monitor Mix "4": Monitor Mix
"5": Spare 1 "5": Spare 1
outputs: outputs:
"0": Sortie Principale "0": L
"1": Retour Scène "1": R
"2": Talkback Console "2": Talkback Console
groups: groups:
- name: Default
audioBitrate: 96
channels: []
- name: Production - name: Production
audioBitrate: 96 audioBitrate: 96
channels: [] channels: []
- name: Technique - name: Technique
audioBitrate: 96 audioBitrate: 96
channels: [] channels: []
- name: Sonorisation
audioBitrate: 128
channels: []
server: server:
host: 0.0.0.0 host: 0.0.0.0
port: 3000 port: 3000
livekit: livekit:
url: AUTO # AUTO = détection automatique IP réseau | ou ws://IP:7880 pour manuel url: AUTO
logging: logging:
level: debug # Changez à 'debug' pour voir plus de détails level: debug
logLatency: false logLatency: false
logAudioStats: false logAudioStats: false