fix: audio bar buggy
This commit is contained in:
@@ -1,16 +1,33 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
|
|
||||||
interface AudioLevelBarsProps {
|
interface AudioLevelBarsProps {
|
||||||
sourceNode: AudioNode;
|
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.
|
* 3-bar VU meter that visualizes audio levels from any AudioNode source.
|
||||||
* Works with both live MediaStream sources and MediaElement sources.
|
* 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) {
|
export function AudioLevelBars({ sourceNode }: AudioLevelBarsProps) {
|
||||||
const [levels, setLevels] = useState([0, 0, 0]);
|
const barRefs = useRef<(HTMLDivElement | null)[]>([]);
|
||||||
const rafRef = useRef<number>(0);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const ctx = sourceNode.context as AudioContext;
|
const ctx = sourceNode.context as AudioContext;
|
||||||
@@ -27,7 +44,15 @@ export function AudioLevelBars({ sourceNode }: AudioLevelBarsProps) {
|
|||||||
|
|
||||||
const dataArray = new Uint8Array(analyser.frequencyBinCount);
|
const dataArray = new Uint8Array(analyser.frequencyBinCount);
|
||||||
|
|
||||||
|
let smoothedLevel = 0;
|
||||||
|
let lastTime = performance.now();
|
||||||
|
let rafId = 0;
|
||||||
|
|
||||||
function tick() {
|
function tick() {
|
||||||
|
const now = performance.now();
|
||||||
|
const dt = now - lastTime;
|
||||||
|
lastTime = now;
|
||||||
|
|
||||||
analyser.getByteTimeDomainData(dataArray);
|
analyser.getByteTimeDomainData(dataArray);
|
||||||
|
|
||||||
// Compute RMS of waveform (128 = silence baseline)
|
// Compute RMS of waveform (128 = silence baseline)
|
||||||
@@ -38,20 +63,37 @@ export function AudioLevelBars({ sourceNode }: AudioLevelBarsProps) {
|
|||||||
}
|
}
|
||||||
const rms = Math.sqrt(sumSquares / dataArray.length);
|
const rms = Math.sqrt(sumSquares / dataArray.length);
|
||||||
|
|
||||||
// VU meter: 3 bars with staggered thresholds
|
// Convert to dB, clamp to noise floor, normalize to 0..1
|
||||||
// Typical speech RMS is ~0.02-0.15 from time-domain data
|
const db = rms > 0 ? 20 * Math.log10(rms) : NOISE_FLOOR_DB;
|
||||||
const bar0 = Math.min(1, rms * 10);
|
const normalizedDb = Math.max(0, (db - NOISE_FLOOR_DB) / DB_RANGE);
|
||||||
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]);
|
|
||||||
|
|
||||||
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 () => {
|
return () => {
|
||||||
cancelAnimationFrame(rafRef.current);
|
cancelAnimationFrame(rafId);
|
||||||
try {
|
try {
|
||||||
sourceNode.disconnect(analyser);
|
sourceNode.disconnect(analyser);
|
||||||
analyser.disconnect(silentGain);
|
analyser.disconnect(silentGain);
|
||||||
@@ -64,11 +106,14 @@ export function AudioLevelBars({ sourceNode }: AudioLevelBarsProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-end gap-1.5">
|
<div className="flex items-end gap-1.5">
|
||||||
{levels.map((level, i) => (
|
{Array.from({ length: BAR_COUNT }, (_, i) => (
|
||||||
<div
|
<div
|
||||||
key={i}
|
key={i}
|
||||||
className="w-1.5 rounded-full bg-green-400 transition-all duration-75"
|
ref={(el) => {
|
||||||
style={{ height: `${Math.max(6, level * 48)}px` }}
|
barRefs.current[i] = el;
|
||||||
|
}}
|
||||||
|
className="w-1.5 rounded-full bg-green-400"
|
||||||
|
style={{ height: `${MIN_HEIGHT_PX}px` }}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user