Files
llink/js/desktop/src/components/audio/centered-waveform.tsx
T
Arjun PatelandGitHub 849d41fa06 feat: use sexier audio waveform (#265)
* use sexier audio waveform

* format
2026-06-11 10:07:25 -07:00

214 lines
6.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
};
}