From ff3eb340d8a38f4b1db01a3d2e74569c7c6abc0e Mon Sep 17 00:00:00 2001 From: sl-webui Date: Mon, 2 Mar 2026 12:11:12 -0500 Subject: [PATCH] feat(webui): audio level meter with speech activity (Issue #234) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- ui/social-bot/src/App.jsx | 3 + ui/social-bot/src/components/AudioMeter.jsx | 321 ++++++++++++++++++++ 2 files changed, 324 insertions(+) create mode 100644 ui/social-bot/src/components/AudioMeter.jsx diff --git a/ui/social-bot/src/App.jsx b/ui/social-bot/src/App.jsx index 645ecb0..0059b7d 100644 --- a/ui/social-bot/src/App.jsx +++ b/ui/social-bot/src/App.jsx @@ -26,6 +26,7 @@ import { FaceGallery } from './components/FaceGallery.jsx'; import { ConversationLog } from './components/ConversationLog.jsx'; import { PersonalityTuner } from './components/PersonalityTuner.jsx'; import { NavModeSelector } from './components/NavModeSelector.jsx'; +import { AudioMeter } from './components/AudioMeter.jsx'; // Telemetry panels import PoseViewer from './components/PoseViewer.jsx'; @@ -66,6 +67,7 @@ const TAB_GROUPS = [ { id: 'conversation', label: 'Convo', }, { id: 'personality', label: 'Personality', }, { id: 'navigation', label: 'Nav Mode', }, + { id: 'audio', label: 'Audio', }, ], }, { @@ -223,6 +225,7 @@ export default function App() { {activeTab === 'conversation' && } {activeTab === 'personality' && } {activeTab === 'navigation' && } + {activeTab === 'audio' && } {activeTab === 'imu' && } {activeTab === 'battery' && } diff --git a/ui/social-bot/src/components/AudioMeter.jsx b/ui/social-bot/src/components/AudioMeter.jsx new file mode 100644 index 0000000..f77c66f --- /dev/null +++ b/ui/social-bot/src/components/AudioMeter.jsx @@ -0,0 +1,321 @@ +/** + * 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 +
+
+
+ ); +}