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:
@@ -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);
|
||||
|
||||
@@ -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">
|
||||
<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 => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user