feat(webui): audio level meter (Issue #234) #238

Merged
sl-jetson merged 1 commits from sl-webui/issue-234-audio-meter into main 2026-03-02 12:15:32 -05:00
2 changed files with 324 additions and 0 deletions
Showing only changes of commit 8d72b85b07 - Show all commits

View File

@ -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' && <ConversationLog subscribe={subscribe} />}
{activeTab === 'personality' && <PersonalityTuner subscribe={subscribe} setParam={setParam} />}
{activeTab === 'navigation' && <NavModeSelector subscribe={subscribe} publish={publishFn} />}
{activeTab === 'audio' && <AudioMeter subscribe={subscribe} />}
{activeTab === 'imu' && <PoseViewer subscribe={subscribe} />}
{activeTab === 'battery' && <BatteryPanel subscribe={subscribe} />}

View File

@ -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 (
<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>
);
}