214 lines
6.6 KiB
TypeScript
214 lines
6.6 KiB
TypeScript
import { useEffect, useRef } from 'react';
|
||
|
||
interface CenteredWaveformProps {
|
||
sourceNode: AudioNode;
|
||
/** Canvas size in CSS pixels. */
|
||
width?: number;
|
||
height?: number;
|
||
barWidth?: number;
|
||
gap?: number;
|
||
/** Bars are drawn with currentColor — set a text-* class to color them. */
|
||
className?: string;
|
||
}
|
||
|
||
// Voice band fanned out from the center bar (lows) to the edges (highs).
|
||
const MIN_FREQ_HZ = 100;
|
||
const MAX_FREQ_HZ = 4500;
|
||
|
||
// Analyser dB range mapped onto bar height; the defaults (-100/-30) waste
|
||
// most of the range on inaudible levels.
|
||
const MIN_DB = -75;
|
||
const MAX_DB = -25;
|
||
|
||
// Per-bar asymmetric smoothing time constants.
|
||
const ATTACK_MS = 40;
|
||
const RELEASE_MS = 220;
|
||
|
||
// Edge bars keep a fraction of their response so the whole row stays alive
|
||
// instead of only the middle moving.
|
||
const EDGE_RESPONSE = 0.3;
|
||
|
||
/**
|
||
* Centered live voice indicator, like a meeting app's "speaking" glyph.
|
||
* Bars fan out symmetrically from the middle: the center tracks the low
|
||
* frequencies where voice energy lives, the edges track the highs, and a
|
||
* cosine envelope tapers the response so the shape blooms from the center
|
||
* while someone speaks and settles into a dot line in silence.
|
||
*
|
||
* Renders on canvas with an rAF loop and no React state. No reduced-motion
|
||
* branch: the animation is signal-bearing audio feedback, not decoration,
|
||
* and only runs while audio is being captured or played.
|
||
*/
|
||
export function CenteredWaveform({
|
||
sourceNode,
|
||
width = 168,
|
||
height = 56,
|
||
barWidth = 4,
|
||
gap = 4,
|
||
className,
|
||
}: CenteredWaveformProps) {
|
||
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||
|
||
useEffect(() => {
|
||
const canvasEl = canvasRef.current;
|
||
const ctx2d = canvasEl?.getContext('2d');
|
||
if (!canvasEl || !ctx2d) return;
|
||
// Rebind post-guard so the narrowed types carry into the closures below.
|
||
const canvas = canvasEl;
|
||
const drawCtx = ctx2d;
|
||
|
||
const { analyser, detach } = attachAnalyser(sourceNode, 512);
|
||
analyser.smoothingTimeConstant = 0.8;
|
||
analyser.minDecibels = MIN_DB;
|
||
analyser.maxDecibels = MAX_DB;
|
||
|
||
const bins = new Uint8Array(analyser.frequencyBinCount);
|
||
|
||
const stride = barWidth + gap;
|
||
// Odd count so one bar sits exactly at the center.
|
||
let barCount = Math.floor((width + gap) / stride);
|
||
if (barCount % 2 === 0) barCount -= 1;
|
||
const half = (barCount - 1) / 2;
|
||
|
||
// Map bar k (0 = center) to a frequency bin, log-spaced so the busy low
|
||
// end of the voice spectrum spreads across several bars.
|
||
const binHz = sourceNode.context.sampleRate / analyser.fftSize;
|
||
const binIndex = new Uint16Array(half + 1);
|
||
for (let k = 0; k <= half; k++) {
|
||
const freq =
|
||
MIN_FREQ_HZ *
|
||
Math.pow(MAX_FREQ_HZ / MIN_FREQ_HZ, half === 0 ? 0 : k / half);
|
||
binIndex[k] = Math.min(bins.length - 1, Math.round(freq / binHz));
|
||
}
|
||
|
||
const levels = new Float32Array(half + 1);
|
||
|
||
const ensureBackingStore = createBackingStoreScaler(
|
||
canvas,
|
||
drawCtx,
|
||
width,
|
||
height,
|
||
);
|
||
// currentColor, cached and refreshed periodically (theme switches show
|
||
// within 200ms) instead of paying a computed-style read every frame.
|
||
let fillColor = '';
|
||
let lastColorTime = 0;
|
||
let lastTime = performance.now();
|
||
let rafId = 0;
|
||
|
||
function tick() {
|
||
const now = performance.now();
|
||
const dt = now - lastTime;
|
||
lastTime = now;
|
||
|
||
analyser.getByteFrequencyData(bins);
|
||
|
||
for (let k = 0; k <= half; k++) {
|
||
const raw = bins[binIndex[k]] / 255;
|
||
const envelope =
|
||
half === 0
|
||
? 1
|
||
: EDGE_RESPONSE +
|
||
(1 - EDGE_RESPONSE) * Math.cos(((k / half) * Math.PI) / 2);
|
||
// Gamma keeps the floor quiet so silence reads as a dot line.
|
||
const target = Math.pow(raw, 1.4) * envelope;
|
||
|
||
// Asymmetric exponential smoothing (frame-rate independent)
|
||
const timeConstant = target > levels[k] ? ATTACK_MS : RELEASE_MS;
|
||
const alpha = 1 - Math.exp(-dt / timeConstant);
|
||
levels[k] += alpha * (target - levels[k]);
|
||
}
|
||
|
||
if (ensureBackingStore() || !fillColor || now - lastColorTime > 200) {
|
||
fillColor = getComputedStyle(canvas).color;
|
||
lastColorTime = now;
|
||
}
|
||
drawCtx.clearRect(0, 0, width, height);
|
||
drawCtx.fillStyle = fillColor;
|
||
|
||
const centerX = width / 2 - barWidth / 2;
|
||
drawCtx.beginPath();
|
||
for (let k = 0; k <= half; k++) {
|
||
const h = Math.max(barWidth, levels[k] * height);
|
||
const y = (height - h) / 2;
|
||
drawCtx.roundRect(centerX + k * stride, y, barWidth, h, barWidth / 2);
|
||
if (k > 0) {
|
||
drawCtx.roundRect(centerX - k * stride, y, barWidth, h, barWidth / 2);
|
||
}
|
||
}
|
||
drawCtx.fill();
|
||
|
||
rafId = requestAnimationFrame(tick);
|
||
}
|
||
|
||
rafId = requestAnimationFrame(tick);
|
||
|
||
return () => {
|
||
cancelAnimationFrame(rafId);
|
||
detach();
|
||
};
|
||
}, [sourceNode, width, height, barWidth, gap]);
|
||
|
||
return (
|
||
<canvas ref={canvasRef} className={className} style={{ width, height }} />
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Connects an AnalyserNode to the source for visualization.
|
||
*
|
||
* The analyser is routed to the destination via a silent gain node — without
|
||
* this, Chromium suspends processing on disconnected audio graphs.
|
||
*/
|
||
function attachAnalyser(
|
||
sourceNode: AudioNode,
|
||
fftSize: number,
|
||
): { analyser: AnalyserNode; detach: () => void } {
|
||
const ctx = sourceNode.context;
|
||
const analyser = ctx.createAnalyser();
|
||
analyser.fftSize = fftSize;
|
||
sourceNode.connect(analyser);
|
||
|
||
const silentGain = ctx.createGain();
|
||
silentGain.gain.value = 0;
|
||
analyser.connect(silentGain);
|
||
silentGain.connect(ctx.destination);
|
||
|
||
return {
|
||
analyser,
|
||
detach() {
|
||
try {
|
||
sourceNode.disconnect(analyser);
|
||
analyser.disconnect(silentGain);
|
||
silentGain.disconnect(ctx.destination);
|
||
} catch {
|
||
// Nodes may already be disconnected
|
||
}
|
||
},
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Keeps the canvas backing store sized to width × height CSS pixels at the
|
||
* current devicePixelRatio, rescaling if the DPR changes (window moved
|
||
* between monitors). Call the returned function each frame; it returns true
|
||
* when the store was (re)initialized so callers can refresh cached state.
|
||
*/
|
||
function createBackingStoreScaler(
|
||
canvas: HTMLCanvasElement,
|
||
drawCtx: CanvasRenderingContext2D,
|
||
width: number,
|
||
height: number,
|
||
): () => boolean {
|
||
let dpr = 0;
|
||
return () => {
|
||
const currentDpr = window.devicePixelRatio || 1;
|
||
if (currentDpr === dpr) return false;
|
||
dpr = currentDpr;
|
||
canvas.width = Math.round(width * dpr);
|
||
canvas.height = Math.round(height * dpr);
|
||
drawCtx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||
return true;
|
||
};
|
||
}
|