* wip * wip * wip * format * wip(mobile): lint and format * cleanup * nits * nits * idiomatic react * nit * format all root files
123 lines
3.6 KiB
TypeScript
123 lines
3.6 KiB
TypeScript
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 (
|
|
<div className="flex items-end gap-1.5">
|
|
{Array.from({ length: BAR_COUNT }, (_, i) => (
|
|
<div
|
|
key={i}
|
|
ref={(el) => {
|
|
barRefs.current[i] = el;
|
|
}}
|
|
className="w-1.5 rounded-full bg-green-400"
|
|
style={{ height: `${MIN_HEIGHT_PX}px` }}
|
|
/>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|