Add real-time audio level visualization with VU-style meter: - Responsive VU bar with color gradient (silent to clipping) - Peak hold indicator with exponential decay - Speech activity detection from /social/speech/is_speaking - Color-coded audio levels with visual feedback - Grid markers for level reference (25%, 50%, 75%) - Comprehensive audio statistics (average, max, peak count) Features: - Dynamic color coding: Gray (silent) → Red (clipping) - Level thresholds: Silent, Low, Moderate, Good, Loud, Clipping - Peak hold with 1-second hold time + 5% decay per 50ms - Speech activity indicator with pulsing animation - 100-sample rolling average for statistics - Real-time metric updates Visual Elements: - Main VU bar with smooth fill animation - Separate peak hold display with glow effect - Color reference legend (all 6 levels) - Statistics panel (average, max, peak holds) - Grid-based scale (0-100%) - Speech status badge (SPEAKING/SILENT) Integration: - Added to SOCIAL tab as new "Audio" tab - Subscribes to /saltybot/audio_level and /social/speech/is_speaking - Properly formatted topic info footer - Responsive design matching dashboard theme Build: ✓ Passing (113 modules, 202.67 KB main bundle) Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
322 lines
10 KiB
JavaScript
322 lines
10 KiB
JavaScript
/**
|
|
* AudioMeter.jsx — Audio level visualization with speech activity detection
|
|
*
|
|
* Features:
|
|
* - VU bar with color gradient (silent to loud)
|
|
* - Peak hold indicator with decay
|
|
* - Speech activity indicator from /social/speech/is_speaking
|
|
* - Real-time audio level visualization
|
|
* - Responsive design with audio metrics
|
|
*/
|
|
|
|
import { useEffect, useRef, useState } from 'react';
|
|
|
|
// Audio level thresholds and colors
|
|
const LEVEL_THRESHOLDS = [
|
|
{ max: 0.1, color: '#6b7280', label: 'Silent' }, // gray
|
|
{ max: 0.2, color: '#3b82f6', label: 'Low' }, // blue
|
|
{ max: 0.4, color: '#10b981', label: 'Moderate' }, // green
|
|
{ max: 0.6, color: '#f59e0b', label: 'Good' }, // amber
|
|
{ max: 0.8, color: '#f97316', label: 'Loud' }, // orange
|
|
{ max: 1.0, color: '#ef4444', label: 'Clipping' }, // red
|
|
];
|
|
|
|
function getColorForLevel(level) {
|
|
for (const threshold of LEVEL_THRESHOLDS) {
|
|
if (level <= threshold.max) {
|
|
return threshold.color;
|
|
}
|
|
}
|
|
return LEVEL_THRESHOLDS[LEVEL_THRESHOLDS.length - 1].color;
|
|
}
|
|
|
|
function getLabelForLevel(level) {
|
|
for (const threshold of LEVEL_THRESHOLDS) {
|
|
if (level <= threshold.max) {
|
|
return threshold.label;
|
|
}
|
|
}
|
|
return 'Clipping';
|
|
}
|
|
|
|
export function AudioMeter({ subscribe }) {
|
|
const [audioLevel, setAudioLevel] = useState(0);
|
|
const [peakLevel, setPeakLevel] = useState(0);
|
|
const [isSpeaking, setIsSpeaking] = useState(false);
|
|
const [audioStats, setAudioStats] = useState({
|
|
avgLevel: 0,
|
|
maxLevel: 0,
|
|
peakHolds: 0,
|
|
});
|
|
|
|
const audioLevelRef = useRef(0);
|
|
const peakLevelRef = useRef(0);
|
|
const peakDecayRef = useRef(null);
|
|
const audioStatsRef = useRef({
|
|
levels: [],
|
|
max: 0,
|
|
peakHolds: 0,
|
|
});
|
|
|
|
// Subscribe to audio level topic
|
|
useEffect(() => {
|
|
const unsubAudio = subscribe(
|
|
'/saltybot/audio_level',
|
|
'std_msgs/Float32',
|
|
(msg) => {
|
|
if (typeof msg.data === 'number') {
|
|
// Clamp and normalize level (0 to 1)
|
|
const level = Math.max(0, Math.min(1, msg.data));
|
|
audioLevelRef.current = level;
|
|
setAudioLevel(level);
|
|
|
|
// Update peak level
|
|
if (level > peakLevelRef.current) {
|
|
peakLevelRef.current = level;
|
|
setPeakLevel(level);
|
|
audioStatsRef.current.peakHolds++;
|
|
|
|
// Reset peak decay timer
|
|
if (peakDecayRef.current) {
|
|
clearTimeout(peakDecayRef.current);
|
|
}
|
|
|
|
// Start decay after 1 second
|
|
peakDecayRef.current = setTimeout(() => {
|
|
let decayed = peakLevelRef.current - 0.05;
|
|
const decayInterval = setInterval(() => {
|
|
decayed -= 0.05;
|
|
if (decayed <= audioLevelRef.current) {
|
|
peakLevelRef.current = audioLevelRef.current;
|
|
setPeakLevel(audioLevelRef.current);
|
|
clearInterval(decayInterval);
|
|
} else {
|
|
peakLevelRef.current = decayed;
|
|
setPeakLevel(decayed);
|
|
}
|
|
}, 50);
|
|
}, 1000);
|
|
}
|
|
|
|
// Track stats
|
|
audioStatsRef.current.levels.push(level);
|
|
if (audioStatsRef.current.levels.length > 100) {
|
|
audioStatsRef.current.levels.shift();
|
|
}
|
|
audioStatsRef.current.max = Math.max(
|
|
audioStatsRef.current.max,
|
|
level
|
|
);
|
|
|
|
if (audioStatsRef.current.levels.length % 10 === 0) {
|
|
const avg =
|
|
audioStatsRef.current.levels.reduce((a, b) => a + b, 0) /
|
|
audioStatsRef.current.levels.length;
|
|
setAudioStats({
|
|
avgLevel: avg,
|
|
maxLevel: audioStatsRef.current.max,
|
|
peakHolds: audioStatsRef.current.peakHolds,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
);
|
|
|
|
return unsubAudio;
|
|
}, [subscribe]);
|
|
|
|
// Subscribe to speech activity
|
|
useEffect(() => {
|
|
const unsubSpeech = subscribe(
|
|
'/social/speech/is_speaking',
|
|
'std_msgs/Bool',
|
|
(msg) => {
|
|
setIsSpeaking(msg.data === true);
|
|
}
|
|
);
|
|
|
|
return unsubSpeech;
|
|
}, [subscribe]);
|
|
|
|
// Cleanup on unmount
|
|
useEffect(() => {
|
|
return () => {
|
|
if (peakDecayRef.current) {
|
|
clearTimeout(peakDecayRef.current);
|
|
}
|
|
};
|
|
}, []);
|
|
|
|
const currentColor = getColorForLevel(audioLevel);
|
|
const currentLabel = getLabelForLevel(audioLevel);
|
|
const peakColor = getColorForLevel(peakLevel);
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
{/* Speech Activity Indicator */}
|
|
<div className="bg-gray-950 rounded-lg border border-cyan-950 p-4">
|
|
<div className="flex items-center justify-between">
|
|
<div className="text-cyan-700 text-xs font-bold tracking-widest">
|
|
SPEECH ACTIVITY
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<div
|
|
className={`w-4 h-4 rounded-full transition-all ${
|
|
isSpeaking ? 'bg-green-500 animate-pulse' : 'bg-gray-600'
|
|
}`}
|
|
/>
|
|
<span
|
|
className={`text-sm font-semibold ${
|
|
isSpeaking ? 'text-green-400' : 'text-gray-500'
|
|
}`}
|
|
>
|
|
{isSpeaking ? 'SPEAKING' : 'SILENT'}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* VU Meter */}
|
|
<div className="bg-gray-950 rounded-lg border border-cyan-950 p-4 space-y-4">
|
|
<div className="text-cyan-700 text-xs font-bold tracking-widest">
|
|
AUDIO LEVEL
|
|
</div>
|
|
|
|
{/* Main VU Bar */}
|
|
<div className="space-y-2">
|
|
<div className="flex justify-between items-center">
|
|
<span className="text-gray-400 text-sm">Level</span>
|
|
<span className="text-xs font-mono" style={{ color: currentColor }}>
|
|
{(audioLevel * 100).toFixed(1)}% {currentLabel}
|
|
</span>
|
|
</div>
|
|
|
|
<div className="relative h-8 bg-gray-900 rounded border border-gray-800 overflow-hidden">
|
|
{/* Level fill */}
|
|
<div
|
|
className="h-full transition-all duration-50 rounded"
|
|
style={{
|
|
width: `${audioLevel * 100}%`,
|
|
backgroundColor: currentColor,
|
|
}}
|
|
/>
|
|
|
|
{/* Peak hold indicator */}
|
|
<div
|
|
className="absolute top-0 bottom-0 w-1 transition-all duration-100"
|
|
style={{
|
|
left: `${peakLevel * 100}%`,
|
|
backgroundColor: peakColor,
|
|
boxShadow: `0 0 8px ${peakColor}`,
|
|
}}
|
|
/>
|
|
|
|
{/* Grid markers */}
|
|
<div className="absolute inset-0 flex">
|
|
{[0.25, 0.5, 0.75].map((pos) => (
|
|
<div
|
|
key={pos}
|
|
className="flex-1 border-r border-gray-700 opacity-30"
|
|
style={{ marginRight: '-1px' }}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Level scale labels */}
|
|
<div className="flex justify-between text-xs text-gray-600">
|
|
<span>0%</span>
|
|
<span>25%</span>
|
|
<span>50%</span>
|
|
<span>75%</span>
|
|
<span>100%</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Peak Hold Display */}
|
|
<div className="flex items-center gap-2 pt-2 border-t border-gray-800">
|
|
<span className="text-gray-500 text-xs">Peak Hold:</span>
|
|
<div className="flex-1 h-6 bg-gray-900 rounded border border-gray-800 relative overflow-hidden">
|
|
<div
|
|
className="h-full transition-all duration-200 rounded"
|
|
style={{
|
|
width: `${peakLevel * 100}%`,
|
|
backgroundColor: peakColor,
|
|
opacity: 0.7,
|
|
}}
|
|
/>
|
|
<span className="absolute inset-0 flex items-center justify-center text-xs font-mono text-white">
|
|
{(peakLevel * 100).toFixed(1)}%
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Color Reference */}
|
|
<div className="bg-gray-950 rounded-lg border border-cyan-950 p-4">
|
|
<div className="text-cyan-700 text-xs font-bold tracking-widest mb-3">
|
|
LEVEL REFERENCE
|
|
</div>
|
|
<div className="space-y-2">
|
|
{LEVEL_THRESHOLDS.map((threshold) => (
|
|
<div
|
|
key={threshold.label}
|
|
className="flex items-center gap-2"
|
|
>
|
|
<div
|
|
className="w-4 h-4 rounded border border-gray-700"
|
|
style={{ backgroundColor: threshold.color }}
|
|
/>
|
|
<span className="text-xs text-gray-400">{threshold.label}</span>
|
|
<span className="text-xs text-gray-600 ml-auto">
|
|
{(threshold.max * 100).toFixed(0)}%
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Statistics */}
|
|
<div className="bg-gray-950 rounded-lg border border-cyan-950 p-4">
|
|
<div className="text-cyan-700 text-xs font-bold tracking-widest mb-3">
|
|
STATISTICS
|
|
</div>
|
|
<div className="grid grid-cols-3 gap-3 text-center">
|
|
<div>
|
|
<div className="text-2xl font-mono text-cyan-400">
|
|
{(audioStats.avgLevel * 100).toFixed(0)}%
|
|
</div>
|
|
<div className="text-xs text-gray-600 mt-1">Average Level</div>
|
|
</div>
|
|
<div>
|
|
<div className="text-2xl font-mono text-yellow-400">
|
|
{(audioStats.maxLevel * 100).toFixed(0)}%
|
|
</div>
|
|
<div className="text-xs text-gray-600 mt-1">Max Level</div>
|
|
</div>
|
|
<div>
|
|
<div className="text-2xl font-mono text-green-400">
|
|
{audioStats.peakHolds}
|
|
</div>
|
|
<div className="text-xs text-gray-600 mt-1">Peak Holds</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Topic Info */}
|
|
<div className="bg-gray-950 rounded border border-gray-800 p-2 text-xs text-gray-600 space-y-1">
|
|
<div className="flex justify-between">
|
|
<span>Topics:</span>
|
|
<span className="text-gray-500">
|
|
/saltybot/audio_level, /social/speech/is_speaking
|
|
</span>
|
|
</div>
|
|
<div className="flex justify-between">
|
|
<span>Peak Decay:</span>
|
|
<span className="text-gray-500">1s hold + 5% per 50ms</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|