/** * 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 (
{/* Speech Activity Indicator */}
SPEECH ACTIVITY
{isSpeaking ? 'SPEAKING' : 'SILENT'}
{/* VU Meter */}
AUDIO LEVEL
{/* Main VU Bar */}
Level {(audioLevel * 100).toFixed(1)}% {currentLabel}
{/* Level fill */}
{/* Peak hold indicator */}
{/* Grid markers */}
{[0.25, 0.5, 0.75].map((pos) => (
))}
{/* Level scale labels */}
0% 25% 50% 75% 100%
{/* Peak Hold Display */}
Peak Hold:
{(peakLevel * 100).toFixed(1)}%
{/* Color Reference */}
LEVEL REFERENCE
{LEVEL_THRESHOLDS.map((threshold) => (
{threshold.label} {(threshold.max * 100).toFixed(0)}%
))}
{/* Statistics */}
STATISTICS
{(audioStats.avgLevel * 100).toFixed(0)}%
Average Level
{(audioStats.maxLevel * 100).toFixed(0)}%
Max Level
{audioStats.peakHolds}
Peak Holds
{/* Topic Info */}
Topics: /saltybot/audio_level, /social/speech/is_speaking
Peak Decay: 1s hold + 5% per 50ms
); }