Compare commits

...

10 Commits

Author SHA1 Message Date
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
12 changed files with 576 additions and 70 deletions
+3
View File
@@ -55,3 +55,6 @@ server.log
# Runtime files
/tmp/ptt-live.pid
# Certificats SSL locaux (mkcert) - contiennent des clés privées
certs/
+64 -26
View File
@@ -5,11 +5,12 @@ Application Electron pour gérer le serveur PTT Live avec interface graphique co
## 📸 Aperçu
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
-**Gestion groupes** : CRUD complet avec API
-**Monitoring** : VU-mètres (prévu), logs filtrables
-**Contrôle serveur** : démarrage/arrêt avec feedback visuel
-**Monitoring** : VU-mètres temps réel via WebSocket, logs filtrables
-**Contrôle serveur** : démarrage manuel/arrêt avec feedback visuel
---
@@ -24,7 +25,7 @@ cd electron
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
**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
- Bouton copier URL
- Placeholder visuel tant que le serveur est arrêté ou qu'aucun QR code n'a été généré
**Utilisateurs** :
- Liste en temps réel
@@ -80,10 +84,10 @@ npm install
### 4. Monitoring
**VU-Mètres** (à venir) :
- Niveaux audio par canal (input/output)
- Temps réel via WebSocket
- Détection clipping
**VU-Mètres** :
- Niveaux audio par canal (input/output) et par groupe
- Temps réel via WebSocket (`/audio-levels`, même port que l'API)
- Reconnexion automatique si la connexion WebSocket tombe
### 5. Logs
@@ -114,21 +118,24 @@ npm install
│ │ │ │
│ │ • HTML/CSS/JS (pas de framework) │ │
│ │ • Fetch API REST :3000/admin/* │ │
│ │ • WebSocket audio levels (prévu) │ │
│ │ • QR Code (qrcode.js) │ │
│ │ • WebSocket audio levels (live) │ │
│ │ • QR Code (data URL via IPC) │ │
│ └───────────────────────────────────────────┘ │
└─────────────────────────────────────────────────┘
↕ HTTP
↕ HTTPS (127.0.0.1, certs mkcert)
┌─────────────────────────────────────────────────┐
│ SERVEUR PTT LIVE (spawned) │
│ │
│ • LiveKit Server (binaire Go)
│ • LiveKit Server (binaire Go) :7880
│ • Audio Bridge Manager │
│ • API REST Express :3000
│ • WebSocket Audio Levels
│ • API REST Express :3000 (HTTPS)
│ • 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
@@ -150,9 +157,31 @@ L'interface desktop utilise toutes les routes admin existantes :
| `/admin/devices/list` | GET | Auto-détection (macOS/Linux) |
| `/admin/logs` | GET | Logs serveur |
| `/health` | GET | Health check |
| `/livekit/*` | ALL | Proxy HTTP vers LiveKit Server (port 7880) |
WebSocket (prévu) :
- `ws://localhost:3000/audio-levels` → VU-mètres temps réel
WebSocket :
- `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,17 +312,25 @@ PORT=3001 npm start
- Vérifier permissions LiveKit binaire
- 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** :
- Vérifier que le serveur tourne
- Voir console : "✅ QR Code généré"
- Script CDN chargé ?
- Vérifier que le serveur tourne (le QR code est réinitialisé tant qu'il est arrêté)
- Le QR code est généré côté Main Process (IPC `qrcode:generate`), pas de dépendance réseau/CDN
---
## 🚧 TODO / Améliorations
### 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
- [ ] **Tray icon** : avec menu contextuel fonctionnel
@@ -304,10 +341,8 @@ PORT=3001 npm start
- [ ] **Notifications desktop** : via Electron Notification API
### Priorité basse
- [ ] **Auth admin** : mot de passe pour accès dashboard
- [ ] **Thème toggle** : dark/light mode
- [ ] **Auto-update** : electron-updater pour mises à jour
- [ ] **I18n** : français/anglais
### Technique
- [ ] **Tests** : Spectron ou Playwright pour Electron
@@ -326,18 +361,21 @@ electron/
│ # - Spawn serveur
│ # - IPC handlers
│ # - Window management
│ # - Setup SSL au premier lancement
├── preload.js # IPC Bridge sécurisé
│ # - contextBridge
│ # - Expose electronAPI
├── setup-helper.js # Installation auto mkcert + génération certificats
│ # - Détection IP réseau
├── package.json # Config Electron + electron-builder
└── ui/ # Renderer Process (Frontend)
├── index.html # Structure UI
├── styles.css # Styles (dark theme)
── app.js # Logic frontend
└── qrcode.min.js # QR Code library
── app.js # Logic frontend (QR code reçu via IPC en data URL)
```
### Communication IPC
@@ -397,4 +435,4 @@ Même licence que PTT Live (MIT)
---
**Version** : 0.3.0
**Dernière mise à jour** : 2026-06-19
**Dernière mise à jour** : 2026-06-30
+7 -7
View File
@@ -100,17 +100,17 @@ function App() {
const data = await response.json();
// En mode dev (HTTPS via Vite), utiliser le proxy WebSocket
// En mode prod (HTTP direct), utiliser l'URL LiveKit directement
// Si HTTPS, utiliser le proxy WebSocket (résout mixed content)
// Sinon utiliser l'URL LiveKit directement
let livekitUrl = data.url;
if (import.meta.env.DEV && window.location.protocol === 'https:') {
// Mode dev avec Vite : utiliser le proxy WSS
livekitUrl = `${window.location.protocol}//${window.location.host}/livekit`;
if (window.location.protocol === 'https:') {
// HTTPS : utiliser le proxy WSS (wss://host:port/livekit)
livekitUrl = `wss://${window.location.host}/livekit`;
console.log('🔒 Mode HTTPS : utilisation proxy WebSocket');
}
console.log('🔗 Connexion LiveKit:', livekitUrl);
console.log('📝 Mode:', import.meta.env.DEV ? 'dev' : 'prod');
// Se connecter à LiveKit avec les canaux virtuels
await connect(livekitUrl, data.token, data.virtualChannels || []);
@@ -154,7 +154,7 @@ function App() {
// Adapter l'URL LiveKit selon le protocole de la page
let livekitUrl = data.url;
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
+32 -2
View File
@@ -7,6 +7,8 @@ const { app, BrowserWindow, ipcMain, Menu, Tray, dialog } = require('electron');
const path = require('path');
const { spawn } = require('child_process');
const http = require('http');
const https = require('https');
const QRCode = require('qrcode');
const setupHelper = require('./setup-helper');
// État de l'application
@@ -17,7 +19,14 @@ let serverStarted = false;
let rendererReady = false;
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');
/**
@@ -133,6 +142,7 @@ async function startServer() {
...process.env,
PORT: SERVER_PORT,
USE_LOCAL_LIVEKIT: 'true',
ENABLE_HTTPS: ENABLE_HTTPS ? 'true' : 'false',
NODE_ENV: isDev ? 'development' : 'production'
}
});
@@ -289,7 +299,13 @@ async function stopServer() {
*/
async function pingServer() {
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 = '';
res.on('data', (chunk) => { data += chunk; });
res.on('end', () => {
@@ -327,6 +343,7 @@ app.whenReady().then(async () => {
return {
running: health.success,
health: health.data,
error: health.error,
url: SERVER_URL
};
});
@@ -335,6 +352,19 @@ app.whenReady().then(async () => {
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();
});
// Créer fenêtre
createWindow();
createTray();
+14
View File
@@ -5,8 +5,16 @@
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
contextBridge.exposeInMainWorld('electronAPI', {
serverUrl: SERVER_URL,
// Contrôle serveur
server: {
start: () => ipcRenderer.invoke('server:start'),
@@ -23,6 +31,12 @@ 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'),
// Helpers
platform: process.platform,
version: process.env.npm_package_version || '0.3.0'
+215 -29
View File
@@ -2,12 +2,23 @@
* PTT Live Desktop - Renderer Process Logic
*/
const API_BASE = 'http://localhost:3000';
const API_BASE = window.electronAPI?.serverUrl || 'http://localhost:3000';
// État global
let serverRunning = false;
let statsInterval = null;
let logsBuffer = [];
let audioLevelsWS = null;
let audioLevelsData = {
inputs: {},
groups: {},
outputs: {},
routing: {
activeInputs: [],
activeGroups: [],
activeOutputs: []
}
};
// ========== Initialisation ==========
@@ -167,6 +178,9 @@ function updateServerStatus(running) {
// Démarrer le polling
startStatsPolling();
// Connecter WebSocket audio levels
connectAudioLevelsWS();
// Charger les données initiales
loadInitialData();
} else {
@@ -177,6 +191,13 @@ function updateServerStatus(running) {
// Arrêter le polling
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 = '--';
}
}
@@ -345,31 +366,29 @@ async function generateQRCode() {
// Détecter l'IP réseau (depuis hostname ou config)
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;
// Générer QR Code
const canvas = document.getElementById('qr-code');
if (canvas && window.QRCode) {
QRCode.toCanvas(canvas, clientUrl, {
width: 256,
margin: 2,
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é');
}
});
// Générer QR Code (rendu côté Main Process, pas de dépendance réseau/CDN)
const img = document.getElementById('qr-code');
if (img) {
const result = await window.electronAPI.generateQRCode(clientUrl);
if (result.success) {
img.src = result.dataUrl;
console.log('✅ QR Code généré');
} else {
console.error('Erreur génération QR Code:', result.error);
}
}
} catch (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)
@@ -385,15 +404,12 @@ async function generateQRCode() {
}
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 {
const config = await apiCall('/admin/config');
if (config && config.server && config.server.livekit && config.server.livekit.url) {
const url = config.server.livekit.url;
// Extraire l'IP depuis ws://IP:7880
const match = url.match(/ws:\/\/([^:]+):/);
if (match) return match[1];
}
const ip = await window.electronAPI.getNetworkIP();
if (ip) return ip;
} catch (error) {
console.error('Erreur détection IP:', error);
}
@@ -475,7 +491,7 @@ async function loadViewData(view) {
await fetchGroups();
break;
case 'monitoring':
// TODO: charger VU-mètres WebSocket
renderVUMeters();
break;
case 'logs':
renderLogs();
@@ -579,6 +595,176 @@ function formatUptime(seconds) {
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) {
if (!isoString) return '--';
const date = new Date(isoString);
+8 -3
View File
@@ -78,7 +78,13 @@
<div class="section">
<h3>📱 Connexion rapide clients</h3>
<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">
<p><strong>URL clients :</strong></p>
<p class="url-text" id="client-url">--</p>
@@ -180,8 +186,7 @@
</main>
</div>
<!-- QR Code Library -->
<script src="https://cdn.jsdelivr.net/npm/qrcode@1.5.3/build/qrcode.min.js"></script>
<!-- Le QR Code est généré côté Main Process (lib qrcode Node), pas de dépendance CDN -->
<script src="app.js"></script>
</body>
</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
+173
View File
@@ -230,11 +230,51 @@ body {
align-items: center;
}
.qr-wrapper {
position: relative;
width: 256px;
height: 256px;
flex-shrink: 0;
}
#qr-code {
display: none;
width: 256px;
height: 256px;
border: 4px solid white;
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 {
flex: 1;
}
@@ -554,3 +594,136 @@ body {
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;
}
}
+45
View File
@@ -16,6 +16,7 @@ import configManager from './config/ConfigManager.js';
import audioBridgeManager from './bridge/AudioBridgeManager.js';
import AudioLevelsServer from './websocket/AudioLevelsServer.js';
import { setGlobalLogLevel } from './utils/Logger.js';
import httpProxy from 'http-proxy';
const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -378,6 +379,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é)
app.use('/api', apiRouter);
app.use(apiRouter); // Routes accessibles aussi sans préfixe /api
@@ -493,8 +521,25 @@ async function start() {
}
// 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 });
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';
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",
"dotenv": "^17.4.2",
"express": "^4.19.2",
"http-proxy": "^1.18.1",
"http-proxy-middleware": "^4.1.1",
"livekit-server-sdk": "^2.6.0",
"opusscript": "^0.1.1",
"qrcode-terminal": "^0.12.0",
+13 -1
View File
@@ -91,9 +91,11 @@ export class AudioLevelsServer extends EventEmitter {
return new Promise((resolve, reject) => {
try {
// 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
const wsOptions = this.options.server
? { server: this.options.server, path: '/audio-levels' }
? { noServer: true }
: { port: this.options.port };
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
*/