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;