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 barRefs = useRef<(HTMLDivElement | null)[]>([]); useEffect(() => { const ctx = sourceNode.context as AudioContext; const analyser = ctx.createAnalyser(); analyser.fftSize = 256; sourceNode.connect(analyser); // Connect to destination via silent gain node — without this, // Chromium suspends processing on disconnected audio graphs. const silentGain = ctx.createGain(); silentGain.gain.value = 0; analyser.connect(silentGain); silentGain.connect(ctx.destination); 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) let sumSquares = 0; for (let i = 0; i < dataArray.length; i++) { const normalized = (dataArray[i] - 128) / 128; sumSquares += normalized * normalized; } const rms = Math.sqrt(sumSquares / dataArray.length); // 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); // 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); } rafId = requestAnimationFrame(tick); return () => { cancelAnimationFrame(rafId); try { sourceNode.disconnect(analyser); analyser.disconnect(silentGain); silentGain.disconnect(ctx.destination); } catch { // Nodes may already be disconnected } }; }, [sourceNode]); return (
{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` }} /> ))}
); }