From 02db9c1ebb9c185b972b0e7811eae55f8d9469c5 Mon Sep 17 00:00:00 2001 From: talksik Date: Sat, 21 Feb 2026 13:31:11 -0800 Subject: [PATCH] fix: audio bar buggy --- js/src/components/audio/audio-level-bars.tsx | 75 ++++++++++++++++---- 1 file changed, 60 insertions(+), 15 deletions(-) diff --git a/js/src/components/audio/audio-level-bars.tsx b/js/src/components/audio/audio-level-bars.tsx index e37289f..a333a83 100644 --- a/js/src/components/audio/audio-level-bars.tsx +++ b/js/src/components/audio/audio-level-bars.tsx @@ -1,16 +1,33 @@ -import { useEffect, useRef, useState } from "react"; +import { useEffect, useRef } from "react"; interface AudioLevelBarsProps { sourceNode: AudioNode; } +const BAR_COUNT = 3; +const MIN_HEIGHT_PX = 6; +const MAX_HEIGHT_PX = 48; + +// dB scale +const NOISE_FLOOR_DB = -50; +const DB_RANGE = -NOISE_FLOOR_DB; // 50dB dynamic range + +// Asymmetric smoothing time constants +const ATTACK_MS = 30; +const RELEASE_MS = 300; + +// Bar activation thresholds on the 0..1 normalized dB scale +const BAR_THRESHOLDS = [0.0, 0.15, 0.35]; + /** * 3-bar VU meter that visualizes audio levels from any AudioNode source. * Works with both live MediaStream sources and MediaElement sources. + * + * Uses direct DOM manipulation with exponential smoothing on a dB scale + * for smooth, jitter-free animation independent of frame rate. */ export function AudioLevelBars({ sourceNode }: AudioLevelBarsProps) { - const [levels, setLevels] = useState([0, 0, 0]); - const rafRef = useRef(0); + const barRefs = useRef<(HTMLDivElement | null)[]>([]); useEffect(() => { const ctx = sourceNode.context as AudioContext; @@ -27,7 +44,15 @@ export function AudioLevelBars({ sourceNode }: AudioLevelBarsProps) { const dataArray = new Uint8Array(analyser.frequencyBinCount); + let smoothedLevel = 0; + let lastTime = performance.now(); + let rafId = 0; + function tick() { + const now = performance.now(); + const dt = now - lastTime; + lastTime = now; + analyser.getByteTimeDomainData(dataArray); // Compute RMS of waveform (128 = silence baseline) @@ -38,20 +63,37 @@ export function AudioLevelBars({ sourceNode }: AudioLevelBarsProps) { } const rms = Math.sqrt(sumSquares / dataArray.length); - // VU meter: 3 bars with staggered thresholds - // Typical speech RMS is ~0.02-0.15 from time-domain data - const bar0 = Math.min(1, rms * 10); - const bar1 = Math.max(0, Math.min(1, (rms - 0.02) * 8)); - const bar2 = Math.max(0, Math.min(1, (rms - 0.06) * 6)); - setLevels([bar0, bar1, bar2]); + // Convert to dB, clamp to noise floor, normalize to 0..1 + const db = rms > 0 ? 20 * Math.log10(rms) : NOISE_FLOOR_DB; + const normalizedDb = Math.max(0, (db - NOISE_FLOOR_DB) / DB_RANGE); - rafRef.current = requestAnimationFrame(tick); + // Asymmetric exponential smoothing (frame-rate independent) + const timeConstant = + normalizedDb > smoothedLevel ? ATTACK_MS : RELEASE_MS; + const alpha = 1 - Math.exp(-dt / timeConstant); + smoothedLevel += alpha * (normalizedDb - smoothedLevel); + + // Update bar heights via direct DOM writes + for (let i = 0; i < BAR_COUNT; i++) { + const el = barRefs.current[i]; + if (!el) continue; + + const threshold = BAR_THRESHOLDS[i]; + const barLevel = + smoothedLevel <= threshold + ? 0 + : Math.min(1, (smoothedLevel - threshold) / (1 - threshold)); + const height = MIN_HEIGHT_PX + barLevel * (MAX_HEIGHT_PX - MIN_HEIGHT_PX); + el.style.height = `${height}px`; + } + + rafId = requestAnimationFrame(tick); } - rafRef.current = requestAnimationFrame(tick); + rafId = requestAnimationFrame(tick); return () => { - cancelAnimationFrame(rafRef.current); + cancelAnimationFrame(rafId); try { sourceNode.disconnect(analyser); analyser.disconnect(silentGain); @@ -64,11 +106,14 @@ export function AudioLevelBars({ sourceNode }: AudioLevelBarsProps) { return (
- {levels.map((level, i) => ( + {Array.from({ length: BAR_COUNT }, (_, i) => (
{ + barRefs.current[i] = el; + }} + className="w-1.5 rounded-full bg-green-400" + style={{ height: `${MIN_HEIGHT_PX}px` }} /> ))}