feat: ajout VU-metres temps reel dans matrice routing

- Hook React useAudioLevels pour WebSocket audio-levels
- Composant VUMeter (mini, horizontal, vertical)
- Integration VU-metres dans headers/labels matrice
- Indicateur etat connexion WebSocket (Live/Offline)
- Affichage RMS, peak, detection clipping
- Design responsive avec animations clipping
This commit is contained in:
2026-05-25 22:17:48 +02:00
parent b64bac1f3d
commit f5a5643f4b
5 changed files with 447 additions and 4 deletions
@@ -19,6 +19,24 @@
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);
}
@@ -82,6 +100,29 @@
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);
+30 -4
View File
@@ -1,9 +1,12 @@
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);
@@ -165,7 +168,15 @@ function AudioRoutingMatrix({ groups, channelNames }) {
return (
<div className="routing-matrix-container">
<div className="routing-matrix-header">
<h3>Matrice de routing audio</h3>
<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>
<div style={{ display: 'flex', gap: '1rem', alignItems: 'center' }}>
<label style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', cursor: 'pointer' }}>
<input
@@ -199,7 +210,12 @@ function AudioRoutingMatrix({ groups, channelNames }) {
{getVisibleInputChannels().map(i => (
<React.Fragment key={`input-row-${i}`}>
<div className="matrix-label-cell">
{getChannelName('inputs', i)}
<div className="label-content">
<span className="label-text">{getChannelName('inputs', i)}</span>
{wsConnected && levels.inputs[i] && (
<VUMeter level={levels.inputs[i]} size="mini" />
)}
</div>
</div>
{groups.map(group => {
@@ -251,14 +267,24 @@ function AudioRoutingMatrix({ groups, channelNames }) {
{getVisibleOutputChannels().map(i => (
<div key={`output-header-${i}`} className="matrix-header-cell">
{getChannelName('outputs', i)}
<div className="header-content">
<span className="header-text">{getChannelName('outputs', i)}</span>
{wsConnected && levels.outputs[i] && (
<VUMeter level={levels.outputs[i]} size="mini" />
)}
</div>
</div>
))}
{groups.map(group => (
<React.Fragment key={`group-row-${group.id}`}>
<div className="matrix-label-cell">
{group.name}
<div className="label-content">
<span className="label-text">{group.name}</span>
{wsConnected && levels.groups[group.id] && (
<VUMeter level={levels.groups[group.id]} size="mini" />
)}
</div>
</div>
{getVisibleOutputChannels().map(i => {
+131
View File
@@ -0,0 +1,131 @@
/* VU-mètre version mini (pour matrice routing) */
.vu-meter-mini {
width: 100%;
height: 4px;
background: rgba(255, 255, 255, 0.1);
border-radius: 2px;
overflow: hidden;
position: relative;
}
.vu-meter-mini.clipping {
box-shadow: 0 0 4px rgba(255, 68, 68, 0.8);
}
.vu-meter-mini-bar {
height: 100%;
transition: width 50ms linear;
border-radius: 2px;
}
/* VU-mètre horizontal */
.vu-meter-horizontal {
width: 100%;
height: 20px;
position: relative;
}
.vu-meter-horizontal.small {
height: 12px;
}
.vu-meter-horizontal.medium {
height: 20px;
}
.vu-meter-horizontal.large {
height: 30px;
}
.vu-meter-horizontal .vu-meter-bar-container {
width: 100%;
height: 100%;
background: rgba(255, 255, 255, 0.1);
border-radius: 4px;
overflow: hidden;
position: relative;
}
.vu-meter-horizontal .vu-meter-bar-rms {
height: 100%;
transition: width 50ms linear;
border-radius: 4px;
}
.vu-meter-horizontal .vu-meter-bar-peak {
position: absolute;
top: 0;
height: 100%;
width: 2px;
background: rgba(255, 255, 255, 0.8);
transition: left 50ms linear;
}
.vu-meter-horizontal.clipping .vu-meter-bar-container {
box-shadow: 0 0 6px rgba(255, 68, 68, 0.8);
animation: clipping-pulse 200ms ease-in-out;
}
/* VU-mètre vertical */
.vu-meter-vertical {
height: 100px;
width: 20px;
position: relative;
}
.vu-meter-vertical.small {
height: 60px;
width: 12px;
}
.vu-meter-vertical.medium {
height: 100px;
width: 20px;
}
.vu-meter-vertical.large {
height: 150px;
width: 30px;
}
.vu-meter-vertical .vu-meter-bar-container {
width: 100%;
height: 100%;
background: rgba(255, 255, 255, 0.1);
border-radius: 4px;
overflow: hidden;
position: relative;
display: flex;
flex-direction: column;
justify-content: flex-end;
}
.vu-meter-vertical .vu-meter-bar-rms {
width: 100%;
transition: height 50ms linear;
border-radius: 4px;
}
.vu-meter-vertical .vu-meter-bar-peak {
position: absolute;
left: 0;
width: 100%;
height: 2px;
background: rgba(255, 255, 255, 0.8);
transition: bottom 50ms linear;
}
.vu-meter-vertical.clipping .vu-meter-bar-container {
box-shadow: 0 0 6px rgba(255, 68, 68, 0.8);
animation: clipping-pulse 200ms ease-in-out;
}
/* Animation clipping */
@keyframes clipping-pulse {
0%, 100% {
box-shadow: 0 0 6px rgba(255, 68, 68, 0.8);
}
50% {
box-shadow: 0 0 12px rgba(255, 68, 68, 1);
}
}
+102
View File
@@ -0,0 +1,102 @@
/**
* VUMeter.jsx
* Composant VU-mètre minimaliste pour affichage niveaux audio temps réel
*/
import React from 'react';
import './VUMeter.css';
/**
* Convertit une valeur dBFS en pourcentage pour affichage
* -120dBFS = 0%, 0dBFS = 100%
*/
function dbToPercent(dbFS) {
const min = -60; // On affiche à partir de -60dBFS
const max = 0;
if (dbFS <= min) return 0;
if (dbFS >= max) return 100;
return ((dbFS - min) / (max - min)) * 100;
}
/**
* Détermine la couleur selon le niveau (style VU professionnel)
*/
function getLevelColor(dbFS) {
if (dbFS >= -3) return '#ff4444'; // Rouge (clipping proche)
if (dbFS >= -12) return '#ffaa00'; // Orange (niveau élevé)
return '#44ff44'; // Vert (niveau nominal)
}
function VUMeter({ level, size = 'small', orientation = 'vertical' }) {
if (!level) {
level = { rms: -120, peak: 0, clipping: false };
}
const rmsPercent = dbToPercent(level.rms);
const peakPercent = (level.peak || 0) * 100;
const color = getLevelColor(level.rms);
const isClipping = level.clipping || level.peak >= 0.99;
if (size === 'mini') {
// Version ultra-compacte pour matrice routing
return (
<div className={`vu-meter-mini ${isClipping ? 'clipping' : ''}`}>
<div
className="vu-meter-mini-bar"
style={{
width: `${rmsPercent}%`,
backgroundColor: color
}}
/>
</div>
);
}
if (orientation === 'horizontal') {
return (
<div className={`vu-meter-horizontal ${size} ${isClipping ? 'clipping' : ''}`}>
<div className="vu-meter-bar-container">
<div
className="vu-meter-bar-rms"
style={{
width: `${rmsPercent}%`,
backgroundColor: color
}}
/>
{level.peak > 0 && (
<div
className="vu-meter-bar-peak"
style={{ left: `${peakPercent}%` }}
/>
)}
</div>
</div>
);
}
// Vertical (défaut)
return (
<div className={`vu-meter-vertical ${size} ${isClipping ? 'clipping' : ''}`}>
<div className="vu-meter-bar-container">
<div
className="vu-meter-bar-rms"
style={{
height: `${rmsPercent}%`,
backgroundColor: color
}}
/>
{level.peak > 0 && (
<div
className="vu-meter-bar-peak"
style={{ bottom: `${peakPercent}%` }}
/>
)}
</div>
</div>
);
}
export default VUMeter;
+143
View File
@@ -0,0 +1,143 @@
/**
* useAudioLevels.js
* Hook React pour recevoir les niveaux audio temps réel via WebSocket
*/
import { useState, useEffect, useRef } from 'react';
const WS_URL = import.meta.env.VITE_WS_AUDIO_LEVELS_URL || 'ws://localhost:3001';
/**
* Hook pour monitoring des niveaux audio temps réel
*/
export function useAudioLevels() {
const [levels, setLevels] = useState({
inputs: {},
groups: {},
outputs: {},
routing: {
activeInputs: [],
activeGroups: [],
activeOutputs: []
}
});
const [connected, setConnected] = useState(false);
const wsRef = useRef(null);
const reconnectTimeoutRef = useRef(null);
const reconnectAttemptsRef = useRef(0);
useEffect(() => {
connect();
return () => {
disconnect();
};
}, []);
const connect = () => {
try {
console.log('Connexion au WebSocket audio-levels...');
const ws = new WebSocket(WS_URL);
ws.onopen = () => {
console.log('WebSocket audio-levels connecté');
setConnected(true);
reconnectAttemptsRef.current = 0;
// Ping périodique pour maintenir la connexion
const pingInterval = setInterval(() => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'ping' }));
}
}, 10000);
ws.pingInterval = pingInterval;
};
ws.onmessage = (event) => {
try {
const message = JSON.parse(event.data);
switch (message.type) {
case 'initial':
case 'levels':
setLevels(message.data);
break;
case 'pong':
// Pong reçu, connexion active
break;
default:
console.warn('Message WebSocket inconnu:', message.type);
}
} catch (error) {
console.error('Erreur parsing message WebSocket:', error);
}
};
ws.onerror = (error) => {
console.error('Erreur WebSocket audio-levels:', error);
};
ws.onclose = () => {
console.log('WebSocket audio-levels déconnecté');
setConnected(false);
if (ws.pingInterval) {
clearInterval(ws.pingInterval);
}
// Reconnexion automatique avec backoff
const delay = Math.min(1000 * Math.pow(2, reconnectAttemptsRef.current), 30000);
console.log(`Reconnexion dans ${delay}ms...`);
reconnectTimeoutRef.current = setTimeout(() => {
reconnectAttemptsRef.current++;
connect();
}, delay);
};
wsRef.current = ws;
} catch (error) {
console.error('Erreur création WebSocket:', error);
setConnected(false);
}
};
const disconnect = () => {
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current);
reconnectTimeoutRef.current = null;
}
if (wsRef.current) {
if (wsRef.current.pingInterval) {
clearInterval(wsRef.current.pingInterval);
}
wsRef.current.close();
wsRef.current = null;
}
setConnected(false);
};
const setUpdateRate = (rateMs) => {
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify({
type: 'setUpdateRate',
rateMs
}));
}
};
return {
levels,
connected,
setUpdateRate
};
}
export default useAudioLevels;