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;
|
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 {
|
.routing-section {
|
||||||
margin-bottom: var(--spacing-xl);
|
margin-bottom: var(--spacing-xl);
|
||||||
}
|
}
|
||||||
@@ -82,6 +100,29 @@
|
|||||||
word-break: break-word;
|
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 {
|
.matrix-cell {
|
||||||
background: var(--color-bg);
|
background: var(--color-bg);
|
||||||
padding: var(--spacing-sm);
|
padding: var(--spacing-sm);
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import './AudioRoutingMatrix.css';
|
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';
|
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000';
|
||||||
|
|
||||||
function AudioRoutingMatrix({ groups, channelNames }) {
|
function AudioRoutingMatrix({ groups, channelNames }) {
|
||||||
|
const { levels, connected: wsConnected } = useAudioLevels();
|
||||||
const [routing, setRouting] = useState({ inputToGroup: {}, groupToOutput: {}, gains: {} });
|
const [routing, setRouting] = useState({ inputToGroup: {}, groupToOutput: {}, gains: {} });
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [showOnlyNamedChannels, setShowOnlyNamedChannels] = useState(false);
|
const [showOnlyNamedChannels, setShowOnlyNamedChannels] = useState(false);
|
||||||
@@ -165,7 +168,15 @@ function AudioRoutingMatrix({ groups, channelNames }) {
|
|||||||
return (
|
return (
|
||||||
<div className="routing-matrix-container">
|
<div className="routing-matrix-container">
|
||||||
<div className="routing-matrix-header">
|
<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' }}>
|
<div style={{ display: 'flex', gap: '1rem', alignItems: 'center' }}>
|
||||||
<label style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', cursor: 'pointer' }}>
|
<label style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', cursor: 'pointer' }}>
|
||||||
<input
|
<input
|
||||||
@@ -199,7 +210,12 @@ function AudioRoutingMatrix({ groups, channelNames }) {
|
|||||||
{getVisibleInputChannels().map(i => (
|
{getVisibleInputChannels().map(i => (
|
||||||
<React.Fragment key={`input-row-${i}`}>
|
<React.Fragment key={`input-row-${i}`}>
|
||||||
<div className="matrix-label-cell">
|
<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>
|
</div>
|
||||||
|
|
||||||
{groups.map(group => {
|
{groups.map(group => {
|
||||||
@@ -251,14 +267,24 @@ function AudioRoutingMatrix({ groups, channelNames }) {
|
|||||||
|
|
||||||
{getVisibleOutputChannels().map(i => (
|
{getVisibleOutputChannels().map(i => (
|
||||||
<div key={`output-header-${i}`} className="matrix-header-cell">
|
<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>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{groups.map(group => (
|
{groups.map(group => (
|
||||||
<React.Fragment key={`group-row-${group.id}`}>
|
<React.Fragment key={`group-row-${group.id}`}>
|
||||||
<div className="matrix-label-cell">
|
<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>
|
</div>
|
||||||
|
|
||||||
{getVisibleOutputChannels().map(i => {
|
{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