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
+
+
+
+ );
+}