Compare commits
27 Commits
d5850a5918
...
macos
| Author | SHA1 | Date | |
|---|---|---|---|
| 060453fe06 | |||
| 2b88ea0ad5 | |||
| 9aff58c528 | |||
| a803250f9f | |||
| 36e1799ec5 | |||
| f302b3f266 | |||
| 91d13d1be7 | |||
| 77bc36b765 | |||
| 58bc91b966 | |||
| c9ec10dfd9 | |||
| d908cf4ee6 | |||
| 522a6255fe | |||
| 5784aa68e1 | |||
| 05e7f69ffb | |||
| 5534a43b0a | |||
| 1941e9c8a1 | |||
| cfeb275d18 | |||
| adadbfeeb7 | |||
| aab23dc51f | |||
| c562415a3d | |||
| 65357c29cc | |||
| 5fd46fb2a3 | |||
| 13d066b188 | |||
| 6630ced079 | |||
| 7e6798cf92 | |||
| 061872b2d7 | |||
| 3041863286 |
@@ -81,7 +81,7 @@ define(['./workbox-290dd570'], (function (workbox) { 'use strict';
|
||||
"revision": "3ca0b8505b4bec776b69afdba2768812"
|
||||
}, {
|
||||
"url": "index.html",
|
||||
"revision": "0.lhgefe7plc8"
|
||||
"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
+65
-45
@@ -1,11 +1,17 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import './Admin.css';
|
||||
import AudioRoutingMatrix from './components/AudioRoutingMatrix';
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL || '/api';
|
||||
|
||||
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 [users, setUsers] = useState([]);
|
||||
const [stats, setStats] = useState(null);
|
||||
@@ -19,11 +25,10 @@ function Admin() {
|
||||
const [selectedInputDevice, setSelectedInputDevice] = useState(null);
|
||||
const [selectedOutputDevice, setSelectedOutputDevice] = useState(null);
|
||||
const [selectedSampleRate, setSelectedSampleRate] = useState(48000);
|
||||
const [isEditingAudio, setIsEditingAudio] = useState(false);
|
||||
const isEditingAudioRef = useRef(false);
|
||||
|
||||
// Channel names (Phase 2.5)
|
||||
const [channelNames, setChannelNames] = useState({ inputs: {}, outputs: {} });
|
||||
const [editingChannelNames, setEditingChannelNames] = useState(false);
|
||||
|
||||
// Gestion formulaire nouveau groupe
|
||||
const [showGroupForm, setShowGroupForm] = useState(false);
|
||||
@@ -33,6 +38,19 @@ function Admin() {
|
||||
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
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
@@ -90,24 +108,27 @@ 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);
|
||||
setChannelNames(channelNamesData.channelNames || { inputs: {}, outputs: {} });
|
||||
|
||||
// Ne réinitialiser les sélections que si l'utilisateur n'est pas en train d'éditer
|
||||
if (!isEditingAudio) {
|
||||
// Ne réinitialiser les sélections que lors du chargement initial (pas en train d'éditer)
|
||||
if (!isEditingAudioRef.current) {
|
||||
setSelectedInputDevice(device.inputDeviceId ?? null);
|
||||
setSelectedOutputDevice(device.outputDeviceId ?? null);
|
||||
setSelectedSampleRate(device.sampleRate || 48000);
|
||||
@@ -216,7 +237,6 @@ function Admin() {
|
||||
|
||||
if (res.ok) {
|
||||
alert('Noms de canaux sauvegardés avec succès!');
|
||||
setEditingChannelNames(false);
|
||||
await loadAudioDevices();
|
||||
} else {
|
||||
const error = await res.json();
|
||||
@@ -251,7 +271,7 @@ function Admin() {
|
||||
});
|
||||
|
||||
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!');
|
||||
await loadAudioDevices();
|
||||
} else {
|
||||
@@ -311,31 +331,31 @@ function Admin() {
|
||||
<nav className="admin-tabs">
|
||||
<button
|
||||
className={activeTab === 'groups' ? 'active' : ''}
|
||||
onClick={() => setActiveTab('groups')}
|
||||
onClick={() => { window.location.hash = 'groups'; setActiveTab('groups'); }}
|
||||
>
|
||||
Groupes
|
||||
</button>
|
||||
<button
|
||||
className={activeTab === 'audio' ? 'active' : ''}
|
||||
onClick={() => setActiveTab('audio')}
|
||||
onClick={() => { window.location.hash = 'audio'; setActiveTab('audio'); }}
|
||||
>
|
||||
Audio
|
||||
</button>
|
||||
<button
|
||||
className={activeTab === 'users' ? 'active' : ''}
|
||||
onClick={() => setActiveTab('users')}
|
||||
onClick={() => { window.location.hash = 'users'; setActiveTab('users'); }}
|
||||
>
|
||||
Utilisateurs ({users.length})
|
||||
</button>
|
||||
<button
|
||||
className={activeTab === 'stats' ? 'active' : ''}
|
||||
onClick={() => setActiveTab('stats')}
|
||||
onClick={() => { window.location.hash = 'stats'; setActiveTab('stats'); }}
|
||||
>
|
||||
Statistiques
|
||||
</button>
|
||||
<button
|
||||
className={activeTab === 'logs' ? 'active' : ''}
|
||||
onClick={() => setActiveTab('logs')}
|
||||
onClick={() => { window.location.hash = 'logs'; setActiveTab('logs'); }}
|
||||
>
|
||||
Logs
|
||||
</button>
|
||||
@@ -438,11 +458,17 @@ function Admin() {
|
||||
|
||||
<div className="audio-config-container">
|
||||
<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
|
||||
value={selectedInputDevice ?? ''}
|
||||
onChange={(e) => {
|
||||
setIsEditingAudio(true);
|
||||
isEditingAudioRef.current = true;
|
||||
setSelectedInputDevice(e.target.value === '' ? null : e.target.value);
|
||||
}}
|
||||
className="device-select"
|
||||
@@ -463,12 +489,14 @@ function Admin() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="audio-section">
|
||||
<h3>Carte son de sortie (Output)</h3>
|
||||
<div>
|
||||
<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
|
||||
value={selectedOutputDevice ?? ''}
|
||||
onChange={(e) => {
|
||||
setIsEditingAudio(true);
|
||||
isEditingAudioRef.current = true;
|
||||
setSelectedOutputDevice(e.target.value === '' ? null : e.target.value);
|
||||
}}
|
||||
className="device-select"
|
||||
@@ -489,12 +517,14 @@ function Admin() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="audio-section">
|
||||
<h3>Sample Rate</h3>
|
||||
<div>
|
||||
<label style={{display: 'block', marginBottom: 'var(--spacing-xs)', fontSize: '0.9rem', fontWeight: 600, color: 'var(--color-text-secondary)'}}>
|
||||
Sample Rate
|
||||
</label>
|
||||
<select
|
||||
value={selectedSampleRate}
|
||||
onChange={(e) => {
|
||||
setIsEditingAudio(true);
|
||||
isEditingAudioRef.current = true;
|
||||
setSelectedSampleRate(parseInt(e.target.value));
|
||||
}}
|
||||
className="device-select"
|
||||
@@ -504,33 +534,19 @@ function Admin() {
|
||||
<option value={96000}>96000 Hz (High quality)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="audio-actions">
|
||||
<button onClick={handleSaveAudioDevice} className="btn-primary">
|
||||
Sauvegarder la configuration
|
||||
Sauvegarder la configuration audio
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="audio-section">
|
||||
<div style={{display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 'var(--spacing-md)'}}>
|
||||
<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>
|
||||
<h4 style={{marginBottom: 'var(--spacing-md)', color: 'var(--color-text-secondary)'}}>
|
||||
Entrées (Inputs) - {currentDevice.inputChannels || 0} canaux disponibles
|
||||
@@ -544,10 +560,9 @@ function Admin() {
|
||||
value={channelNames.inputs?.[i] || ''}
|
||||
onChange={(e) => updateChannelName('inputs', i, e.target.value)}
|
||||
placeholder={`Input ${i}`}
|
||||
disabled={!editingChannelNames}
|
||||
style={{
|
||||
padding: 'var(--spacing-sm)',
|
||||
background: editingChannelNames ? 'var(--color-bg)' : 'var(--color-surface-hover)',
|
||||
background: 'var(--color-bg)',
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: '6px',
|
||||
color: 'var(--color-text)',
|
||||
@@ -572,10 +587,9 @@ function Admin() {
|
||||
value={channelNames.outputs?.[i] || ''}
|
||||
onChange={(e) => updateChannelName('outputs', i, e.target.value)}
|
||||
placeholder={`Output ${i}`}
|
||||
disabled={!editingChannelNames}
|
||||
style={{
|
||||
padding: 'var(--spacing-sm)',
|
||||
background: editingChannelNames ? 'var(--color-bg)' : 'var(--color-surface-hover)',
|
||||
background: 'var(--color-bg)',
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: '6px',
|
||||
color: 'var(--color-text)',
|
||||
@@ -587,6 +601,12 @@ function Admin() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="audio-actions">
|
||||
<button onClick={handleSaveChannelNames} className="btn-primary">
|
||||
Sauvegarder les noms des canaux
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AudioRoutingMatrix groups={groups} channelNames={channelNames} />
|
||||
|
||||
@@ -6,6 +6,13 @@
|
||||
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 {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
@@ -169,9 +176,9 @@
|
||||
width: 100%;
|
||||
padding: 4px 8px;
|
||||
font-size: 0.75rem;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
color: var(--color-text);
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
background: rgba(59, 130, 246, 0.2);
|
||||
color: #ffffff;
|
||||
border: 1px solid rgba(255, 255, 255, 1);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
@@ -180,8 +187,9 @@
|
||||
|
||||
.gain-select:focus {
|
||||
outline: none;
|
||||
border-color: rgba(255, 255, 255, 0.6);
|
||||
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.2);
|
||||
background: rgba(59, 130, 246, 0.3);
|
||||
border-color: rgba(255, 255, 255, 1);
|
||||
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.2);
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
|
||||
@@ -194,7 +194,6 @@ function AudioRoutingMatrix({ groups, channelNames }) {
|
||||
{wsConnected ? '● Live' : '○ Offline'}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '1rem', alignItems: 'center' }}>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', cursor: 'pointer' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -203,10 +202,6 @@ function AudioRoutingMatrix({ groups, channelNames }) {
|
||||
/>
|
||||
<span>Afficher uniquement les canaux nommés</span>
|
||||
</label>
|
||||
<button onClick={saveRouting} className="btn-primary">
|
||||
Sauvegarder le routing
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="routing-section">
|
||||
@@ -341,6 +336,12 @@ function AudioRoutingMatrix({ groups, channelNames }) {
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="routing-actions">
|
||||
<button onClick={saveRouting} className="btn-primary">
|
||||
Sauvegarder le routing audio
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -22,16 +22,6 @@ export default function PTTButton({ isTalking, onPressStart, onPressEnd, audioLe
|
||||
const currentYRef = useRef(null);
|
||||
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(() => {
|
||||
const button = buttonRef.current;
|
||||
if (!button) return;
|
||||
|
||||
@@ -3,8 +3,22 @@
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 0;
|
||||
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 {
|
||||
@@ -17,12 +31,14 @@
|
||||
}
|
||||
|
||||
.pwa-prompt {
|
||||
background: var(--bg-secondary);
|
||||
width: 100%;
|
||||
background: #1a1a1a;
|
||||
border-top-left-radius: 16px;
|
||||
border-top-right-radius: 16px;
|
||||
box-shadow: 0 -4px 20px rgba(0, 0, 0, 0.3);
|
||||
max-height: 80vh;
|
||||
overflow-y: auto;
|
||||
animation: slideUp 0.3s ease;
|
||||
}
|
||||
|
||||
.pwa-prompt-header {
|
||||
@@ -36,13 +52,13 @@
|
||||
.pwa-prompt-header h3 {
|
||||
margin: 0;
|
||||
font-size: 1.2rem;
|
||||
color: var(--text-primary);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.pwa-prompt-close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-secondary);
|
||||
color: #9ca3af;
|
||||
cursor: pointer;
|
||||
padding: 0.5rem;
|
||||
border-radius: 8px;
|
||||
@@ -50,8 +66,8 @@
|
||||
}
|
||||
|
||||
.pwa-prompt-close:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.pwa-prompt-content {
|
||||
@@ -59,8 +75,8 @@
|
||||
}
|
||||
|
||||
.pwa-prompt-content > p {
|
||||
margin: 0 0 var(--spacing-lg) 0;
|
||||
color: var(--text-secondary);
|
||||
margin: 0 0 1.5rem 0;
|
||||
color: #d1d5db;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
@@ -73,9 +89,9 @@
|
||||
.pwa-prompt-step {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-md);
|
||||
padding: var(--spacing-md);
|
||||
background: var(--bg-hover);
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
@@ -85,7 +101,7 @@
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
background: var(--primary-color);
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
border-radius: 50%;
|
||||
font-weight: 600;
|
||||
@@ -95,13 +111,13 @@
|
||||
.pwa-prompt-step p {
|
||||
flex: 1;
|
||||
margin: 0;
|
||||
color: var(--text-primary);
|
||||
color: #ffffff;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.pwa-prompt-step svg {
|
||||
flex-shrink: 0;
|
||||
color: var(--primary-color);
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
.pwa-prompt-footer {
|
||||
|
||||
@@ -4,9 +4,7 @@ import './Settings.css';
|
||||
const STORAGE_KEY = 'ptt-live-settings';
|
||||
|
||||
const defaultSettings = {
|
||||
defaultPTTMode: 'normal', // 'normal' ou 'continuous'
|
||||
vibrationEnabled: true,
|
||||
audioFeedbackEnabled: true
|
||||
vibrationEnabled: true
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -68,39 +66,6 @@ export default function Settings({ isOpen, onClose }) {
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<h3>Feedback</h3>
|
||||
|
||||
@@ -112,19 +77,7 @@ export default function Settings({ isOpen, onClose }) {
|
||||
/>
|
||||
<div>
|
||||
<strong>Vibrations</strong>
|
||||
<p>Activer le retour haptique (si disponible)</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>
|
||||
<p>Activer le retour haptique lors du verrouillage PTT</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
+68
-85
@@ -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 });
|
||||
|
||||
@@ -679,39 +635,46 @@ router.get('/devices/list', async (req, res) => {
|
||||
|
||||
// Détection selon la plateforme
|
||||
if (process.platform === 'darwin') {
|
||||
// macOS : utiliser CoreAudio via sox
|
||||
const { exec } = await import('child_process');
|
||||
const { promisify } = await import('util');
|
||||
const execPromise = promisify(exec);
|
||||
|
||||
// macOS : utiliser CoreAudioBackend.getDevices()
|
||||
try {
|
||||
// Utiliser sox pour lister les devices audio
|
||||
const { stdout } = await execPromise('sox -V6 2>&1');
|
||||
const coreAudioDevices = CoreAudioBackend.getDevices();
|
||||
|
||||
// Parser la sortie sox pour extraire les devices
|
||||
// Format typique : "Input Device [0]: MacBook Pro Microphone"
|
||||
const inputMatches = stdout.matchAll(/Input Device \[(\d+)\]: (.+)/g);
|
||||
const outputMatches = stdout.matchAll(/Output Device \[(\d+)\]: (.+)/g);
|
||||
|
||||
for (const match of inputMatches) {
|
||||
// Séparer inputs et outputs
|
||||
coreAudioDevices.forEach(device => {
|
||||
if (device.maxInputChannels > 0) {
|
||||
devices.inputs.push({
|
||||
id: parseInt(match[1], 10),
|
||||
name: match[2].trim()
|
||||
id: device.name, // Utiliser le nom comme ID (compatible avec inputDeviceName)
|
||||
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({
|
||||
id: parseInt(match[1], 10),
|
||||
name: match[2].trim()
|
||||
id: device.name, // Utiliser le nom comme ID (compatible avec outputDeviceName)
|
||||
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
|
||||
devices.inputs.push({ id: 0, name: 'Default Input (Built-in Microphone)', isDefault: true });
|
||||
devices.outputs.push({ id: 0, name: 'Default Output (Built-in Speakers)', isDefault: true });
|
||||
devices.inputs.push({ id: 'builtin-mic', name: 'Built-in Microphone', isDefault: true });
|
||||
devices.outputs.push({ id: 'builtin-output', name: 'Built-in Output', isDefault: true });
|
||||
}
|
||||
|
||||
} else if (process.platform === 'linux') {
|
||||
@@ -736,21 +699,41 @@ router.get('/devices/list', async (req, res) => {
|
||||
}
|
||||
});
|
||||
} 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: 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()) {
|
||||
paDevices.split('\n').filter(Boolean).forEach((line, idx) => {
|
||||
const name = line.split('\t')[1] || `Device ${idx}`;
|
||||
devices.inputs.push({ id: idx, name });
|
||||
paDevices.split('\n').filter(Boolean).forEach((line) => {
|
||||
const parts = line.split('\t');
|
||||
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()) {
|
||||
paSinks.split('\n').filter(Boolean).forEach((line, idx) => {
|
||||
const name = line.split('\t')[1] || `Device ${idx}`;
|
||||
devices.outputs.push({ id: idx, name });
|
||||
paSinks.split('\n').filter(Boolean).forEach((line) => {
|
||||
const parts = line.split('\t');
|
||||
const deviceId = parts[1]; // Nom du device (ex: alsa_output.pci-...)
|
||||
if (deviceId) {
|
||||
devices.outputs.push({
|
||||
id: deviceId,
|
||||
name: getDeviceDescription(deviceId)
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+228
-75
@@ -54,7 +54,7 @@ export class AudioBridge extends EventEmitter {
|
||||
this.opusEncoder = null;
|
||||
this.opusDecoder = null;
|
||||
this.jitterBuffer = null;
|
||||
this.liveKitClient = null;
|
||||
this.liveKitClients = new Map(); // Map<groupName, LiveKitClient> - un client par groupe
|
||||
this.groupAudioRouter = null;
|
||||
|
||||
// État
|
||||
@@ -65,6 +65,9 @@ export class AudioBridge extends EventEmitter {
|
||||
this.inputChannelBuffers = new Map(); // Map<channelId, 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
|
||||
this.bufferPool = {
|
||||
float32: [], // Pool de Float32Array réutilisables
|
||||
@@ -213,17 +216,28 @@ export class AudioBridge extends EventEmitter {
|
||||
}
|
||||
|
||||
// Initialisation du backend sélectionné
|
||||
this.audioBackend = new BackendClass({
|
||||
const backendOptions = {
|
||||
sampleRate: this.options.sampleRate,
|
||||
channels: this.options.channels,
|
||||
framesPerBuffer: this.options.frameSize,
|
||||
inputDeviceId: this.options.inputDeviceId,
|
||||
inputDeviceName: inputDeviceName,
|
||||
outputDeviceId: this.options.outputDeviceId,
|
||||
outputDeviceName: outputDeviceName,
|
||||
// Options spécifiques PipeWire
|
||||
latency: this.options.latency || 20
|
||||
});
|
||||
};
|
||||
|
||||
// PipeWire utilise targetDevice, CoreAudio utilise inputDeviceName/outputDeviceName
|
||||
if (this.backendType === 'PipeWire') {
|
||||
// Pour PipeWire, on utilise inputDeviceId directement comme targetDevice
|
||||
// (startCapture et startPlayback peuvent avoir des targets différents)
|
||||
backendOptions.inputTargetDevice = this.options.inputDeviceId;
|
||||
backendOptions.outputTargetDevice = this.options.outputDeviceId;
|
||||
} else {
|
||||
// CoreAudio et autres backends
|
||||
backendOptions.inputDeviceId = this.options.inputDeviceId;
|
||||
backendOptions.inputDeviceName = inputDeviceName;
|
||||
backendOptions.outputDeviceId = this.options.outputDeviceId;
|
||||
backendOptions.outputDeviceName = outputDeviceName;
|
||||
}
|
||||
|
||||
this.audioBackend = new BackendClass(backendOptions);
|
||||
|
||||
// Liste des devices disponibles
|
||||
devices = BackendClass.getDevices();
|
||||
@@ -313,56 +327,63 @@ export class AudioBridge extends EventEmitter {
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialise la connexion LiveKit
|
||||
* Initialise les connexions LiveKit (une par groupe)
|
||||
* @private
|
||||
*/
|
||||
async _initLiveKit() {
|
||||
if (!this.options.liveKitToken) {
|
||||
throw new Error('Token LiveKit requis');
|
||||
if (!this.options.liveKitTokens || !Array.isArray(this.options.liveKitTokens)) {
|
||||
throw new Error('liveKitTokens requis (tableau d\'objets { groupName, groupId, token })');
|
||||
}
|
||||
|
||||
this.liveKitClient = new LiveKitClient({
|
||||
console.log(`🔌 Initialisation ${this.options.liveKitTokens.length} connexions LiveKit (une par groupe)...`);
|
||||
|
||||
// Créer un LiveKitClient pour chaque groupe
|
||||
for (const { groupName, groupId, token } of this.options.liveKitTokens) {
|
||||
const roomName = groupId; // La room porte le nom du groupId (slugifié)
|
||||
|
||||
const client = new LiveKitClient({
|
||||
url: this.options.liveKitUrl,
|
||||
token: this.options.liveKitToken,
|
||||
roomName: this.options.roomName,
|
||||
participantName: 'AudioBridge',
|
||||
token,
|
||||
roomName,
|
||||
participantName: `AudioBridge-${groupId}`,
|
||||
sampleRate: this.options.sampleRate,
|
||||
channels: this.options.channels,
|
||||
audioBitrate: this.opusEncoder.options.bitrate
|
||||
});
|
||||
|
||||
// Events LiveKit
|
||||
this.liveKitClient.on('connected', () => {
|
||||
console.log('✓ LiveKit connecté');
|
||||
// Events LiveKit pour ce groupe
|
||||
client.on('connected', () => {
|
||||
console.log(`✓ LiveKit connecté pour groupe "${groupName}" (room: ${roomName})`);
|
||||
});
|
||||
|
||||
this.liveKitClient.on('disconnected', (data) => {
|
||||
client.on('disconnected', (data) => {
|
||||
const reason = data?.reason || 'unknown';
|
||||
console.warn('⚠️ LiveKit déconnecté:', reason);
|
||||
console.warn(`⚠️ LiveKit déconnecté pour groupe "${groupName}":`, reason);
|
||||
this.stats.errors.network++;
|
||||
});
|
||||
|
||||
this.liveKitClient.on('reconnecting', () => {
|
||||
console.log('🔄 LiveKit reconnexion...');
|
||||
client.on('reconnecting', () => {
|
||||
console.log(`🔄 LiveKit reconnexion pour groupe "${groupName}"...`);
|
||||
});
|
||||
|
||||
this.liveKitClient.on('audioTrackSubscribed', ({ track, participant }) => {
|
||||
console.log(`🎵 Nouveau track audio : ${participant.identity}`);
|
||||
client.on('audioTrackSubscribed', ({ track, participant }) => {
|
||||
console.log(`🎵 Nouveau track audio dans groupe "${groupName}": ${participant.identity}`);
|
||||
});
|
||||
|
||||
// Réception audio depuis les clients LiveKit
|
||||
this.liveKitClient.on('audioData', ({ participantName, pcmData, sampleRate, channels }) => {
|
||||
console.log(`[AudioBridge FLUX 2] Audio reçu de ${participantName}: ${pcmData.length} bytes (${sampleRate}Hz, ${channels}ch)`);
|
||||
|
||||
// Pour l'instant, on route vers le groupe principal
|
||||
// TODO: Mapper les participants aux groupes selon la configuration
|
||||
const groupName = 'Equipe'; // Groupe par défaut
|
||||
this.emit('groupAudioIn', { groupName, pcmBuffer: pcmData });
|
||||
|
||||
console.log(`[AudioBridge FLUX 2] Événement groupAudioIn émis pour groupe "${groupName}"`);
|
||||
// Réception audio depuis les clients LiveKit de ce groupe
|
||||
client.on('audioData', ({ participantName, pcmData, sampleRate, channels }) => {
|
||||
// Router vers le bon groupe
|
||||
this.emit('groupAudioIn', { groupName: groupId, pcmBuffer: pcmData });
|
||||
});
|
||||
|
||||
await this.liveKitClient.connect();
|
||||
// Connexion
|
||||
await client.connect();
|
||||
|
||||
// Stocker le client par groupId
|
||||
this.liveKitClients.set(groupId, client);
|
||||
}
|
||||
|
||||
console.log(`✓ ${this.liveKitClients.size} connexions LiveKit établies`);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -378,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
|
||||
// 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(
|
||||
@@ -389,36 +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 LiveKit
|
||||
// É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);
|
||||
|
||||
// Envoi vers LiveKit via sendAudioData (prend du PCM, pas de l'Opus)
|
||||
// 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
|
||||
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 (this.liveKitClient && this.liveKitClient.isConnected) {
|
||||
this.liveKitClient.sendAudioData(pcmBuffer);
|
||||
if (client && client.isConnected) {
|
||||
client.sendAudioData(pcmBuffer);
|
||||
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 {
|
||||
if (this.stats.framesCapture % 100 === 0) {
|
||||
console.log(`[AudioBridge] ⚠️ LiveKit non connecté, audio non envoyé`);
|
||||
console.log(`[AudioBridge] ⚠️ LiveKit non connecté pour groupe "${groupName}", audio non envoyé`);
|
||||
}
|
||||
}
|
||||
|
||||
// É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)
|
||||
@@ -429,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 ${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.framesPlayback++;
|
||||
@@ -453,32 +560,57 @@ export class AudioBridge extends EventEmitter {
|
||||
// Écouter l'audio entrant de LiveKit (sera connecté par LiveKitServerBridge)
|
||||
this.on('groupAudioIn', ({ groupName, pcmBuffer }) => {
|
||||
try {
|
||||
console.log(`[AudioBridge FLUX 2] Handler groupAudioIn: groupe="${groupName}", buffer=${pcmBuffer.length} bytes`);
|
||||
|
||||
// Stocker le buffer du groupe pour le routing
|
||||
// Convertir PCM Buffer → Float32Array
|
||||
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)
|
||||
const outputBuffers = this.groupAudioRouter.processGroupsToOutputs(
|
||||
this.groupBuffersFromLiveKit
|
||||
);
|
||||
|
||||
console.log(`[AudioBridge FLUX 2] GroupRouter processGroupsToOutputs: ${this.groupBuffersFromLiveKit.size} groupes → ${outputBuffers.size} outputs`);
|
||||
|
||||
// ÉTAPE 4 : Envoyer chaque output à la carte son
|
||||
outputBuffers.forEach((outputBuffer, channelId) => {
|
||||
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);
|
||||
});
|
||||
|
||||
// Réinitialiser l'accumulateur
|
||||
accumulator.offset = 0;
|
||||
accumulator.buffer.fill(0);
|
||||
|
||||
this.stats.framesPlayback++;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Erreur routing lecture:', error);
|
||||
this.stats.errors.playback++;
|
||||
@@ -547,14 +679,33 @@ export class AudioBridge extends EventEmitter {
|
||||
}
|
||||
|
||||
/**
|
||||
* Convertit Buffer PCM 16-bit → Float32Array [-1.0, 1.0]
|
||||
* @param {Buffer} buffer - Buffer PCM 16-bit signed
|
||||
* Convertit Buffer/Int16Array PCM 16-bit → Float32Array [-1.0, 1.0]
|
||||
* @param {Buffer|Int16Array|Uint8Array} buffer - Buffer PCM 16-bit signed
|
||||
* @returns {Float32Array}
|
||||
* @private
|
||||
*/
|
||||
_bufferToFloat32(buffer) {
|
||||
const samples = buffer.length / 2; // 2 bytes per sample (16-bit)
|
||||
const float32 = this._acquireFloat32Buffer(samples);
|
||||
let 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++) {
|
||||
// Lire 16-bit signed little-endian
|
||||
@@ -602,10 +753,12 @@ export class AudioBridge extends EventEmitter {
|
||||
this.audioBackend = null;
|
||||
}
|
||||
|
||||
if (this.liveKitClient) {
|
||||
await this.liveKitClient.destroy();
|
||||
this.liveKitClient = null;
|
||||
// Déconnecter tous les clients LiveKit
|
||||
for (const [groupName, client] of this.liveKitClients.entries()) {
|
||||
console.log(`🔌 Déconnexion LiveKit groupe "${groupName}"...`);
|
||||
await client.destroy();
|
||||
}
|
||||
this.liveKitClients.clear();
|
||||
|
||||
if (this.groupAudioRouter) {
|
||||
this.groupAudioRouter.destroy();
|
||||
|
||||
@@ -34,31 +34,60 @@ class AudioBridgeManager extends EventEmitter {
|
||||
const config = configManager.get();
|
||||
console.log('🎵 Démarrage AudioBridge avec configuration:', config.audio);
|
||||
|
||||
// Génération du token JWT pour le participant serveur
|
||||
// Générer un token JWT par groupe
|
||||
const liveKitTokens = [];
|
||||
|
||||
// Fonction pour slugifier le nom (identique à admin.js)
|
||||
const slugify = (text) => {
|
||||
return text
|
||||
.toString()
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/\s+/g, '-')
|
||||
.replace(/[^\w-]+/g, '')
|
||||
.replace(/--+/g, '-');
|
||||
};
|
||||
|
||||
for (const group of config.groups || []) {
|
||||
const groupId = slugify(group.name);
|
||||
const groupName = group.name;
|
||||
|
||||
const token = new AccessToken(
|
||||
config.server?.livekit?.apiKey || 'devkey',
|
||||
config.server?.livekit?.apiSecret || 'secret',
|
||||
{
|
||||
identity: 'AudioBridge',
|
||||
name: 'Audio Bridge Server',
|
||||
identity: `AudioBridge-${groupId}`,
|
||||
name: `Audio Bridge - ${groupName}`,
|
||||
metadata: JSON.stringify({
|
||||
role: 'bridge',
|
||||
group: groupId,
|
||||
capabilities: ['audio-routing', 'monitoring']
|
||||
})
|
||||
}
|
||||
);
|
||||
|
||||
// Permissions complètes pour le bridge serveur
|
||||
// Permissions complètes pour ce groupe
|
||||
token.addGrant({
|
||||
room: 'main',
|
||||
room: groupId, // Chaque groupe a sa propre room
|
||||
roomJoin: true,
|
||||
canPublish: true,
|
||||
canSubscribe: true,
|
||||
canPublishData: true
|
||||
});
|
||||
|
||||
const liveKitToken = await token.toJwt();
|
||||
console.log('✓ Token JWT généré pour AudioBridge');
|
||||
const jwt = await token.toJwt();
|
||||
liveKitTokens.push({ groupName, groupId, token: jwt });
|
||||
|
||||
console.log(`✓ Token JWT généré pour groupe "${groupName}" (room: ${groupId})`);
|
||||
}
|
||||
|
||||
if (liveKitTokens.length === 0) {
|
||||
console.warn('⚠️ Aucun groupe configuré, AudioBridge ne pourra pas démarrer');
|
||||
this.isRunning = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Import dynamique du AudioBridge
|
||||
const { AudioBridge } = await import('./AudioBridge.js');
|
||||
@@ -91,10 +120,9 @@ class AudioBridgeManager extends EventEmitter {
|
||||
// Créer l'instance avec la config
|
||||
this.bridge = new AudioBridge({
|
||||
...audioConfig,
|
||||
// Options LiveKit
|
||||
// Options LiveKit (multi-rooms)
|
||||
liveKitUrl,
|
||||
liveKitToken,
|
||||
roomName: 'main',
|
||||
liveKitTokens, // Tableau de { groupName, groupId, token }
|
||||
// Options de routing
|
||||
routing: config.audio?.routing || {},
|
||||
groups: config.groups || [],
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -270,17 +288,11 @@ export class GroupAudioRouter extends EventEmitter {
|
||||
// Réinitialise les buffers de sortie
|
||||
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
|
||||
groupBuffersData.forEach((pcmData, groupName) => {
|
||||
const routes = this.groupToOutputRoutes.get(groupName);
|
||||
|
||||
logger.debug(`[GroupRouter] Groupe "${groupName}": ${routes ? routes.length : 0} routes trouvées`);
|
||||
|
||||
if (!routes || routes.length === 0) {
|
||||
logger.warn(`[GroupRouter] Aucune route de sortie configurée pour groupe "${groupName}" - audio ignoré`);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
* - Reconnexion automatique
|
||||
*/
|
||||
|
||||
import { Room, RoomEvent, AudioSource, AudioFrame, LocalAudioTrack, TrackSource } from '@livekit/rtc-node';
|
||||
import { Room, RoomEvent, AudioSource, AudioFrame, LocalAudioTrack, TrackSource, AudioStream, TrackKind } from '@livekit/rtc-node';
|
||||
import { EventEmitter } from 'events';
|
||||
|
||||
export class LiveKitClient extends EventEmitter {
|
||||
@@ -136,8 +136,19 @@ export class LiveKitClient extends EventEmitter {
|
||||
});
|
||||
|
||||
// Participants
|
||||
this.room.on(RoomEvent.ParticipantConnected, (participant) => {
|
||||
this.room.on(RoomEvent.ParticipantConnected, async (participant) => {
|
||||
console.log(`➕ Participant connecté: ${participant.identity}`);
|
||||
|
||||
// Parcourir les tracks publiés par ce participant et s'y abonner manuellement
|
||||
for (const [trackSid, publication] of participant.trackPublications) {
|
||||
console.log(` 📝 Track disponible: ${publication.kind} (${trackSid}), muted: ${publication.muted}`);
|
||||
|
||||
if (publication.kind === TrackKind.KIND_AUDIO && publication.track) {
|
||||
console.log(` ⚡ Souscription manuelle au track audio ${trackSid}...`);
|
||||
await this._handleAudioTrack(publication.track, publication, participant);
|
||||
}
|
||||
}
|
||||
|
||||
this.emit('participantConnected', participant);
|
||||
});
|
||||
|
||||
@@ -147,13 +158,47 @@ export class LiveKitClient extends EventEmitter {
|
||||
this.emit('participantDisconnected', participant);
|
||||
});
|
||||
|
||||
// Tracks
|
||||
this.room.on(RoomEvent.TrackSubscribed, (track, publication, participant) => {
|
||||
if (track.kind === 'audio') {
|
||||
console.log(`🎵 Track audio souscrit de ${participant.identity}`);
|
||||
// Tracks - Debug tous les événements
|
||||
this.room.on(RoomEvent.TrackPublished, async (publication, participant) => {
|
||||
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
|
||||
if (publication.kind === TrackKind.KIND_AUDIO && publication.track) {
|
||||
console.log(` ⚡ Track audio détecté, souscription...`);
|
||||
await this._handleAudioTrack(publication.track, publication, participant);
|
||||
} else if (publication.kind === TrackKind.KIND_AUDIO && !publication.track) {
|
||||
console.log(` ⚠️ Track audio publié mais track object non disponible encore`);
|
||||
}
|
||||
});
|
||||
|
||||
this.room.on(RoomEvent.TrackSubscribed, async (track, publication, participant) => {
|
||||
console.log(`🎵 Track souscrit de ${participant.identity}: ${track.kind} (${publication.sid})`);
|
||||
|
||||
if (track.kind === TrackKind.KIND_AUDIO) {
|
||||
console.log(`🎵 Track AUDIO souscrit de ${participant.identity} (événement TrackSubscribed)`);
|
||||
await this._handleAudioTrack(track, publication, participant);
|
||||
}
|
||||
});
|
||||
|
||||
this.room.on(RoomEvent.TrackUnsubscribed, (track, publication, participant) => {
|
||||
if (track.kind === TrackKind.KIND_AUDIO) {
|
||||
console.log(`🔇 Track audio désouscrit de ${participant.identity}`);
|
||||
this.remoteParticipants.delete(participant.sid);
|
||||
this.emit('audioTrackUnsubscribed', { track, participant });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Gère un track audio (création AudioStream et lecture)
|
||||
* @private
|
||||
*/
|
||||
async _handleAudioTrack(track, publication, participant) {
|
||||
console.log(`🎧 Création AudioStream pour ${participant.identity}...`);
|
||||
|
||||
// Création d'un AudioStream pour recevoir les données PCM
|
||||
const stream = new track.AudioStream(
|
||||
const stream = new AudioStream(
|
||||
track,
|
||||
this.options.sampleRate,
|
||||
this.options.channels
|
||||
);
|
||||
@@ -170,16 +215,6 @@ export class LiveKitClient extends EventEmitter {
|
||||
|
||||
this.emit('audioTrackSubscribed', { track, participant });
|
||||
}
|
||||
});
|
||||
|
||||
this.room.on(RoomEvent.TrackUnsubscribed, (track, publication, participant) => {
|
||||
if (track.kind === 'audio') {
|
||||
console.log(`🔇 Track audio désouscrit de ${participant.identity}`);
|
||||
this.remoteParticipants.delete(participant.sid);
|
||||
this.emit('audioTrackUnsubscribed', { track, participant });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Démarre la réception audio d'un participant
|
||||
@@ -229,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
|
||||
@@ -314,7 +356,14 @@ export class LiveKitClient extends EventEmitter {
|
||||
if (this.room) {
|
||||
// Unpublish track
|
||||
if (this.localAudioTrack) {
|
||||
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();
|
||||
|
||||
@@ -23,7 +23,8 @@ export class PipeWireBackend extends EventEmitter {
|
||||
sampleRate: options.sampleRate || 48000,
|
||||
channels: options.channels || 1,
|
||||
framesPerBuffer: options.framesPerBuffer || 960,
|
||||
targetDevice: options.targetDevice || null,
|
||||
inputTargetDevice: options.inputTargetDevice || null,
|
||||
outputTargetDevice: options.outputTargetDevice || null,
|
||||
latency: options.latency || 20, // ms
|
||||
...options
|
||||
};
|
||||
@@ -32,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;
|
||||
}
|
||||
@@ -186,14 +193,24 @@ export class PipeWireBackend extends EventEmitter {
|
||||
];
|
||||
|
||||
// Ajoute le device cible si spécifié
|
||||
if (this.options.targetDevice) {
|
||||
args.push(`--target=${this.options.targetDevice}`);
|
||||
if (this.options.inputTargetDevice) {
|
||||
args.push(`--target=${this.options.inputTargetDevice}`);
|
||||
}
|
||||
|
||||
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) => {
|
||||
@@ -230,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');
|
||||
}
|
||||
}
|
||||
@@ -258,8 +276,8 @@ export class PipeWireBackend extends EventEmitter {
|
||||
'-' // Lecture depuis stdin
|
||||
];
|
||||
|
||||
if (this.options.targetDevice) {
|
||||
args.push(`--target=${this.options.targetDevice}`);
|
||||
if (this.options.outputTargetDevice) {
|
||||
args.push(`--target=${this.options.outputTargetDevice}`);
|
||||
}
|
||||
|
||||
this.playbackProcess = spawn('pw-cat', args);
|
||||
@@ -359,6 +377,7 @@ export class PipeWireBackend extends EventEmitter {
|
||||
* Arrête tous les streams
|
||||
*/
|
||||
destroy() {
|
||||
this.shuttingDown = true;
|
||||
this.stopCapture();
|
||||
this.stopPlayback();
|
||||
this.removeAllListeners();
|
||||
|
||||
File diff suppressed because one or more lines are too long
+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: null
|
||||
outputDeviceId: null
|
||||
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: info # Changez à 'debug' pour voir plus de détails
|
||||
level: debug
|
||||
logLatency: false
|
||||
logAudioStats: false
|
||||
|
||||
+21
-5
@@ -129,16 +129,27 @@ function startLiveKitServer() {
|
||||
|
||||
livekitProcess = spawn(livekitBinary, args, {
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
env: { ...process.env },
|
||||
env: {
|
||||
...process.env,
|
||||
LIVEKIT_LOG_LEVEL: 'info' // Réduit les logs LiveKit (debug → info)
|
||||
},
|
||||
shell: true // Permet de trouver le binaire dans PATH
|
||||
});
|
||||
|
||||
livekitProcess.stdout.on('data', (data) => {
|
||||
const output = data.toString().trim();
|
||||
if (output) {
|
||||
log('debug', '[LiveKit]', output);
|
||||
if (!output) return;
|
||||
|
||||
// Filtrer les logs trop verbeux
|
||||
if (output.includes('DEBUG') ||
|
||||
output.includes('received signal request') ||
|
||||
output.includes('sending signal response') ||
|
||||
output.includes('handling signal request')) {
|
||||
return; // Ignorer ces logs
|
||||
}
|
||||
|
||||
log('debug', '[LiveKit]', output);
|
||||
|
||||
// Détection démarrage réussi
|
||||
if (output.includes('starting server') || output.includes('rtc server')) {
|
||||
resolve();
|
||||
@@ -147,9 +158,14 @@ function startLiveKitServer() {
|
||||
|
||||
livekitProcess.stderr.on('data', (data) => {
|
||||
const output = data.toString().trim();
|
||||
if (output) {
|
||||
log('warn', '[LiveKit Error]', output);
|
||||
if (!output) return;
|
||||
|
||||
// Filtrer les logs DEBUG de stderr aussi
|
||||
if (output.includes('DEBUG')) {
|
||||
return;
|
||||
}
|
||||
|
||||
log('warn', '[LiveKit Error]', output);
|
||||
});
|
||||
|
||||
livekitProcess.on('error', (error) => {
|
||||
|
||||
Reference in New Issue
Block a user