Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9654c7f421 | |||
| f5a5643f4b | |||
| b64bac1f3d | |||
| 5ae9dfe2ac | |||
| 8c43c7e8af | |||
| 3ee474d90c | |||
| 86b86e9037 | |||
| 7682b90557 | |||
| 63147f93f4 | |||
| 42badb1fdf | |||
| 7037517ca2 | |||
| ba3d32fd3d | |||
| 0b31708b48 |
@@ -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)
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
if('serviceWorker' in navigator) navigator.serviceWorker.register('/dev-sw.js?dev-sw', { scope: '/', type: 'classic' })
|
||||||
@@ -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} didn’t 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
@@ -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');
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
|||||||
+44
-6
@@ -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>
|
||||||
|
<div style={{ display: 'flex', gap: '0.5rem' }}>
|
||||||
|
<button
|
||||||
|
className="btn-icon"
|
||||||
|
onClick={() => setShowSettings(true)}
|
||||||
|
title="Paramètres"
|
||||||
|
>
|
||||||
|
<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
|
<button
|
||||||
className="btn-disconnect"
|
className="btn-disconnect"
|
||||||
onClick={handleDisconnect}
|
onClick={handleDisconnect}
|
||||||
>
|
>
|
||||||
Déconnexion
|
Déconnexion
|
||||||
</button>
|
</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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,11 +168,29 @@ 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">
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
|
||||||
<h3>Matrice de routing audio</h3>
|
<h3>Matrice de routing audio</h3>
|
||||||
|
<span
|
||||||
|
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">
|
<button onClick={saveRouting} className="btn-primary">
|
||||||
Sauvegarder le routing
|
Sauvegarder le routing
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="routing-section">
|
<div className="routing-section">
|
||||||
<h4>Inputs vers Groupes</h4>
|
<h4>Inputs vers Groupes</h4>
|
||||||
@@ -119,33 +198,60 @@ 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>
|
||||||
))}
|
))}
|
||||||
</div>
|
|
||||||
|
|
||||||
{Array.from({length: 8}, (_, i) => (
|
{getVisibleInputChannels().map(i => (
|
||||||
<div key={`input-row-${i}`} className="matrix-row">
|
<React.Fragment key={`input-row-${i}`}>
|
||||||
<div className="matrix-label-cell">
|
<div className="matrix-label-cell">
|
||||||
{getChannelName('inputs', i)}
|
<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>
|
</div>
|
||||||
|
|
||||||
{groups.map(group => (
|
{groups.map(group => {
|
||||||
|
const isRouted = isInputRoutedToGroup(String(i), group.id);
|
||||||
|
const gain = getGainForInputToGroup(String(i), group.id);
|
||||||
|
|
||||||
|
return (
|
||||||
<div
|
<div
|
||||||
key={`${i}-${group.id}`}
|
key={`${i}-${group.id}`}
|
||||||
className={`matrix-cell ${isInputRoutedToGroup(String(i), group.id) ? 'active' : ''}`}
|
className={`matrix-cell ${isRouted ? 'active' : ''}`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="cell-checkbox"
|
||||||
onClick={() => toggleInputToGroup(String(i), group.id)}
|
onClick={() => toggleInputToGroup(String(i), group.id)}
|
||||||
>
|
>
|
||||||
{isInputRoutedToGroup(String(i), group.id) && <span className="checkmark">✓</span>}
|
{isRouted && <span className="checkmark">✓</span>}
|
||||||
</div>
|
</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>
|
</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">
|
||||||
{getChannelName('outputs', i)}
|
<div className="header-content">
|
||||||
|
<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 => {
|
||||||
|
const isRouted = isGroupRoutedToOutput(group.id, String(i));
|
||||||
|
const gain = getGainForGroupToOutput(group.id, String(i));
|
||||||
|
|
||||||
|
return (
|
||||||
<div
|
<div
|
||||||
key={`${group.id}-${i}`}
|
key={`${group.id}-${i}`}
|
||||||
className={`matrix-cell ${isGroupRoutedToOutput(group.id, String(i)) ? 'active' : ''}`}
|
className={`matrix-cell ${isRouted ? 'active' : ''}`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="cell-checkbox"
|
||||||
onClick={() => toggleGroupToOutput(group.id, String(i))}
|
onClick={() => toggleGroupToOutput(group.id, String(i))}
|
||||||
>
|
>
|
||||||
{isGroupRoutedToOutput(group.id, String(i)) && <span className="checkmark">✓</span>}
|
{isRouted && <span className="checkmark">✓</span>}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</React.Fragment>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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' : ''}`}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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) {
|
||||||
|
|||||||
@@ -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;
|
||||||
Reference in New Issue
Block a user