/** * 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 (