feat(webui): audio level meter (Issue #234) #238
@ -26,6 +26,7 @@ import { FaceGallery } from './components/FaceGallery.jsx';
|
|||||||
import { ConversationLog } from './components/ConversationLog.jsx';
|
import { ConversationLog } from './components/ConversationLog.jsx';
|
||||||
import { PersonalityTuner } from './components/PersonalityTuner.jsx';
|
import { PersonalityTuner } from './components/PersonalityTuner.jsx';
|
||||||
import { NavModeSelector } from './components/NavModeSelector.jsx';
|
import { NavModeSelector } from './components/NavModeSelector.jsx';
|
||||||
|
import { AudioMeter } from './components/AudioMeter.jsx';
|
||||||
|
|
||||||
// Telemetry panels
|
// Telemetry panels
|
||||||
import PoseViewer from './components/PoseViewer.jsx';
|
import PoseViewer from './components/PoseViewer.jsx';
|
||||||
@ -66,6 +67,7 @@ const TAB_GROUPS = [
|
|||||||
{ id: 'conversation', label: 'Convo', },
|
{ id: 'conversation', label: 'Convo', },
|
||||||
{ id: 'personality', label: 'Personality', },
|
{ id: 'personality', label: 'Personality', },
|
||||||
{ id: 'navigation', label: 'Nav Mode', },
|
{ id: 'navigation', label: 'Nav Mode', },
|
||||||
|
{ id: 'audio', label: 'Audio', },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -223,6 +225,7 @@ export default function App() {
|
|||||||
{activeTab === 'conversation' && <ConversationLog subscribe={subscribe} />}
|
{activeTab === 'conversation' && <ConversationLog subscribe={subscribe} />}
|
||||||
{activeTab === 'personality' && <PersonalityTuner subscribe={subscribe} setParam={setParam} />}
|
{activeTab === 'personality' && <PersonalityTuner subscribe={subscribe} setParam={setParam} />}
|
||||||
{activeTab === 'navigation' && <NavModeSelector subscribe={subscribe} publish={publishFn} />}
|
{activeTab === 'navigation' && <NavModeSelector subscribe={subscribe} publish={publishFn} />}
|
||||||
|
{activeTab === 'audio' && <AudioMeter subscribe={subscribe} />}
|
||||||
|
|
||||||
{activeTab === 'imu' && <PoseViewer subscribe={subscribe} />}
|
{activeTab === 'imu' && <PoseViewer subscribe={subscribe} />}
|
||||||
{activeTab === 'battery' && <BatteryPanel subscribe={subscribe} />}
|
{activeTab === 'battery' && <BatteryPanel subscribe={subscribe} />}
|
||||||
|
|||||||
321
ui/social-bot/src/components/AudioMeter.jsx
Normal file
321
ui/social-bot/src/components/AudioMeter.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user