Compare commits

...

13 Commits

Author SHA1 Message Date
benoit 9654c7f421 docs: mise a jour TODO.md - Phase 2.5 terminee
- Configuration audio visuelle complete
- GroupAudioRouter avec routing multi-canaux et gains
- Matrice routing avec dropdowns gain et VU-metres temps reel
- WebSocket audio-levels operationnel
- Phase 2 entierement terminee
2026-05-25 22:18:45 +02:00
benoit f5a5643f4b feat: ajout VU-metres temps reel dans matrice routing
- Hook React useAudioLevels pour WebSocket audio-levels
- Composant VUMeter (mini, horizontal, vertical)
- Integration VU-metres dans headers/labels matrice
- Indicateur etat connexion WebSocket (Live/Offline)
- Affichage RMS, peak, detection clipping
- Design responsive avec animations clipping
2026-05-25 22:17:48 +02:00
benoit b64bac1f3d feat: ajout WebSocket server pour monitoring niveaux audio temps réel
- Calcul RMS et peak par canal (dBFS)
- Détection clipping automatique
- Broadcast temps réel 20 fois/sec (configurable)
- Support inputs, groups, outputs
- Gestion multi-clients WebSocket
- API pour mise à jour depuis GroupAudioRouter
2026-05-25 22:12:48 +02:00
benoit 5ae9dfe2ac feat: ajout dropdowns gain par route dans matrice routing
- Dropdown gain -12dB à +6dB pour chaque route active
- Interface visuelle améliorée (checkbox + gain)
- Gestion gains input->group et group->output
- Sauvegarde gains dans config.yaml
- Design responsive mobile
2026-05-25 22:11:43 +02:00
benoit 8c43c7e8af feat: ajout GroupAudioRouter.js pour routing audio multi-canaux avec gains
- Mix de plusieurs canaux physiques vers groupes (gains individuels)
- Distribution groupes vers plusieurs canaux physiques (gains individuels)
- Support canaux partagés avec mixage additif
- Gestion gains par route (-120dB à +6dB)
- Anti-clipping automatique
- Statistiques routing temps réel
2026-05-25 22:07:44 +02:00
benoit 3ee474d90c fix: correction adresse IP proxy LiveKit dans vite.config 2026-05-25 21:47:48 +02:00
benoit 86b86e9037 feat: ajout système de notifications Web Push et prompt installation PWA iOS 2026-05-25 21:12:05 +02:00
benoit 7682b90557 feat: ajout système de préférences utilisateur avec mode PTT par défaut 2026-05-25 21:10:16 +02:00
benoit 63147f93f4 feat: ajout filtre canaux nommés dans matrice routing 2026-05-25 21:04:59 +02:00
benoit 42badb1fdf refactor: remplacement système de canaux statiques par canaux virtuels depuis routing 2026-05-25 21:03:40 +02:00
benoit 7037517ca2 fix: correction route POST /audio/routing (suppression /admin en double)
- Route montée sous /admin dans index.js
- Ne pas répéter /admin dans la définition de route
- Correction 404 Cannot POST /admin/audio/routing
2026-05-25 10:06:14 +02:00
benoit ba3d32fd3d fix: correction warnings React et gestion erreurs matrice routing
- Ajout React.Fragment avec keys pour éliminer warnings
- Import de React pour utiliser React.Fragment
- Meilleure gestion erreurs HTTP (status check)
- Messages d'erreur plus détaillés avec status code
- Logging amélioré pour debugging
2026-05-25 10:04:22 +02:00
benoit 0b31708b48 fix: correction layout matrice routing (affichage en grille)
- Ajout grid-template-columns dynamique basé sur nombre groupes
- Suppression display:contents qui causait le bug
- Utilisation de fragments React au lieu de div wrapper
- Matrice 1: 120px + N colonnes pour les groupes
- Matrice 2: 120px + 8 colonnes pour les outputs
- Nettoyage CSS classes inutilisées
2026-05-25 10:02:20 +02:00
30 changed files with 7126 additions and 189 deletions
+29 -24
View File
@@ -1,7 +1,7 @@
# TODO.md - Plan de développement PTT Live # TODO.md - Plan de développement PTT Live
**Dernière mise à jour** : 2026-05-24 **Dernière mise à jour** : 2026-05-25
**Phase actuelle** : PHASE 2 - Fonctionnalités professionnelles (En cours - Phase 2.5 Configuration audio visuelle) **Phase actuelle** : PHASE 2 - Fonctionnalités professionnelles (Phase 2.5 TERMINÉE - Configuration audio visuelle complète)
--- ---
@@ -156,7 +156,7 @@ Valider la faisabilité technique : 2-4 clients, PTT basique, latence < 150ms, m
### 2.2 Modes PTT avancés ### 2.2 Modes PTT avancés
- [x] Mode continu : toggle ON/OFF (appui long 3s) - [x] Mode continu : toggle ON/OFF (appui long 3s)
- [x] Vibration + indicateur visuel rouge (lock actif) - [x] Vibration + indicateur visuel rouge (lock actif)
- [ ] Préférences utilisateur (mode par défaut) - [x] Préférences utilisateur (mode par défaut)
### 2.3 Interface admin ### 2.3 Interface admin
- [x] Page admin web (/admin) - [x] Page admin web (/admin)
@@ -177,7 +177,7 @@ Valider la faisabilité technique : 2-4 clients, PTT basique, latence < 150ms, m
- [x] API PUT /api/audio/channels/names (sauvegarde noms canaux) - [x] API PUT /api/audio/channels/names (sauvegarde noms canaux)
- [x] API GET /api/audio/channels/names (récupération noms) - [x] API GET /api/audio/channels/names (récupération noms)
- [x] Page admin : formulaire nommage canaux (inputs/outputs) - [x] Page admin : formulaire nommage canaux (inputs/outputs)
- [ ] Page admin : filtre "canaux nommés uniquement" - [x] Page admin : filtre "canaux nommés uniquement"
- [x] Sauvegarde automatique dans config.yaml - [x] Sauvegarde automatique dans config.yaml
#### Matrice de routing (style Dante Controller) #### Matrice de routing (style Dante Controller)
@@ -186,25 +186,29 @@ Valider la faisabilité technique : 2-4 clients, PTT basique, latence < 150ms, m
- [x] Component React : AudioRoutingMatrix.jsx - [x] Component React : AudioRoutingMatrix.jsx
- [x] Matrice inputs → groups (checkboxes) - [x] Matrice inputs → groups (checkboxes)
- [x] Matrice groups → outputs (checkboxes) - [x] Matrice groups → outputs (checkboxes)
- [ ] Dropdowns gain par route (-12dB à +6dB) - Phase 3 - [x] Dropdowns gain par route (-12dB à +6dB)
- [ ] Indicateurs niveaux temps réel (WebSocket) - Phase 3 - [x] Indicateurs niveaux temps réel (WebSocket)
- [ ] Backend : GroupAudioRouter.js (routing par groupe) - Phase 3 - [x] Backend : GroupAudioRouter.js (routing par groupe)
- [ ] Mix canaux physiques multiples → groupe - [x] Mix canaux physiques multiples → groupe
- [ ] Distribution groupe → canaux physiques multiples - [x] Distribution groupe → canaux physiques multiples
- [ ] Gestion gains individuels - [x] Gestion gains individuels
- [ ] Support canaux partagés (mixage additif) - [x] Support canaux partagés (mixage additif)
- [x] Backend : ConfigManager.js (lecture/écriture YAML) - [x] Backend : ConfigManager.js (lecture/écriture YAML)
- [x] Méthodes update pour device/channels/routing - [x] Méthodes update pour device/channels/routing
- [x] Sauvegarde atomique avec backup auto - [x] Sauvegarde atomique avec backup auto
- [x] Émission événement config-updated - [x] Émission événement config-updated
- [ ] WebSocket audio-levels (monitoring temps réel) - Phase 3 - [x] WebSocket audio-levels (monitoring temps réel)
- [x] Server WebSocket AudioLevelsServer.js
- [x] Hook React useAudioLevels
- [x] Composant VUMeter (mini/horizontal/vertical)
- [x] Intégration VU-mètres dans matrice routing
- [ ] Tests : routing multi-canaux, canaux partagés - Phase 3 - [ ] Tests : routing multi-canaux, canaux partagés - Phase 3
### 2.4 Notifications ### 2.4 Notifications
- [ ] Web Push : appels privés - [x] Web Push : appels privés (infrastructure prête)
- [ ] Service Worker : gestion notifications - [x] Service Worker : gestion notifications
- [ ] iOS : message onboarding "Installer sur écran d'accueil" - [x] iOS : message onboarding "Installer sur écran d'accueil"
- [ ] Permissions notification au premier lancement - [x] Permissions notification au premier lancement
--- ---
@@ -238,17 +242,18 @@ Valider la faisabilité technique : 2-4 clients, PTT basique, latence < 150ms, m
## Prochaines actions immédiates ## Prochaines actions immédiates
### Phase 2 - Suite (PRIORITÉS) ### Phase 2 - TERMINÉE
1. ✅ Multi-groupes avec sélection dynamique (2.1) 1. ✅ Multi-groupes avec sélection dynamique (2.1)
2. ✅ Mode PTT continu par appui long (2.2) 2. ✅ Mode PTT continu par appui long (2.2)
3. ✅ Interface admin web (/admin) pour gestion groupes (2.3) 3. ✅ Interface admin web (/admin) pour gestion groupes (2.3)
4. 🎯 **Configuration audio visuelle (2.5)** ← PRIORITÉ ABSOLUE 4. **Configuration audio visuelle (2.5)** - TERMINÉ
- Détection/sélection carte son via interface admin - Détection/sélection carte son via interface admin
- Nommage canaux (inputs/outputs) - Nommage canaux (inputs/outputs)
- Matrice routing style Dante Controller - Matrice routing style Dante Controller avec gains
- Sauvegarde automatique dans YAML - ✅ VU-mètres temps réel WebSocket
5. ⏭️ Préférences utilisateur pour mode PTT par défaut (2.2) - ✅ Sauvegarde automatique dans YAML
6. ⏭️ Web Push notifications pour appels privés (2.4) 5. ✅ Préférences utilisateur pour mode PTT par défaut (2.2)
6. ✅ Web Push notifications pour appels privés (2.4)
### Phase 3 - Préparation ### Phase 3 - Préparation
- Support Linux (JACK/PipeWire backends) - Support Linux (JACK/PipeWire backends)
+1
View File
@@ -0,0 +1 @@
if('serviceWorker' in navigator) navigator.serviceWorker.register('/dev-sw.js?dev-sw', { scope: '/', type: 'classic' })
+100
View File
@@ -0,0 +1,100 @@
/**
* Copyright 2018 Google Inc. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// If the loader is already loaded, just stop.
if (!self.define) {
let registry = {};
// Used for `eval` and `importScripts` where we can't get script URL by other means.
// In both cases, it's safe to use a global var because those functions are synchronous.
let nextDefineUri;
const singleRequire = (uri, parentUri) => {
uri = new URL(uri + ".js", parentUri).href;
return registry[uri] || (
new Promise(resolve => {
if ("document" in self) {
const script = document.createElement("script");
script.src = uri;
script.onload = resolve;
document.head.appendChild(script);
} else {
nextDefineUri = uri;
importScripts(uri);
resolve();
}
})
.then(() => {
let promise = registry[uri];
if (!promise) {
throw new Error(`Module ${uri} didnt register its module`);
}
return promise;
})
);
};
self.define = (depsNames, factory) => {
const uri = nextDefineUri || ("document" in self ? document.currentScript.src : "") || location.href;
if (registry[uri]) {
// Module is already loading or loaded.
return;
}
let exports = {};
const require = depUri => singleRequire(depUri, uri);
const specialDeps = {
module: { uri },
exports,
require
};
registry[uri] = Promise.all(depsNames.map(
depName => specialDeps[depName] || require(depName)
)).then(deps => {
factory(...deps);
return exports;
});
};
}
define(['./workbox-290dd570'], (function (workbox) { 'use strict';
self.skipWaiting();
workbox.clientsClaim();
/**
* The precacheAndRoute() method efficiently caches and responds to
* requests for URLs in the manifest.
* See https://goo.gl/S9QRab
*/
workbox.precacheAndRoute([{
"url": "registerSW.js",
"revision": "3ca0b8505b4bec776b69afdba2768812"
}, {
"url": "index.html",
"revision": "0.guj84039cv8"
}], {});
workbox.cleanupOutdatedCaches();
workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), {
allowlist: [/^\/$/]
}));
workbox.registerRoute(/^https:\/\/.*\.livekit\.cloud\/.*/i, new workbox.NetworkFirst({
"cacheName": "livekit-cache",
plugins: [new workbox.ExpirationPlugin({
maxEntries: 10,
maxAgeSeconds: 86400
})]
}), 'GET');
}));
//# sourceMappingURL=sw.js.map
//# sourceMappingURL=sw.js.map
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
+74
View File
@@ -0,0 +1,74 @@
// Service Worker personnalisé pour PTT Live
// Gère les notifications push pour les appels privés
self.addEventListener('install', (event) => {
console.log('Service Worker: Installation');
self.skipWaiting();
});
self.addEventListener('activate', (event) => {
console.log('Service Worker: Activation');
event.waitUntil(self.clients.claim());
});
// Écouter les notifications push du serveur
self.addEventListener('push', (event) => {
console.log('Service Worker: Push reçu');
let data = {
title: 'PTT Live',
body: 'Nouveau message',
icon: '/pwa-192x192.png',
badge: '/badge-72x72.png'
};
if (event.data) {
try {
data = event.data.json();
} catch (error) {
console.error('Erreur parsing push data:', error);
}
}
const options = {
body: data.body,
icon: data.icon || '/pwa-192x192.png',
badge: data.badge || '/badge-72x72.png',
vibrate: [200, 100, 200],
tag: data.tag || 'ptt-notification',
requireInteraction: data.requireInteraction || false,
data: data.data || {}
};
event.waitUntil(
self.registration.showNotification(data.title, options)
);
});
// Gérer les clics sur les notifications
self.addEventListener('notificationclick', (event) => {
console.log('Service Worker: Notification cliquée');
event.notification.close();
// Ouvrir l'application ou focus si déjà ouverte
event.waitUntil(
clients.matchAll({ type: 'window', includeUncontrolled: true })
.then((clientList) => {
// Si une fenêtre est déjà ouverte, la focus
for (const client of clientList) {
if (client.url.includes(self.registration.scope) && 'focus' in client) {
return client.focus();
}
}
// Sinon ouvrir une nouvelle fenêtre
if (clients.openWindow) {
return clients.openWindow('/');
}
})
);
});
// Gérer la fermeture des notifications
self.addEventListener('notificationclose', (event) => {
console.log('Service Worker: Notification fermée');
});
+16
View File
@@ -125,6 +125,22 @@
font-size: 0.85rem; font-size: 0.85rem;
} }
.btn-icon {
padding: var(--spacing-sm);
background: var(--color-surface-hover);
color: var(--color-text-secondary);
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
}
.btn-icon:hover {
background: var(--color-border);
color: var(--color-text);
}
.btn-disconnect { .btn-disconnect {
padding: var(--spacing-sm) var(--spacing-md); padding: var(--spacing-sm) var(--spacing-md);
background: var(--color-surface-hover); background: var(--color-surface-hover);
+50 -12
View File
@@ -1,8 +1,11 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import useLiveKit from './hooks/useLiveKit'; import useLiveKit from './hooks/useLiveKit';
import usePush from './hooks/usePush';
import PTTButton from './components/PTTButton'; import PTTButton from './components/PTTButton';
import UserList from './components/UserList'; import UserList from './components/UserList';
import GroupSelector from './components/GroupSelector'; import GroupSelector from './components/GroupSelector';
import Settings from './components/Settings';
import PWAInstallPrompt from './components/PWAInstallPrompt';
import './App.css'; import './App.css';
const API_URL = import.meta.env.VITE_API_URL || '/api'; const API_URL = import.meta.env.VITE_API_URL || '/api';
@@ -13,6 +16,7 @@ function App() {
const [groups, setGroups] = useState([]); const [groups, setGroups] = useState([]);
const [isConnecting, setIsConnecting] = useState(false); const [isConnecting, setIsConnecting] = useState(false);
const [error, setError] = useState(null); const [error, setError] = useState(null);
const [showSettings, setShowSettings] = useState(false);
const { const {
isConnected, isConnected,
@@ -23,9 +27,17 @@ function App() {
disconnect, disconnect,
switchGroup, switchGroup,
startTalking, startTalking,
stopTalking stopTalking,
toggleParticipantMute
} = useLiveKit(); } = useLiveKit();
const {
isSupported: isPushSupported,
isPermissionGranted: isPushGranted,
requestPermission: requestPushPermission,
showNotification
} = usePush();
// Charger configuration au démarrage // Charger configuration au démarrage
useEffect(() => { useEffect(() => {
fetch(`${API_URL}/config`) fetch(`${API_URL}/config`)
@@ -57,6 +69,12 @@ function App() {
setError(null); setError(null);
try { try {
// Demander permission notifications au premier lancement
if (isPushSupported && !isPushGranted) {
console.log('Demande permission notifications...');
await requestPushPermission();
}
// IMPORTANT iOS : Demander permission microphone AVANT tout // IMPORTANT iOS : Demander permission microphone AVANT tout
console.log('🎤 Demande permission microphone...'); console.log('🎤 Demande permission microphone...');
try { try {
@@ -91,8 +109,8 @@ function App() {
console.log('🔗 Connexion LiveKit:', livekitUrl); console.log('🔗 Connexion LiveKit:', livekitUrl);
// Se connecter à LiveKit // Se connecter à LiveKit avec les canaux virtuels
await connect(livekitUrl, data.token); await connect(livekitUrl, data.token, data.virtualChannels || []);
} catch (err) { } catch (err) {
console.error('Erreur connexion:', err); console.error('Erreur connexion:', err);
@@ -136,8 +154,8 @@ function App() {
livekitUrl = `${window.location.protocol}//${window.location.host}/livekit`; livekitUrl = `${window.location.protocol}//${window.location.host}/livekit`;
} }
// Changer de room LiveKit // Changer de room LiveKit avec les canaux virtuels du nouveau groupe
await switchGroup(livekitUrl, data.token); await switchGroup(livekitUrl, data.token, data.virtualChannels || []);
// Mettre à jour l'état // Mettre à jour l'état
setGroupId(newGroupId); setGroupId(newGroupId);
@@ -221,12 +239,23 @@ function App() {
{groups.find(g => g.id === groupId)?.name || groupId} {groups.find(g => g.id === groupId)?.name || groupId}
</p> </p>
</div> </div>
<button <div style={{ display: 'flex', gap: '0.5rem' }}>
className="btn-disconnect" <button
onClick={handleDisconnect} className="btn-icon"
> onClick={() => setShowSettings(true)}
Déconnexion title="Paramètres"
</button> >
<svg viewBox="0 0 24 24" fill="currentColor" width="20" height="20">
<path d="M19.14,12.94c0.04-0.3,0.06-0.61,0.06-0.94c0-0.32-0.02-0.64-0.07-0.94l2.03-1.58c0.18-0.14,0.23-0.41,0.12-0.61 l-1.92-3.32c-0.12-0.22-0.37-0.29-0.59-0.22l-2.39,0.96c-0.5-0.38-1.03-0.7-1.62-0.94L14.4,2.81c-0.04-0.24-0.24-0.41-0.48-0.41 h-3.84c-0.24,0-0.43,0.17-0.47,0.41L9.25,5.35C8.66,5.59,8.12,5.92,7.63,6.29L5.24,5.33c-0.22-0.08-0.47,0-0.59,0.22L2.74,8.87 C2.62,9.08,2.66,9.34,2.86,9.48l2.03,1.58C4.84,11.36,4.8,11.69,4.8,12s0.02,0.64,0.07,0.94l-2.03,1.58 c-0.18,0.14-0.23,0.41-0.12,0.61l1.92,3.32c0.12,0.22,0.37,0.29,0.59,0.22l2.39-0.96c0.5,0.38,1.03,0.7,1.62,0.94l0.36,2.54 c0.05,0.24,0.24,0.41,0.48,0.41h3.84c0.24,0,0.44-0.17,0.47-0.41l0.36-2.54c0.59-0.24,1.13-0.56,1.62-0.94l2.39,0.96 c0.22,0.08,0.47,0,0.59-0.22l1.92-3.32c0.12-0.22,0.07-0.47-0.12-0.61L19.14,12.94z M12,15.6c-1.98,0-3.6-1.62-3.6-3.6 s1.62-3.6,3.6-3.6s3.6,1.62,3.6,3.6S13.98,15.6,12,15.6z"/>
</svg>
</button>
<button
className="btn-disconnect"
onClick={handleDisconnect}
>
Déconnexion
</button>
</div>
</header> </header>
<main className="app-main"> <main className="app-main">
@@ -238,7 +267,10 @@ function App() {
/> />
{/* Liste des participants */} {/* Liste des participants */}
<UserList participants={participants} /> <UserList
participants={participants}
onToggleMute={toggleParticipantMute}
/>
{/* Bouton PTT principal avec VU-mètre intégré */} {/* Bouton PTT principal avec VU-mètre intégré */}
<PTTButton <PTTButton
@@ -248,6 +280,12 @@ function App() {
audioLevel={audioLevel} audioLevel={audioLevel}
/> />
</main> </main>
{/* Modal de paramètres */}
<Settings isOpen={showSettings} onClose={() => setShowSettings(false)} />
{/* Prompt installation PWA (iOS) */}
<PWAInstallPrompt />
</div> </div>
); );
} }
+93 -19
View File
@@ -19,6 +19,24 @@
font-weight: 600; font-weight: 600;
} }
.ws-status {
font-size: 0.8rem;
font-weight: 600;
padding: 4px 10px;
border-radius: 12px;
white-space: nowrap;
}
.ws-status.connected {
color: #44ff44;
background: rgba(68, 255, 68, 0.1);
}
.ws-status.disconnected {
color: #888;
background: rgba(136, 136, 136, 0.1);
}
.routing-section { .routing-section {
margin-bottom: var(--spacing-xl); margin-bottom: var(--spacing-xl);
} }
@@ -41,20 +59,18 @@
} }
.routing-matrix { .routing-matrix {
display: grid; display: inline-grid;
gap: 2px; gap: 2px;
background: var(--color-border); background: var(--color-border);
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
border-radius: 8px; border-radius: 8px;
overflow: hidden; overflow-x: auto;
max-width: 100%;
} }
.matrix-corner { .matrix-corner {
background: var(--color-surface-hover); background: var(--color-surface-hover);
} min-height: 50px;
.matrix-header-row {
display: contents;
} }
.matrix-header-cell { .matrix-header-cell {
@@ -72,10 +88,6 @@
hyphens: auto; hyphens: auto;
} }
.matrix-row {
display: contents;
}
.matrix-label-cell { .matrix-label-cell {
background: var(--color-surface-hover); background: var(--color-surface-hover);
padding: var(--spacing-sm); padding: var(--spacing-sm);
@@ -88,17 +100,50 @@
word-break: break-word; word-break: break-word;
} }
.label-content {
display: flex;
flex-direction: column;
gap: 4px;
width: 100%;
}
.label-text {
flex: 1;
}
.header-content {
display: flex;
flex-direction: column;
gap: 4px;
width: 100%;
align-items: center;
}
.header-text {
text-align: center;
}
.matrix-cell { .matrix-cell {
background: var(--color-bg); background: var(--color-bg);
padding: var(--spacing-md); padding: var(--spacing-sm);
min-height: 40px; min-height: 60px;
min-width: 60px; min-width: 80px;
cursor: pointer;
transition: all 0.2s; transition: all 0.2s;
display: flex; display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--spacing-xs);
position: relative;
}
.cell-checkbox {
width: 100%;
min-height: 30px;
display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
position: relative; cursor: pointer;
} }
.matrix-cell:hover { .matrix-cell:hover {
@@ -120,6 +165,25 @@
font-weight: bold; font-weight: bold;
} }
.gain-select {
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);
border-radius: 4px;
cursor: pointer;
font-weight: 600;
text-align: center;
}
.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);
}
@media (max-width: 1024px) { @media (max-width: 1024px) {
.matrix-header-cell, .matrix-header-cell,
.matrix-label-cell { .matrix-label-cell {
@@ -129,10 +193,15 @@
} }
.matrix-cell { .matrix-cell {
min-width: 50px; min-width: 70px;
min-height: 35px; min-height: 50px;
padding: var(--spacing-sm); padding: var(--spacing-sm);
} }
.gain-select {
font-size: 0.7rem;
padding: 3px 6px;
}
} }
@media (max-width: 768px) { @media (max-width: 768px) {
@@ -153,11 +222,16 @@
} }
.matrix-cell { .matrix-cell {
min-width: 40px; min-width: 65px;
min-height: 30px; min-height: 45px;
} }
.checkmark { .checkmark {
font-size: 1rem; font-size: 1rem;
} }
.gain-select {
font-size: 0.65rem;
padding: 2px 4px;
}
} }
+189 -51
View File
@@ -1,11 +1,15 @@
import { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import './AudioRoutingMatrix.css'; import './AudioRoutingMatrix.css';
import VUMeter from './VUMeter.jsx';
import { useAudioLevels } from '../hooks/useAudioLevels.js';
const API_URL = import.meta.env.VITE_API_URL || '/api'; const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000';
function AudioRoutingMatrix({ groups, channelNames }) { function AudioRoutingMatrix({ groups, channelNames }) {
const { levels, connected: wsConnected } = useAudioLevels();
const [routing, setRouting] = useState({ inputToGroup: {}, groupToOutput: {}, gains: {} }); const [routing, setRouting] = useState({ inputToGroup: {}, groupToOutput: {}, gains: {} });
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [showOnlyNamedChannels, setShowOnlyNamedChannels] = useState(false);
useEffect(() => { useEffect(() => {
loadRouting(); loadRouting();
@@ -14,6 +18,9 @@ function AudioRoutingMatrix({ groups, channelNames }) {
const loadRouting = async () => { const loadRouting = async () => {
try { try {
const res = await fetch(`${API_URL}/admin/audio/routing`); const res = await fetch(`${API_URL}/admin/audio/routing`);
if (!res.ok) {
throw new Error(`HTTP error! status: ${res.status}`);
}
const data = await res.json(); const data = await res.json();
setRouting(data.routing || { inputToGroup: {}, groupToOutput: {}, gains: {} }); setRouting(data.routing || { inputToGroup: {}, groupToOutput: {}, gains: {} });
} catch (error) { } catch (error) {
@@ -34,8 +41,9 @@ function AudioRoutingMatrix({ groups, channelNames }) {
if (res.ok) { if (res.ok) {
alert('Configuration de routing sauvegardée!'); alert('Configuration de routing sauvegardée!');
} else { } else {
const error = await res.json(); const errorText = await res.text();
alert(`Erreur: ${error.error}`); console.error('Erreur serveur:', errorText);
alert(`Erreur: ${res.status} - ${errorText}`);
} }
} catch (error) { } catch (error) {
console.error('Erreur sauvegarde routing:', error); console.error('Erreur sauvegarde routing:', error);
@@ -95,11 +103,64 @@ function AudioRoutingMatrix({ groups, channelNames }) {
return routing.groupToOutput[groupId]?.includes(outputId) || false; return routing.groupToOutput[groupId]?.includes(outputId) || false;
}; };
const getGainForInputToGroup = (inputId, groupId) => {
const key = `in_${inputId}_${groupId}`;
return routing.gains?.[key] || 0.0;
};
const getGainForGroupToOutput = (groupId, outputId) => {
const key = `${groupId}_out_${outputId}`;
return routing.gains?.[key] || 0.0;
};
const setGainForInputToGroup = (inputId, groupId, gainDb) => {
setRouting(prev => {
const gains = { ...prev.gains };
const key = `in_${inputId}_${groupId}`;
gains[key] = parseFloat(gainDb);
return { ...prev, gains };
});
};
const setGainForGroupToOutput = (groupId, outputId, gainDb) => {
setRouting(prev => {
const gains = { ...prev.gains };
const key = `${groupId}_out_${outputId}`;
gains[key] = parseFloat(gainDb);
return { ...prev, gains };
});
};
const formatGain = (gainDb) => {
if (gainDb === 0) return '0dB';
return gainDb > 0 ? `+${gainDb}dB` : `${gainDb}dB`;
};
const getChannelName = (type, id) => { const getChannelName = (type, id) => {
const name = channelNames?.[type]?.[id]; const name = channelNames?.[type]?.[id];
return name || `${type === 'inputs' ? 'Input' : 'Output'} ${id}`; return name || `${type === 'inputs' ? 'Input' : 'Output'} ${id}`;
}; };
const hasCustomName = (type, id) => {
return channelNames?.[type]?.[id] !== undefined;
};
const getVisibleInputChannels = () => {
const allInputs = Array.from({length: 8}, (_, i) => i);
if (showOnlyNamedChannels) {
return allInputs.filter(i => hasCustomName('inputs', i));
}
return allInputs;
};
const getVisibleOutputChannels = () => {
const allOutputs = Array.from({length: 8}, (_, i) => i);
if (showOnlyNamedChannels) {
return allOutputs.filter(i => hasCustomName('outputs', i));
}
return allOutputs;
};
if (loading) { if (loading) {
return <div style={{padding: 'var(--spacing-xl)', textAlign: 'center'}}>Chargement...</div>; return <div style={{padding: 'var(--spacing-xl)', textAlign: 'center'}}>Chargement...</div>;
} }
@@ -107,10 +168,28 @@ function AudioRoutingMatrix({ groups, channelNames }) {
return ( return (
<div className="routing-matrix-container"> <div className="routing-matrix-container">
<div className="routing-matrix-header"> <div className="routing-matrix-header">
<h3>Matrice de routing audio</h3> <div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
<button onClick={saveRouting} className="btn-primary"> <h3>Matrice de routing audio</h3>
Sauvegarder le routing <span
</button> className={`ws-status ${wsConnected ? 'connected' : 'disconnected'}`}
title={wsConnected ? 'Monitoring temps réel actif' : 'Monitoring temps réel déconnecté'}
>
{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"
checked={showOnlyNamedChannels}
onChange={(e) => setShowOnlyNamedChannels(e.target.checked)}
/>
<span>Afficher uniquement les canaux nommés</span>
</label>
<button onClick={saveRouting} className="btn-primary">
Sauvegarder le routing
</button>
</div>
</div> </div>
<div className="routing-section"> <div className="routing-section">
@@ -119,34 +198,61 @@ function AudioRoutingMatrix({ groups, channelNames }) {
Sélectionnez quels inputs audio alimentent chaque groupe Sélectionnez quels inputs audio alimentent chaque groupe
</p> </p>
<div className="routing-matrix"> <div className="routing-matrix" style={{gridTemplateColumns: `120px repeat(${groups.length}, minmax(60px, 1fr))`}}>
<div className="matrix-corner"></div> <div className="matrix-corner"></div>
<div className="matrix-header-row"> {groups.map(group => (
{groups.map(group => ( <div key={group.id} className="matrix-header-cell">
<div key={group.id} className="matrix-header-cell"> {group.name}
{group.name}
</div>
))}
</div>
{Array.from({length: 8}, (_, i) => (
<div key={`input-row-${i}`} className="matrix-row">
<div className="matrix-label-cell">
{getChannelName('inputs', i)}
</div>
{groups.map(group => (
<div
key={`${i}-${group.id}`}
className={`matrix-cell ${isInputRoutedToGroup(String(i), group.id) ? 'active' : ''}`}
onClick={() => toggleInputToGroup(String(i), group.id)}
>
{isInputRoutedToGroup(String(i), group.id) && <span className="checkmark"></span>}
</div>
))}
</div> </div>
))} ))}
{getVisibleInputChannels().map(i => (
<React.Fragment key={`input-row-${i}`}>
<div className="matrix-label-cell">
<div className="label-content">
<span className="label-text">{getChannelName('inputs', i)}</span>
{wsConnected && levels.inputs[i] && (
<VUMeter level={levels.inputs[i]} size="mini" />
)}
</div>
</div>
{groups.map(group => {
const isRouted = isInputRoutedToGroup(String(i), group.id);
const gain = getGainForInputToGroup(String(i), group.id);
return (
<div
key={`${i}-${group.id}`}
className={`matrix-cell ${isRouted ? 'active' : ''}`}
>
<div
className="cell-checkbox"
onClick={() => toggleInputToGroup(String(i), group.id)}
>
{isRouted && <span className="checkmark"></span>}
</div>
{isRouted && (
<select
className="gain-select"
value={gain}
onChange={(e) => setGainForInputToGroup(String(i), group.id, e.target.value)}
onClick={(e) => e.stopPropagation()}
>
<option value="-12">-12dB</option>
<option value="-6">-6dB</option>
<option value="-3">-3dB</option>
<option value="0">0dB</option>
<option value="3">+3dB</option>
<option value="6">+6dB</option>
</select>
)}
</div>
);
})}
</React.Fragment>
))}
</div> </div>
</div> </div>
@@ -156,33 +262,65 @@ function AudioRoutingMatrix({ groups, channelNames }) {
Sélectionnez vers quels outputs chaque groupe envoie son audio Sélectionnez vers quels outputs chaque groupe envoie son audio
</p> </p>
<div className="routing-matrix"> <div className="routing-matrix" style={{gridTemplateColumns: `120px repeat(${getVisibleOutputChannels().length}, minmax(60px, 1fr))`}}>
<div className="matrix-corner"></div> <div className="matrix-corner"></div>
<div className="matrix-header-row"> {getVisibleOutputChannels().map(i => (
{Array.from({length: 8}, (_, i) => ( <div key={`output-header-${i}`} className="matrix-header-cell">
<div key={`output-header-${i}`} className="matrix-header-cell"> <div className="header-content">
{getChannelName('outputs', i)} <span className="header-text">{getChannelName('outputs', i)}</span>
{wsConnected && levels.outputs[i] && (
<VUMeter level={levels.outputs[i]} size="mini" />
)}
</div> </div>
))} </div>
</div> ))}
{groups.map(group => ( {groups.map(group => (
<div key={`group-row-${group.id}`} className="matrix-row"> <React.Fragment key={`group-row-${group.id}`}>
<div className="matrix-label-cell"> <div className="matrix-label-cell">
{group.name} <div className="label-content">
<span className="label-text">{group.name}</span>
{wsConnected && levels.groups[group.id] && (
<VUMeter level={levels.groups[group.id]} size="mini" />
)}
</div>
</div> </div>
{Array.from({length: 8}, (_, i) => ( {getVisibleOutputChannels().map(i => {
<div const isRouted = isGroupRoutedToOutput(group.id, String(i));
key={`${group.id}-${i}`} const gain = getGainForGroupToOutput(group.id, String(i));
className={`matrix-cell ${isGroupRoutedToOutput(group.id, String(i)) ? 'active' : ''}`}
onClick={() => toggleGroupToOutput(group.id, String(i))} return (
> <div
{isGroupRoutedToOutput(group.id, String(i)) && <span className="checkmark"></span>} key={`${group.id}-${i}`}
</div> className={`matrix-cell ${isRouted ? 'active' : ''}`}
))} >
</div> <div
className="cell-checkbox"
onClick={() => toggleGroupToOutput(group.id, String(i))}
>
{isRouted && <span className="checkmark"></span>}
</div>
{isRouted && (
<select
className="gain-select"
value={gain}
onChange={(e) => setGainForGroupToOutput(group.id, String(i), e.target.value)}
onClick={(e) => e.stopPropagation()}
>
<option value="-12">-12dB</option>
<option value="-6">-6dB</option>
<option value="-3">-3dB</option>
<option value="0">0dB</option>
<option value="3">+3dB</option>
<option value="6">+6dB</option>
</select>
)}
</div>
);
})}
</React.Fragment>
))} ))}
</div> </div>
</div> </div>
+19 -7
View File
@@ -1,12 +1,13 @@
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import './PTTButton.css'; import './PTTButton.css';
import { loadSettings } from './Settings';
/** /**
* Bouton PTT principal * Bouton PTT principal
* Gère touch et mouse events pour desktop et mobile * Gère touch et mouse events pour desktop et mobile
* Modes : * Modes :
* - PTT classique : maintenir pour parler * - PTT classique : maintenir pour parler
* - Mode continu (lock) : glisser vers le haut pendant qu'on parle * - Mode continu (lock) : glisser vers le haut pendant qu'on parle OU mode par défaut
* Inclut VU-mètre intégré (anneau autour du bouton) * Inclut VU-mètre intégré (anneau autour du bouton)
*/ */
export default function PTTButton({ isTalking, onPressStart, onPressEnd, audioLevel = 0 }) { export default function PTTButton({ isTalking, onPressStart, onPressEnd, audioLevel = 0 }) {
@@ -14,12 +15,23 @@ export default function PTTButton({ isTalking, onPressStart, onPressEnd, audioLe
const isPressingRef = useRef(false); const isPressingRef = useRef(false);
const [isLockMode, setIsLockMode] = useState(false); const [isLockMode, setIsLockMode] = useState(false);
const isLockModeRef = useRef(false); // Ref pour accès immédiat dans event handlers const isLockModeRef = useRef(false); // Ref pour accès immédiat dans event handlers
const [settings, setSettings] = useState(loadSettings());
// Drag tracking // Drag tracking
const dragStartYRef = useRef(null); const dragStartYRef = useRef(null);
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;
@@ -207,8 +219,8 @@ export default function PTTButton({ isTalking, onPressStart, onPressEnd, audioLe
// Le micro est déjà actif (onPressStart a été appelé) // Le micro est déjà actif (onPressStart a été appelé)
// Vibration pour feedback // Vibration pour feedback (si activé dans les paramètres)
if (navigator.vibrate) { if (settings.vibrationEnabled && navigator.vibrate) {
navigator.vibrate([100, 50, 100]); navigator.vibrate([100, 50, 100]);
} }
}; };
@@ -226,8 +238,8 @@ export default function PTTButton({ isTalking, onPressStart, onPressEnd, audioLe
console.log('🔒 Mode lock ON'); console.log('🔒 Mode lock ON');
onPressStart(); onPressStart();
// Vibration pour feedback // Vibration pour feedback (si activé dans les paramètres)
if (navigator.vibrate) { if (settings.vibrationEnabled && navigator.vibrate) {
navigator.vibrate([100, 50, 100]); navigator.vibrate([100, 50, 100]);
} }
} else { } else {
@@ -235,8 +247,8 @@ export default function PTTButton({ isTalking, onPressStart, onPressEnd, audioLe
console.log('🔓 Mode lock OFF'); console.log('🔓 Mode lock OFF');
onPressEnd(); onPressEnd();
// Vibration pour feedback // Vibration pour feedback (si activé dans les paramètres)
if (navigator.vibrate) { if (settings.vibrationEnabled && navigator.vibrate) {
navigator.vibrate(50); navigator.vibrate(50);
} }
} }
+117
View File
@@ -0,0 +1,117 @@
.pwa-prompt-overlay {
position: fixed;
bottom: 0;
left: 0;
right: 0;
z-index: 1001;
animation: slideUp 0.3s ease;
}
@keyframes slideUp {
from {
transform: translateY(100%);
}
to {
transform: translateY(0);
}
}
.pwa-prompt {
background: var(--bg-secondary);
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;
}
.pwa-prompt-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--spacing-lg);
border-bottom: 1px solid var(--border-color);
}
.pwa-prompt-header h3 {
margin: 0;
font-size: 1.2rem;
color: var(--text-primary);
}
.pwa-prompt-close {
background: none;
border: none;
color: var(--text-secondary);
cursor: pointer;
padding: 0.5rem;
border-radius: 8px;
transition: all 0.2s;
}
.pwa-prompt-close:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.pwa-prompt-content {
padding: var(--spacing-lg);
}
.pwa-prompt-content > p {
margin: 0 0 var(--spacing-lg) 0;
color: var(--text-secondary);
line-height: 1.6;
}
.pwa-prompt-steps {
display: flex;
flex-direction: column;
gap: var(--spacing-md);
}
.pwa-prompt-step {
display: flex;
align-items: center;
gap: var(--spacing-md);
padding: var(--spacing-md);
background: var(--bg-hover);
border-radius: 8px;
}
.step-number {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
background: var(--primary-color);
color: white;
border-radius: 50%;
font-weight: 600;
flex-shrink: 0;
}
.pwa-prompt-step p {
flex: 1;
margin: 0;
color: var(--text-primary);
font-size: 0.95rem;
}
.pwa-prompt-step svg {
flex-shrink: 0;
color: var(--primary-color);
}
.pwa-prompt-footer {
padding: var(--spacing-lg);
border-top: 1px solid var(--border-color);
display: flex;
justify-content: center;
}
.pwa-prompt-footer .btn-primary {
width: 100%;
max-width: 300px;
}
@@ -0,0 +1,91 @@
import { useState, useEffect } from 'react';
import './PWAInstallPrompt.css';
/**
* Composant pour afficher un message d'onboarding PWA
* Spécialement pour iOS qui nécessite l'installation manuelle
*/
export default function PWAInstallPrompt() {
const [showPrompt, setShowPrompt] = useState(false);
const [isIOS, setIsIOS] = useState(false);
const [isStandalone, setIsStandalone] = useState(false);
useEffect(() => {
// Détecter iOS
const iOS = /iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream;
setIsIOS(iOS);
// Détecter si déjà en mode standalone (installé)
const standalone = window.matchMedia('(display-mode: standalone)').matches
|| window.navigator.standalone
|| document.referrer.includes('android-app://');
setIsStandalone(standalone);
// Vérifier si l'utilisateur a déjà vu le prompt
const hasSeenPrompt = localStorage.getItem('pwa-install-prompt-seen');
// Afficher le prompt si iOS, pas installé, et jamais vu
if (iOS && !standalone && !hasSeenPrompt) {
// Afficher après 3 secondes pour ne pas être intrusif
setTimeout(() => {
setShowPrompt(true);
}, 3000);
}
}, []);
const handleDismiss = () => {
setShowPrompt(false);
localStorage.setItem('pwa-install-prompt-seen', 'true');
};
if (!showPrompt || !isIOS || isStandalone) {
return null;
}
return (
<div className="pwa-prompt-overlay">
<div className="pwa-prompt">
<div className="pwa-prompt-header">
<h3>Installation requise pour les notifications</h3>
<button className="pwa-prompt-close" onClick={handleDismiss}>
<svg viewBox="0 0 24 24" fill="currentColor" width="24" height="24">
<path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12 19 6.41z"/>
</svg>
</button>
</div>
<div className="pwa-prompt-content">
<p>
Pour recevoir les notifications d'appels, vous devez installer l'application sur votre écran d'accueil.
</p>
<div className="pwa-prompt-steps">
<div className="pwa-prompt-step">
<div className="step-number">1</div>
<p>Appuyez sur le bouton <strong>Partager</strong></p>
<svg viewBox="0 0 24 24" fill="currentColor" width="24" height="24">
<path d="M16 5l-1.42 1.42-1.59-1.59V16h-1.98V4.83L9.42 6.42 8 5l4-4 4 4zm4 5v11c0 1.1-.9 2-2 2H6c-1.11 0-2-.9-2-2V10c0-1.11.89-2 2-2h3v2H6v11h12V10h-3V8h3c1.1 0 2 .89 2 2z"/>
</svg>
</div>
<div className="pwa-prompt-step">
<div className="step-number">2</div>
<p>Sélectionnez <strong>Sur l'écran d'accueil</strong></p>
</div>
<div className="pwa-prompt-step">
<div className="step-number">3</div>
<p>Tapez <strong>Ajouter</strong></p>
</div>
</div>
</div>
<div className="pwa-prompt-footer">
<button className="btn-primary" onClick={handleDismiss}>
J'ai compris
</button>
</div>
</div>
</div>
);
}
+139
View File
@@ -0,0 +1,139 @@
.settings-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: var(--spacing-md);
}
.settings-modal {
background: var(--bg-secondary);
border-radius: 12px;
width: 100%;
max-width: 500px;
max-height: 90vh;
overflow: auto;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.5);
}
.settings-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--spacing-lg);
border-bottom: 1px solid var(--border-color);
}
.settings-header h2 {
margin: 0;
font-size: 1.5rem;
}
.close-btn {
background: none;
border: none;
color: var(--text-secondary);
cursor: pointer;
padding: 0.5rem;
border-radius: 8px;
transition: all 0.2s;
}
.close-btn:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.settings-content {
padding: var(--spacing-lg);
}
.setting-section {
margin-bottom: var(--spacing-xl);
}
.setting-section:last-child {
margin-bottom: 0;
}
.setting-section h3 {
margin: 0 0 var(--spacing-sm) 0;
font-size: 1.1rem;
color: var(--text-primary);
}
.setting-description {
margin: 0 0 var(--spacing-md) 0;
color: var(--text-secondary);
font-size: 0.9rem;
}
.radio-option,
.checkbox-option {
display: flex;
align-items: flex-start;
gap: var(--spacing-md);
padding: var(--spacing-md);
border: 2px solid var(--border-color);
border-radius: 8px;
margin-bottom: var(--spacing-sm);
cursor: pointer;
transition: all 0.2s;
}
.radio-option:hover,
.checkbox-option:hover {
background: var(--bg-hover);
border-color: var(--primary-color);
}
.radio-option:has(input:checked),
.checkbox-option:has(input:checked) {
background: rgba(59, 130, 246, 0.1);
border-color: var(--primary-color);
}
.radio-option input[type="radio"],
.checkbox-option input[type="checkbox"] {
margin-top: 0.25rem;
cursor: pointer;
width: 18px;
height: 18px;
flex-shrink: 0;
}
.radio-option div,
.checkbox-option div {
flex: 1;
}
.radio-option strong,
.checkbox-option strong {
display: block;
margin-bottom: 0.25rem;
color: var(--text-primary);
}
.radio-option p,
.checkbox-option p {
margin: 0;
color: var(--text-secondary);
font-size: 0.85rem;
}
.settings-footer {
padding: var(--spacing-lg);
border-top: 1px solid var(--border-color);
display: flex;
justify-content: flex-end;
}
.settings-footer .btn-primary {
padding: var(--spacing-sm) var(--spacing-xl);
}
+141
View File
@@ -0,0 +1,141 @@
import { useState, useEffect } from 'react';
import './Settings.css';
const STORAGE_KEY = 'ptt-live-settings';
const defaultSettings = {
defaultPTTMode: 'normal', // 'normal' ou 'continuous'
vibrationEnabled: true,
audioFeedbackEnabled: true
};
/**
* Charge les paramètres depuis localStorage
*/
export function loadSettings() {
try {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored) {
return { ...defaultSettings, ...JSON.parse(stored) };
}
} catch (error) {
console.error('Erreur chargement paramètres:', error);
}
return defaultSettings;
}
/**
* Sauvegarde les paramètres dans localStorage
*/
export function saveSettings(settings) {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(settings));
} catch (error) {
console.error('Erreur sauvegarde paramètres:', error);
}
}
/**
* Composant modal de paramètres
*/
export default function Settings({ isOpen, onClose }) {
const [settings, setSettings] = useState(defaultSettings);
useEffect(() => {
if (isOpen) {
setSettings(loadSettings());
}
}, [isOpen]);
const handleChange = (key, value) => {
const newSettings = { ...settings, [key]: value };
setSettings(newSettings);
saveSettings(newSettings);
};
if (!isOpen) return null;
return (
<div className="settings-overlay" onClick={onClose}>
<div className="settings-modal" onClick={(e) => e.stopPropagation()}>
<div className="settings-header">
<h2>Paramètres</h2>
<button className="close-btn" onClick={onClose}>
<svg viewBox="0 0 24 24" fill="currentColor" width="24" height="24">
<path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12 19 6.41z"/>
</svg>
</button>
</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>
<label className="checkbox-option">
<input
type="checkbox"
checked={settings.vibrationEnabled}
onChange={(e) => handleChange('vibrationEnabled', e.target.checked)}
/>
<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>
</div>
</label>
</div>
</div>
<div className="settings-footer">
<button className="btn-primary" onClick={onClose}>
Fermer
</button>
</div>
</div>
</div>
);
}
+55
View File
@@ -84,6 +84,20 @@
background: rgba(16, 185, 129, 0.1); background: rgba(16, 185, 129, 0.1);
} }
/* Canal virtuel */
.user-item.virtual-channel {
border-left: 3px solid var(--color-accent);
}
.user-item.virtual-channel.muted {
opacity: 0.5;
border-left-color: var(--color-text-secondary);
}
.user-avatar.channel {
background: var(--color-accent);
}
/* Avatar */ /* Avatar */
.user-avatar { .user-avatar {
width: 40px; width: 40px;
@@ -190,3 +204,44 @@
max-height: 120px; max-height: 120px;
} }
} }
/* Bouton mute/unmute */
.mute-button {
width: 40px;
height: 40px;
border: none;
background: rgba(255, 255, 255, 0.1);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s;
flex-shrink: 0;
color: var(--color-text-primary);
}
.mute-button:hover {
background: rgba(255, 255, 255, 0.2);
transform: scale(1.05);
}
.mute-button:active {
transform: scale(0.95);
}
.mute-button svg {
width: 20px;
height: 20px;
}
.user-item.muted .mute-button {
background: rgba(239, 68, 68, 0.2);
color: var(--color-error);
}
.channel-label {
font-size: 0.75rem;
color: var(--color-accent);
font-weight: 500;
}
+47 -5
View File
@@ -1,27 +1,69 @@
import './UserList.css'; import './UserList.css';
/** /**
* Liste des participants connectés * Liste des participants connectés (utilisateurs + canaux virtuels)
*/ */
export default function UserList({ participants }) { export default function UserList({ participants, onToggleMute }) {
if (participants.length === 0) { if (participants.length === 0) {
return ( return (
<div className="user-list empty"> <div className="user-list empty">
<p className="empty-message">Aucun autre participant</p> <p className="empty-message">Aucun participant ou canal</p>
</div> </div>
); );
} }
// Séparer canaux virtuels et utilisateurs
const virtualChannels = participants.filter(p => p.isVirtual);
const users = participants.filter(p => !p.isVirtual);
return ( return (
<div className="user-list"> <div className="user-list">
<div className="user-list-header"> <div className="user-list-header">
<span className="user-count"> <span className="user-count">
{participants.length} participant{participants.length > 1 ? 's' : ''} {virtualChannels.length > 0 && `${virtualChannels.length} canal${virtualChannels.length > 1 ? 'aux' : ''}`}
{virtualChannels.length > 0 && users.length > 0 && ' • '}
{users.length > 0 && `${users.length} utilisateur${users.length > 1 ? 's' : ''}`}
</span> </span>
</div> </div>
<div className="user-list-items"> <div className="user-list-items">
{participants.map((participant) => ( {/* Canaux virtuels en premier */}
{virtualChannels.map((participant) => (
<div
key={participant.identity}
className={`user-item virtual-channel ${participant.isSpeaking ? 'speaking' : ''} ${participant.isMuted ? 'muted' : ''}`}
>
<div className="user-avatar channel">
<svg viewBox="0 0 24 24" fill="currentColor" width="24" height="24">
<path d="M12 3v9.28c-.47-.17-.97-.28-1.5-.28C8.01 12 6 14.01 6 16.5S8.01 21 10.5 21c2.31 0 4.2-1.75 4.45-4H15V6h4V3h-7z"/>
</svg>
</div>
<div className="user-info">
<span className="user-name">{participant.name}</span>
<span className="user-status channel-label">Canal audio</span>
</div>
<button
className="mute-button"
onClick={() => onToggleMute(participant.identity, participant.isVirtual)}
title={participant.isMuted ? 'Activer' : 'Désactiver'}
>
{participant.isMuted ? (
<svg viewBox="0 0 24 24" fill="currentColor">
<path d="M16.5 12c0-1.77-1.02-3.29-2.5-4.03v2.21l2.45 2.45c.03-.2.05-.41.05-.63zm2.5 0c0 .94-.2 1.82-.54 2.64l1.51 1.51C20.63 14.91 21 13.5 21 12c0-4.28-2.99-7.86-7-8.77v2.06c2.89.86 5 3.54 5 6.71zM4.27 3L3 4.27 7.73 9H3v6h4l5 5v-6.73l4.25 4.25c-.67.52-1.42.93-2.25 1.18v2.06c1.38-.31 2.63-.95 3.69-1.81L19.73 21 21 19.73l-9-9L4.27 3zM12 4L9.91 6.09 12 8.18V4z"/>
</svg>
) : (
<svg viewBox="0 0 24 24" fill="currentColor">
<path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z"/>
</svg>
)}
</button>
</div>
))}
{/* Utilisateurs WebRTC */}
{users.map((participant) => (
<div <div
key={participant.identity} key={participant.identity}
className={`user-item ${participant.isSpeaking ? 'speaking' : ''}`} className={`user-item ${participant.isSpeaking ? 'speaking' : ''}`}
+131
View File
@@ -0,0 +1,131 @@
/* VU-mètre version mini (pour matrice routing) */
.vu-meter-mini {
width: 100%;
height: 4px;
background: rgba(255, 255, 255, 0.1);
border-radius: 2px;
overflow: hidden;
position: relative;
}
.vu-meter-mini.clipping {
box-shadow: 0 0 4px rgba(255, 68, 68, 0.8);
}
.vu-meter-mini-bar {
height: 100%;
transition: width 50ms linear;
border-radius: 2px;
}
/* VU-mètre horizontal */
.vu-meter-horizontal {
width: 100%;
height: 20px;
position: relative;
}
.vu-meter-horizontal.small {
height: 12px;
}
.vu-meter-horizontal.medium {
height: 20px;
}
.vu-meter-horizontal.large {
height: 30px;
}
.vu-meter-horizontal .vu-meter-bar-container {
width: 100%;
height: 100%;
background: rgba(255, 255, 255, 0.1);
border-radius: 4px;
overflow: hidden;
position: relative;
}
.vu-meter-horizontal .vu-meter-bar-rms {
height: 100%;
transition: width 50ms linear;
border-radius: 4px;
}
.vu-meter-horizontal .vu-meter-bar-peak {
position: absolute;
top: 0;
height: 100%;
width: 2px;
background: rgba(255, 255, 255, 0.8);
transition: left 50ms linear;
}
.vu-meter-horizontal.clipping .vu-meter-bar-container {
box-shadow: 0 0 6px rgba(255, 68, 68, 0.8);
animation: clipping-pulse 200ms ease-in-out;
}
/* VU-mètre vertical */
.vu-meter-vertical {
height: 100px;
width: 20px;
position: relative;
}
.vu-meter-vertical.small {
height: 60px;
width: 12px;
}
.vu-meter-vertical.medium {
height: 100px;
width: 20px;
}
.vu-meter-vertical.large {
height: 150px;
width: 30px;
}
.vu-meter-vertical .vu-meter-bar-container {
width: 100%;
height: 100%;
background: rgba(255, 255, 255, 0.1);
border-radius: 4px;
overflow: hidden;
position: relative;
display: flex;
flex-direction: column;
justify-content: flex-end;
}
.vu-meter-vertical .vu-meter-bar-rms {
width: 100%;
transition: height 50ms linear;
border-radius: 4px;
}
.vu-meter-vertical .vu-meter-bar-peak {
position: absolute;
left: 0;
width: 100%;
height: 2px;
background: rgba(255, 255, 255, 0.8);
transition: bottom 50ms linear;
}
.vu-meter-vertical.clipping .vu-meter-bar-container {
box-shadow: 0 0 6px rgba(255, 68, 68, 0.8);
animation: clipping-pulse 200ms ease-in-out;
}
/* Animation clipping */
@keyframes clipping-pulse {
0%, 100% {
box-shadow: 0 0 6px rgba(255, 68, 68, 0.8);
}
50% {
box-shadow: 0 0 12px rgba(255, 68, 68, 1);
}
}
+102
View File
@@ -0,0 +1,102 @@
/**
* VUMeter.jsx
* Composant VU-mètre minimaliste pour affichage niveaux audio temps réel
*/
import React from 'react';
import './VUMeter.css';
/**
* Convertit une valeur dBFS en pourcentage pour affichage
* -120dBFS = 0%, 0dBFS = 100%
*/
function dbToPercent(dbFS) {
const min = -60; // On affiche à partir de -60dBFS
const max = 0;
if (dbFS <= min) return 0;
if (dbFS >= max) return 100;
return ((dbFS - min) / (max - min)) * 100;
}
/**
* Détermine la couleur selon le niveau (style VU professionnel)
*/
function getLevelColor(dbFS) {
if (dbFS >= -3) return '#ff4444'; // Rouge (clipping proche)
if (dbFS >= -12) return '#ffaa00'; // Orange (niveau élevé)
return '#44ff44'; // Vert (niveau nominal)
}
function VUMeter({ level, size = 'small', orientation = 'vertical' }) {
if (!level) {
level = { rms: -120, peak: 0, clipping: false };
}
const rmsPercent = dbToPercent(level.rms);
const peakPercent = (level.peak || 0) * 100;
const color = getLevelColor(level.rms);
const isClipping = level.clipping || level.peak >= 0.99;
if (size === 'mini') {
// Version ultra-compacte pour matrice routing
return (
<div className={`vu-meter-mini ${isClipping ? 'clipping' : ''}`}>
<div
className="vu-meter-mini-bar"
style={{
width: `${rmsPercent}%`,
backgroundColor: color
}}
/>
</div>
);
}
if (orientation === 'horizontal') {
return (
<div className={`vu-meter-horizontal ${size} ${isClipping ? 'clipping' : ''}`}>
<div className="vu-meter-bar-container">
<div
className="vu-meter-bar-rms"
style={{
width: `${rmsPercent}%`,
backgroundColor: color
}}
/>
{level.peak > 0 && (
<div
className="vu-meter-bar-peak"
style={{ left: `${peakPercent}%` }}
/>
)}
</div>
</div>
);
}
// Vertical (défaut)
return (
<div className={`vu-meter-vertical ${size} ${isClipping ? 'clipping' : ''}`}>
<div className="vu-meter-bar-container">
<div
className="vu-meter-bar-rms"
style={{
height: `${rmsPercent}%`,
backgroundColor: color
}}
/>
{level.peak > 0 && (
<div
className="vu-meter-bar-peak"
style={{ bottom: `${peakPercent}%` }}
/>
)}
</div>
</div>
);
}
export default VUMeter;
+143
View File
@@ -0,0 +1,143 @@
/**
* useAudioLevels.js
* Hook React pour recevoir les niveaux audio temps réel via WebSocket
*/
import { useState, useEffect, useRef } from 'react';
const WS_URL = import.meta.env.VITE_WS_AUDIO_LEVELS_URL || 'ws://localhost:3001';
/**
* Hook pour monitoring des niveaux audio temps réel
*/
export function useAudioLevels() {
const [levels, setLevels] = useState({
inputs: {},
groups: {},
outputs: {},
routing: {
activeInputs: [],
activeGroups: [],
activeOutputs: []
}
});
const [connected, setConnected] = useState(false);
const wsRef = useRef(null);
const reconnectTimeoutRef = useRef(null);
const reconnectAttemptsRef = useRef(0);
useEffect(() => {
connect();
return () => {
disconnect();
};
}, []);
const connect = () => {
try {
console.log('Connexion au WebSocket audio-levels...');
const ws = new WebSocket(WS_URL);
ws.onopen = () => {
console.log('WebSocket audio-levels connecté');
setConnected(true);
reconnectAttemptsRef.current = 0;
// Ping périodique pour maintenir la connexion
const pingInterval = setInterval(() => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'ping' }));
}
}, 10000);
ws.pingInterval = pingInterval;
};
ws.onmessage = (event) => {
try {
const message = JSON.parse(event.data);
switch (message.type) {
case 'initial':
case 'levels':
setLevels(message.data);
break;
case 'pong':
// Pong reçu, connexion active
break;
default:
console.warn('Message WebSocket inconnu:', message.type);
}
} catch (error) {
console.error('Erreur parsing message WebSocket:', error);
}
};
ws.onerror = (error) => {
console.error('Erreur WebSocket audio-levels:', error);
};
ws.onclose = () => {
console.log('WebSocket audio-levels déconnecté');
setConnected(false);
if (ws.pingInterval) {
clearInterval(ws.pingInterval);
}
// Reconnexion automatique avec backoff
const delay = Math.min(1000 * Math.pow(2, reconnectAttemptsRef.current), 30000);
console.log(`Reconnexion dans ${delay}ms...`);
reconnectTimeoutRef.current = setTimeout(() => {
reconnectAttemptsRef.current++;
connect();
}, delay);
};
wsRef.current = ws;
} catch (error) {
console.error('Erreur création WebSocket:', error);
setConnected(false);
}
};
const disconnect = () => {
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current);
reconnectTimeoutRef.current = null;
}
if (wsRef.current) {
if (wsRef.current.pingInterval) {
clearInterval(wsRef.current.pingInterval);
}
wsRef.current.close();
wsRef.current = null;
}
setConnected(false);
};
const setUpdateRate = (rateMs) => {
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify({
type: 'setUpdateRate',
rateMs
}));
}
};
return {
levels,
connected,
setUpdateRate
};
}
export default useAudioLevels;
+83 -9
View File
@@ -16,6 +16,8 @@ export default function useLiveKit() {
const analyserRef = useRef(null); const analyserRef = useRef(null);
const animationFrameRef = useRef(null); const animationFrameRef = useRef(null);
const isAudioUnlockedRef = useRef(false); const isAudioUnlockedRef = useRef(false);
const virtualChannelsRef = useRef([]);
const mutedChannelsRef = useRef(new Set()); // IDs des canaux muted
// Analyseur audio pour pistes distantes (audio entrant) // Analyseur audio pour pistes distantes (audio entrant)
const remoteAudioContextRef = useRef(null); const remoteAudioContextRef = useRef(null);
@@ -25,8 +27,11 @@ export default function useLiveKit() {
/** /**
* Connexion à la room LiveKit * Connexion à la room LiveKit
*/ */
const connect = useCallback(async (url, token) => { const connect = useCallback(async (url, token, virtualChannels = []) => {
try { try {
// Stocker les canaux virtuels
virtualChannelsRef.current = virtualChannels;
// Créer room // Créer room
const room = new Room({ const room = new Room({
adaptiveStream: true, adaptiveStream: true,
@@ -154,7 +159,7 @@ export default function useLiveKit() {
/** /**
* Changer de groupe (reconnexion à une nouvelle room) * Changer de groupe (reconnexion à une nouvelle room)
*/ */
const switchGroup = useCallback(async (url, token) => { const switchGroup = useCallback(async (url, token, virtualChannels = []) => {
console.log('🔄 Changement de groupe...'); console.log('🔄 Changement de groupe...');
// Déconnexion propre // Déconnexion propre
@@ -167,8 +172,11 @@ export default function useLiveKit() {
setIsConnected(false); setIsConnected(false);
setParticipants([]); setParticipants([]);
// Reset canaux muted
mutedChannelsRef.current.clear();
// Reconnexion avec nouveau token // Reconnexion avec nouveau token
await connect(url, token); await connect(url, token, virtualChannels);
}, [connect]); }, [connect]);
/** /**
@@ -252,15 +260,30 @@ export default function useLiveKit() {
}, []); }, []);
/** /**
* Mise à jour liste participants * Mise à jour liste participants (inclut canaux virtuels)
*/ */
const updateParticipants = () => { const updateParticipants = useCallback(() => {
if (!roomRef.current) return; if (!roomRef.current) return;
const room = roomRef.current; const room = roomRef.current;
const participantsList = []; const participantsList = [];
// Participants distants // Canaux virtuels (affichés en premier)
virtualChannelsRef.current.forEach((channel) => {
participantsList.push({
identity: channel.id,
name: channel.name,
isLocal: false,
isVirtual: true,
isSpeaking: false, // TODO: détection audio depuis bridge
hasAudio: true,
isMuted: mutedChannelsRef.current.has(channel.id),
audioInput: channel.audioInput,
audioOutput: channel.audioOutput
});
});
// Participants distants (utilisateurs WebRTC)
room.remoteParticipants.forEach((participant) => { room.remoteParticipants.forEach((participant) => {
const audioTracks = participant.audioTracks ? Array.from(participant.audioTracks.values()) : []; const audioTracks = participant.audioTracks ? Array.from(participant.audioTracks.values()) : [];
const audioPublication = audioTracks[0]; const audioPublication = audioTracks[0];
@@ -270,13 +293,63 @@ export default function useLiveKit() {
identity: participant.identity, identity: participant.identity,
name: participant.name || participant.identity, name: participant.name || participant.identity,
isLocal: false, isLocal: false,
isVirtual: false,
isSpeaking, isSpeaking,
hasAudio: audioPublication?.isSubscribed || false hasAudio: audioPublication?.isSubscribed || false,
isMuted: false
}); });
}); });
setParticipants(participantsList); setParticipants(participantsList);
}; }, []);
/**
* Toggle mute/unmute d'un participant (canal virtuel ou utilisateur)
*/
const toggleParticipantMute = useCallback((participantId, isVirtual) => {
if (isVirtual) {
// Canal virtuel : toggle dans l'état local
const isMuted = mutedChannelsRef.current.has(participantId);
if (isMuted) {
mutedChannelsRef.current.delete(participantId);
console.log('🔊 Canal virtuel unmuted:', participantId);
} else {
mutedChannelsRef.current.add(participantId);
console.log('🔇 Canal virtuel muted:', participantId);
}
// TODO Phase 3: Envoyer commande au bridge audio via DataChannel
// pour vraiment muter/unmuter le canal physique
// Mettre à jour l'affichage
updateParticipants();
} else {
// Utilisateur WebRTC : muter localement la lecture audio
if (!roomRef.current) return;
const participant = roomRef.current.remoteParticipants.get(participantId);
if (!participant) return;
const audioTracks = Array.from(participant.audioTracks.values());
const audioPublication = audioTracks[0];
if (audioPublication && audioPublication.audioTrack) {
const track = audioPublication.audioTrack;
const newMutedState = !track.isMuted;
if (newMutedState) {
track.mute();
console.log('🔇 Participant muted:', participantId);
} else {
track.unmute();
console.log('🔊 Participant unmuted:', participantId);
}
updateParticipants();
}
}
}, [updateParticipants]);
/** /**
* Setup analyseur audio pour VU-mètre (micro local) * Setup analyseur audio pour VU-mètre (micro local)
@@ -412,6 +485,7 @@ export default function useLiveKit() {
disconnect, disconnect,
switchGroup, switchGroup,
startTalking, startTalking,
stopTalking stopTalking,
toggleParticipantMute
}; };
} }
+150
View File
@@ -0,0 +1,150 @@
import { useState, useEffect, useCallback } from 'react';
/**
* Hook pour gérer les notifications Web Push
* Utilisé pour les appels privés et notifications de groupe
*/
export default function usePush() {
const [isSupported, setIsSupported] = useState(false);
const [isPermissionGranted, setIsPermissionGranted] = useState(false);
const [subscription, setSubscription] = useState(null);
useEffect(() => {
// Vérifier si les notifications sont supportées
const supported = 'Notification' in window && 'serviceWorker' in navigator;
setIsSupported(supported);
if (supported) {
// Vérifier la permission actuelle
setIsPermissionGranted(Notification.permission === 'granted');
}
}, []);
/**
* Demander la permission pour les notifications
*/
const requestPermission = useCallback(async () => {
if (!isSupported) {
console.warn('Notifications non supportées sur ce navigateur');
return false;
}
try {
const permission = await Notification.requestPermission();
const granted = permission === 'granted';
setIsPermissionGranted(granted);
if (granted) {
console.log('Permission notifications accordée');
} else {
console.warn('Permission notifications refusée');
}
return granted;
} catch (error) {
console.error('Erreur demande permission notifications:', error);
return false;
}
}, [isSupported]);
/**
* S'abonner aux notifications push (via service worker)
*/
const subscribeToPush = useCallback(async () => {
if (!isSupported || !isPermissionGranted) {
console.warn('Impossible de s\'abonner : permission non accordée');
return null;
}
try {
// Attendre que le service worker soit prêt
const registration = await navigator.serviceWorker.ready;
// Créer l'abonnement push
const sub = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(
// TODO: Remplacer par la vraie clé VAPID du serveur
import.meta.env.VITE_VAPID_PUBLIC_KEY || ''
)
});
console.log('Abonnement push créé:', sub);
setSubscription(sub);
return sub;
} catch (error) {
console.error('Erreur abonnement push:', error);
return null;
}
}, [isSupported, isPermissionGranted]);
/**
* Se désabonner des notifications push
*/
const unsubscribeFromPush = useCallback(async () => {
if (!subscription) {
return true;
}
try {
await subscription.unsubscribe();
console.log('Désabonnement push réussi');
setSubscription(null);
return true;
} catch (error) {
console.error('Erreur désabonnement push:', error);
return false;
}
}, [subscription]);
/**
* Envoyer une notification locale (sans push serveur)
*/
const showNotification = useCallback(async (title, options = {}) => {
if (!isSupported || !isPermissionGranted) {
console.warn('Impossible d\'afficher la notification : permission non accordée');
return;
}
try {
const registration = await navigator.serviceWorker.ready;
await registration.showNotification(title, {
icon: '/icon-192x192.png',
badge: '/badge-72x72.png',
vibrate: [200, 100, 200],
...options
});
} catch (error) {
console.error('Erreur affichage notification:', error);
}
}, [isSupported, isPermissionGranted]);
return {
isSupported,
isPermissionGranted,
subscription,
requestPermission,
subscribeToPush,
unsubscribeFromPush,
showNotification
};
}
/**
* Convertir une clé VAPID base64 en Uint8Array
*/
function urlBase64ToUint8Array(base64String) {
const padding = '='.repeat((4 - base64String.length % 4) % 4);
const base64 = (base64String + padding)
.replace(/\-/g, '+')
.replace(/_/g, '/');
const rawData = window.atob(base64);
const outputArray = new Uint8Array(rawData.length);
for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i);
}
return outputArray;
}
+2
View File
@@ -13,6 +13,8 @@
--color-success: #10b981; --color-success: #10b981;
--color-warning: #f59e0b; --color-warning: #f59e0b;
--color-danger: #ef4444; --color-danger: #ef4444;
--color-accent: #8b5cf6;
--color-error: #ef4444;
/* PTT States */ /* PTT States */
--color-ptt-idle: #374151; --color-ptt-idle: #374151;
+5 -1
View File
@@ -9,6 +9,10 @@ export default defineConfig({
VitePWA({ VitePWA({
registerType: 'autoUpdate', registerType: 'autoUpdate',
includeAssets: ['favicon.ico', 'robots.txt', 'apple-touch-icon.png'], includeAssets: ['favicon.ico', 'robots.txt', 'apple-touch-icon.png'],
injectRegister: 'auto',
devOptions: {
enabled: true
},
manifest: { manifest: {
name: 'PTT Live', name: 'PTT Live',
short_name: 'PTT Live', short_name: 'PTT Live',
@@ -69,7 +73,7 @@ export default defineConfig({
rewrite: (path) => path.replace(/^\/api/, '') rewrite: (path) => path.replace(/^\/api/, '')
}, },
'/livekit': { '/livekit': {
target: 'ws://10.1.1.111:7880', target: 'ws://192.168.0.146:7880',
ws: true, ws: true,
changeOrigin: true, changeOrigin: true,
rewrite: (path) => path.replace(/^\/livekit/, '') rewrite: (path) => path.replace(/^\/livekit/, '')
+12 -37
View File
@@ -52,16 +52,12 @@ function loadConfig() {
const configFile = readFileSync(configPath, 'utf8'); const configFile = readFileSync(configPath, 'utf8');
const config = YAML.parse(configFile); const config = YAML.parse(configFile);
// Générer les IDs pour les groupes et canaux // Générer les IDs pour les groupes
config.groups = config.groups.map(group => { config.groups = config.groups.map(group => {
const groupId = slugify(group.name); const groupId = slugify(group.name);
return { return {
...group, ...group,
id: groupId, id: groupId
channels: group.channels ? group.channels.map(channel => ({
...channel,
id: channel.id || `${groupId}-${slugify(channel.name)}`
})) : []
}; };
}); });
@@ -78,13 +74,7 @@ function saveConfig(config) {
...config, ...config,
groups: config.groups.map(group => { groups: config.groups.map(group => {
const { id, ...groupWithoutId } = group; const { id, ...groupWithoutId } = group;
return { return groupWithoutId;
...groupWithoutId,
channels: group.channels ? group.channels.map(channel => {
const { id: channelId, ...channelWithoutId } = channel;
return channelWithoutId;
}) : []
};
}) })
}; };
@@ -194,11 +184,11 @@ router.get('/groups', (req, res) => {
*/ */
router.post('/groups', (req, res) => { router.post('/groups', (req, res) => {
try { try {
const { name, audioBitrate, channels } = req.body; const { name, audioBitrate } = req.body;
if (!name || !channels || !Array.isArray(channels)) { if (!name) {
return res.status(400).json({ return res.status(400).json({
error: 'Missing required fields: name, channels' error: 'Missing required field: name'
}); });
} }
@@ -214,17 +204,10 @@ router.post('/groups', (req, res) => {
}); });
} }
// Générer les IDs pour les canaux // Créer le nouveau groupe (sans channels)
const channelsWithIds = channels.map(channel => ({
...channel,
id: channel.id || `${id}-${slugify(channel.name)}`
}));
// Créer le nouveau groupe
const newGroup = { const newGroup = {
name, name,
audioBitrate: audioBitrate || config.audio.defaultBitrate, ...(audioBitrate && { audioBitrate })
channels: channelsWithIds
}; };
config.groups.push(newGroup); config.groups.push(newGroup);
@@ -246,13 +229,13 @@ router.post('/groups', (req, res) => {
/** /**
* PUT /admin/groups/:id * PUT /admin/groups/:id
* Modifie un groupe existant * Modifie un groupe existant
* Body: { name?, audioBitrate?, channels? } * Body: { name?, audioBitrate? }
* Note: l'ID est un slug généré, on cherche le groupe par nom dans le YAML * Note: l'ID est un slug généré, on cherche le groupe par nom dans le YAML
*/ */
router.put('/groups/:id', (req, res) => { router.put('/groups/:id', (req, res) => {
try { try {
const { id } = req.params; const { id } = req.params;
const { name, audioBitrate, channels } = req.body; const { name, audioBitrate } = req.body;
const config = loadConfig(); const config = loadConfig();
@@ -268,14 +251,6 @@ router.put('/groups/:id', (req, res) => {
// Mettre à jour les champs fournis // Mettre à jour les champs fournis
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;
if (channels !== undefined) {
// Pas besoin de générer les IDs ici, ils seront générés au chargement
config.groups[groupIndex].channels = channels.map(channel => ({
name: channel.name,
audioInput: channel.audioInput,
audioOutput: channel.audioOutput
}));
}
saveConfig(config); saveConfig(config);
@@ -598,11 +573,11 @@ router.get('/audio/routing', (req, res) => {
}); });
/** /**
* POST /admin/audio/routing * POST /audio/routing
* Sauvegarde la configuration de routing * Sauvegarde la configuration de routing
* Body: { inputToGroup: {...}, groupToOutput: {...}, gains: {...} } * Body: { inputToGroup: {...}, groupToOutput: {...}, gains: {...} }
*/ */
router.post('/admin/audio/routing', (req, res) => { router.post('/audio/routing', (req, res) => {
try { try {
const { inputToGroup, groupToOutput, gains } = req.body; const { inputToGroup, groupToOutput, gains } = req.body;
+383
View File
@@ -0,0 +1,383 @@
/**
* GroupAudioRouter.js
* Gestion du routing audio multi-canaux entre entrées physiques, groupes LiveKit et sorties physiques
*
* Architecture :
* - Mix de plusieurs canaux physiques vers un groupe (avec gains individuels)
* - Distribution d'un groupe vers plusieurs canaux physiques (avec gains individuels)
* - Support canaux partagés (mixage additif)
* - Gestion gains par route (-120dB à +6dB)
*/
import { EventEmitter } from 'events';
/**
* Représente une route audio avec gain
*/
class AudioRoute {
constructor(source, destination, gain = 0.0) {
this.source = source; // Numéro de canal ou nom de groupe
this.destination = destination; // Nom de groupe ou numéro de canal
this.gain = gain; // Gain en dB (-120 à +6)
this.linearGain = this._dbToLinear(gain);
}
/**
* Met à jour le gain en dB
*/
setGain(gainDb) {
this.gain = Math.max(-120, Math.min(6, gainDb));
this.linearGain = this._dbToLinear(this.gain);
}
/**
* Convertit dB en gain linéaire
*/
_dbToLinear(db) {
if (db <= -120) return 0.0;
return Math.pow(10, db / 20);
}
}
/**
* Router audio principal
*/
export class GroupAudioRouter extends EventEmitter {
constructor(config = {}) {
super();
this.config = {
sampleRate: config.sampleRate || 48000,
frameSize: config.frameSize || 960, // 20ms à 48kHz
maxInputChannels: config.maxInputChannels || 32,
maxOutputChannels: config.maxOutputChannels || 32,
groups: config.groups || []
};
// Routes : input -> group
this.inputToGroupRoutes = new Map(); // Map<string, AudioRoute[]>
// Routes : group -> output
this.groupToOutputRoutes = new Map(); // Map<string, AudioRoute[]>
// Buffers audio
this.inputBuffers = new Map(); // Map<number, Float32Array>
this.groupBuffers = new Map(); // Map<string, Float32Array>
this.outputBuffers = new Map(); // Map<number, Float32Array>
// Statistiques
this.stats = {
framesProcessed: 0,
clippingEvents: 0,
routesActive: 0
};
}
/**
* Configure le routing depuis la config YAML
*/
configure(routingConfig) {
console.log('Configuration du routing audio...');
// Réinitialise les routes
this.inputToGroupRoutes.clear();
this.groupToOutputRoutes.clear();
// Configure input -> group
if (routingConfig.inputToGroup) {
Object.entries(routingConfig.inputToGroup).forEach(([channelId, groups]) => {
const channel = parseInt(channelId);
groups.forEach(groupName => {
this.addInputToGroupRoute(channel, groupName, this._getGain(routingConfig.gains, `in_${channel}_${groupName}`));
});
});
}
// Configure group -> output
if (routingConfig.groupToOutput) {
Object.entries(routingConfig.groupToOutput).forEach(([groupName, channels]) => {
channels.forEach(channelId => {
const channel = parseInt(channelId);
this.addGroupToOutputRoute(groupName, channel, this._getGain(routingConfig.gains, `${groupName}_out_${channel}`));
});
});
}
this._updateStatsActiveRoutes();
console.log(`Routing configuré : ${this.stats.routesActive} routes actives`);
this.emit('configured', this.stats);
}
/**
* Récupère le gain depuis la config
*/
_getGain(gainsConfig, routeKey) {
return gainsConfig && gainsConfig[routeKey] ? gainsConfig[routeKey] : 0.0;
}
/**
* Ajoute une route input -> group
*/
addInputToGroupRoute(inputChannel, groupName, gainDb = 0.0) {
const key = `in_${inputChannel}`;
if (!this.inputToGroupRoutes.has(key)) {
this.inputToGroupRoutes.set(key, []);
}
const route = new AudioRoute(inputChannel, groupName, gainDb);
this.inputToGroupRoutes.get(key).push(route);
console.log(`Route ajoutée : Input ${inputChannel} -> Group "${groupName}" (${gainDb}dB)`);
this._updateStatsActiveRoutes();
}
/**
* Ajoute une route group -> output
*/
addGroupToOutputRoute(groupName, outputChannel, gainDb = 0.0) {
const key = groupName;
if (!this.groupToOutputRoutes.has(key)) {
this.groupToOutputRoutes.set(key, []);
}
const route = new AudioRoute(groupName, outputChannel, gainDb);
this.groupToOutputRoutes.get(key).push(route);
console.log(`Route ajoutée : Group "${groupName}" -> Output ${outputChannel} (${gainDb}dB)`);
this._updateStatsActiveRoutes();
}
/**
* Supprime toutes les routes d'une entrée
*/
removeInputRoutes(inputChannel) {
this.inputToGroupRoutes.delete(`in_${inputChannel}`);
this._updateStatsActiveRoutes();
}
/**
* Supprime toutes les routes d'un groupe vers les sorties
*/
removeGroupOutputRoutes(groupName) {
this.groupToOutputRoutes.delete(groupName);
this._updateStatsActiveRoutes();
}
/**
* Met à jour le gain d'une route spécifique
*/
setRouteGain(source, destination, gainDb) {
// Cherche dans input -> group
const inputKey = typeof source === 'number' ? `in_${source}` : null;
if (inputKey && this.inputToGroupRoutes.has(inputKey)) {
const routes = this.inputToGroupRoutes.get(inputKey);
const route = routes.find(r => r.destination === destination);
if (route) {
route.setGain(gainDb);
console.log(`Gain modifié : Input ${source} -> Group "${destination}" = ${gainDb}dB`);
return true;
}
}
// Cherche dans group -> output
if (typeof source === 'string' && this.groupToOutputRoutes.has(source)) {
const routes = this.groupToOutputRoutes.get(source);
const route = routes.find(r => r.destination === destination);
if (route) {
route.setGain(gainDb);
console.log(`Gain modifié : Group "${source}" -> Output ${destination} = ${gainDb}dB`);
return true;
}
}
return false;
}
/**
* ÉTAPE 1 : Traite les entrées audio physiques vers les buffers de groupe
* Mixe plusieurs canaux d'entrée vers chaque groupe (avec gains individuels)
*
* @param {Map<number, Float32Array>} inputChannelsData - Données PCM par canal d'entrée
*/
processInputsToGroups(inputChannelsData) {
// Réinitialise les buffers de groupe
this.groupBuffers.clear();
this.config.groups.forEach(group => {
this.groupBuffers.set(group.name, new Float32Array(this.config.frameSize));
});
// Pour chaque canal d'entrée
inputChannelsData.forEach((pcmData, channelId) => {
const key = `in_${channelId}`;
const routes = this.inputToGroupRoutes.get(key);
if (!routes || routes.length === 0) return;
// Stocke le buffer d'entrée
this.inputBuffers.set(channelId, pcmData);
// Applique chaque route (mixage additif vers les groupes)
routes.forEach(route => {
const groupBuffer = this.groupBuffers.get(route.destination);
if (!groupBuffer) return;
// Mixage avec gain
for (let i = 0; i < pcmData.length && i < groupBuffer.length; i++) {
groupBuffer[i] += pcmData[i] * route.linearGain;
}
});
});
// Normalisation anti-clipping (soft limiter simple)
this.groupBuffers.forEach((buffer, groupName) => {
for (let i = 0; i < buffer.length; i++) {
if (Math.abs(buffer[i]) > 1.0) {
this.stats.clippingEvents++;
buffer[i] = Math.sign(buffer[i]) * 1.0; // Hard clipping
}
}
});
this.stats.framesProcessed++;
return this.groupBuffers;
}
/**
* ÉTAPE 2 : Traite les buffers de groupe vers les sorties audio physiques
* Distribue chaque groupe vers plusieurs canaux de sortie (avec gains individuels)
* Support du mixage additif si plusieurs groupes vont vers la même sortie
*
* @param {Map<string, Float32Array>} groupBuffersData - Données PCM par groupe (depuis LiveKit)
* @returns {Map<number, Float32Array>} Buffers de sortie par canal physique
*/
processGroupsToOutputs(groupBuffersData) {
// Réinitialise les buffers de sortie
this.outputBuffers.clear();
// Pour chaque groupe
groupBuffersData.forEach((pcmData, groupName) => {
const routes = this.groupToOutputRoutes.get(groupName);
if (!routes || routes.length === 0) return;
// Applique chaque route vers les sorties
routes.forEach(route => {
const outputChannel = route.destination;
// Crée le buffer de sortie si nécessaire
if (!this.outputBuffers.has(outputChannel)) {
this.outputBuffers.set(outputChannel, new Float32Array(this.config.frameSize));
}
const outputBuffer = this.outputBuffers.get(outputChannel);
// Mixage avec gain (additif si canal partagé)
for (let i = 0; i < pcmData.length && i < outputBuffer.length; i++) {
outputBuffer[i] += pcmData[i] * route.linearGain;
}
});
});
// Normalisation anti-clipping sur les sorties
this.outputBuffers.forEach((buffer, channelId) => {
for (let i = 0; i < buffer.length; i++) {
if (Math.abs(buffer[i]) > 1.0) {
this.stats.clippingEvents++;
buffer[i] = Math.sign(buffer[i]) * 1.0; // Hard clipping
}
}
});
return this.outputBuffers;
}
/**
* Récupère le buffer d'un groupe spécifique
*/
getGroupBuffer(groupName) {
return this.groupBuffers.get(groupName) || null;
}
/**
* Récupère le buffer d'une sortie spécifique
*/
getOutputBuffer(channelId) {
return this.outputBuffers.get(channelId) || null;
}
/**
* Récupère toutes les routes configurées
*/
getRoutingConfig() {
const inputToGroup = {};
const groupToOutput = {};
const gains = {};
// Input -> Group
this.inputToGroupRoutes.forEach((routes, key) => {
const inputChannel = key.replace('in_', '');
inputToGroup[inputChannel] = routes.map(r => r.destination);
routes.forEach(route => {
if (route.gain !== 0.0) {
gains[`in_${inputChannel}_${route.destination}`] = route.gain;
}
});
});
// Group -> Output
this.groupToOutputRoutes.forEach((routes, groupName) => {
groupToOutput[groupName] = routes.map(r => r.destination);
routes.forEach(route => {
if (route.gain !== 0.0) {
gains[`${groupName}_out_${route.destination}`] = route.gain;
}
});
});
return { inputToGroup, groupToOutput, gains };
}
/**
* Récupère les statistiques
*/
getStats() {
return {
framesProcessed: this.stats.framesProcessed,
clippingEvents: this.stats.clippingEvents,
routesActive: this.stats.routesActive,
inputToGroupRoutes: this.inputToGroupRoutes.size,
groupToOutputRoutes: this.groupToOutputRoutes.size,
activeGroups: this.groupBuffers.size,
activeOutputs: this.outputBuffers.size
};
}
/**
* Met à jour le compteur de routes actives
*/
_updateStatsActiveRoutes() {
let count = 0;
this.inputToGroupRoutes.forEach(routes => count += routes.length);
this.groupToOutputRoutes.forEach(routes => count += routes.length);
this.stats.routesActive = count;
}
/**
* Détruit le router et libère les ressources
*/
destroy() {
this.inputToGroupRoutes.clear();
this.groupToOutputRoutes.clear();
this.inputBuffers.clear();
this.groupBuffers.clear();
this.outputBuffers.clear();
this.removeAllListeners();
console.log('GroupAudioRouter détruit');
}
}
export default GroupAudioRouter;
+24 -18
View File
@@ -7,30 +7,36 @@ audio:
inputDeviceId: 0 inputDeviceId: 0
outputDeviceId: 2 outputDeviceId: 2
sampleRate: 48000 sampleRate: 48000
routing:
inputToGroup:
"1":
- technique
"2":
- technique
"4":
- technique
"5":
- technique
groupToOutput: {}
gains: {}
channelNames:
inputs:
"0": "Micro Régisseur"
"1": "Talkback FOH"
"2": "Retour Console"
"3": "Liaison Scène"
"4": "Monitor Mix"
"5": "Spare 1"
outputs:
"0": "Sortie Principale"
"1": "Retour Scène"
"2": "Talkback Console"
groups: groups:
- name: Production - name: Production
audioBitrate: 96 audioBitrate: 96
channels:
- name: Principal
audioInput: 0
audioOutput: 0
- name: Backup
audioInput: 1
audioOutput: 1
- name: Technique - name: Technique
channels:
- name: Général
audioInput: 2
audioOutput: 2
- name: Sonorisation - name: Sonorisation
audioBitrate: 128 audioBitrate: 128
channels:
- name: Principal
audioInput: 3
audioOutput: 3
- name: Retours
audioInput: 4
audioOutput: 4
server: server:
host: 0.0.0.0 host: 0.0.0.0
port: 3000 port: 3000
+21 -6
View File
@@ -196,11 +196,7 @@ app.get('/config', (req, res) => {
const clientConfig = { const clientConfig = {
groups: config.groups.map(g => ({ groups: config.groups.map(g => ({
id: g.id, id: g.id,
name: g.name, name: g.name
channels: g.channels.map(c => ({
id: c.id,
name: c.name
}))
})), })),
audio: { audio: {
sampleRate: config.audio.sampleRate, sampleRate: config.audio.sampleRate,
@@ -281,11 +277,30 @@ app.post('/token', async (req, res) => {
// Enregistrer l'utilisateur dans le système admin // Enregistrer l'utilisateur dans le système admin
registerUser(participantIdentity, username, groupId, roomName); registerUser(participantIdentity, username, groupId, roomName);
// Générer les canaux virtuels depuis le routing (inputs uniquement)
const virtualChannels = [];
const inputToGroup = config.audio?.routing?.inputToGroup || {};
const channelNames = config.audio?.channelNames?.inputs || {};
// Trouver tous les canaux physiques routés vers ce groupe
for (const [inputChannel, groups] of Object.entries(inputToGroup)) {
if (groups.includes(groupId)) {
const channelName = channelNames[inputChannel] || `Canal ${inputChannel}`;
virtualChannels.push({
id: `input-${inputChannel}`,
name: channelName,
isVirtual: true,
audioInput: parseInt(inputChannel, 10)
});
}
}
res.json({ res.json({
token, token,
url: LIVEKIT_URL, url: LIVEKIT_URL,
roomName, roomName,
participantIdentity participantIdentity,
virtualChannels
}); });
} catch (error) { } catch (error) {
+366
View File
@@ -0,0 +1,366 @@
/**
* AudioLevelsServer.js
* WebSocket server pour streaming des niveaux audio temps réel
*
* Permet à l'interface admin de visualiser :
* - Niveaux d'entrée physiques (VU-mètres)
* - Niveaux de groupes LiveKit
* - Niveaux de sortie physiques
* - Détection de clipping
* - État des routes actives
*/
import { WebSocketServer } from 'ws';
import { EventEmitter } from 'events';
/**
* Calcule le niveau RMS d'un buffer audio (dBFS)
*/
function calculateRMS(buffer) {
if (!buffer || buffer.length === 0) return -120; // Silence
let sum = 0;
for (let i = 0; i < buffer.length; i++) {
sum += buffer[i] * buffer[i];
}
const rms = Math.sqrt(sum / buffer.length);
// Conversion en dBFS (0dBFS = niveau max)
if (rms === 0) return -120;
const dbFS = 20 * Math.log10(rms);
return Math.max(-120, Math.min(0, dbFS));
}
/**
* Calcule le peak d'un buffer audio
*/
function calculatePeak(buffer) {
if (!buffer || buffer.length === 0) return 0;
let peak = 0;
for (let i = 0; i < buffer.length; i++) {
peak = Math.max(peak, Math.abs(buffer[i]));
}
return peak;
}
/**
* Serveur WebSocket pour monitoring audio
*/
export class AudioLevelsServer extends EventEmitter {
constructor(options = {}) {
super();
this.options = {
port: options.port || 3001,
updateRateMs: options.updateRateMs || 50, // 20 fois/sec
...options
};
this.wss = null;
this.clients = new Set();
this.updateInterval = null;
// Données à broadcaster
this.levels = {
inputs: {}, // { channelId: { rms: -12, peak: 0.5, clipping: false } }
groups: {}, // { groupName: { rms: -8, peak: 0.7, clipping: false } }
outputs: {}, // { channelId: { rms: -10, peak: 0.6, clipping: false } }
routing: {
activeInputs: [],
activeGroups: [],
activeOutputs: []
}
};
this.stats = {
connectedClients: 0,
messagesSent: 0,
errors: 0
};
}
/**
* Démarre le serveur WebSocket
*/
start() {
return new Promise((resolve, reject) => {
try {
this.wss = new WebSocketServer({ port: this.options.port });
this.wss.on('connection', (ws, req) => {
this._handleNewConnection(ws, req);
});
this.wss.on('error', (error) => {
console.error('Erreur WebSocket server:', error);
this.stats.errors++;
this.emit('error', error);
});
// Démarrage du broadcast périodique
this._startBroadcast();
console.log(`WebSocket AudioLevels démarré sur ws://localhost:${this.options.port}`);
this.emit('started');
resolve();
} catch (error) {
reject(error);
}
});
}
/**
* Gère une nouvelle connexion client
*/
_handleNewConnection(ws, req) {
const clientId = `${req.socket.remoteAddress}:${req.socket.remotePort}`;
console.log(`Nouveau client audio-levels: ${clientId}`);
this.clients.add(ws);
this.stats.connectedClients = this.clients.size;
// Envoi des données actuelles immédiatement
this._sendToClient(ws, {
type: 'initial',
data: this.levels
});
ws.on('message', (message) => {
try {
const data = JSON.parse(message);
this._handleClientMessage(ws, data);
} catch (error) {
console.error('Erreur parsing message client:', error);
}
});
ws.on('close', () => {
console.log(`Client déconnecté: ${clientId}`);
this.clients.delete(ws);
this.stats.connectedClients = this.clients.size;
});
ws.on('error', (error) => {
console.error(`Erreur client ${clientId}:`, error);
this.clients.delete(ws);
this.stats.connectedClients = this.clients.size;
});
this.emit('clientConnected', { clientId, totalClients: this.clients.size });
}
/**
* Gère les messages entrants des clients
*/
_handleClientMessage(ws, message) {
switch (message.type) {
case 'ping':
this._sendToClient(ws, { type: 'pong', timestamp: Date.now() });
break;
case 'setUpdateRate':
// Permet au client de modifier le taux de rafraîchissement
if (message.rateMs >= 20 && message.rateMs <= 1000) {
this._restartBroadcast(message.rateMs);
}
break;
default:
console.warn('Message client inconnu:', message.type);
}
}
/**
* Démarre le broadcast périodique
*/
_startBroadcast() {
if (this.updateInterval) {
clearInterval(this.updateInterval);
}
this.updateInterval = setInterval(() => {
this._broadcastLevels();
}, this.options.updateRateMs);
}
/**
* Redémarre le broadcast avec un nouveau taux
*/
_restartBroadcast(newRateMs) {
this.options.updateRateMs = newRateMs;
this._startBroadcast();
console.log(`Taux de rafraîchissement modifié: ${newRateMs}ms`);
}
/**
* Broadcast les niveaux à tous les clients connectés
*/
_broadcastLevels() {
if (this.clients.size === 0) return;
const message = {
type: 'levels',
timestamp: Date.now(),
data: this.levels
};
this._broadcast(message);
}
/**
* Envoie un message à tous les clients
*/
_broadcast(message) {
const payload = JSON.stringify(message);
this.clients.forEach(ws => {
if (ws.readyState === 1) { // OPEN
try {
ws.send(payload);
this.stats.messagesSent++;
} catch (error) {
console.error('Erreur envoi message:', error);
this.stats.errors++;
}
}
});
}
/**
* Envoie un message à un client spécifique
*/
_sendToClient(ws, message) {
if (ws.readyState === 1) {
try {
ws.send(JSON.stringify(message));
this.stats.messagesSent++;
} catch (error) {
console.error('Erreur envoi message client:', error);
this.stats.errors++;
}
}
}
/**
* Met à jour les niveaux d'entrée
* Appelé par le GroupAudioRouter après processInputsToGroups()
*/
updateInputLevels(inputBuffers) {
inputBuffers.forEach((buffer, channelId) => {
const rms = calculateRMS(buffer);
const peak = calculatePeak(buffer);
const clipping = peak >= 0.99;
this.levels.inputs[channelId] = { rms, peak, clipping };
});
this.levels.routing.activeInputs = Array.from(inputBuffers.keys());
}
/**
* Met à jour les niveaux de groupe
* Appelé par le GroupAudioRouter après processInputsToGroups()
*/
updateGroupLevels(groupBuffers) {
groupBuffers.forEach((buffer, groupName) => {
const rms = calculateRMS(buffer);
const peak = calculatePeak(buffer);
const clipping = peak >= 0.99;
this.levels.groups[groupName] = { rms, peak, clipping };
});
this.levels.routing.activeGroups = Array.from(groupBuffers.keys());
}
/**
* Met à jour les niveaux de sortie
* Appelé par le GroupAudioRouter après processGroupsToOutputs()
*/
updateOutputLevels(outputBuffers) {
outputBuffers.forEach((buffer, channelId) => {
const rms = calculateRMS(buffer);
const peak = calculatePeak(buffer);
const clipping = peak >= 0.99;
this.levels.outputs[channelId] = { rms, peak, clipping };
});
this.levels.routing.activeOutputs = Array.from(outputBuffers.keys());
}
/**
* Réinitialise tous les niveaux (silence)
*/
resetLevels() {
this.levels = {
inputs: {},
groups: {},
outputs: {},
routing: {
activeInputs: [],
activeGroups: [],
activeOutputs: []
}
};
}
/**
* Récupère les statistiques
*/
getStats() {
return {
...this.stats,
updateRateMs: this.options.updateRateMs,
port: this.options.port
};
}
/**
* Arrête le serveur
*/
async stop() {
console.log('Arrêt AudioLevelsServer...');
if (this.updateInterval) {
clearInterval(this.updateInterval);
this.updateInterval = null;
}
if (this.wss) {
// Ferme toutes les connexions clients
this.clients.forEach(ws => {
ws.close(1000, 'Server shutdown');
});
this.clients.clear();
// Ferme le serveur
await new Promise((resolve) => {
this.wss.close(() => {
console.log('WebSocket AudioLevels arrêté');
resolve();
});
});
this.wss = null;
}
this.emit('stopped');
}
/**
* Détruit le serveur
*/
async destroy() {
await this.stop();
this.removeAllListeners();
console.log('AudioLevelsServer détruit');
}
}
export default AudioLevelsServer;