From c21433b9eb8fb4f5fdca8aec5eca0a552f86a9b9 Mon Sep 17 00:00:00 2001 From: Benoit Date: Fri, 19 Jun 2026 13:40:03 +0200 Subject: [PATCH] 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 --- DESKTOP-APP.md | 2 +- electron/ui/app.js | 189 ++++++++++++++++++++++++++++++++++++++++- electron/ui/styles.css | 133 +++++++++++++++++++++++++++++ 3 files changed, 322 insertions(+), 2 deletions(-) diff --git a/DESKTOP-APP.md b/DESKTOP-APP.md index 44ff17b..0ba6666 100644 --- a/DESKTOP-APP.md +++ b/DESKTOP-APP.md @@ -293,7 +293,7 @@ PORT=3001 npm start ## 🚧 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 diff --git a/electron/ui/app.js b/electron/ui/app.js index 4c39e3d..ebd7e71 100644 --- a/electron/ui/app.js +++ b/electron/ui/app.js @@ -8,6 +8,17 @@ const API_BASE = 'http://localhost:3000'; 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,9 @@ function updateServerStatus(running) { // Arrêter le polling stopStatsPolling(); + + // Déconnecter WebSocket audio levels + disconnectAudioLevelsWS(); } } @@ -475,7 +492,7 @@ async function loadViewData(view) { await fetchGroups(); break; case 'monitoring': - // TODO: charger VU-mètres WebSocket + renderVUMeters(); break; case 'logs': renderLogs(); @@ -579,6 +596,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 = 'ws://localhost:3000/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 = ` +

WebSocket: En attente de connexion...

+

Aucune donnée audio disponible

+ `; + return; + } + + let html = '
WebSocket: Connecté
'; + + // Inputs + if (Object.keys(audioLevelsData.inputs).length > 0) { + html += '

Entrées Audio

'; + Object.entries(audioLevelsData.inputs).forEach(([channelId, data]) => { + html += renderVUMeter(channelId, data, 'input'); + }); + html += '
'; + } + + // Groups + if (Object.keys(audioLevelsData.groups).length > 0) { + html += '

Groupes

'; + Object.entries(audioLevelsData.groups).forEach(([groupName, data]) => { + html += renderVUMeter(groupName, data, 'group'); + }); + html += '
'; + } + + // Outputs + if (Object.keys(audioLevelsData.outputs).length > 0) { + html += '

Sorties Audio

'; + Object.entries(audioLevelsData.outputs).forEach(([channelId, data]) => { + html += renderVUMeter(channelId, data, 'output'); + }); + html += '
'; + } + + 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 ` +
+
${escapeHtml(label)}
+
+
+
+
+
+ ${rms.toFixed(1)} dB + ${clipping ? 'CLIP!' : ''} +
+
+ `; +} + function formatTime(isoString) { if (!isoString) return '--'; const date = new Date(isoString); diff --git a/electron/ui/styles.css b/electron/ui/styles.css index ed0c2b1..86d9242 100644 --- a/electron/ui/styles.css +++ b/electron/ui/styles.css @@ -554,3 +554,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; + } +}