Compare commits

23 Commits
Author SHA1 Message Date
benoit 675348794c docs: mettre à jour README (v0.3.0, structure bridge, licence MIT) 2026-07-07 21:57:56 +02:00
benoit 9e1b025c8f docs: ajouter licence MIT 2026-07-07 21:54:47 +02:00
benoit a351116f58 Merge pull request 'Routage complexe' (#4) from Routage-complexe into desktop-app
Reviewed-on: #4
2026-07-03 17:29:07 +02:00
benoit bb31b142c9 Merge pull request 'Routage complexe' (#3) from Routage-complexe into desktop-app
Reviewed-on: #3
2026-07-03 17:27:53 +02:00
benoit 0cbad12e49 refactor: simplifier UI server audio users — entrée vide = écoute seule
Supprime la checkbox "Publier audio" et la colonne Mode.
Le comportement est déduit de l'entrée : aucune entrée = écoute seule.
Option "Aucune entrée" ajoutée au select canal d'entrée (comme pour sortie).
publish dérivé de inputChannel !== null dans AudioBridgeManager.
2026-07-03 17:21:48 +02:00
benoit b0f7d294d8 feat: mode écoute seule pour les server audio users (Master par groupe)
Un participant serveur peut être configuré sans publier de micro —
il reçoit le mix du groupe et le sort sur un canal physique.
- ServerAudioUser: flag publish (défaut true), sendAudio no-op si false
- AudioBridgeManager: canPublish LiveKit selon flag, input_channel null si écoute
- AudioBridge: passe publish à ServerAudioUser, log adapté
- Electron UI: checkbox "Écoute seule" dans add/edit, badges 🎤/👂 dans table
- main.js IPC: persist publish + input_channel null en écoute
2026-07-03 17:13:58 +02:00
benoit 06cb6a7dd1 refactor: simplifier AudioBridge, filtrer bridge dans PWA, option aucune sortie
- AudioBridge: retire GroupAudioRouter, LiveKitClient, routing per-group
- AudioBridgeManager: génère tokens uniquement pour server_audio_users
- ServerAudioUser: outputChannel null = pas d'émission outputReady
- PWA useLiveKit: filtre les participants role=bridge de la liste
- Electron UI: page Routing sans matrices, noms canaux + server audio users
- config.yaml: nettoyé (pas de section routing)
2026-07-03 14:55:55 +02:00
benoit bf960f49bb refactor: supprimer complètement les matrices de routing inputToGroup/groupToOutput
Le paradigme devient : pour brancher un canal physique sur un groupe,
créer un server audio user. Les matrices sont retirées de l'UI Electron,
de l'admin PWA, de l'API REST et du backend (GroupAudioRouter supprimé).
AudioBridgeManager ne génère plus de tokens per-group.
Option "aucune sortie" ajoutée pour les server audio users.
2026-07-03 14:55:37 +02:00
benoit 9a2bec6d2f feat: page Routing complète + gestion server_audio_users dans Config
- Page Routing : matrices Entrées→Groupes et Groupes→Sorties avec checkboxes,
  éditeur de noms de canaux (ajout/suppression dynamique), sauvegarde YAML directe
- Page Config : section Utilisateurs Audio Serveur (CRUD complet, modal avec
  sélecteur de groupe, canaux entrée/sortie)
- IPC main.js : handlers server-audio-users:list/create/update/delete,
  routing:get, routing:save
- preload.js : namespaces electronAPI.serverAudioUsers et electronAPI.routing
- showModal : support du type 'select' avec options
- loadViewData : routing et config lisibles sans serveur (YAML direct)
2026-07-01 14:27:35 +02:00
benoit 87a1370ad4 feat: ajouter les server audio users (participants LiveKit côté serveur avec I/O physique)
Chaque server audio user est un participant LiveKit indépendant géré par le serveur :
- publie un canal physique d'entrée comme track audio
- reçoit et mixe l'audio de tous les autres participants (mix-minus naturel)
- sort le mix vers un canal physique dédié

Nouvelle classe ServerAudioUser.js, intégration dans AudioBridge et AudioBridgeManager,
section server_audio_users dans config.yaml (vide par défaut, exemple commenté).
2026-07-01 14:14:26 +02:00
benoit 51245db256 feat: gestion groupes sans serveur via IPC + lecture YAML directe
- Ajout du package yaml dans electron pour parser config.yaml
- Handlers IPC groups:list/create/update/delete lisent/écrivent config.yaml
  directement depuis le Main Process (sans serveur requis)
- fetchGroups() utilise toujours IPC (plus d'appel REST pour la lecture)
- editGroup/deleteGroup/addGroup : REST API si serveur actif, IPC sinon
- loadViewData : onglet Groupes chargé même si serveur arrêté
- Note affichée quand le serveur est arrêté : modifications appliquées
  au prochain démarrage
2026-07-01 13:36:12 +02:00
benoit b3fbe31a2d fix: remplacer prompt/confirm par modals HTML (non supportés dans Electron)
Electron bloque prompt() et confirm() dans le renderer process.
Ajout d'un showModal() générique (champs configurables, mode confirmation)
avec overlay, focus auto, Escape/Enter, et fermeture en cliquant l'overlay.
Tous les appels prompt/confirm dans editGroup, deleteGroup et addGroup
sont migrés vers showModal.
2026-07-01 13:28:08 +02:00
benoit 955bfdfe07 feat: activer boutons Groupes et ajouter export logs/config desktop
- Onglet Groupes : boutons Modifier et Supprimer fonctionnels via délégation
  d'événements et data-attributes ; slugify() côté client synchronisé avec
  le serveur ; classe CSS btn-danger pour Supprimer
- Export logs : bouton "Exporter JSON" dans l'onglet Logs (filtre niveau actif)
- Export/Import config.yaml : section dédiée dans Configuration avec dialog
  système (backup automatique .bak avant import) via IPC Electron
  (config:export / config:import dans main.js + preload.js)
2026-07-01 13:21:59 +02:00
benoit a7a488403f Update documentation 2026-06-30 14:18:08 +02:00
benoit 22bb66b680 fix: corriger la détection de statut serveur et l'URL/QR code de connexion clients
Statut serveur :
- SERVER_URL utilisait "localhost", que le Node embarqué par Electron peut
  résoudre en IPv6 (::1) en priorité ; le serveur n'écoutant qu'en IPv4
  (host: 0.0.0.0), le ping de statut échouait silencieusement alors que le
  serveur tournait. Bascule sur 127.0.0.1 (main.js + preload.js).
- L'erreur réelle de pingServer() n'était jamais remontée au renderer
  (health.error perdu) ; elle est maintenant incluse dans la réponse IPC.

URL/QR code clients :
- L'URL affichée utilisait un remplacement de chaîne ("localhost" -> IP) qui
  ne matchait plus rien depuis le passage à 127.0.0.1 ; remplacé par un
  parsing d'URL qui ne réutilise que le protocole/port.
- Le QR code dépendait d'une lib chargée depuis un CDN externe, inadapté à
  une app self-hosted censée fonctionner sans accès Internet sur le WiFi
  d'un événement. Généré désormais côté Main Process avec la lib qrcode
  (déjà en dépendance, jamais utilisée) et transmis au renderer en data URL ;
  suppression du fichier placeholder et de la dépendance CDN.
- getNetworkIP() lisait /admin/config, qui renvoie la valeur YAML brute
  "AUTO" (jamais résolue), donc retombait toujours sur "localhost".
  Remplacé par la détection réseau du Main Process (même logique que pour
  les certificats mkcert).
- Ajout d'un placeholder visuel (icône + message) tant qu'aucun QR code
  n'est généré ou que le serveur est arrêté, en CSS pur.
2026-06-30 14:11:29 +02:00
benoit 144caac183 fix: activer HTTPS par défaut dans l'app Electron et fiabiliser les appels au serveur local
ENABLE_HTTPS était lu depuis l'environnement sans jamais être positionné par le
flow Electron (start-desktop.sh → electron .), donc le serveur enfant tournait
toujours en HTTP malgré le setup mkcert automatique au premier lancement.
ENABLE_HTTPS est désormais activé par défaut (ENABLE_HTTPS=false pour revenir
en HTTP explicitement).

Corrections induites par ce changement de protocole par défaut :
- pingServer() utilisait le module http en dur même en HTTPS ; bascule sur
  https avec rejectUnauthorized: false (ping local vers notre propre process
  enfant, le module https de Node ne lisant pas le trousseau macOS où mkcert
  installe sa CA, contrairement à Safari/Chrome/Electron renderer).
- Le dashboard (electron/ui/app.js) avait l'URL de l'API et celle du
  WebSocket VU-mètres codées en dur en http/ws ; elles utilisent maintenant
  l'URL réelle exposée par preload.js (serverUrl), cohérente avec le
  protocole effectif du serveur.
2026-06-30 13:45:33 +02:00
benoit b7911badb2 fix: corriger conflit upgrade WebSocket entre proxy LiveKit et audio-levels
AudioLevelsServer s'auto-attachait à l'événement 'upgrade' du serveur HTTP
via la lib ws (server + path), en plus du listener manuel du proxy LiveKit.
Pour toute connexion /livekit, les deux listeners s'exécutaient : le proxy
LiveKit aboutissait bien côté upstream, mais le listener ws (path
/audio-levels ne matchant pas) appelait abortHandshake(socket, 400) sur le
même socket juste après, cassant la connexion côté client en HTTPS prod.

AudioLevelsServer passe maintenant en noServer: true et expose
handleUpgrade(), appelée par un dispatcher 'upgrade' unique dans
server/index.js qui route explicitement par chemin (/livekit vs
/audio-levels).

Ajout de certs/ au .gitignore (clés privées SSL locales mkcert).
2026-06-30 13:29:09 +02:00
benoit dfe5db979a feat: ajout proxy HTTP pour routes REST LiveKit
Ajout proxy pour requetes HTTP LiveKit (ex: /rtc/validate).
Le client LiveKit fait d'abord une requete HTTP avant WebSocket.
2026-06-19 14:14:22 +02:00
benoit d3558388ad fix: remplacement http-proxy-middleware par http-proxy natif
http-proxy-middleware ne gere pas correctement WebSocket upgrade avec HTTPS.
Utilisation de http-proxy natif pour proxy direct WSS vers WS.

Modifications:
- Remplacement createProxyMiddleware par httpProxy.createProxyServer
- Gestion native de l'upgrade WebSocket
- Reecriture URL /livekit -> / pour LiveKit
- Logs info niveau pour debug facilite
2026-06-19 14:02:04 +02:00
benoit 8d2b83be0a fix: amelioration gestion upgrade WebSocket pour proxy LiveKit
Ajout logs debug pour tracer les requetes WebSocket upgrade.
Gestion explicite des chemins /livekit et /audio-levels.
2026-06-19 13:58:27 +02:00
benoit 861448f565 fix: utiliser proxy WSS pour LiveKit en mode HTTPS
Correction detection du protocole pour utiliser le bon endpoint LiveKit.

Modifications:
- client/src/App.jsx: detection HTTPS au lieu de import.meta.env.DEV
- Si HTTPS: utiliser wss://host/livekit (proxy)
- Si HTTP: utiliser URL LiveKit directe

Probleme resolu:
- Mixed content error (HTTPS ne peut pas WS)
- Fonctionne maintenant en prod HTTPS build
2026-06-19 13:52:47 +02:00
benoit 32158079c6 feat: ajout proxy WebSocket pour LiveKit en mode HTTPS
Permet au client PWA charge en HTTPS de se connecter a LiveKit via proxy WSS.

Modifications:
- server/index.js: ajout proxy http-proxy-middleware
- Route /livekit proxie vers http://localhost:7880
- Upgrade WebSocket active pour le proxy
- server/package.json: ajout dependance http-proxy-middleware

Fonctionnement:
- Client HTTPS se connecte a wss://localhost:3000/livekit
- Serveur Express proxie vers ws://localhost:7880
- Resout probleme mixed content (HTTPS ne peut pas WS)
2026-06-19 13:49:28 +02:00
benoit c21433b9eb feat: implementation WebSocket VU-metres dans interface desktop
Ajout connexion WebSocket temps reel pour monitoring audio dans l'app Electron.

Modifications:
- electron/ui/app.js: connexion WebSocket /audio-levels
- Rendu VU-metres pour inputs/groups/outputs
- Reconnexion automatique en cas de deconnexion
- Gestion cycle de vie (demarrage/arret serveur)
- electron/ui/styles.css: styles pour VU-metres
- Barres horizontales avec couleurs (vert/jaune/rouge)
- Indicateur peak temps reel
- Animation clipping si saturation
- DESKTOP-APP.md: marque TODO comme complete

Fonctionnalites:
- Affichage niveaux RMS et peak en dBFS
- Detection clipping avec animation
- Status connexion WebSocket visible
- Mise a jour 20 fois/seconde (50ms)
- Sections separees: entrees, groupes, sorties
2026-06-19 13:40:03 +02:00
25 changed files with 2273 additions and 1565 deletions
+4
View File
@@ -9,6 +9,7 @@ pnpm-lock.yaml
.env.local .env.local
.env.*.local .env.*.local
server/.env server/.env
server/config/config.yaml
client/.env client/.env
# Keep .env.example files (templates) # Keep .env.example files (templates)
@@ -55,3 +56,6 @@ server.log
# Runtime files # Runtime files
/tmp/ptt-live.pid /tmp/ptt-live.pid
# Certificats SSL locaux (mkcert) - contiennent des clés privées
certs/
+66 -28
View File
@@ -5,11 +5,12 @@ Application Electron pour gérer le serveur PTT Live avec interface graphique co
## 📸 Aperçu ## 📸 Aperçu
L'application desktop intègre : L'application desktop intègre :
-**Dashboard temps réel** : stats, utilisateurs, QR Code -**Dashboard temps réel** : stats, utilisateurs, QR Code (généré côté Main Process, sans dépendance CDN)
-**HTTPS automatique** : certificats locaux mkcert installés au premier lancement
-**Configuration audio** : sélection devices, sample rate, bitrate -**Configuration audio** : sélection devices, sample rate, bitrate
-**Gestion groupes** : CRUD complet avec API -**Gestion groupes** : CRUD complet avec API
-**Monitoring** : VU-mètres (prévu), logs filtrables -**Monitoring** : VU-mètres temps réel via WebSocket, logs filtrables
-**Contrôle serveur** : démarrage/arrêt avec feedback visuel -**Contrôle serveur** : démarrage manuel/arrêt avec feedback visuel
--- ---
@@ -24,7 +25,7 @@ cd electron
npm start npm start
``` ```
L'application démarre automatiquement le serveur PTT Live au lancement. Au premier lancement, l'app configure automatiquement les certificats HTTPS locaux (mkcert) — voir [HTTPS et certificats](#-https-et-certificats). Le serveur PTT Live **ne démarre pas automatiquement** : cliquez sur "Démarrer" dans le dashboard pour le lancer.
--- ---
@@ -50,9 +51,12 @@ npm install
- Total connexions - Total connexions
**QR Code** : **QR Code** :
- Généré automatiquement avec l'IP réseau - Généré côté Main Process (lib `qrcode`, pas de CDN externe — fonctionne sans accès Internet sur le WiFi d'un événement)
- IP réseau détectée par le Main Process (même logique que pour les certificats mkcert)
- URL construite à partir du protocole/port réels du serveur (HTTPS par défaut)
- Scanner depuis smartphone pour connexion rapide - Scanner depuis smartphone pour connexion rapide
- Bouton copier URL - Bouton copier URL
- Placeholder visuel tant que le serveur est arrêté ou qu'aucun QR code n'a été généré
**Utilisateurs** : **Utilisateurs** :
- Liste en temps réel - Liste en temps réel
@@ -80,10 +84,10 @@ npm install
### 4. Monitoring ### 4. Monitoring
**VU-Mètres** (à venir) : **VU-Mètres** :
- Niveaux audio par canal (input/output) - Niveaux audio par canal (input/output) et par groupe
- Temps réel via WebSocket - Temps réel via WebSocket (`/audio-levels`, même port que l'API)
- Détection clipping - Reconnexion automatique si la connexion WebSocket tombe
### 5. Logs ### 5. Logs
@@ -114,21 +118,24 @@ npm install
│ │ │ │ │ │ │ │
│ │ • HTML/CSS/JS (pas de framework) │ │ │ │ • HTML/CSS/JS (pas de framework) │ │
│ │ • Fetch API REST :3000/admin/* │ │ │ │ • Fetch API REST :3000/admin/* │ │
│ │ • WebSocket audio levels (prévu) │ │ │ │ • WebSocket audio levels (live) │ │
│ │ • QR Code (qrcode.js) │ │ │ │ • QR Code (data URL via IPC) │ │
│ └───────────────────────────────────────────┘ │ │ └───────────────────────────────────────────┘ │
└─────────────────────────────────────────────────┘ └─────────────────────────────────────────────────┘
↕ HTTP ↕ HTTPS (127.0.0.1, certs mkcert)
┌─────────────────────────────────────────────────┐ ┌─────────────────────────────────────────────────┐
│ SERVEUR PTT LIVE (spawned) │ │ SERVEUR PTT LIVE (spawned) │
│ │ │ │
│ • LiveKit Server (binaire Go) │ • LiveKit Server (binaire Go) :7880
│ • Audio Bridge Manager │ │ • Audio Bridge Manager │
│ • API REST Express :3000 │ • API REST Express :3000 (HTTPS)
│ • WebSocket Audio Levels │ • Proxy HTTP + WS → LiveKit (/livekit/*)
│ • WebSocket Audio Levels (/audio-levels) │
└─────────────────────────────────────────────────┘ └─────────────────────────────────────────────────┘
``` ```
Le proxy `/livekit/*` (http-proxy natif) permet aux clients de joindre LiveKit via le même port/certificat HTTPS que l'API, sans exposer le port 7880 séparément. Le serveur Express dispatch lui-même les événements `upgrade` (un seul listener) entre le proxy LiveKit et le WebSocket audio-levels, qui partagent le même port.
--- ---
## 🔌 API Consommées ## 🔌 API Consommées
@@ -150,9 +157,31 @@ L'interface desktop utilise toutes les routes admin existantes :
| `/admin/devices/list` | GET | Auto-détection (macOS/Linux) | | `/admin/devices/list` | GET | Auto-détection (macOS/Linux) |
| `/admin/logs` | GET | Logs serveur | | `/admin/logs` | GET | Logs serveur |
| `/health` | GET | Health check | | `/health` | GET | Health check |
| `/livekit/*` | ALL | Proxy HTTP vers LiveKit Server (port 7880) |
WebSocket (prévu) : WebSocket :
- `ws://localhost:3000/audio-levels` → VU-mètres temps réel - `wss://127.0.0.1:3000/audio-levels` → VU-mètres temps réel
- `wss://127.0.0.1:3000/livekit/*` → Proxy WebSocket signaling LiveKit (clients PWA)
---
## 🔒 HTTPS et certificats
L'app est en HTTPS par défaut (`ENABLE_HTTPS=false` pour revenir en HTTP explicitement).
### Setup automatique (premier lancement)
Au premier démarrage, si `certs/localhost.pem` et `certs/localhost-key.pem` sont absents, `electron/setup-helper.js` :
1. Installe `mkcert` automatiquement (Homebrew sur macOS, téléchargement direct sur Linux)
2. Installe la CA locale (`mkcert -install`) dans le trousseau système
3. Détecte l'IP réseau et génère les certificats pour `localhost`, `127.0.0.1` et cette IP
4. Affiche des dialogs de progression/erreur (avec fallback manuel `./setup-certificates.sh`)
### Points d'attention
- **127.0.0.1, pas localhost** : le serveur écoute en IPv4 (`host: 0.0.0.0`), mais le Node embarqué par Electron peut résoudre `localhost` en IPv6 (`::1`) en priorité. `main.js` et `preload.js` utilisent donc `127.0.0.1` pour tous les appels internes (ping, health check) afin d'éviter des échecs silencieux.
- **Ping interne et `rejectUnauthorized`** : le module `https` de Node ne lit pas le trousseau système où mkcert installe sa CA (contrairement à Safari/Chrome/Electron renderer) ; `pingServer()` passe donc `rejectUnauthorized: false` pour son propre ping local.
- **Proxy LiveKit en HTTPS** : LiveKit Server local tourne en HTTP brut (port 7880) ; le proxy Express (`http-proxy`) fait le pont HTTPS ↔ HTTP côté clients.
--- ---
@@ -283,31 +312,37 @@ PORT=3001 npm start
- Vérifier permissions LiveKit binaire - Vérifier permissions LiveKit binaire
- Voir logs dans DevTools console - Voir logs dans DevTools console
**Certificats SSL manquants / setup mkcert échoue** :
- Exécuter manuellement : `./setup-certificates.sh`
- Ou installer mkcert : https://github.com/FiloSottile/mkcert puis `mkcert -install`
- Vérifier la présence de `certs/localhost.pem` et `certs/localhost-key.pem`
**Statut serveur affiché à tort comme "arrêté"** :
- Vérifier que le ping utilise bien `127.0.0.1` (pas `localhost`, qui peut résoudre en IPv6 alors que le serveur n'écoute qu'en IPv4)
- En HTTPS, le ping interne ignore volontairement les erreurs de certificat (`rejectUnauthorized: false`) puisque Node ne lit pas le trousseau système où mkcert installe sa CA
**QR Code ne s'affiche pas** : **QR Code ne s'affiche pas** :
- Vérifier que le serveur tourne - Vérifier que le serveur tourne (le QR code est réinitialisé tant qu'il est arrêté)
- Voir console : "✅ QR Code généré" - Le QR code est généré côté Main Process (IPC `qrcode:generate`), pas de dépendance réseau/CDN
- Script CDN chargé ?
--- ---
## 🚧 TODO / Améliorations ## 🚧 TODO / Améliorations
### Priorité haute ### Priorité haute
- [ ] **WebSocket VU-mètres** : implémenter connexion `/audio-levels` - [x] **WebSocket VU-mètres** : implémenter connexion `/audio-levels`
- [ ] **Vraies icônes** : icns/png pour macOS/Linux - [ ] **Vraies icônes** : icns/png pour macOS/Linux
- [ ] **Tray icon** : avec menu contextuel fonctionnel - [ ] **Tray icon** : avec menu contextuel fonctionnel
### Priorité moyenne ### Priorité moyenne
- [ ] **Graphiques monitoring** : Chart.js pour latence/bande passante - [ ] **Graphiques monitoring** : Chart.js pour latence/bande passante
- [ ] **Export logs** : bouton télécharger CSV/JSON - [x] **Export logs** : bouton télécharger JSON (filtre niveau appliqué)
- [ ] **Matrice routing** : interface graphique drag & drop - [ ] **Matrice routing** : interface graphique drag & drop
- [ ] **Notifications desktop** : via Electron Notification API - [x] **Export & import config** : bouton télécharger YAML et charger config (backup auto .bak)
### Priorité basse ### Priorité basse
- [ ] **Auth admin** : mot de passe pour accès dashboard
- [ ] **Thème toggle** : dark/light mode - [ ] **Thème toggle** : dark/light mode
- [ ] **Auto-update** : electron-updater pour mises à jour - [ ] **Auto-update** : electron-updater pour mises à jour
- [ ] **I18n** : français/anglais
### Technique ### Technique
- [ ] **Tests** : Spectron ou Playwright pour Electron - [ ] **Tests** : Spectron ou Playwright pour Electron
@@ -326,18 +361,21 @@ electron/
│ # - Spawn serveur │ # - Spawn serveur
│ # - IPC handlers │ # - IPC handlers
│ # - Window management │ # - Window management
│ # - Setup SSL au premier lancement
├── preload.js # IPC Bridge sécurisé ├── preload.js # IPC Bridge sécurisé
│ # - contextBridge │ # - contextBridge
│ # - Expose electronAPI │ # - Expose electronAPI
├── setup-helper.js # Installation auto mkcert + génération certificats
│ # - Détection IP réseau
├── package.json # Config Electron + electron-builder ├── package.json # Config Electron + electron-builder
└── ui/ # Renderer Process (Frontend) └── ui/ # Renderer Process (Frontend)
├── index.html # Structure UI ├── index.html # Structure UI
├── styles.css # Styles (dark theme) ├── styles.css # Styles (dark theme)
── app.js # Logic frontend ── app.js # Logic frontend (QR code reçu via IPC en data URL)
└── qrcode.min.js # QR Code library
``` ```
### Communication IPC ### Communication IPC
@@ -397,4 +435,4 @@ Même licence que PTT Live (MIT)
--- ---
**Version** : 0.3.0 **Version** : 0.3.0
**Dernière mise à jour** : 2026-06-19 **Dernière mise à jour** : 2026-06-30
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Benoit S
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+17 -14
View File
@@ -114,8 +114,6 @@ Communiquez via smartphone (PWA) en WiFi, le serveur fait le pont avec l'install
--- ---
---
## 🐛 Dépannage : "Connexion impossible" ## 🐛 Dépannage : "Connexion impossible"
**Cause** : Clés LiveKit non configurées ou invalides. **Cause** : Clés LiveKit non configurées ou invalides.
@@ -132,7 +130,9 @@ Voir le guide complet : [docs/SETUP_LIVEKIT.md](docs/SETUP_LIVEKIT.md)
## 📚 Documentation ## 📚 Documentation
- **[README-PORTABLE.md](README-PORTABLE.md)** - 🆕 **Guide déploiement portable** (zéro config) - **[DESKTOP-APP.md](DESKTOP-APP.md)** - Application desktop Electron (dashboard + config)
- **[README-PORTABLE.md](README-PORTABLE.md)** - Guide déploiement portable (zéro config)
- **[SSL-SETUP.md](SSL-SETUP.md)** - Configuration certificats HTTPS locaux
- **[NETWORK_SETUP.md](NETWORK_SETUP.md)** - Configuration réseau multi-appareils - **[NETWORK_SETUP.md](NETWORK_SETUP.md)** - Configuration réseau multi-appareils
- **[docs/SETUP_LIVEKIT.md](docs/SETUP_LIVEKIT.md)** - Configuration LiveKit (Cloud + Local) - **[docs/SETUP_LIVEKIT.md](docs/SETUP_LIVEKIT.md)** - Configuration LiveKit (Cloud + Local)
- **[CLAUDE.md](CLAUDE.md)** - Documentation développement complète - **[CLAUDE.md](CLAUDE.md)** - Documentation développement complète
@@ -144,10 +144,11 @@ Voir le guide complet : [docs/SETUP_LIVEKIT.md](docs/SETUP_LIVEKIT.md)
- ✅ **Phase 1** : MVP fonctionnel (WebRTC + PTT) - ✅ **Phase 1** : MVP fonctionnel (WebRTC + PTT)
- ✅ **Phase 2** : Fonctionnalités avancées (groupes, routing, admin) - ✅ **Phase 2** : Fonctionnalités avancées (groupes, routing, admin)
- 🆕 **Portable** : Installation zéro-config macOS/Linux - **Portable** : Installation zéro-config macOS/Linux
- ✅ **Desktop** : Application Electron avec dashboard complet
- ⏳ **Phase 3** : Intégrations audio pro (Dante, AES67) - ⏳ **Phase 3** : Intégrations audio pro (Dante, AES67)
**Version actuelle** : 0.2.0 (Portable - production-ready) **Version actuelle** : 0.3.0 (Desktop App)
--- ---
@@ -299,15 +300,17 @@ project/
│ ├── index.js # Point d'entrée unique │ ├── index.js # Point d'entrée unique
│ ├── livekit-server # Binaire (téléchargé à l'install) │ ├── livekit-server # Binaire (téléchargé à l'install)
│ ├── bridge/ │ ├── bridge/
│ │ ├── audio.js # Détection + abstraction │ │ ├── AudioBridge.js # Classe principale
│ │ ├── backends/ │ │ ├── AudioBridgeManager.js # Gestion cycle de vie
│ │ │ ├── jack.js │ │ ├── LiveKitClient.js # Connexion SFU
│ │ │ ├── pipewire.js │ │ ├── OpusCodec.js # Transcodage PCM ↔ Opus
│ │ │ ├── coreaudio.js │ │ ├── JitterBuffer.js # Buffer 40ms
│ │ │ └── wasapi.js │ │ ├── ServerAudioUser.js # Utilisateur audio serveur
│ │ ── livekit.js │ │ ── backends/
│ │ ├── opus.js │ │ ├── CoreAudioBackend.js
│ │ └── jitter.js │ │ ├── JACKBackend.js
│ │ ├── PipeWireBackend.js
│ │ └── WASAPIBackend.js
│ ├── api/ # Admin REST │ ├── api/ # Admin REST
│ └── config/ │ └── config/
│ └── config.yaml │ └── config.yaml
-5
View File
@@ -1,6 +1,5 @@
import { useState, useEffect, useRef } from 'react'; import { useState, useEffect, useRef } from 'react';
import './Admin.css'; import './Admin.css';
import AudioRoutingMatrix from './components/AudioRoutingMatrix';
const API_URL = import.meta.env.VITE_API_URL || '/api'; const API_URL = import.meta.env.VITE_API_URL || '/api';
@@ -409,9 +408,6 @@ function Admin() {
</label> </label>
</div> </div>
<p style={{color: 'var(--color-text-secondary)', fontSize: '0.9rem', marginTop: 'var(--spacing-md)'}}>
Le routing audio se configure dans l'onglet "Audio" via la matrice de routing.
</p>
<div className="form-actions"> <div className="form-actions">
<button type="submit" className="btn-primary"> <button type="submit" className="btn-primary">
@@ -609,7 +605,6 @@ function Admin() {
</div> </div>
</div> </div>
<AudioRoutingMatrix groups={groups} channelNames={channelNames} />
{currentDevice && currentDevice.inputDeviceId && ( {currentDevice && currentDevice.inputDeviceId && (
<div className="current-config"> <div className="current-config">
+7 -7
View File
@@ -100,17 +100,17 @@ function App() {
const data = await response.json(); const data = await response.json();
// En mode dev (HTTPS via Vite), utiliser le proxy WebSocket // Si HTTPS, utiliser le proxy WebSocket (résout mixed content)
// En mode prod (HTTP direct), utiliser l'URL LiveKit directement // Sinon utiliser l'URL LiveKit directement
let livekitUrl = data.url; let livekitUrl = data.url;
if (import.meta.env.DEV && window.location.protocol === 'https:') { if (window.location.protocol === 'https:') {
// Mode dev avec Vite : utiliser le proxy WSS // HTTPS : utiliser le proxy WSS (wss://host:port/livekit)
livekitUrl = `${window.location.protocol}//${window.location.host}/livekit`; livekitUrl = `wss://${window.location.host}/livekit`;
console.log('🔒 Mode HTTPS : utilisation proxy WebSocket');
} }
console.log('🔗 Connexion LiveKit:', livekitUrl); console.log('🔗 Connexion LiveKit:', livekitUrl);
console.log('📝 Mode:', import.meta.env.DEV ? 'dev' : 'prod');
// Se connecter à LiveKit avec les canaux virtuels // Se connecter à LiveKit avec les canaux virtuels
await connect(livekitUrl, data.token, data.virtualChannels || []); await connect(livekitUrl, data.token, data.virtualChannels || []);
@@ -154,7 +154,7 @@ function App() {
// Adapter l'URL LiveKit selon le protocole de la page // Adapter l'URL LiveKit selon le protocole de la page
let livekitUrl = data.url; let livekitUrl = data.url;
if (window.location.protocol === 'https:') { if (window.location.protocol === 'https:') {
livekitUrl = `${window.location.protocol}//${window.location.host}/livekit`; livekitUrl = `wss://${window.location.host}/livekit`;
} }
// Changer de room LiveKit avec les canaux virtuels du nouveau groupe // Changer de room LiveKit avec les canaux virtuels du nouveau groupe
@@ -1,245 +0,0 @@
.routing-matrix-container {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 12px;
padding: var(--spacing-xl);
margin-top: var(--spacing-lg);
}
.routing-actions {
margin-top: var(--spacing-xl);
display: flex;
justify-content: flex-start;
gap: var(--spacing-sm);
}
.routing-matrix-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--spacing-xl);
}
.routing-matrix-header h3 {
margin: 0;
font-size: 1.25rem;
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 {
margin-bottom: var(--spacing-xl);
}
.routing-section:last-child {
margin-bottom: 0;
}
.routing-section h4 {
margin: 0 0 var(--spacing-sm) 0;
font-size: 1rem;
font-weight: 600;
color: var(--color-text);
}
.routing-description {
margin: 0 0 var(--spacing-lg) 0;
color: var(--color-text-secondary);
font-size: 0.9rem;
}
.routing-matrix {
display: inline-grid;
gap: 2px;
background: var(--color-border);
border: 1px solid var(--color-border);
border-radius: 8px;
overflow-x: auto;
max-width: 100%;
}
.matrix-corner {
background: var(--color-surface-hover);
min-height: 50px;
}
.matrix-header-cell {
background: var(--color-surface-hover);
padding: var(--spacing-sm);
font-size: 0.85rem;
font-weight: 600;
color: var(--color-text-secondary);
text-align: center;
display: flex;
align-items: center;
justify-content: center;
min-height: 50px;
word-break: break-word;
hyphens: auto;
}
.matrix-label-cell {
background: var(--color-surface-hover);
padding: var(--spacing-sm);
font-size: 0.85rem;
font-weight: 600;
color: var(--color-text-secondary);
display: flex;
align-items: center;
min-width: 120px;
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 {
background: var(--color-bg);
padding: var(--spacing-sm);
min-height: 60px;
min-width: 80px;
transition: all 0.2s;
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;
justify-content: center;
cursor: pointer;
}
.matrix-cell:hover {
background: var(--color-surface-hover);
border: 1px solid var(--color-primary);
}
.matrix-cell.active {
background: var(--color-primary);
color: white;
}
.matrix-cell.active:hover {
background: var(--color-primary-hover);
}
.checkmark {
font-size: 1.2rem;
font-weight: bold;
}
.gain-select {
width: 100%;
padding: 4px 8px;
font-size: 0.75rem;
background: rgba(59, 130, 246, 0.2);
color: #ffffff;
border: 1px solid rgba(255, 255, 255, 1);
border-radius: 4px;
cursor: pointer;
font-weight: 600;
text-align: center;
}
.gain-select:focus {
outline: none;
background: rgba(59, 130, 246, 0.3);
border-color: rgba(255, 255, 255, 1);
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.2);
}
@media (max-width: 1024px) {
.matrix-header-cell,
.matrix-label-cell {
font-size: 0.75rem;
padding: var(--spacing-xs);
min-width: 80px;
}
.matrix-cell {
min-width: 70px;
min-height: 50px;
padding: var(--spacing-sm);
}
.gain-select {
font-size: 0.7rem;
padding: 3px 6px;
}
}
@media (max-width: 768px) {
.routing-matrix-container {
padding: var(--spacing-md);
}
.routing-matrix-header {
flex-direction: column;
gap: var(--spacing-md);
align-items: stretch;
}
.matrix-header-cell,
.matrix-label-cell {
font-size: 0.7rem;
min-width: 60px;
}
.matrix-cell {
min-width: 65px;
min-height: 45px;
}
.checkmark {
font-size: 1rem;
}
.gain-select {
font-size: 0.65rem;
padding: 2px 4px;
}
}
@@ -1,349 +0,0 @@
import React, { useState, useEffect } from 'react';
import './AudioRoutingMatrix.css';
import VUMeter from './VUMeter.jsx';
import { useAudioLevels } from '../hooks/useAudioLevels.js';
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000';
function AudioRoutingMatrix({ groups, channelNames }) {
const { levels, connected: wsConnected } = useAudioLevels();
const [routing, setRouting] = useState({ inputToGroup: {}, groupToOutput: {}, gains: {} });
const [loading, setLoading] = useState(true);
const [showOnlyNamedChannels, setShowOnlyNamedChannels] = useState(false);
const [audioDevice, setAudioDevice] = useState({ inputChannels: 8, outputChannels: 8 });
useEffect(() => {
loadRouting();
loadAudioDevice();
}, []);
const loadRouting = async () => {
try {
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();
setRouting(data.routing || { inputToGroup: {}, groupToOutput: {}, gains: {} });
} catch (error) {
console.error('Erreur chargement routing:', error);
} finally {
setLoading(false);
}
};
const loadAudioDevice = async () => {
try {
const res = await fetch(`${API_URL}/admin/audio/device`);
if (res.ok) {
const data = await res.json();
setAudioDevice({
inputChannels: data.device?.inputChannels || 8,
outputChannels: data.device?.outputChannels || 8
});
}
} catch (error) {
console.error('Erreur chargement audio device:', error);
}
};
const saveRouting = async () => {
try {
const res = await fetch(`${API_URL}/admin/audio/routing`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(routing)
});
if (res.ok) {
alert('Configuration de routing sauvegardée!');
} else {
const errorText = await res.text();
console.error('Erreur serveur:', errorText);
alert(`Erreur: ${res.status} - ${errorText}`);
}
} catch (error) {
console.error('Erreur sauvegarde routing:', error);
alert('Erreur lors de la sauvegarde');
}
};
const toggleInputToGroup = (inputId, groupId) => {
setRouting(prev => {
const inputToGroup = { ...prev.inputToGroup };
if (!inputToGroup[inputId]) {
inputToGroup[inputId] = [];
}
const groupArray = [...inputToGroup[inputId]];
const index = groupArray.indexOf(groupId);
if (index > -1) {
groupArray.splice(index, 1);
} else {
groupArray.push(groupId);
}
inputToGroup[inputId] = groupArray;
return { ...prev, inputToGroup };
});
};
const toggleGroupToOutput = (groupId, outputId) => {
setRouting(prev => {
const groupToOutput = { ...prev.groupToOutput };
if (!groupToOutput[groupId]) {
groupToOutput[groupId] = [];
}
const outputArray = [...groupToOutput[groupId]];
const index = outputArray.indexOf(outputId);
if (index > -1) {
outputArray.splice(index, 1);
} else {
outputArray.push(outputId);
}
groupToOutput[groupId] = outputArray;
return { ...prev, groupToOutput };
});
};
const isInputRoutedToGroup = (inputId, groupId) => {
return routing.inputToGroup[inputId]?.includes(groupId) || false;
};
const isGroupRoutedToOutput = (groupId, outputId) => {
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 name = channelNames?.[type]?.[id];
return name || `${type === 'inputs' ? 'Input' : 'Output'} ${id}`;
};
const hasCustomName = (type, id) => {
return channelNames?.[type]?.[id] !== undefined;
};
const getVisibleInputChannels = () => {
const allInputs = Array.from({length: audioDevice.inputChannels}, (_, i) => i);
if (showOnlyNamedChannels) {
return allInputs.filter(i => hasCustomName('inputs', i));
}
return allInputs;
};
const getVisibleOutputChannels = () => {
const allOutputs = Array.from({length: audioDevice.outputChannels}, (_, i) => i);
if (showOnlyNamedChannels) {
return allOutputs.filter(i => hasCustomName('outputs', i));
}
return allOutputs;
};
if (loading) {
return <div style={{padding: 'var(--spacing-xl)', textAlign: 'center'}}>Chargement...</div>;
}
return (
<div className="routing-matrix-container">
<div className="routing-matrix-header">
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
<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>
<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>
</div>
<div className="routing-section">
<h4>Inputs vers Groupes</h4>
<p className="routing-description">
Sélectionnez quels inputs audio alimentent chaque groupe
</p>
<div className="routing-matrix" style={{gridTemplateColumns: `120px repeat(${groups.length}, minmax(60px, 1fr))`}}>
<div className="matrix-corner"></div>
{groups.map(group => (
<div key={group.id} className="matrix-header-cell">
{group.name}
</div>
))}
{getVisibleInputChannels().map(i => (
<React.Fragment key={`input-row-${i}`}>
<div className="matrix-label-cell">
<div className="label-content">
<span className="label-text">{getChannelName('inputs', i)}</span>
{wsConnected && levels.inputs[i] && (
<VUMeter level={levels.inputs[i]} size="mini" />
)}
</div>
</div>
{groups.map(group => {
const isRouted = isInputRoutedToGroup(String(i), group.id);
const gain = getGainForInputToGroup(String(i), group.id);
return (
<div
key={`${i}-${group.id}`}
className={`matrix-cell ${isRouted ? 'active' : ''}`}
>
<div
className="cell-checkbox"
onClick={() => toggleInputToGroup(String(i), group.id)}
>
{isRouted && <span className="checkmark"></span>}
</div>
{isRouted && (
<select
className="gain-select"
value={gain}
onChange={(e) => setGainForInputToGroup(String(i), group.id, e.target.value)}
onClick={(e) => e.stopPropagation()}
>
<option value="-12">-12dB</option>
<option value="-6">-6dB</option>
<option value="-3">-3dB</option>
<option value="0">0dB</option>
<option value="3">+3dB</option>
<option value="6">+6dB</option>
</select>
)}
</div>
);
})}
</React.Fragment>
))}
</div>
</div>
<div className="routing-section">
<h4>Groupes vers Outputs</h4>
<p className="routing-description">
Sélectionnez vers quels outputs chaque groupe envoie son audio
</p>
<div className="routing-matrix" style={{gridTemplateColumns: `120px repeat(${getVisibleOutputChannels().length}, minmax(60px, 1fr))`}}>
<div className="matrix-corner"></div>
{getVisibleOutputChannels().map(i => (
<div key={`output-header-${i}`} className="matrix-header-cell">
<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>
))}
{groups.map(group => (
<React.Fragment key={`group-row-${group.id}`}>
<div className="matrix-label-cell">
<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>
{getVisibleOutputChannels().map(i => {
const isRouted = isGroupRoutedToOutput(group.id, String(i));
const gain = getGainForGroupToOutput(group.id, String(i));
return (
<div
key={`${group.id}-${i}`}
className={`matrix-cell ${isRouted ? 'active' : ''}`}
>
<div
className="cell-checkbox"
onClick={() => toggleGroupToOutput(group.id, String(i))}
>
{isRouted && <span className="checkmark"></span>}
</div>
{isRouted && (
<select
className="gain-select"
value={gain}
onChange={(e) => setGainForGroupToOutput(group.id, String(i), e.target.value)}
onClick={(e) => e.stopPropagation()}
>
<option value="-12">-12dB</option>
<option value="-6">-6dB</option>
<option value="-3">-3dB</option>
<option value="0">0dB</option>
<option value="3">+3dB</option>
<option value="6">+6dB</option>
</select>
)}
</div>
);
})}
</React.Fragment>
))}
</div>
</div>
<div className="routing-actions">
<button onClick={saveRouting} className="btn-primary">
Sauvegarder le routing audio
</button>
</div>
</div>
);
}
export default AudioRoutingMatrix;
+10 -1
View File
@@ -283,8 +283,17 @@ export default function useLiveKit() {
}); });
}); });
// Participants distants (utilisateurs WebRTC) // Participants distants (utilisateurs WebRTC + server audio users)
// Exclure les participants internes de routage (role: 'bridge')
room.remoteParticipants.forEach((participant) => { room.remoteParticipants.forEach((participant) => {
let role = null;
try {
const meta = participant.metadata ? JSON.parse(participant.metadata) : {};
role = meta.role || null;
} catch (_) {}
if (role === 'bridge') return;
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];
const isSpeaking = room.activeSpeakers.some(s => s.identity === participant.identity); const isSpeaking = room.activeSpeakers.some(s => s.identity === participant.identity);
+284 -2
View File
@@ -5,10 +5,29 @@
const { app, BrowserWindow, ipcMain, Menu, Tray, dialog } = require('electron'); const { app, BrowserWindow, ipcMain, Menu, Tray, dialog } = require('electron');
const path = require('path'); const path = require('path');
const fs = require('fs');
const { spawn } = require('child_process'); const { spawn } = require('child_process');
const http = require('http'); const http = require('http');
const https = require('https');
const QRCode = require('qrcode');
const yaml = require('yaml');
const setupHelper = require('./setup-helper'); const setupHelper = require('./setup-helper');
const CONFIG_PATH = path.join(__dirname, '..', 'server', 'config', 'config.yaml');
function readConfig() {
return yaml.parse(fs.readFileSync(CONFIG_PATH, 'utf8'));
}
function writeConfig(config) {
fs.writeFileSync(CONFIG_PATH, yaml.stringify(config), 'utf8');
}
function slugify(text) {
return text.toString().normalize('NFD').replace(/[̀-ͯ]/g, '')
.toLowerCase().trim().replace(/\s+/g, '-').replace(/[^\w-]+/g, '').replace(/--+/g, '-');
}
// État de l'application // État de l'application
let mainWindow = null; let mainWindow = null;
let tray = null; let tray = null;
@@ -17,7 +36,14 @@ let serverStarted = false;
let rendererReady = false; let rendererReady = false;
const SERVER_PORT = process.env.PORT || 3000; const SERVER_PORT = process.env.PORT || 3000;
const SERVER_URL = `http://localhost:${SERVER_PORT}`; // HTTPS activé par défaut (cohérent avec le setup mkcert automatique au premier
// lancement) ; ENABLE_HTTPS=false permet de revenir explicitement en HTTP
const ENABLE_HTTPS = process.env.ENABLE_HTTPS !== 'false';
const SERVER_PROTOCOL = ENABLE_HTTPS ? 'https' : 'http';
// 127.0.0.1 plutôt que localhost : le serveur n'écoute qu'en IPv4 (host: 0.0.0.0
// dans config.yaml), or le Node embarqué par Electron peut résoudre "localhost"
// en IPv6 (::1) en priorité, ce qui ferait échouer silencieusement le ping
const SERVER_URL = `${SERVER_PROTOCOL}://127.0.0.1:${SERVER_PORT}`;
const isDev = process.argv.includes('--dev'); const isDev = process.argv.includes('--dev');
/** /**
@@ -133,6 +159,7 @@ async function startServer() {
...process.env, ...process.env,
PORT: SERVER_PORT, PORT: SERVER_PORT,
USE_LOCAL_LIVEKIT: 'true', USE_LOCAL_LIVEKIT: 'true',
ENABLE_HTTPS: ENABLE_HTTPS ? 'true' : 'false',
NODE_ENV: isDev ? 'development' : 'production' NODE_ENV: isDev ? 'development' : 'production'
} }
}); });
@@ -289,7 +316,13 @@ async function stopServer() {
*/ */
async function pingServer() { async function pingServer() {
return new Promise((resolve) => { return new Promise((resolve) => {
http.get(`${SERVER_URL}/health`, (res) => { const client = ENABLE_HTTPS ? https : http;
// rejectUnauthorized: false : le cert mkcert est approuvé par le Keychain
// macOS (Safari/Chrome/Electron renderer), mais le module https de Node
// ne lit pas ce trust store et rejetterait sinon ce ping vers notre
// propre serveur local.
const options = ENABLE_HTTPS ? { rejectUnauthorized: false } : {};
client.get(`${SERVER_URL}/health`, options, (res) => {
let data = ''; let data = '';
res.on('data', (chunk) => { data += chunk; }); res.on('data', (chunk) => { data += chunk; });
res.on('end', () => { res.on('end', () => {
@@ -327,6 +360,7 @@ app.whenReady().then(async () => {
return { return {
running: health.success, running: health.success,
health: health.data, health: health.data,
error: health.error,
url: SERVER_URL url: SERVER_URL
}; };
}); });
@@ -335,6 +369,254 @@ app.whenReady().then(async () => {
return await pingServer(); return await pingServer();
}); });
ipcMain.handle('qrcode:generate', async (event, text) => {
try {
const dataUrl = await QRCode.toDataURL(text, { width: 256, margin: 2 });
return { success: true, dataUrl };
} catch (error) {
return { success: false, error: error.message };
}
});
ipcMain.handle('network:ip', async () => {
return setupHelper.getNetworkIP();
});
// ========== Groupes (lecture/écriture YAML directe, sans serveur) ==========
ipcMain.handle('groups:list', () => {
try {
const config = readConfig();
return { groups: config.groups || [] };
} catch (error) {
return { groups: [], error: error.message };
}
});
ipcMain.handle('groups:create', (event, { name, audioBitrate }) => {
try {
const config = readConfig();
const id = slugify(name);
if ((config.groups || []).find(g => slugify(g.name) === id)) {
return { success: false, error: `Un groupe "${name}" existe déjà` };
}
const group = { name, ...(audioBitrate ? { audioBitrate } : {}) };
config.groups = [...(config.groups || []), group];
writeConfig(config);
return { success: true, group };
} catch (error) {
return { success: false, error: error.message };
}
});
ipcMain.handle('groups:update', (event, { id, name, audioBitrate }) => {
try {
const config = readConfig();
const idx = (config.groups || []).findIndex(g => slugify(g.name) === id);
if (idx === -1) return { success: false, error: `Groupe ${id} introuvable` };
if (name !== undefined) config.groups[idx].name = name;
if (audioBitrate !== undefined) config.groups[idx].audioBitrate = audioBitrate;
writeConfig(config);
return { success: true, group: config.groups[idx] };
} catch (error) {
return { success: false, error: error.message };
}
});
ipcMain.handle('groups:delete', (event, { id }) => {
try {
const config = readConfig();
const idx = (config.groups || []).findIndex(g => slugify(g.name) === id);
if (idx === -1) return { success: false, error: `Groupe ${id} introuvable` };
config.groups.splice(idx, 1);
writeConfig(config);
return { success: true };
} catch (error) {
return { success: false, error: error.message };
}
});
// ========== Server Audio Users (lecture/écriture YAML directe) ==========
ipcMain.handle('server-audio-users:list', () => {
try {
const config = readConfig();
return { users: config.server_audio_users || [] };
} catch (error) {
return { users: [], error: error.message };
}
});
ipcMain.handle('server-audio-users:create', (event, { name, group, input_channel, output_channel }) => {
try {
const config = readConfig();
const users = config.server_audio_users || [];
if (users.find(u => u.name === name)) {
return { success: false, error: `Un utilisateur "${name}" existe déjà` };
}
const parsedInput = input_channel !== null && input_channel !== undefined ? parseInt(input_channel) : null;
const user = {
name,
group,
input_channel: parsedInput,
output_channel: output_channel !== null && output_channel !== '' ? parseInt(output_channel) : null
};
config.server_audio_users = [...users, user];
writeConfig(config);
return { success: true, user };
} catch (error) {
return { success: false, error: error.message };
}
});
ipcMain.handle('server-audio-users:update', (event, { name, group, input_channel, output_channel }) => {
try {
const config = readConfig();
const users = config.server_audio_users || [];
const idx = users.findIndex(u => u.name === name);
if (idx === -1) return { success: false, error: `Utilisateur "${name}" introuvable` };
const parsedInput = input_channel !== null && input_channel !== undefined ? parseInt(input_channel) : null;
config.server_audio_users[idx] = {
name,
group,
input_channel: parsedInput,
output_channel: output_channel !== null && output_channel !== '' ? parseInt(output_channel) : null
};
writeConfig(config);
return { success: true };
} catch (error) {
return { success: false, error: error.message };
}
});
ipcMain.handle('server-audio-users:delete', (event, { name }) => {
try {
const config = readConfig();
const users = config.server_audio_users || [];
const idx = users.findIndex(u => u.name === name);
if (idx === -1) return { success: false, error: `Utilisateur "${name}" introuvable` };
config.server_audio_users.splice(idx, 1);
writeConfig(config);
return { success: true };
} catch (error) {
return { success: false, error: error.message };
}
});
// ========== Routing (lecture/écriture YAML directe) ==========
ipcMain.handle('routing:get', () => {
try {
const config = readConfig();
return {
channelNames: config.audio?.channelNames || { inputs: {}, outputs: {} },
groups: config.groups || [],
serverAudioUsers: config.server_audio_users || []
};
} catch (error) {
return { error: error.message };
}
});
// ========== Devices : découverte canaux physiques ==========
ipcMain.handle('devices:getChannels', () => {
try {
const config = readConfig();
const inputDeviceName = config.audio?.device?.inputDeviceId;
const outputDeviceName = config.audio?.device?.outputDeviceId;
let inputDevice = { name: inputDeviceName || 'Non configuré', channels: 0 };
let outputDevice = { name: outputDeviceName || 'Non configuré', channels: 0 };
if (process.platform === 'darwin') {
try {
const { execSync } = require('child_process');
const raw = execSync('system_profiler SPAudioDataType -json', { encoding: 'utf8', timeout: 5000 });
const data = JSON.parse(raw);
if (data.SPAudioDataType) {
data.SPAudioDataType.forEach(item => {
(item._items || []).forEach(dev => {
const name = dev._name || '';
const inCh = parseInt(dev.coreaudio_device_input) || 0;
const outCh = parseInt(dev.coreaudio_device_output) || 0;
if (inputDeviceName && name === inputDeviceName && inCh > 0) {
inputDevice = { name, channels: inCh };
}
if (outputDeviceName && name === outputDeviceName && outCh > 0) {
outputDevice = { name, channels: outCh };
}
});
});
}
} catch (_) { /* detection failed, keep defaults */ }
}
return { inputDevice, outputDevice };
} catch (error) {
return { error: error.message, inputDevice: { name: 'Inconnu', channels: 0 }, outputDevice: { name: 'Inconnu', channels: 0 } };
}
});
ipcMain.handle('routing:save', (event, { channelNames }) => {
try {
const config = readConfig();
if (!config.audio) config.audio = {};
config.audio.channelNames = channelNames;
writeConfig(config);
return { success: true };
} catch (error) {
return { success: false, error: error.message };
}
});
ipcMain.handle('config:export', async () => {
const configPath = path.join(__dirname, '..', 'server', 'config', 'config.yaml');
try {
const content = fs.readFileSync(configPath, 'utf8');
const { filePath } = await dialog.showSaveDialog(mainWindow, {
title: 'Exporter la configuration',
defaultPath: 'config.yaml',
filters: [{ name: 'YAML', extensions: ['yaml', 'yml'] }]
});
if (!filePath) return { success: false, cancelled: true };
fs.writeFileSync(filePath, content, 'utf8');
return { success: true, filePath };
} catch (error) {
return { success: false, error: error.message };
}
});
ipcMain.handle('config:import', async () => {
const { filePaths } = await dialog.showOpenDialog(mainWindow, {
title: 'Importer une configuration',
filters: [{ name: 'YAML', extensions: ['yaml', 'yml'] }],
properties: ['openFile']
});
if (!filePaths || filePaths.length === 0) return { success: false, cancelled: true };
try {
const content = fs.readFileSync(filePaths[0], 'utf8');
const configPath = path.join(__dirname, '..', 'server', 'config', 'config.yaml');
// Backup de l'ancienne config avant remplacement
if (fs.existsSync(configPath)) {
fs.copyFileSync(configPath, configPath + '.bak');
}
fs.writeFileSync(configPath, content, 'utf8');
return { success: true };
} catch (error) {
return { success: false, error: error.message };
}
});
// Créer fenêtre // Créer fenêtre
createWindow(); createWindow();
createTray(); createTray();
+2 -1
View File
@@ -55,6 +55,7 @@
}, },
"dependencies": { "dependencies": {
"electron-store": "^8.1.0", "electron-store": "^8.1.0",
"qrcode": "^1.5.4" "qrcode": "^1.5.4",
"yaml": "^2.9.0"
} }
} }
+47
View File
@@ -5,8 +5,16 @@
const { contextBridge, ipcRenderer } = require('electron'); const { contextBridge, ipcRenderer } = require('electron');
// Même logique que dans main.js : doit rester synchronisé avec SERVER_URL
// (127.0.0.1 : le serveur n'écoute qu'en IPv4, voir le commentaire dans main.js)
const SERVER_PORT = process.env.PORT || 3000;
const ENABLE_HTTPS = process.env.ENABLE_HTTPS !== 'false';
const SERVER_URL = `${ENABLE_HTTPS ? 'https' : 'http'}://127.0.0.1:${SERVER_PORT}`;
// Exposer l'API au renderer de manière sécurisée // Exposer l'API au renderer de manière sécurisée
contextBridge.exposeInMainWorld('electronAPI', { contextBridge.exposeInMainWorld('electronAPI', {
serverUrl: SERVER_URL,
// Contrôle serveur // Contrôle serveur
server: { server: {
start: () => ipcRenderer.invoke('server:start'), start: () => ipcRenderer.invoke('server:start'),
@@ -23,6 +31,45 @@ contextBridge.exposeInMainWorld('electronAPI', {
} }
}, },
// QR Code (généré côté Main Process, pas de dépendance CDN)
generateQRCode: (text) => ipcRenderer.invoke('qrcode:generate', text),
// IP réseau locale (même détection que pour les certificats mkcert)
getNetworkIP: () => ipcRenderer.invoke('network:ip'),
// Export/import configuration YAML via dialog système
config: {
export: () => ipcRenderer.invoke('config:export'),
import: () => ipcRenderer.invoke('config:import')
},
// Groupes : lecture/écriture YAML directe (fonctionne sans serveur)
groups: {
list: () => ipcRenderer.invoke('groups:list'),
create: (data) => ipcRenderer.invoke('groups:create', data),
update: (data) => ipcRenderer.invoke('groups:update', data),
delete: (data) => ipcRenderer.invoke('groups:delete', data)
},
// Utilisateurs audio serveur : lecture/écriture YAML directe (fonctionne sans serveur)
serverAudioUsers: {
list: () => ipcRenderer.invoke('server-audio-users:list'),
create: (data) => ipcRenderer.invoke('server-audio-users:create', data),
update: (data) => ipcRenderer.invoke('server-audio-users:update', data),
delete: (data) => ipcRenderer.invoke('server-audio-users:delete', data)
},
// Routing audio : lecture/écriture YAML directe (fonctionne sans serveur)
routing: {
get: () => ipcRenderer.invoke('routing:get'),
save: (data) => ipcRenderer.invoke('routing:save', data)
},
// Découverte canaux physiques de la carte son sélectionnée
devices: {
getChannels: () => ipcRenderer.invoke('devices:getChannels')
},
// Helpers // Helpers
platform: process.platform, platform: process.platform,
version: process.env.npm_package_version || '0.3.0' version: process.env.npm_package_version || '0.3.0'
+837 -48
View File
@@ -2,12 +2,25 @@
* PTT Live Desktop - Renderer Process Logic * PTT Live Desktop - Renderer Process Logic
*/ */
const API_BASE = 'http://localhost:3000'; const API_BASE = window.electronAPI?.serverUrl || 'http://localhost:3000';
// État global // État global
let serverRunning = false; let serverRunning = false;
let statsInterval = null; let statsInterval = null;
let logsBuffer = []; let logsBuffer = [];
let audioLevelsWS = null;
let routingData = null;
let deviceChannels = null;
let audioLevelsData = {
inputs: {},
groups: {},
outputs: {},
routing: {
activeInputs: [],
activeGroups: [],
activeOutputs: []
}
};
// ========== Initialisation ========== // ========== Initialisation ==========
@@ -167,6 +180,9 @@ function updateServerStatus(running) {
// Démarrer le polling // Démarrer le polling
startStatsPolling(); startStatsPolling();
// Connecter WebSocket audio levels
connectAudioLevelsWS();
// Charger les données initiales // Charger les données initiales
loadInitialData(); loadInitialData();
} else { } else {
@@ -177,6 +193,13 @@ function updateServerStatus(running) {
// Arrêter le polling // Arrêter le polling
stopStatsPolling(); stopStatsPolling();
// Déconnecter WebSocket audio levels
disconnectAudioLevelsWS();
// QR code obsolète tant que le serveur est arrêté : revenir au placeholder
document.getElementById('qr-code').removeAttribute('src');
document.getElementById('client-url').textContent = '--';
} }
} }
@@ -281,28 +304,125 @@ async function fetchDevices() {
} }
async function fetchGroups() { async function fetchGroups() {
const data = await apiCall('/admin/groups');
if (!data) return;
const container = document.getElementById('groups-list'); const container = document.getElementById('groups-list');
// Lecture directe depuis config.yaml via IPC (fonctionne sans serveur)
const data = await window.electronAPI.groups.list();
if (!data.groups || data.groups.length === 0) { if (!data.groups || data.groups.length === 0) {
container.innerHTML = '<p class="empty-state">Aucun groupe configuré</p>'; container.innerHTML = '<p class="empty-state">Aucun groupe configuré</p>';
return; return;
} }
container.innerHTML = data.groups.map(group => ` const serverNote = serverRunning ? '' : '<p class="config-note" style="margin-bottom:1rem">Serveur arrêté — les modifications seront appliquées au prochain démarrage.</p>';
container.innerHTML = serverNote + data.groups.map(group => {
const id = slugify(group.name);
return `
<div class="group-item"> <div class="group-item">
<div class="group-info"> <div class="group-info">
<h4>${group.name}</h4> <h4>${escapeHtml(group.name)}</h4>
<p>Bitrate: ${group.audioBitrate || 96} kbps • ID: ${group.id}</p> <p>Bitrate: ${group.audioBitrate || 96} kbps • ID: ${escapeHtml(id)}</p>
</div> </div>
<div class="group-actions"> <div class="group-actions">
<button class="btn btn-small btn-secondary">Modifier</button> <button class="btn btn-small btn-secondary"
<button class="btn btn-small btn-secondary">Supprimer</button> data-action="edit"
data-id="${escapeHtml(id)}"
data-name="${escapeHtml(group.name)}"
data-bitrate="${group.audioBitrate || 96}">Modifier</button>
<button class="btn btn-small btn-danger"
data-action="delete"
data-id="${escapeHtml(id)}"
data-name="${escapeHtml(group.name)}">Supprimer</button>
</div> </div>
</div> </div>
`).join(''); `;
}).join('');
}
async function editGroup(id, currentName, currentBitrate) {
const result = await showModal({
title: 'Modifier le groupe',
fields: [
{ name: 'name', label: 'Nom', default: currentName },
{ name: 'bitrate', label: 'Bitrate (kbps)', type: 'number', default: currentBitrate, min: 32, max: 320, step: 1 }
],
confirmLabel: 'Modifier'
});
if (!result) return;
const newName = result.name.trim();
const newBitrate = parseInt(result.bitrate);
if (!newName) { showNotification('Nom requis', 'error'); return; }
if (isNaN(newBitrate) || newBitrate < 32 || newBitrate > 320) {
showNotification('Bitrate invalide (32-320 kbps)', 'error');
return;
}
try {
let ok, errorMsg;
if (serverRunning) {
const response = await fetch(`${API_BASE}/admin/groups/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: newName, audioBitrate: newBitrate })
});
ok = response.ok;
if (!ok) errorMsg = (await response.json().catch(() => ({}))).error;
} else {
const res = await window.electronAPI.groups.update({ id, name: newName, audioBitrate: newBitrate });
ok = res.success;
errorMsg = res.error;
}
if (ok) {
showNotification('Groupe modifié', 'success');
await fetchGroups();
} else {
showNotification('Erreur: ' + (errorMsg || 'Modification échouée'), 'error');
}
} catch (error) {
console.error('Erreur edit group:', error);
showNotification('Erreur réseau', 'error');
}
}
async function deleteGroup(id, name) {
const confirmed = await showModal({
title: 'Supprimer le groupe',
message: `Supprimer le groupe "${name}" ? Cette action est irréversible.`,
confirmLabel: 'Supprimer',
confirmClass: 'btn-danger'
});
if (!confirmed) return;
try {
let ok, errorMsg;
if (serverRunning) {
const response = await fetch(`${API_BASE}/admin/groups/${id}`, { method: 'DELETE' });
ok = response.ok;
if (!ok) errorMsg = (await response.json().catch(() => ({}))).error;
} else {
const res = await window.electronAPI.groups.delete({ id });
ok = res.success;
errorMsg = res.error;
}
if (ok) {
showNotification('Groupe supprimé', 'success');
await fetchGroups();
} else {
showNotification('Erreur: ' + (errorMsg || 'Suppression échouée'), 'error');
}
} catch (error) {
console.error('Erreur delete group:', error);
showNotification('Erreur réseau', 'error');
}
} }
async function fetchConfig() { async function fetchConfig() {
@@ -345,31 +465,29 @@ async function generateQRCode() {
// Détecter l'IP réseau (depuis hostname ou config) // Détecter l'IP réseau (depuis hostname ou config)
const networkIP = await getNetworkIP(); const networkIP = await getNetworkIP();
const clientUrl = `https://${networkIP}:5173`; // Mode dev Vite // En prod (Electron), le client buildé est servi par le serveur Express
// lui-même (même port que l'API), pas par Vite (port 5173, dev only)
// API_BASE pointe sur 127.0.0.1 (loopback, pour le ping interne) :
// on ne réutilise que protocole + port, l'IP doit être celle du réseau local
const serverOrigin = new URL(API_BASE);
const clientUrl = `${serverOrigin.protocol}//${networkIP}:${serverOrigin.port}`;
document.getElementById('client-url').textContent = clientUrl; document.getElementById('client-url').textContent = clientUrl;
// Générer QR Code // Générer QR Code (rendu côté Main Process, pas de dépendance réseau/CDN)
const canvas = document.getElementById('qr-code'); const img = document.getElementById('qr-code');
if (canvas && window.QRCode) { if (img) {
QRCode.toCanvas(canvas, clientUrl, { const result = await window.electronAPI.generateQRCode(clientUrl);
width: 256, if (result.success) {
margin: 2, img.src = result.dataUrl;
color: {
dark: '#000000',
light: '#ffffff'
}
}, (error) => {
if (error) {
console.error('Erreur génération QR Code:', error);
} else {
console.log('✅ QR Code généré'); console.log('✅ QR Code généré');
} else {
console.error('Erreur génération QR Code:', result.error);
} }
});
} }
} catch (error) { } catch (error) {
console.error('Erreur récupération URL:', error); console.error('Erreur récupération URL:', error);
document.getElementById('client-url').textContent = 'https://localhost:5173'; document.getElementById('client-url').textContent = API_BASE;
} }
// Bouton copier URL (setup une seule fois) // Bouton copier URL (setup une seule fois)
@@ -385,15 +503,12 @@ async function generateQRCode() {
} }
async function getNetworkIP() { async function getNetworkIP() {
// Méthode 1 : depuis l'API serveur (qui détecte déjà l'IP) // Détection via le Main Process (même logique que pour les certs mkcert) :
// /admin/config renvoie la valeur YAML brute ("AUTO"), jamais l'IP résolue,
// donc inutilisable ici.
try { try {
const config = await apiCall('/admin/config'); const ip = await window.electronAPI.getNetworkIP();
if (config && config.server && config.server.livekit && config.server.livekit.url) { if (ip) return ip;
const url = config.server.livekit.url;
// Extraire l'IP depuis ws://IP:7880
const match = url.match(/ws:\/\/([^:]+):/);
if (match) return match[1];
}
} catch (error) { } catch (error) {
console.error('Erreur détection IP:', error); console.error('Erreur détection IP:', error);
} }
@@ -459,6 +574,25 @@ async function loadInitialData() {
} }
async function loadViewData(view) { async function loadViewData(view) {
// Ces vues lisent config.yaml directement (fonctionne sans serveur)
if (view === 'groups') {
await fetchGroups();
return;
}
if (view === 'routing') {
await fetchRouting();
return;
}
if (view === 'config') {
if (serverRunning) {
await fetchDevices();
await fetchConfig();
}
return;
}
if (!serverRunning) return; if (!serverRunning) return;
switch (view) { switch (view) {
@@ -467,15 +601,8 @@ async function loadViewData(view) {
await fetchUsers(); await fetchUsers();
await generateQRCode(); await generateQRCode();
break; break;
case 'config':
await fetchDevices();
await fetchConfig();
break;
case 'groups':
await fetchGroups();
break;
case 'monitoring': case 'monitoring':
// TODO: charger VU-mètres WebSocket renderVUMeters();
break; break;
case 'logs': case 'logs':
renderLogs(); renderLogs();
@@ -543,21 +670,42 @@ document.addEventListener('DOMContentLoaded', () => {
const btnAddGroup = document.getElementById('btn-add-group'); const btnAddGroup = document.getElementById('btn-add-group');
if (btnAddGroup) { if (btnAddGroup) {
btnAddGroup.addEventListener('click', async () => { btnAddGroup.addEventListener('click', async () => {
const name = prompt('Nom du groupe:'); const result = await showModal({
if (!name) return; title: 'Nouveau groupe',
fields: [
{ name: 'name', label: 'Nom du groupe' },
{ name: 'bitrate', label: 'Bitrate (kbps)', type: 'number', default: 96, min: 32, max: 320, step: 1 }
],
confirmLabel: 'Créer'
});
if (!result || !result.name.trim()) return;
const name = result.name.trim();
const audioBitrate = parseInt(result.bitrate) || 96;
try { try {
let ok, errorMsg;
if (serverRunning) {
const response = await fetch(`${API_BASE}/admin/groups`, { const response = await fetch(`${API_BASE}/admin/groups`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, audioBitrate: 96 }) body: JSON.stringify({ name, audioBitrate })
}); });
ok = response.ok;
if (!ok) errorMsg = (await response.json().catch(() => ({}))).error;
} else {
const res = await window.electronAPI.groups.create({ name, audioBitrate });
ok = res.success;
errorMsg = res.error;
}
if (response.ok) { if (ok) {
showNotification('Groupe créé', 'success'); showNotification('Groupe créé', 'success');
await fetchGroups(); await fetchGroups();
} else { } else {
showNotification('Erreur création groupe', 'error'); showNotification('Erreur: ' + (errorMsg || 'Création échouée'), 'error');
} }
} catch (error) { } catch (error) {
console.error('Erreur add group:', error); console.error('Erreur add group:', error);
@@ -565,10 +713,481 @@ document.addEventListener('DOMContentLoaded', () => {
} }
}); });
} }
// Exporter config.yaml
const btnExportConfig = document.getElementById('btn-export-config');
if (btnExportConfig) {
btnExportConfig.addEventListener('click', async () => {
const result = await window.electronAPI.config.export();
if (result.success) {
showNotification('Configuration exportée', 'success');
} else if (!result.cancelled) {
showNotification('Erreur export: ' + (result.error || 'Échec'), 'error');
}
});
}
// Importer config.yaml
const btnImportConfig = document.getElementById('btn-import-config');
if (btnImportConfig) {
btnImportConfig.addEventListener('click', async () => {
const result = await window.electronAPI.config.import();
if (result.success) {
showNotification('Configuration importée - Redémarrez le serveur pour appliquer', 'warning');
if (serverRunning) {
await fetchConfig();
}
} else if (!result.cancelled) {
showNotification('Erreur import: ' + (result.error || 'Échec'), 'error');
}
});
}
// Exporter les logs
const btnExportLogs = document.getElementById('btn-export-logs');
if (btnExportLogs) {
btnExportLogs.addEventListener('click', () => {
const levelFilter = document.getElementById('log-level-filter').value;
const logs = levelFilter ? logsBuffer.filter(l => l.level === levelFilter) : logsBuffer;
if (logs.length === 0) {
showNotification('Aucun log à exporter', 'info');
return;
}
const content = JSON.stringify(logs, null, 2);
const blob = new Blob([content], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `ptt-live-logs-${new Date().toISOString().slice(0, 19).replace(/[:.]/g, '-')}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
showNotification(`${logs.length} logs exportés`, 'success');
});
}
// Délégation d'événements pour modifier/supprimer un groupe
const groupsList = document.getElementById('groups-list');
if (groupsList) {
groupsList.addEventListener('click', async (e) => {
const btn = e.target.closest('[data-action]');
if (!btn) return;
const action = btn.dataset.action;
const id = btn.dataset.id;
const name = btn.dataset.name;
if (action === 'edit') {
await editGroup(id, name, parseInt(btn.dataset.bitrate));
} else if (action === 'delete') {
await deleteGroup(id, name);
}
});
}
// Boutons routing
document.getElementById('btn-save-routing')?.addEventListener('click', saveRouting);
document.getElementById('btn-reload-routing')?.addEventListener('click', fetchRouting);
document.getElementById('btn-refresh-channels')?.addEventListener('click', fetchRouting);
document.getElementById('btn-add-server-audio-user')?.addEventListener('click', addServerAudioUser);
// Délégation modifier/supprimer participant serveur
document.getElementById('server-audio-users-list')?.addEventListener('click', async (e) => {
const btn = e.target.closest('[data-sau-action]');
if (!btn) return;
const action = btn.dataset.sauAction;
const name = btn.dataset.sauName;
if (action === 'edit') {
await editServerAudioUser(name, btn.dataset.sauGroup, btn.dataset.sauInput, btn.dataset.sauOutput);
} else if (action === 'delete') {
await deleteServerAudioUser(name);
}
});
}); });
// ========== Server Audio Users (dans la vue Routing) ==========
function renderServerAudioUsers() {
const container = document.getElementById('server-audio-users-list');
if (!container) return;
const users = routingData?.serverAudioUsers || [];
const inputs = routingData?.channelNames?.inputs || {};
const outputs = routingData?.channelNames?.outputs || {};
const chLabel = (ch, dir) => {
if (ch === null || ch === undefined) return 'Aucune';
const name = dir === 'input' ? inputs[ch] : outputs[ch];
return name ? `Ch ${ch} · ${name}` : `Ch ${ch}`;
};
if (users.length === 0) {
container.innerHTML = '<p class="empty-state" style="margin-top:1rem">Aucun participant serveur configuré</p>';
return;
}
container.innerHTML = `
<table class="sau-table">
<thead>
<tr><th>Nom</th><th>Groupe</th><th>Entrée</th><th>Sortie</th><th></th></tr>
</thead>
<tbody>
${users.map(u => `
<tr>
<td class="sau-name">${escapeHtml(u.name)}</td>
<td><span class="ch-badge ch-badge-group">${escapeHtml(u.group)}</span></td>
<td><span class="ch-badge">${chLabel(u.input_channel, 'input')}</span></td>
<td><span class="ch-badge">${chLabel(u.output_channel, 'output')}</span></td>
<td class="sau-actions">
<button class="btn btn-small btn-secondary"
data-sau-action="edit"
data-sau-name="${escapeHtml(u.name)}"
data-sau-group="${escapeHtml(u.group)}"
data-sau-input="${u.input_channel}"
data-sau-output="${u.output_channel}">Éditer</button>
<button class="btn btn-small btn-danger"
data-sau-action="delete"
data-sau-name="${escapeHtml(u.name)}">✕</button>
</td>
</tr>`).join('')}
</tbody>
</table>`;
}
function buildChannelOptions(dir) {
const channels = dir === 'input'
? routingData?.deviceChannels?.inputDevice?.channels || 0
: routingData?.deviceChannels?.outputDevice?.channels || 0;
const names = dir === 'input'
? routingData?.channelNames?.inputs || {}
: routingData?.channelNames?.outputs || {};
if (channels === 0) return null; // fallback to number input
const opts = Array.from({ length: channels }, (_, i) => ({
value: String(i),
label: names[i] ? `Ch ${i}: ${names[i]}` : `Ch ${i}`
}));
opts.unshift({ value: '', label: dir === 'input' ? 'Aucune entrée' : 'Aucune sortie' });
return opts;
}
async function addServerAudioUser() {
const groupsData = await window.electronAPI.groups.list();
const groupOptions = (groupsData.groups || []).map(g => ({ value: slugify(g.name), label: g.name }));
const defaultGroup = groupOptions[0]?.value || 'default';
const inOpts = buildChannelOptions('input');
const outOpts = buildChannelOptions('output');
const inputField = inOpts
? { name: 'input_channel', label: 'Canal d\'entrée', type: 'select', options: inOpts, default: '' }
: { name: 'input_channel', label: 'Canal entrée (index, vide = aucune)', type: 'number', default: '', min: 0, max: 63 };
const outputField = outOpts
? { name: 'output_channel', label: 'Canal de sortie', type: 'select', options: outOpts, default: '' }
: { name: 'output_channel', label: 'Canal sortie (index, vide = aucune)', type: 'number', default: '', min: 0, max: 63 };
const result = await showModal({
title: 'Nouveau participant serveur',
fields: [
{ name: 'name', label: 'Nom (identifiant unique, ex: foh)' },
{ name: 'group', label: 'Groupe', type: 'select', options: groupOptions, default: defaultGroup },
inputField,
outputField
],
confirmLabel: 'Ajouter'
});
if (!result || !result.name.trim()) return;
const res = await window.electronAPI.serverAudioUsers.create({
name: result.name.trim(),
group: result.group,
input_channel: result.input_channel !== '' ? parseInt(result.input_channel) : null,
output_channel: result.output_channel !== '' ? parseInt(result.output_channel) : null
});
if (res.success) {
showNotification('Participant serveur ajouté', 'success');
await fetchRouting();
} else {
showNotification('Erreur: ' + (res.error || 'Création échouée'), 'error');
}
}
async function editServerAudioUser(name, group, input_channel, output_channel) {
const groupsData = await window.electronAPI.groups.list();
const groupOptions = (groupsData.groups || []).map(g => ({ value: slugify(g.name), label: g.name }));
const inOpts = buildChannelOptions('input');
const outOpts = buildChannelOptions('output');
const inputDefault = input_channel !== null && input_channel !== undefined && input_channel !== 'null' ? String(input_channel) : '';
const inputField = inOpts
? { name: 'input_channel', label: 'Canal d\'entrée', type: 'select', options: inOpts, default: inputDefault }
: { name: 'input_channel', label: 'Canal entrée (index, vide = aucune)', type: 'number', default: inputDefault, min: 0, max: 63 };
const outputDefault = output_channel !== null && output_channel !== undefined && output_channel !== 'null' ? String(output_channel) : '';
const outputField = outOpts
? { name: 'output_channel', label: 'Canal de sortie', type: 'select', options: outOpts, default: outputDefault }
: { name: 'output_channel', label: 'Canal sortie (index, vide = aucune)', type: 'number', default: outputDefault, min: 0, max: 63 };
const result = await showModal({
title: `Modifier "${name}"`,
fields: [
{ name: 'group', label: 'Groupe', type: 'select', options: groupOptions, default: group },
inputField,
outputField
],
confirmLabel: 'Modifier'
});
if (!result) return;
const res = await window.electronAPI.serverAudioUsers.update({
name,
group: result.group,
input_channel: result.input_channel !== '' ? parseInt(result.input_channel) : null,
output_channel: result.output_channel !== '' ? parseInt(result.output_channel) : null
});
if (res.success) {
showNotification('Participant serveur modifié', 'success');
await fetchRouting();
} else {
showNotification('Erreur: ' + (res.error || 'Modification échouée'), 'error');
}
}
async function deleteServerAudioUser(name) {
const confirmed = await showModal({
title: 'Supprimer le participant serveur',
message: `Supprimer "${name}" ? Cette action est irréversible.`,
confirmLabel: 'Supprimer',
confirmClass: 'btn-danger'
});
if (!confirmed) return;
const res = await window.electronAPI.serverAudioUsers.delete({ name });
if (res.success) {
showNotification('Participant serveur supprimé', 'success');
await fetchRouting();
} else {
showNotification('Erreur: ' + (res.error || 'Suppression échouée'), 'error');
}
}
// ========== Routing ==========
async function fetchRouting() {
const [routingResult, devResult] = await Promise.all([
window.electronAPI.routing.get(),
window.electronAPI.devices.getChannels()
]);
if (routingResult.error) {
showNotification('Erreur chargement routing: ' + routingResult.error, 'error');
return;
}
deviceChannels = devResult;
routingData = { ...routingResult, deviceChannels: devResult };
renderRoutingView();
}
function renderRoutingView() {
if (!routingData) return;
renderDeviceBanner();
renderChannelLabels();
renderServerAudioUsers();
}
function renderDeviceBanner() {
const el = document.getElementById('routing-device-info');
if (!el) return;
const dc = routingData.deviceChannels;
if (!dc || dc.error || (dc.inputDevice?.channels === 0 && dc.outputDevice?.channels === 0)) {
el.innerHTML = `<span class="device-unset">Aucun device configuré — sélectionnez une carte son dans <strong>Configuration</strong></span>`;
return;
}
const { inputDevice, outputDevice } = dc;
el.innerHTML = `
<span class="device-entry"><strong>Entrée :</strong> ${escapeHtml(inputDevice.name)}
<span class="device-ch-badge">${inputDevice.channels} ch</span></span>
<span class="device-sep">·</span>
<span class="device-entry"><strong>Sortie :</strong> ${escapeHtml(outputDevice.name)}
<span class="device-ch-badge">${outputDevice.channels} ch</span></span>`;
}
function renderChannelLabels() {
const { channelNames, deviceChannels: dc } = routingData;
const inputs = channelNames?.inputs || {};
const outputs = channelNames?.outputs || {};
const inputCount = dc?.inputDevice?.channels || 0;
const outputCount = dc?.outputDevice?.channels || 0;
const inputsEl = document.getElementById('channel-names-inputs');
const outputsEl = document.getElementById('channel-names-outputs');
if (inputsEl) {
inputsEl.innerHTML = inputCount > 0
? Array.from({ length: inputCount }, (_, i) => channelLabelRow('input', i, inputs[i] || '')).join('')
: '<p class="config-note">Aucun device d\'entrée détecté.</p>';
}
if (outputsEl) {
outputsEl.innerHTML = outputCount > 0
? Array.from({ length: outputCount }, (_, i) => channelLabelRow('output', i, outputs[i] || '')).join('')
: '<p class="config-note">Aucun device de sortie détecté.</p>';
}
}
function channelLabelRow(dir, ch, name) {
return `
<div class="channel-name-row" data-channel="${ch}" data-dir="${dir}">
<span class="channel-index">Ch ${ch}</span>
<input type="text" class="form-control form-control-small channel-name-input"
data-dir="${dir}" data-channel="${ch}"
value="${escapeHtml(name)}" placeholder="Label canal ${ch}">
</div>`;
}
async function saveRouting() {
if (!routingData) return;
const newChannelNames = { inputs: {}, outputs: {} };
document.querySelectorAll('.channel-name-input[data-dir="input"]').forEach(el => {
newChannelNames.inputs[el.dataset.channel] = el.value;
});
document.querySelectorAll('.channel-name-input[data-dir="output"]').forEach(el => {
newChannelNames.outputs[el.dataset.channel] = el.value;
});
const result = await window.electronAPI.routing.save({ channelNames: newChannelNames });
if (result.success) {
routingData.channelNames = newChannelNames;
renderRoutingView();
showNotification('Noms de canaux sauvegardés', 'success');
} else {
showNotification('Erreur: ' + (result.error || 'Sauvegarde échouée'), 'error');
}
}
// ========== Helpers ========== // ========== Helpers ==========
/**
* Modal générique (remplace prompt/confirm, non supportés dans Electron).
* - fields[] → formulaire ; message → confirmation simple
* Retourne : objet {champ: valeur} | true (confirm) | null (annulé)
*/
function showModal({ title, fields = [], confirmLabel = 'Confirmer', confirmClass = 'btn-primary', message = null }) {
return new Promise((resolve) => {
const overlay = document.getElementById('modal-overlay');
const titleEl = document.getElementById('modal-title');
const bodyEl = document.getElementById('modal-body');
const cancelBtn = document.getElementById('modal-cancel');
const confirmBtn = document.getElementById('modal-confirm');
titleEl.textContent = title;
confirmBtn.textContent = confirmLabel;
confirmBtn.className = `btn ${confirmClass}`;
if (message) {
bodyEl.innerHTML = `<p class="modal-message">${escapeHtml(message)}</p>`;
} else {
bodyEl.innerHTML = fields.map(field => {
if (field.type === 'select') {
const optionsHtml = (field.options || []).map(opt =>
`<option value="${escapeHtml(opt.value)}" ${opt.value === field.default ? 'selected' : ''}>${escapeHtml(opt.label)}</option>`
).join('');
return `
<div class="form-group">
<label>${escapeHtml(field.label)}</label>
<select id="modal-field-${field.name}" class="form-control">${optionsHtml}</select>
</div>`;
}
if (field.type === 'checkbox') {
return `
<div class="form-group form-group-check">
<label class="check-label">
<input type="checkbox" id="modal-field-${field.name}" ${field.default !== false ? 'checked' : ''}>
${escapeHtml(field.label)}
</label>
</div>`;
}
return `
<div class="form-group">
<label>${escapeHtml(field.label)}</label>
<input
type="${field.type || 'text'}"
id="modal-field-${field.name}"
class="form-control"
value="${escapeHtml(String(field.default ?? ''))}"
${field.min !== undefined ? `min="${field.min}"` : ''}
${field.max !== undefined ? `max="${field.max}"` : ''}
${field.step !== undefined ? `step="${field.step}"` : ''}>
</div>`;
}).join('');
}
overlay.classList.remove('hidden');
const firstInput = bodyEl.querySelector('input');
if (firstInput) { firstInput.focus(); firstInput.select(); }
function cleanup() {
overlay.classList.add('hidden');
cancelBtn.removeEventListener('click', onCancel);
confirmBtn.removeEventListener('click', onConfirm);
overlay.removeEventListener('click', onOverlayClick);
document.removeEventListener('keydown', onKeydown);
}
function onCancel() { cleanup(); resolve(null); }
function onConfirm() {
if (message) {
cleanup(); resolve(true);
} else {
const result = {};
fields.forEach(f => {
const input = document.getElementById(`modal-field-${f.name}`);
if (!input) { result[f.name] = ''; return; }
result[f.name] = f.type === 'checkbox' ? input.checked : input.value;
});
cleanup(); resolve(result);
}
}
function onOverlayClick(e) { if (e.target === overlay) onCancel(); }
function onKeydown(e) {
if (e.key === 'Escape') onCancel();
if (e.key === 'Enter' && document.activeElement?.tagName !== 'BUTTON') onConfirm();
}
cancelBtn.addEventListener('click', onCancel);
confirmBtn.addEventListener('click', onConfirm);
overlay.addEventListener('click', onOverlayClick);
document.addEventListener('keydown', onKeydown);
});
}
function slugify(text) {
return text.toString().normalize('NFD').replace(/[̀-ͯ]/g, '')
.toLowerCase().trim().replace(/\s+/g, '-').replace(/[^\w-]+/g, '').replace(/--+/g, '-');
}
function formatUptime(seconds) { function formatUptime(seconds) {
if (!seconds) return '--'; if (!seconds) return '--';
@@ -579,6 +1198,176 @@ function formatUptime(seconds) {
return `${h}h ${m}m ${s}s`; return `${h}h ${m}m ${s}s`;
} }
// ========== WebSocket Audio Levels ==========
function connectAudioLevelsWS() {
if (audioLevelsWS && audioLevelsWS.readyState === WebSocket.OPEN) {
console.log('WebSocket audio-levels déjà connecté');
return;
}
const wsUrl = API_BASE.replace(/^http/, 'ws') + '/audio-levels';
console.log('Connexion WebSocket audio-levels...', wsUrl);
try {
audioLevelsWS = new WebSocket(wsUrl);
audioLevelsWS.onopen = () => {
console.log('WebSocket audio-levels connecté');
updateVUMetersStatus('Connecté');
};
audioLevelsWS.onmessage = (event) => {
try {
const message = JSON.parse(event.data);
switch (message.type) {
case 'initial':
case 'levels':
audioLevelsData = message.data;
renderVUMeters();
break;
case 'pong':
break;
default:
console.warn('Message WebSocket inconnu:', message.type);
}
} catch (error) {
console.error('Erreur parsing message WebSocket:', error);
}
};
audioLevelsWS.onerror = (error) => {
console.error('Erreur WebSocket audio-levels:', error);
updateVUMetersStatus('Erreur de connexion');
};
audioLevelsWS.onclose = () => {
console.log('WebSocket audio-levels déconnecté');
audioLevelsWS = null;
updateVUMetersStatus('Déconnecté');
// Reconnexion automatique si serveur actif
if (serverRunning) {
setTimeout(() => {
connectAudioLevelsWS();
}, 3000);
}
};
// Ping périodique
const pingInterval = setInterval(() => {
if (audioLevelsWS && audioLevelsWS.readyState === WebSocket.OPEN) {
audioLevelsWS.send(JSON.stringify({ type: 'ping' }));
} else {
clearInterval(pingInterval);
}
}, 10000);
} catch (error) {
console.error('Erreur création WebSocket:', error);
updateVUMetersStatus('Erreur de connexion');
}
}
function disconnectAudioLevelsWS() {
if (audioLevelsWS) {
audioLevelsWS.close();
audioLevelsWS = null;
}
}
function updateVUMetersStatus(status) {
const container = document.getElementById('vu-meters');
if (!container) return;
const statusEl = container.querySelector('.vu-status');
if (statusEl) {
statusEl.textContent = `WebSocket: ${status}`;
statusEl.className = `vu-status ${status === 'Connecté' ? 'connected' : 'disconnected'}`;
}
}
function renderVUMeters() {
const container = document.getElementById('vu-meters');
if (!container) return;
const hasData =
Object.keys(audioLevelsData.inputs).length > 0 ||
Object.keys(audioLevelsData.groups).length > 0 ||
Object.keys(audioLevelsData.outputs).length > 0;
if (!hasData) {
container.innerHTML = `
<p class="vu-status">WebSocket: En attente de connexion...</p>
<p class="empty-state">Aucune donnée audio disponible</p>
`;
return;
}
let html = '<div class="vu-status connected">WebSocket: Connecté</div>';
// Inputs
if (Object.keys(audioLevelsData.inputs).length > 0) {
html += '<div class="vu-section"><h4>Entrées Audio</h4><div class="vu-grid">';
Object.entries(audioLevelsData.inputs).forEach(([channelId, data]) => {
html += renderVUMeter(channelId, data, 'input');
});
html += '</div></div>';
}
// Groups
if (Object.keys(audioLevelsData.groups).length > 0) {
html += '<div class="vu-section"><h4>Groupes</h4><div class="vu-grid">';
Object.entries(audioLevelsData.groups).forEach(([groupName, data]) => {
html += renderVUMeter(groupName, data, 'group');
});
html += '</div></div>';
}
// Outputs
if (Object.keys(audioLevelsData.outputs).length > 0) {
html += '<div class="vu-section"><h4>Sorties Audio</h4><div class="vu-grid">';
Object.entries(audioLevelsData.outputs).forEach(([channelId, data]) => {
html += renderVUMeter(channelId, data, 'output');
});
html += '</div></div>';
}
container.innerHTML = html;
}
function renderVUMeter(label, data, type) {
const { rms, peak, clipping } = data;
// Convertir dBFS en pourcentage pour la barre (0dB = 100%, -60dB = 0%)
const rmsPercent = Math.max(0, Math.min(100, ((rms + 60) / 60) * 100));
const peakPercent = Math.max(0, Math.min(100, ((peak * 60 - 60 + 60) / 60) * 100));
// Couleur selon le niveau
let barClass = 'vu-bar-green';
if (rms > -6) barClass = 'vu-bar-red';
else if (rms > -12) barClass = 'vu-bar-yellow';
const clippingClass = clipping ? 'vu-meter-clipping' : '';
return `
<div class="vu-meter ${clippingClass}">
<div class="vu-label">${escapeHtml(label)}</div>
<div class="vu-bar-container">
<div class="vu-bar ${barClass}" style="width: ${rmsPercent}%"></div>
<div class="vu-peak" style="left: ${peakPercent}%"></div>
</div>
<div class="vu-values">
<span class="vu-rms">${rms.toFixed(1)} dB</span>
${clipping ? '<span class="vu-clip">CLIP!</span>' : ''}
</div>
</div>
`;
}
function formatTime(isoString) { function formatTime(isoString) {
if (!isoString) return '--'; if (!isoString) return '--';
const date = new Date(isoString); const date = new Date(isoString);
+82 -3
View File
@@ -40,6 +40,9 @@
<button class="nav-item" data-view="groups"> <button class="nav-item" data-view="groups">
👥 Groupes 👥 Groupes
</button> </button>
<button class="nav-item" data-view="routing">
🔀 Routing
</button>
<button class="nav-item" data-view="monitoring"> <button class="nav-item" data-view="monitoring">
📈 Monitoring 📈 Monitoring
</button> </button>
@@ -78,7 +81,13 @@
<div class="section"> <div class="section">
<h3>📱 Connexion rapide clients</h3> <h3>📱 Connexion rapide clients</h3>
<div class="qr-container"> <div class="qr-container">
<canvas id="qr-code" width="256" height="256"></canvas> <div class="qr-wrapper">
<img id="qr-code" width="256" height="256" alt="QR Code connexion" />
<div class="qr-placeholder" id="qr-placeholder">
<span class="qr-placeholder-icon">📷</span>
<span>En attente du démarrage du serveur</span>
</div>
</div>
<div class="qr-info"> <div class="qr-info">
<p><strong>URL clients :</strong></p> <p><strong>URL clients :</strong></p>
<p class="url-text" id="client-url">--</p> <p class="url-text" id="client-url">--</p>
@@ -117,6 +126,15 @@
<button class="btn btn-primary" id="btn-save-device">Appliquer</button> <button class="btn btn-primary" id="btn-save-device">Appliquer</button>
</div> </div>
<div class="section">
<h3>💾 Sauvegarde de configuration</h3>
<div class="config-actions">
<button class="btn btn-secondary" id="btn-export-config">Exporter config.yaml</button>
<button class="btn btn-secondary" id="btn-import-config">Importer config.yaml</button>
</div>
<p class="config-note">L'import remplace config.yaml (backup automatique en .bak). Redémarrez le serveur pour appliquer.</p>
</div>
<div class="section"> <div class="section">
<h3>🎚️ Paramètres Audio</h3> <h3>🎚️ Paramètres Audio</h3>
<div class="form-group"> <div class="form-group">
@@ -148,6 +166,53 @@
</div> </div>
</div> </div>
<!-- Routing View -->
<div id="view-routing" class="view">
<h2>Routing Audio</h2>
<!-- Device info -->
<div class="section">
<div class="routing-device-banner">
<div class="routing-device-info" id="routing-device-info">Chargement...</div>
<button class="btn btn-small btn-secondary" id="btn-refresh-channels">🔄 Actualiser</button>
</div>
</div>
<!-- Labels canaux physiques -->
<div class="section">
<h3>🏷️ Canaux Physiques</h3>
<p class="config-note">Labels des canaux de votre carte son. La liste est déterminée par le device sélectionné en <strong>Configuration</strong>.</p>
<div class="channel-names-grid">
<div class="channel-names-col">
<h4>Entrées</h4>
<div id="channel-names-inputs" class="channel-names-list">
<p class="empty-state">Chargement...</p>
</div>
</div>
<div class="channel-names-col">
<h4>Sorties</h4>
<div id="channel-names-outputs" class="channel-names-list">
<p class="empty-state">Chargement...</p>
</div>
</div>
</div>
</div>
<!-- Participants Serveur -->
<div class="section">
<h3>🎙️ Participants Serveur</h3>
<p class="config-note">Participants LiveKit côté serveur — chaque ligne branche des canaux physiques d'E/S sur un groupe.</p>
<button class="btn btn-primary btn-small" id="btn-add-server-audio-user"> Ajouter</button>
<div id="server-audio-users-list" class="sau-list"></div>
</div>
<!-- Actions -->
<div class="routing-actions">
<button class="btn btn-primary" id="btn-save-routing">Sauvegarder noms de canaux</button>
<button class="btn btn-secondary" id="btn-reload-routing">Recharger</button>
</div>
</div>
<!-- Monitoring View --> <!-- Monitoring View -->
<div id="view-monitoring" class="view"> <div id="view-monitoring" class="view">
<h2>Monitoring Audio</h2> <h2>Monitoring Audio</h2>
@@ -164,6 +229,7 @@
<h2>Logs Serveur</h2> <h2>Logs Serveur</h2>
<div class="logs-controls"> <div class="logs-controls">
<button class="btn btn-small" id="btn-clear-logs">Effacer</button> <button class="btn btn-small" id="btn-clear-logs">Effacer</button>
<button class="btn btn-small btn-secondary" id="btn-export-logs">Exporter JSON</button>
<select id="log-level-filter" class="form-control form-control-small"> <select id="log-level-filter" class="form-control form-control-small">
<option value="">Tous les niveaux</option> <option value="">Tous les niveaux</option>
<option value="error">Erreurs</option> <option value="error">Erreurs</option>
@@ -180,8 +246,21 @@
</main> </main>
</div> </div>
<!-- QR Code Library --> <!-- Modal générique -->
<script src="https://cdn.jsdelivr.net/npm/qrcode@1.5.3/build/qrcode.min.js"></script> <div id="modal-overlay" class="modal-overlay hidden">
<div class="modal">
<div class="modal-header">
<h3 id="modal-title"></h3>
</div>
<div class="modal-body" id="modal-body"></div>
<div class="modal-footer">
<button class="btn btn-secondary" id="modal-cancel">Annuler</button>
<button class="btn btn-primary" id="modal-confirm">Confirmer</button>
</div>
</div>
</div>
<!-- Le QR Code est généré côté Main Process (lib qrcode Node), pas de dépendance CDN -->
<script src="app.js"></script> <script src="app.js"></script>
</body> </body>
</html> </html>
-2
View File
@@ -1,2 +0,0 @@
// Placeholder - QR Code sera généré via CDN
// En production, utiliser une lib locale ou CDN
+529
View File
@@ -121,6 +121,15 @@ body {
font-size: 0.8125rem; font-size: 0.8125rem;
} }
.btn-danger {
background: var(--accent-error);
color: white;
}
.btn-danger:hover:not(:disabled) {
background: #d32f2f;
}
/* Main Content */ /* Main Content */
.main-content { .main-content {
display: flex; display: flex;
@@ -230,11 +239,51 @@ body {
align-items: center; align-items: center;
} }
.qr-wrapper {
position: relative;
width: 256px;
height: 256px;
flex-shrink: 0;
}
#qr-code { #qr-code {
display: none;
width: 256px;
height: 256px;
border: 4px solid white; border: 4px solid white;
border-radius: 8px; border-radius: 8px;
} }
/* L'image n'a un attribut src qu'une fois le QR code généré */
#qr-code[src] {
display: block;
}
#qr-code[src] ~ .qr-placeholder {
display: none;
}
.qr-placeholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.5rem;
width: 100%;
height: 100%;
border: 2px dashed var(--border-color);
border-radius: 8px;
color: var(--text-secondary);
text-align: center;
padding: 1rem;
box-sizing: border-box;
}
.qr-placeholder-icon {
font-size: 2.5rem;
opacity: 0.5;
}
.qr-info { .qr-info {
flex: 1; flex: 1;
} }
@@ -330,6 +379,70 @@ body {
border-color: var(--accent-primary); border-color: var(--accent-primary);
} }
/* Modal */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
z-index: 10000;
}
.modal-overlay.hidden {
display: none;
}
.modal {
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 10px;
width: 420px;
max-width: 90vw;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
}
.modal-header {
padding: 1.25rem 1.5rem;
border-bottom: 1px solid var(--border-color);
}
.modal-header h3 {
font-size: 1.125rem;
margin: 0;
}
.modal-body {
padding: 1.5rem;
}
.modal-message {
color: var(--text-primary);
line-height: 1.5;
margin: 0;
}
.modal-footer {
padding: 1rem 1.5rem;
border-top: 1px solid var(--border-color);
display: flex;
justify-content: flex-end;
gap: 0.75rem;
}
/* Config actions */
.config-actions {
display: flex;
gap: 0.75rem;
margin-bottom: 0.75rem;
}
.config-note {
font-size: 0.8125rem;
color: var(--text-secondary);
}
/* Groups List */ /* Groups List */
.groups-list { .groups-list {
display: flex; display: flex;
@@ -554,3 +667,419 @@ body {
opacity: 1; opacity: 1;
} }
} }
/* VU Meters */
.vu-meters {
padding: 1rem 0;
}
.vu-status {
font-size: 0.875rem;
padding: 0.5rem;
margin-bottom: 1rem;
border-radius: 4px;
background: var(--bg-tertiary);
text-align: center;
}
.vu-status.connected {
background: rgba(76, 175, 80, 0.2);
color: var(--accent-success);
}
.vu-status.disconnected {
background: rgba(244, 67, 54, 0.2);
color: var(--accent-error);
}
.vu-section {
margin-bottom: 2rem;
}
.vu-section h4 {
font-size: 0.875rem;
color: var(--text-secondary);
margin-bottom: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.vu-grid {
display: grid;
gap: 0.75rem;
}
.vu-meter {
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 4px;
padding: 0.75rem;
transition: border-color 0.2s;
}
.vu-meter-clipping {
border-color: var(--accent-error);
animation: pulseClipping 0.5s ease-in-out infinite;
}
@keyframes pulseClipping {
0%, 100% {
border-color: var(--accent-error);
}
50% {
border-color: transparent;
}
}
.vu-label {
font-size: 0.875rem;
font-weight: 500;
margin-bottom: 0.5rem;
color: var(--text-primary);
}
.vu-bar-container {
position: relative;
height: 24px;
background: var(--bg-tertiary);
border-radius: 3px;
overflow: hidden;
margin-bottom: 0.5rem;
}
.vu-bar {
height: 100%;
transition: width 0.05s linear;
border-radius: 3px;
}
.vu-bar-green {
background: linear-gradient(to right, #4caf50, #66bb6a);
}
.vu-bar-yellow {
background: linear-gradient(to right, #ff9800, #ffa726);
}
.vu-bar-red {
background: linear-gradient(to right, #f44336, #e57373);
}
.vu-peak {
position: absolute;
top: 0;
width: 2px;
height: 100%;
background: #ffffff;
box-shadow: 0 0 4px rgba(255, 255, 255, 0.8);
transition: left 0.1s linear;
}
.vu-values {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 0.75rem;
}
.vu-rms {
color: var(--text-secondary);
}
.vu-clip {
color: var(--accent-error);
font-weight: bold;
animation: blinkClip 0.5s ease-in-out infinite;
}
@keyframes blinkClip {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.3;
}
}
/* ========== Routing View ========== */
.routing-matrix-wrapper {
overflow-x: auto;
}
.routing-matrix-scroll {
overflow-x: auto;
max-width: 100%;
}
.routing-matrix {
border-collapse: collapse;
font-size: 0.875rem;
min-width: 100%;
}
.routing-matrix th,
.routing-matrix td {
padding: 0.5rem 0.75rem;
border: 1px solid var(--border-color);
text-align: center;
vertical-align: middle;
}
.routing-matrix thead th {
background: var(--bg-tertiary);
font-weight: 600;
position: sticky;
top: 0;
z-index: 1;
}
.matrix-label-cell {
text-align: left;
font-weight: 600;
background: var(--bg-tertiary);
min-width: 200px;
position: sticky;
left: 0;
z-index: 2;
}
.matrix-group-header {
min-width: 110px;
font-size: 0.8125rem;
line-height: 1.4;
}
.matrix-channel-label {
text-align: left;
white-space: nowrap;
background: var(--bg-secondary);
position: sticky;
left: 0;
z-index: 1;
}
.ch-index {
display: inline-block;
background: var(--bg-primary);
color: var(--accent-primary);
font-family: 'Courier New', monospace;
font-size: 0.75rem;
padding: 0.1rem 0.4rem;
border-radius: 3px;
margin-right: 0.5rem;
min-width: 24px;
text-align: center;
}
.ch-name {
color: var(--text-secondary);
font-size: 0.8125rem;
}
.matrix-cell {
text-align: center;
}
.matrix-cell input[type="checkbox"] {
width: 18px;
height: 18px;
cursor: pointer;
accent-color: var(--accent-primary);
}
.routing-matrix tbody tr:hover .matrix-channel-label,
.routing-matrix tbody tr:hover td {
background: rgba(74, 158, 255, 0.05);
}
/* Device banner */
.routing-device-banner {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0.75rem 1rem;
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
border-radius: 6px;
}
.routing-device-info {
display: flex;
align-items: center;
gap: 1rem;
flex-wrap: wrap;
font-size: 0.875rem;
}
.device-entry {
display: flex;
align-items: center;
gap: 0.4rem;
}
.device-sep {
color: var(--text-secondary);
}
.device-ch-badge {
display: inline-block;
background: var(--accent-primary);
color: #fff;
font-size: 0.7rem;
font-weight: 600;
padding: 0.1rem 0.4rem;
border-radius: 10px;
letter-spacing: 0.02em;
}
.device-unset {
color: var(--text-secondary);
font-size: 0.875rem;
}
/* Channel labels */
.channel-names-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 2rem;
}
.channel-names-col h4 {
font-size: 0.8125rem;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.06em;
margin: 0 0 0.75rem;
padding-bottom: 0.5rem;
border-bottom: 1px solid var(--border-color);
}
.channel-names-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.channel-name-row {
display: flex;
align-items: center;
gap: 0.625rem;
}
.channel-index {
min-width: 40px;
font-size: 0.8rem;
color: var(--accent-primary);
font-family: 'Courier New', monospace;
font-weight: 600;
flex-shrink: 0;
}
/* Server Audio Users table */
.sau-list {
margin-top: 1rem;
}
.sau-table {
width: 100%;
border-collapse: collapse;
font-size: 0.875rem;
margin-top: 0.75rem;
}
.sau-table th {
background: var(--bg-tertiary);
padding: 0.5rem 0.75rem;
text-align: left;
font-size: 0.8rem;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.05em;
border-bottom: 1px solid var(--border-color);
}
.sau-table td {
padding: 0.5rem 0.75rem;
border-bottom: 1px solid var(--border-color);
vertical-align: middle;
}
.sau-table tbody tr:hover td {
background: rgba(74, 158, 255, 0.04);
}
.sau-name {
font-weight: 600;
color: var(--text-primary);
}
.sau-actions {
text-align: right;
white-space: nowrap;
display: flex;
gap: 0.4rem;
justify-content: flex-end;
}
.ch-badge {
display: inline-block;
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
color: var(--text-secondary);
font-size: 0.78rem;
padding: 0.1rem 0.5rem;
border-radius: 4px;
font-family: 'Courier New', monospace;
}
.ch-badge-group {
background: rgba(74, 158, 255, 0.12);
border-color: rgba(74, 158, 255, 0.3);
color: var(--accent-primary);
font-family: inherit;
}
.form-group-check {
display: flex;
align-items: center;
gap: 0.5rem;
}
.check-label {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
font-size: 0.9rem;
color: var(--text-primary);
}
.check-label input[type="checkbox"] {
width: 1rem;
height: 1rem;
cursor: pointer;
accent-color: var(--accent-primary);
}
/* Routing actions bar */
.routing-actions {
display: flex;
align-items: center;
gap: 1rem;
padding: 0.5rem 0 1.5rem;
}
.routing-restart-note {
color: var(--accent-warning);
}
.routing-restart-note.hidden {
display: none;
}
-60
View File
@@ -529,66 +529,6 @@ router.put('/audio/channels/names', (req, res) => {
} }
}); });
/**
* GET /admin/audio/routing
* Récupère la configuration de routing actuelle
* Format: { inputToGroup: { "0": ["production"], "1": ["technique"] }, groupToOutput: { "production": ["0", "1"] } }
*/
router.get('/audio/routing', (req, res) => {
try {
const config = configManager.get();
const routing = config.audio?.routing || { inputToGroup: {}, groupToOutput: {}, gains: {} };
res.json({
routing
});
} catch (error) {
console.error('Erreur GET /admin/audio/routing:', error);
res.status(500).json({ error: 'Failed to load routing' });
}
});
/**
* POST /audio/routing
* Sauvegarde la configuration de routing
* Body: { inputToGroup: {...}, groupToOutput: {...}, gains: {...} }
*/
router.post('/audio/routing', (req, res) => {
try {
const { inputToGroup, groupToOutput, gains } = req.body;
const config = configManager.get();
if (!config.audio.routing) {
config.audio.routing = { inputToGroup: {}, groupToOutput: {}, gains: {} };
}
if (inputToGroup !== undefined) {
config.audio.routing.inputToGroup = inputToGroup;
}
if (groupToOutput !== undefined) {
config.audio.routing.groupToOutput = groupToOutput;
}
if (gains !== undefined) {
config.audio.routing.gains = gains;
}
configManager.save(config);
addLog('info', 'Audio routing updated');
res.json({
message: 'Audio routing updated',
routing: config.audio.routing
});
} catch (error) {
console.error('Erreur POST /admin/audio/routing:', error);
res.status(500).json({ error: 'Failed to update routing' });
}
});
/** /**
* POST /admin/audio/device * POST /admin/audio/device
+67 -304
View File
@@ -17,8 +17,7 @@ import JACKBackend from './backends/JACKBackend.js';
import PipeWireBackend from './backends/PipeWireBackend.js'; import PipeWireBackend from './backends/PipeWireBackend.js';
import OpusCodec, { OpusPresets } from './OpusCodec.js'; import OpusCodec, { OpusPresets } from './OpusCodec.js';
import JitterBuffer, { JitterBufferPresets } from './JitterBuffer.js'; import JitterBuffer, { JitterBufferPresets } from './JitterBuffer.js';
import LiveKitClient from './LiveKitClient.js'; import ServerAudioUser from './ServerAudioUser.js';
import GroupAudioRouter from './GroupAudioRouter.js';
export class AudioBridge extends EventEmitter { export class AudioBridge extends EventEmitter {
constructor(options = {}) { constructor(options = {}) {
@@ -54,8 +53,6 @@ export class AudioBridge extends EventEmitter {
this.opusEncoder = null; this.opusEncoder = null;
this.opusDecoder = null; this.opusDecoder = null;
this.jitterBuffer = null; this.jitterBuffer = null;
this.liveKitClients = new Map(); // Map<groupName, LiveKitClient> - un client par groupe
this.groupAudioRouter = null;
// État // État
this.isRunning = false; this.isRunning = false;
@@ -63,10 +60,9 @@ export class AudioBridge extends EventEmitter {
// Buffers pour routing multi-canaux // Buffers pour routing multi-canaux
this.inputChannelBuffers = new Map(); // Map<channelId, Float32Array> this.inputChannelBuffers = new Map(); // Map<channelId, Float32Array>
this.groupBuffersFromLiveKit = new Map(); // Map<groupName, Float32Array>
// Frame accumulators pour LiveKit (240 samples → 960 samples) // Utilisateurs audio gérés côté serveur (participants LiveKit avec I/O physique dédiés)
this.liveKitFrameAccumulators = new Map(); // Map<groupName, { buffer: Float32Array, offset: number }> this.serverAudioUsers = new Map(); // Map<name, ServerAudioUser>
// Pool de buffers pré-alloués pour éviter allocations répétées // Pool de buffers pré-alloués pour éviter allocations répétées
this.bufferPool = { this.bufferPool = {
@@ -114,13 +110,10 @@ export class AudioBridge extends EventEmitter {
// 3. Initialisation du jitter buffer // 3. Initialisation du jitter buffer
this._initJitterBuffer(); this._initJitterBuffer();
// 4. Initialisation du GroupAudioRouter // 4. Initialisation des server audio users
this._initGroupAudioRouter(); await this._initServerAudioUsers();
// 5. Connexion à LiveKit // 5. Démarrage du routing audio
await this._initLiveKit();
// 6. Démarrage du routing audio
await this._startAudioRouting(); await this._startAudioRouting();
this.isRunning = true; this.isRunning = true;
@@ -301,329 +294,105 @@ export class AudioBridge extends EventEmitter {
} }
/** /**
* Initialise le GroupAudioRouter pour le routing multi-canaux * Initialise les utilisateurs audio serveur (participants LiveKit avec I/O physique)
* @private * @private
*/ */
_initGroupAudioRouter() { async _initServerAudioUsers() {
this.groupAudioRouter = new GroupAudioRouter({ const users = this.options.serverAudioUsers;
if (!users || users.length === 0) return;
console.log(`🎤 Initialisation ${users.length} server audio user(s)...`);
for (const userConfig of users) {
const user = new ServerAudioUser({
name: userConfig.name,
groupId: userConfig.groupId,
inputChannel: userConfig.inputChannel,
outputChannel: userConfig.outputChannel,
publish: userConfig.publish !== false,
liveKitUrl: this.options.liveKitUrl,
token: userConfig.token,
sampleRate: this.options.sampleRate, sampleRate: this.options.sampleRate,
frameSize: this.options.frameSize, frameSize: this.options.frameSize
maxInputChannels: this.options.maxInputChannels || 32,
maxOutputChannels: this.options.maxOutputChannels || 32,
groups: this.options.groups || []
}); });
// Charger la configuration de routing depuis les options // Quand une frame de mix est prête, l'envoyer vers le canal physique de sortie
if (this.options.routing) { const outputCh = userConfig.outputChannel;
this.groupAudioRouter.configure(this.options.routing); user.on('outputReady', (mixBuffer) => {
if (!this.audioBackend) return;
const numChannels = this.options.channels || 1;
const frameSize = this.options.frameSize;
if (numChannels <= 1) {
const pcmBuffer = this._float32ToBuffer(mixBuffer);
this.audioBackend.queueAudio(pcmBuffer);
} else {
// Construire un buffer multi-canaux avec l'audio du user sur son canal de sortie
const interleaved = new Float32Array(frameSize * numChannels);
for (let i = 0; i < frameSize; i++) {
interleaved[i * numChannels + outputCh] = mixBuffer[i];
}
const pcmBuffer = this._float32ToBuffer(interleaved);
this.audioBackend.queueAudio(pcmBuffer);
}
});
await user.start();
this.serverAudioUsers.set(userConfig.name, user);
const modeStr = userConfig.publish !== false
? `canal ${userConfig.inputChannel} → sortie canal ${userConfig.outputChannel ?? 'aucune'}`
: `écoute seule → sortie canal ${userConfig.outputChannel ?? 'aucune'}`;
console.log(`✓ Server audio user "${userConfig.name}" démarré (${modeStr}, room: ${userConfig.groupId})`);
} }
// Events du router console.log(`${this.serverAudioUsers.size} server audio user(s) initialisés`);
this.groupAudioRouter.on('configured', (stats) => {
console.log(`✓ GroupAudioRouter configuré : ${stats.routesActive} routes`);
});
console.log('✓ GroupAudioRouter initialisé');
} }
/** /**
* Initialise les connexions LiveKit (une par groupe) * Démarre le routing audio : capture physique → server audio users
* @private
*/
async _initLiveKit() {
if (!this.options.liveKitTokens || !Array.isArray(this.options.liveKitTokens)) {
throw new Error('liveKitTokens requis (tableau d\'objets { groupName, groupId, token })');
}
console.log(`🔌 Initialisation ${this.options.liveKitTokens.length} connexions LiveKit (une par groupe)...`);
// Créer un LiveKitClient pour chaque groupe
for (const { groupName, groupId, token } of this.options.liveKitTokens) {
const roomName = groupId; // La room porte le nom du groupId (slugifié)
const client = new LiveKitClient({
url: this.options.liveKitUrl,
token,
roomName,
participantName: `AudioBridge-${groupId}`,
sampleRate: this.options.sampleRate,
channels: this.options.channels,
audioBitrate: this.opusEncoder.options.bitrate
});
// Events LiveKit pour ce groupe
client.on('connected', () => {
console.log(`✓ LiveKit connecté pour groupe "${groupName}" (room: ${roomName})`);
});
client.on('disconnected', (data) => {
const reason = data?.reason || 'unknown';
console.warn(`⚠️ LiveKit déconnecté pour groupe "${groupName}":`, reason);
this.stats.errors.network++;
});
client.on('reconnecting', () => {
console.log(`🔄 LiveKit reconnexion pour groupe "${groupName}"...`);
});
client.on('audioTrackSubscribed', ({ track, participant }) => {
console.log(`🎵 Nouveau track audio dans groupe "${groupName}": ${participant.identity}`);
});
// Réception audio depuis les clients LiveKit de ce groupe
client.on('audioData', ({ participantName, pcmData, sampleRate, channels }) => {
// Router vers le bon groupe
this.emit('groupAudioIn', { groupName: groupId, pcmBuffer: pcmData });
});
// Connexion
await client.connect();
// Stocker le client par groupId
this.liveKitClients.set(groupId, client);
}
console.log(`${this.liveKitClients.size} connexions LiveKit établies`);
}
/**
* Démarre le routing audio bidirectionnel complet
* @private * @private
*/ */
async _startAudioRouting() { async _startAudioRouting() {
console.log('🔄 Démarrage routing audio bidirectionnel...'); console.log('🔄 Démarrage routing audio...');
// ===== FLUX 1 : CAPTURE (Carte Son → Groupes → LiveKit → Clients) =====
this.audioBackend.on('audioData', (pcmData) => { this.audioBackend.on('audioData', (pcmData) => {
try { try {
// Convertir PCM Buffer → Float32Array (pour GroupAudioRouter)
const float32Data = this._bufferToFloat32(pcmData); const float32Data = this._bufferToFloat32(pcmData);
// Séparer les canaux si audio multi-canaux (entrelacé)
const numChannels = this.options.channels || 1; const numChannels = this.options.channels || 1;
if (numChannels === 1) { if (numChannels === 1) {
// Mono : un seul canal this.inputChannelBuffers.set(0, float32Data);
const channelId = this.options.inputDeviceChannel || 0;
this.inputChannelBuffers.set(channelId, float32Data);
} else { } else {
// Multi-canaux : dé-entrelacer les samples
// Format entrelacé : [L0, R0, L1, R1, ...] → [L0, L1, ...] et [R0, R1, ...]
const samplesPerChannel = float32Data.length / numChannels; const samplesPerChannel = float32Data.length / numChannels;
for (let ch = 0; ch < numChannels; ch++) { for (let ch = 0; ch < numChannels; ch++) {
const channelBuffer = new Float32Array(samplesPerChannel); const channelBuffer = new Float32Array(samplesPerChannel);
for (let i = 0; i < samplesPerChannel; i++) { for (let i = 0; i < samplesPerChannel; i++) {
channelBuffer[i] = float32Data[i * numChannels + ch]; channelBuffer[i] = float32Data[i * numChannels + ch];
} }
this.inputChannelBuffers.set(ch, channelBuffer);
// Mapper canal hardware → canal logique (peut être configuré)
const logicalChannelId = this.options.channelMapping?.[ch] ?? ch;
this.inputChannelBuffers.set(logicalChannelId, channelBuffer);
} }
} }
// ÉTAPE 1 : Inputs physiques → Groupes (via GroupAudioRouter) // Alimenter chaque server audio user avec son canal d'entrée
const groupBuffers = this.groupAudioRouter.processInputsToGroups( for (const [, user] of this.serverAudioUsers) {
this.inputChannelBuffers const channelData = this.inputChannelBuffers.get(user.inputChannel);
); if (channelData) {
user.sendAudio(channelData);
if (this.stats.framesCapture % 100 === 0) {
// Détecter si l'audio est du silence (toutes les samples < 0.001)
let totalEnergy = 0;
this.inputChannelBuffers.forEach((buffer) => {
for (let i = 0; i < buffer.length; i++) {
totalEnergy += Math.abs(buffer[i]);
}
});
const avgEnergy = totalEnergy / (this.inputChannelBuffers.size * (this.options.frameSize || 960));
console.log(`[AudioBridge] Frame ${this.stats.framesCapture}: ${this.inputChannelBuffers.size} inputs → ${groupBuffers.size} groupes | Énergie audio: ${avgEnergy.toFixed(6)}`);
}
// ÉTAPE 2 : Pour chaque groupe, envoyer vers le LiveKitClient correspondant
groupBuffers.forEach((groupBuffer, groupName) => {
// Les groupes sont MONO (Float32Array de N samples)
// Mais la config globale peut être STÉRÉO (channels=2)
// → Adapter selon la configuration
let pcmBuffer;
const configChannels = this.options.channels || 1;
if (configChannels === 1) {
// Config MONO : envoyer directement
pcmBuffer = this._float32ToBuffer(groupBuffer);
} else if (configChannels === 2) {
// Config STÉRÉO : dupliquer le canal mono
const samplesPerChannel = groupBuffer.length;
const stereoBuffer = new Float32Array(samplesPerChannel * 2);
// Entrelacer : [M0, M1, M2, ...] → [M0, M0, M1, M1, M2, M2, ...]
for (let i = 0; i < samplesPerChannel; i++) {
stereoBuffer[i * 2] = groupBuffer[i]; // Canal gauche
stereoBuffer[i * 2 + 1] = groupBuffer[i]; // Canal droit (dupliqué)
}
pcmBuffer = this._float32ToBuffer(stereoBuffer);
} else {
console.error(`❌ Nombre de canaux non supporté: ${configChannels}`);
return;
}
// Récupérer le client LiveKit pour ce groupe
const client = this.liveKitClients.get(groupName);
// Envoi vers LiveKit via sendAudioData (prend du PCM 16-bit)
// Note: LiveKit gère lui-même l'encodage Opus en interne
if (client && client.isConnected) {
client.sendAudioData(pcmBuffer);
if (this.stats.framesCapture % 100 === 0) {
const channelLabel = configChannels === 1 ? 'mono' : `${configChannels}ch`;
console.log(`[AudioBridge] → LiveKit groupe "${groupName}": ${pcmBuffer.length} bytes (${channelLabel})`);
}
} else {
if (this.stats.framesCapture % 100 === 0) {
console.log(`[AudioBridge] ⚠️ LiveKit non connecté pour groupe "${groupName}", audio non envoyé`);
}
}
// Émettre aussi pour monitoring/debug
this.emit('groupAudioOut', { groupName, pcmBuffer });
});
// ÉTAPE 3 : Loopback local - Groupes → Outputs physiques (sans passer par LiveKit)
const outputBuffers = this.groupAudioRouter.processGroupsToOutputs(groupBuffers);
if (this.stats.framesCapture % 100 === 0) {
console.log(`[AudioBridge] Loopback local: ${groupBuffers.size} groupes → ${outputBuffers.size} outputs`);
}
// ÉTAPE 4 : Envoyer chaque output à la carte son
const numOutputChannels = this.options.channels || 1;
if (numOutputChannels === 1) {
// Mono : un seul output
if (outputBuffers.size > 0) {
const [firstChannelId, outputBuffer] = outputBuffers.entries().next().value;
const pcmBuffer = this._float32ToBuffer(outputBuffer);
this.audioBackend.queueAudio(pcmBuffer);
if (this.stats.framesCapture % 100 === 0) {
console.log(`[AudioBridge] → Output mono (canal ${firstChannelId}): ${pcmBuffer.length} bytes`);
}
}
} else {
// Multi-canaux : entrelacer les samples
// Récupérer les buffers dans l'ordre des canaux hardware
const channelBuffers = [];
const samplesPerChannel = this.options.frameSize;
for (let ch = 0; ch < numOutputChannels; ch++) {
const logicalChannelId = this.options.channelMapping?.[ch] ?? ch;
const buffer = outputBuffers.get(logicalChannelId);
if (buffer && buffer.length === samplesPerChannel) {
channelBuffers.push(buffer);
} else {
// Canal absent ou taille incorrecte : silence
channelBuffers.push(new Float32Array(samplesPerChannel));
}
}
// Entrelacer : [L0, L1, ...] et [R0, R1, ...] → [L0, R0, L1, R1, ...]
const interleavedBuffer = new Float32Array(samplesPerChannel * numOutputChannels);
for (let i = 0; i < samplesPerChannel; i++) {
for (let ch = 0; ch < numOutputChannels; ch++) {
interleavedBuffer[i * numOutputChannels + ch] = channelBuffers[ch][i];
}
}
const pcmBuffer = this._float32ToBuffer(interleavedBuffer);
this.audioBackend.queueAudio(pcmBuffer);
if (this.stats.framesCapture % 100 === 0) {
console.log(`[AudioBridge] → Output multi-canaux (${numOutputChannels}ch): ${pcmBuffer.length} bytes`);
} }
} }
this.stats.framesCapture++; this.stats.framesCapture++;
this.stats.framesPlayback++;
} catch (error) { } catch (error) {
console.error('Erreur routing capture:', error); console.error('Erreur routing capture:', error);
this.stats.errors.capture++; this.stats.errors.capture++;
} }
}); });
// ===== FLUX 2 : LECTURE (Clients → LiveKit → Groupes → Carte Son) =====
// Écouter l'audio entrant de LiveKit (sera connecté par LiveKitServerBridge)
this.on('groupAudioIn', ({ groupName, pcmBuffer }) => {
try {
// Convertir PCM Buffer → Float32Array
const float32Data = this._bufferToFloat32(pcmBuffer);
const samplesReceived = float32Data.length;
// Initialiser l'accumulateur pour ce groupe si nécessaire
if (!this.liveKitFrameAccumulators.has(groupName)) {
this.liveKitFrameAccumulators.set(groupName, {
buffer: new Float32Array(960), // Frame size attendu par GroupRouter
offset: 0
});
}
const accumulator = this.liveKitFrameAccumulators.get(groupName);
// Vérifier que le buffer ne débordera pas
const availableSpace = 960 - accumulator.offset;
const samplesToCopy = Math.min(samplesReceived, availableSpace);
// Copier les samples dans l'accumulateur
if (samplesToCopy > 0) {
accumulator.buffer.set(float32Data.subarray(0, samplesToCopy), accumulator.offset);
accumulator.offset += samplesToCopy;
}
// Si on a accumulé assez de samples (960), router vers les outputs
if (accumulator.offset >= 960) {
// Vérifier que le backend est toujours actif (évite crash pendant shutdown)
if (!this.audioBackend) {
return;
}
// Stocker le buffer complet pour le routing
this.groupBuffersFromLiveKit.set(groupName, accumulator.buffer);
// ÉTAPE 3 : Groupes → Outputs physiques (via GroupAudioRouter)
const outputBuffers = this.groupAudioRouter.processGroupsToOutputs(
this.groupBuffersFromLiveKit
);
// ÉTAPE 4 : Envoyer chaque output à la carte son
outputBuffers.forEach((outputBuffer, channelId) => {
const pcmBuffer = this._float32ToBuffer(outputBuffer);
this.audioBackend.queueAudio(pcmBuffer);
});
// Réinitialiser l'accumulateur
accumulator.offset = 0;
accumulator.buffer.fill(0);
this.stats.framesPlayback++;
}
} catch (error) {
console.error('Erreur routing lecture:', error);
this.stats.errors.playback++;
}
});
// Démarrage des streams audio
await this.audioBackend.startCapture(); await this.audioBackend.startCapture();
await this.audioBackend.startPlayback(); await this.audioBackend.startPlayback();
console.log('✓ Routing audio bidirectionnel actif'); console.log('✓ Routing audio actif');
console.log(' → Carte Son → GroupRouter → LiveKit → Clients'); console.log(' → Carte Son → Server Audio Users → LiveKit → Clients');
console.log(' ← Carte Son ← GroupRouter ← LiveKit ← Clients');
} }
/** /**
@@ -753,17 +522,12 @@ export class AudioBridge extends EventEmitter {
this.audioBackend = null; this.audioBackend = null;
} }
// Déconnecter tous les clients LiveKit // Arrêter les server audio users
for (const [groupName, client] of this.liveKitClients.entries()) { for (const [name, user] of this.serverAudioUsers.entries()) {
console.log(`🔌 Déconnexion LiveKit groupe "${groupName}"...`); console.log(`🔌 Arrêt server audio user "${name}"...`);
await client.destroy(); await user.stop();
}
this.liveKitClients.clear();
if (this.groupAudioRouter) {
this.groupAudioRouter.destroy();
this.groupAudioRouter = null;
} }
this.serverAudioUsers.clear();
if (this.jitterBuffer) { if (this.jitterBuffer) {
this.jitterBuffer.destroy(); this.jitterBuffer.destroy();
@@ -782,7 +546,6 @@ export class AudioBridge extends EventEmitter {
// Nettoyer les buffers // Nettoyer les buffers
this.inputChannelBuffers.clear(); this.inputChannelBuffers.clear();
this.groupBuffersFromLiveKit.clear();
// Nettoyer le pool de buffers // Nettoyer le pool de buffers
this.bufferPool.float32 = []; this.bufferPool.float32 = [];
+28 -27
View File
@@ -34,15 +34,12 @@ class AudioBridgeManager extends EventEmitter {
const config = configManager.get(); const config = configManager.get();
console.log('🎵 Démarrage AudioBridge avec configuration:', config.audio); console.log('🎵 Démarrage AudioBridge avec configuration:', config.audio);
// Générer un token JWT par groupe
const liveKitTokens = [];
// Fonction pour slugifier le nom (identique à admin.js) // Fonction pour slugifier le nom (identique à admin.js)
const slugify = (text) => { const slugify = (text) => {
return text return text
.toString() .toString()
.normalize('NFD') .normalize('NFD')
.replace(/[\u0300-\u036f]/g, '') .replace(/[̀-ͯ]/g, '')
.toLowerCase() .toLowerCase()
.trim() .trim()
.replace(/\s+/g, '-') .replace(/\s+/g, '-')
@@ -50,43 +47,51 @@ class AudioBridgeManager extends EventEmitter {
.replace(/--+/g, '-'); .replace(/--+/g, '-');
}; };
for (const group of config.groups || []) { // Générer un token JWT par server audio user
const groupId = slugify(group.name); const serverAudioUsers = [];
const groupName = group.name;
for (const user of config.server_audio_users || []) {
const groupId = slugify(user.group);
const token = new AccessToken( const token = new AccessToken(
config.server?.livekit?.apiKey || 'devkey', config.server?.livekit?.apiKey || 'devkey',
config.server?.livekit?.apiSecret || 'secret', config.server?.livekit?.apiSecret || 'secret',
{ {
identity: `AudioBridge-${groupId}`, identity: `server-${user.name}`,
name: `Audio Bridge - ${groupName}`, name: `Server Audio - ${user.name}`,
metadata: JSON.stringify({ metadata: JSON.stringify({
role: 'bridge', role: 'server-audio-user',
group: groupId, group: groupId
capabilities: ['audio-routing', 'monitoring']
}) })
} }
); );
// Permissions complètes pour ce groupe const rawInputChannel = user.input_channel ?? user.inputChannel ?? null;
const inputChannel = rawInputChannel !== null && rawInputChannel !== undefined ? rawInputChannel : null;
const publish = inputChannel !== null;
token.addGrant({ token.addGrant({
room: groupId, // Chaque groupe a sa propre room room: groupId,
roomJoin: true, roomJoin: true,
canPublish: true, canPublish: publish,
canSubscribe: true, canSubscribe: true,
canPublishData: true canPublishData: true
}); });
const jwt = await token.toJwt(); const jwt = await token.toJwt();
liveKitTokens.push({ groupName, groupId, token: jwt });
console.log(`✓ Token JWT généré pour groupe "${groupName}" (room: ${groupId})`); const outputChannel = user.output_channel ?? user.outputChannel;
}
if (liveKitTokens.length === 0) { serverAudioUsers.push({
console.warn('⚠️ Aucun groupe configuré, AudioBridge ne pourra pas démarrer'); name: user.name,
this.isRunning = false; groupId,
return; inputChannel,
outputChannel: outputChannel !== null && outputChannel !== undefined ? outputChannel : null,
publish,
token: jwt
});
console.log(`✓ Token JWT généré pour server audio user "${user.name}" (room: ${groupId})`);
} }
// Import dynamique du AudioBridge // Import dynamique du AudioBridge
@@ -120,15 +125,11 @@ class AudioBridgeManager extends EventEmitter {
// Créer l'instance avec la config // Créer l'instance avec la config
this.bridge = new AudioBridge({ this.bridge = new AudioBridge({
...audioConfig, ...audioConfig,
// Options LiveKit (multi-rooms)
liveKitUrl, liveKitUrl,
liveKitTokens, // Tableau de { groupName, groupId, token } serverAudioUsers,
// Options de routing
routing: config.audio?.routing || {},
groups: config.groups || [], groups: config.groups || [],
maxInputChannels: 32, maxInputChannels: 32,
maxOutputChannels: 32, maxOutputChannels: 32,
// Device IDs extraits
inputDeviceId, inputDeviceId,
outputDeviceId outputDeviceId
}); });
-417
View File
@@ -1,417 +0,0 @@
/**
* 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';
import { getLogger } from '../utils/Logger.js';
const logger = getLogger('Routing');
/**
* 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) {
logger.info('Configuration du routing audio...');
logger.debug(' Groupes disponibles:', this.config.groups.map(g => `${g.name || g} (id: ${g.id || g})`).join(', '));
logger.debug(' inputToGroup:', JSON.stringify(routingConfig.inputToGroup || {}));
logger.debug(' groupToOutput:', JSON.stringify(routingConfig.groupToOutput || {}));
// 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();
logger.success(`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);
logger.info(`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);
logger.info(`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 => {
// Utiliser l'ID (slugifié) plutôt que le nom pour correspondre au routing
const groupId = group.id || group.name || group;
this.groupBuffers.set(groupId, new Float32Array(this.config.frameSize));
});
// Compter le nombre de sources par groupe pour normalisation
const groupSourceCount = new Map();
inputChannelsData.forEach((_, channelId) => {
const key = `in_${channelId}`;
const routes = this.inputToGroupRoutes.get(key);
if (routes) {
routes.forEach(route => {
groupSourceCount.set(
route.destination,
(groupSourceCount.get(route.destination) || 0) + 1
);
});
}
});
// Pour chaque canal d'entrée
inputChannelsData.forEach((pcmData, channelId) => {
const key = `in_${channelId}`;
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) {
logger.warn(`Buffer groupe "${route.destination}" introuvable pour routing depuis Input ${channelId}`);
return;
}
// Mixage avec gain + atténuation par nombre de sources
const sourceCount = groupSourceCount.get(route.destination) || 1;
const mixGain = route.linearGain / sourceCount;
for (let i = 0; i < pcmData.length && i < groupBuffer.length; i++) {
groupBuffer[i] += pcmData[i] * mixGain;
}
});
});
// 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++;
if (this.stats.clippingEvents % 1000 === 1) {
logger.warn(`Clipping détecté sur groupe "${groupName}" (${this.stats.clippingEvents} événements)`);
}
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();
logger.info('GroupAudioRouter détruit');
}
}
export default GroupAudioRouter;
+196
View File
@@ -0,0 +1,196 @@
/**
* ServerAudioUser.js
* Utilisateur audio géré côté serveur : participant LiveKit indépendant
* avec un canal physique d'entrée dédié et un canal physique de sortie dédié.
*
* Chaque instance :
* - Publie son canal physique d'entrée comme track LiveKit
* - Reçoit l'audio de tous les autres participants (mix-minus naturel)
* - Émet 'outputReady' avec le mix Float32 quand une frame complète est prête
*/
import { EventEmitter } from 'events';
import LiveKitClient from './LiveKitClient.js';
class ServerAudioUser extends EventEmitter {
constructor(options) {
super();
this.name = options.name;
this.inputChannel = (options.inputChannel !== null && options.inputChannel !== undefined)
? parseInt(options.inputChannel, 10)
: null;
this.outputChannel = (options.outputChannel !== null && options.outputChannel !== undefined)
? parseInt(options.outputChannel, 10)
: null;
this.publish = options.publish !== false; // false = écoute seule
this.groupId = options.groupId;
this.frameSize = options.frameSize || 960;
this.sampleRate = options.sampleRate || 48000;
this.client = new LiveKitClient({
url: options.liveKitUrl,
token: options.token,
roomName: options.groupId,
participantName: `server-${options.name}`,
sampleRate: this.sampleRate,
channels: 1,
});
// Accumulateurs PCM par participant distant (pour pouvoir mixer leurs frames)
this.participantAccumulators = new Map(); // Map<participantSid, { buffer: Float32Array, offset: number }>
// Dernier mix calculé (prêt à être envoyé vers le canal physique de sortie)
this.mixedOutput = null; // Float32Array de frameSize samples
this._setupClientEvents();
}
_setupClientEvents() {
this.client.on('connected', () => {
const mode = this.publish ? `in:${this.inputChannel} → out:${this.outputChannel ?? 'aucune'}` : `écoute → out:${this.outputChannel ?? 'aucune'}`;
console.log(`[ServerAudioUser:${this.name}] Connecté à room "${this.groupId}" (${mode})`);
this.emit('connected');
});
this.client.on('disconnected', (data) => {
console.warn(`[ServerAudioUser:${this.name}] Déconnecté:`, data?.reason || 'unknown');
this.emit('disconnected', data);
});
// Réception audio depuis les autres participants → accumulation et mix
this.client.on('audioData', ({ participantSid, pcmData }) => {
this._accumulate(participantSid, pcmData);
});
// Nettoyage des buffers quand un participant quitte
this.client.on('participantDisconnected', (participant) => {
this.participantAccumulators.delete(participant.sid);
});
}
/**
* Démarre la connexion LiveKit
*/
async start() {
await this.client.connect();
}
/**
* Envoie les données audio du canal d'entrée physique vers LiveKit.
* Appelé par AudioBridge à chaque frame de capture.
* @param {Float32Array} float32Data - Données PCM normalisées [-1.0, 1.0]
*/
sendAudio(float32Data) {
if (!this.publish || !this.client.isConnected) return;
const pcmBuffer = this._float32ToBuffer(float32Data);
this.client.sendAudioData(pcmBuffer);
}
/**
* Retourne le dernier mix calculé, ou null si aucune frame reçue.
* @returns {Float32Array|null}
*/
getMixedOutput() {
return this.mixedOutput;
}
/**
* Accumule les frames PCM reçues d'un participant.
* Quand une frame complète est disponible, calcule le mix.
* @private
*/
_accumulate(participantSid, pcmData) {
const float32 = this._bufferToFloat32(pcmData);
if (!this.participantAccumulators.has(participantSid)) {
this.participantAccumulators.set(participantSid, {
buffer: new Float32Array(this.frameSize),
offset: 0
});
}
const acc = this.participantAccumulators.get(participantSid);
const toCopy = Math.min(float32.length, this.frameSize - acc.offset);
if (toCopy > 0) {
acc.buffer.set(float32.subarray(0, toCopy), acc.offset);
acc.offset += toCopy;
}
if (acc.offset >= this.frameSize) {
this._computeMix();
acc.offset = 0;
acc.buffer.fill(0);
}
}
/**
* Calcule le mix additif de tous les participants et émet 'outputReady'.
* @private
*/
_computeMix() {
const mix = new Float32Array(this.frameSize);
for (const { buffer } of this.participantAccumulators.values()) {
for (let i = 0; i < this.frameSize; i++) {
mix[i] += buffer[i];
}
}
// Clamp
for (let i = 0; i < mix.length; i++) {
mix[i] = Math.max(-1.0, Math.min(1.0, mix[i]));
}
this.mixedOutput = mix;
if (this.outputChannel !== null) {
this.emit('outputReady', mix);
}
}
/**
* Convertit Buffer/Int16Array PCM 16-bit Float32Array [-1.0, 1.0]
* @private
*/
_bufferToFloat32(buffer) {
if (buffer instanceof Int16Array) {
const f = new Float32Array(buffer.length);
for (let i = 0; i < buffer.length; i++) f[i] = buffer[i] / 32768.0;
return f;
}
if (!(buffer instanceof Buffer)) buffer = Buffer.from(buffer);
const samples = buffer.length / 2;
const f = new Float32Array(samples);
for (let i = 0; i < samples; i++) {
f[i] = buffer.readInt16LE(i * 2) / 32768.0;
}
return f;
}
/**
* Convertit Float32Array [-1.0, 1.0] Buffer PCM 16-bit
* @private
*/
_float32ToBuffer(float32) {
const buf = Buffer.alloc(float32.length * 2);
for (let i = 0; i < float32.length; i++) {
const clamped = Math.max(-1.0, Math.min(1.0, float32[i]));
buf.writeInt16LE(Math.round(clamped * 32767), i * 2);
}
return buf;
}
/**
* Arrête l'utilisateur et libère les ressources.
*/
async stop() {
await this.client.destroy();
this.participantAccumulators.clear();
this.mixedOutput = null;
this.removeAllListeners();
}
}
export default ServerAudioUser;
+13 -30
View File
@@ -2,43 +2,26 @@ audio:
sampleRate: 48000 sampleRate: 48000
channels: 2 channels: 2
frameSize: 20 frameSize: 20
defaultBitrate: 96 defaultBitrate: 128
jitterBufferMs: 40 jitterBufferMs: 40
device: device:
inputDeviceId: Loopback Audio 4 inputDeviceId: Loopback Audio 4
outputDeviceId: Haut-parleurs MacBook Pro outputDeviceId: Périphérique agrégé
sampleRate: 48000 sampleRate: 48000
routing:
inputToGroup:
"0":
- default
"1": []
"2": []
"4":
- technique
"5":
- technique
groupToOutput:
technique:
- "1"
production:
- "0"
- "1"
default:
- "0"
gains: {}
channelNames: channelNames:
inputs: inputs:
"0": Mac "0": Loopback L
"1": Talkback FOH "1": Loopback R
"2": Retour Console
"3": Liaison Scène
"4": Monitor Mix
"5": Spare 1
outputs: outputs:
"0": L "0": Casque L
"1": R "1": Casque R
"2": Talkback Console "2": Mac L
"3": Mac R
server_audio_users:
- name: Utilisateur Serveur
group: default
input_channel: 0
output_channel: 0
groups: groups:
- name: Default - name: Default
audioBitrate: 96 audioBitrate: 96
+46 -19
View File
@@ -16,6 +16,7 @@ import configManager from './config/ConfigManager.js';
import audioBridgeManager from './bridge/AudioBridgeManager.js'; import audioBridgeManager from './bridge/AudioBridgeManager.js';
import AudioLevelsServer from './websocket/AudioLevelsServer.js'; import AudioLevelsServer from './websocket/AudioLevelsServer.js';
import { setGlobalLogLevel } from './utils/Logger.js'; import { setGlobalLogLevel } from './utils/Logger.js';
import httpProxy from 'http-proxy';
const __dirname = dirname(fileURLToPath(import.meta.url)); const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -333,30 +334,12 @@ apiRouter.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 virtualChannels: []
}); });
} catch (error) { } catch (error) {
@@ -378,6 +361,33 @@ apiRouter.get('/health', (req, res) => {
}); });
}); });
// Créer proxy WebSocket natif pour LiveKit (wss → ws)
const livekitProxy = httpProxy.createProxyServer({
target: 'http://localhost:7880',
ws: true,
changeOrigin: true
});
livekitProxy.on('error', (err, req, res) => {
log('error', `❌ Erreur proxy LiveKit: ${err.message}`);
if (res && res.writeHead) {
res.writeHead(502, { 'Content-Type': 'text/plain' });
res.end('Proxy error');
}
});
livekitProxy.on('proxyReqWs', (proxyReq, req, socket, options, head) => {
log('debug', `🔀 Proxy WebSocket: ${req.url} → ws://localhost:7880`);
});
// Proxy HTTP pour LiveKit (requêtes REST comme /rtc/validate)
app.use('/livekit', (req, res) => {
log('debug', `🔀 Proxy HTTP: ${req.originalUrl} → http://localhost:7880${req.url}`);
livekitProxy.web(req, res, {
target: 'http://localhost:7880'
});
});
// Monter le router API sous /api ET à la racine (rétrocompatibilité) // Monter le router API sous /api ET à la racine (rétrocompatibilité)
app.use('/api', apiRouter); app.use('/api', apiRouter);
app.use(apiRouter); // Routes accessibles aussi sans préfixe /api app.use(apiRouter); // Routes accessibles aussi sans préfixe /api
@@ -493,8 +503,25 @@ async function start() {
} }
// 2.5 Démarrer WebSocket Audio Levels (même port que l'API) // 2.5 Démarrer WebSocket Audio Levels (même port que l'API)
// noServer: true en interne, l'upgrade est dispatché ci-dessous
const audioLevelsServer = new AudioLevelsServer({ server }); const audioLevelsServer = new AudioLevelsServer({ server });
audioLevelsServer.start(); audioLevelsServer.start();
// 2.6 Dispatcher unique pour les upgrades WebSocket du port HTTP/HTTPS
// (proxy LiveKit et audio-levels partagent le même serveur, donc le même
// événement 'upgrade' : un seul listener doit trancher par chemin)
server.on('upgrade', (req, socket, head) => {
if (req.url.startsWith('/livekit')) {
req.url = req.url.replace(/^\/livekit/, '');
livekitProxy.ws(req, socket, head);
} else if (req.url.startsWith('/audio-levels')) {
audioLevelsServer.handleUpgrade(req, socket, head);
} else {
log('warn', `⚠️ Unknown WebSocket path: ${req.url}`);
socket.destroy();
}
});
const wsProtocol = ENABLE_HTTPS ? 'wss' : 'ws'; const wsProtocol = ENABLE_HTTPS ? 'wss' : 'ws';
log('info', `✓ WebSocket Audio Levels démarré sur ${wsProtocol}://${SERVER_HOST}:${SERVER_PORT}`); log('info', `✓ WebSocket Audio Levels démarré sur ${wsProtocol}://${SERVER_HOST}:${SERVER_PORT}`);
+2
View File
@@ -22,6 +22,8 @@
"@livekit/rtc-node": "^0.13.28", "@livekit/rtc-node": "^0.13.28",
"dotenv": "^17.4.2", "dotenv": "^17.4.2",
"express": "^4.19.2", "express": "^4.19.2",
"http-proxy": "^1.18.1",
"http-proxy-middleware": "^4.1.1",
"livekit-server-sdk": "^2.6.0", "livekit-server-sdk": "^2.6.0",
"opusscript": "^0.1.1", "opusscript": "^0.1.1",
"qrcode-terminal": "^0.12.0", "qrcode-terminal": "^0.12.0",
+13 -1
View File
@@ -91,9 +91,11 @@ export class AudioLevelsServer extends EventEmitter {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
try { try {
// Si un serveur HTTP est fourni, utiliser le même port (upgrade HTTP → WebSocket) // Si un serveur HTTP est fourni, utiliser le même port (upgrade HTTP → WebSocket)
// noServer: true car l'upgrade est dispatché manuellement par server/index.js
// (un seul listener 'upgrade' partagé avec le proxy LiveKit, voir handleUpgrade())
// Sinon, créer un serveur WebSocket standalone sur son propre port // Sinon, créer un serveur WebSocket standalone sur son propre port
const wsOptions = this.options.server const wsOptions = this.options.server
? { server: this.options.server, path: '/audio-levels' } ? { noServer: true }
: { port: this.options.port }; : { port: this.options.port };
this.wss = new WebSocketServer(wsOptions); this.wss = new WebSocketServer(wsOptions);
@@ -125,6 +127,16 @@ export class AudioLevelsServer extends EventEmitter {
}); });
} }
/**
* Complète l'upgrade WebSocket pour une requête déjà identifiée comme
* ciblant ce serveur (voir le dispatcher 'upgrade' dans server/index.js)
*/
handleUpgrade(req, socket, head) {
this.wss.handleUpgrade(req, socket, head, (ws) => {
this.wss.emit('connection', ws, req);
});
}
/** /**
* Gère une nouvelle connexion client * Gère une nouvelle connexion client
*/ */