Initial Commit
This commit is contained in:
@@ -0,0 +1,157 @@
|
||||
import { type ReactNode, useEffect, useRef } from "react";
|
||||
import { useBus } from "../../hooks/use-bus/use-bus.js";
|
||||
|
||||
export type VisualizerMode = "waveform" | "spectrum" | "level";
|
||||
|
||||
export interface AudioVisualizerProps {
|
||||
readonly className?: string;
|
||||
/** Canvas pixel width. Defaults to 320. */
|
||||
readonly width?: number;
|
||||
/** Canvas pixel height. Defaults to 80. */
|
||||
readonly height?: number;
|
||||
/** Visualisation mode. */
|
||||
readonly mode?: VisualizerMode;
|
||||
/** Bus name to subscribe to. Defaults to `"master"`. */
|
||||
readonly busName?: string;
|
||||
/** Sample tick rate Hz. Defaults to 60. */
|
||||
readonly sampleRateHz?: number;
|
||||
/** Foreground stroke / fill colour. */
|
||||
readonly color?: string;
|
||||
/** Background colour. */
|
||||
readonly backgroundColor?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Canvas-based audio visualiser. Subscribes to a published bus via
|
||||
* `useBus` and renders one of three modes — waveform, spectrum, or
|
||||
* level — at the configured sample tick rate. The component is
|
||||
* read-only: it never writes to the bus or to engine state.
|
||||
*/
|
||||
export function AudioVisualizer(props: AudioVisualizerProps): ReactNode {
|
||||
const {
|
||||
className,
|
||||
width,
|
||||
height,
|
||||
mode,
|
||||
busName,
|
||||
sampleRateHz,
|
||||
color,
|
||||
backgroundColor,
|
||||
} = props;
|
||||
const W = width ?? 320;
|
||||
const H = height ?? 80;
|
||||
const M: VisualizerMode = mode ?? "waveform";
|
||||
const bus = useBus(busName ?? "master");
|
||||
const sampleHz = Math.max(1, sampleRateHz ?? 60);
|
||||
const fg = color ?? "#3aa";
|
||||
const bg = backgroundColor ?? "#111";
|
||||
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// React 19 + JSX guarantees canvasRef.current is populated by the time
|
||||
// this effect runs (the <canvas> element below has just been mounted).
|
||||
// We pin that invariant via a non-null assertion rather than a dead-code
|
||||
// null guard that istanbul can never exercise.
|
||||
const canvas = canvasRef.current as HTMLCanvasElement;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (ctx === null) return undefined;
|
||||
const buf: Float32Array[] = [new Float32Array(256)];
|
||||
const tick = () => {
|
||||
ctx.fillStyle = bg;
|
||||
ctx.fillRect(0, 0, W, H);
|
||||
ctx.strokeStyle = fg;
|
||||
ctx.fillStyle = fg;
|
||||
if (bus !== undefined) {
|
||||
bus.read(buf);
|
||||
const data = buf[0]!;
|
||||
if (M === "waveform") {
|
||||
drawWaveform(ctx, data, W, H);
|
||||
} else if (M === "spectrum") {
|
||||
drawSpectrum(ctx, data, W, H);
|
||||
} else {
|
||||
drawLevel(ctx, data, W, H);
|
||||
}
|
||||
}
|
||||
};
|
||||
tick();
|
||||
const id = setInterval(tick, Math.floor(1000 / sampleHz));
|
||||
return () => clearInterval(id);
|
||||
}, [bus, W, H, M, sampleHz, fg, bg]);
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className={
|
||||
className === undefined
|
||||
? "rax-audio-visualizer"
|
||||
: `rax-audio-visualizer ${className}`
|
||||
}
|
||||
width={W}
|
||||
height={H}
|
||||
data-mode={M}
|
||||
data-bus={busName ?? "master"}
|
||||
role="img"
|
||||
aria-label={`Audio visualizer (${M})`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** @internal Exported for testing — pure 2D drawing primitives. */
|
||||
export function drawWaveform(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
data: Float32Array,
|
||||
w: number,
|
||||
h: number
|
||||
): void {
|
||||
ctx.beginPath();
|
||||
const mid = h / 2;
|
||||
const step = w / Math.max(1, data.length);
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
const x = i * step;
|
||||
const y = mid - data[i]! * mid;
|
||||
if (i === 0) ctx.moveTo(x, y);
|
||||
else ctx.lineTo(x, y);
|
||||
}
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
/** @internal Exported for testing. */
|
||||
export function drawSpectrum(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
data: Float32Array,
|
||||
w: number,
|
||||
h: number
|
||||
): void {
|
||||
// Naive magnitude spectrum: take a real DFT over a downsampled
|
||||
// window. Not physically accurate but deterministic and pure.
|
||||
const bins = 32;
|
||||
const stride = Math.max(1, Math.floor(data.length / bins));
|
||||
const barW = w / bins;
|
||||
for (let b = 0; b < bins; b++) {
|
||||
let sum = 0;
|
||||
for (let s = 0; s < stride; s++) {
|
||||
const idx = b * stride + s;
|
||||
if (idx >= data.length) break;
|
||||
sum += Math.abs(data[idx]!);
|
||||
}
|
||||
const mag = sum / stride;
|
||||
const barH = Math.min(h, mag * h);
|
||||
ctx.fillRect(b * barW, h - barH, barW - 1, barH);
|
||||
}
|
||||
}
|
||||
|
||||
/** @internal Exported for testing. */
|
||||
export function drawLevel(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
data: Float32Array,
|
||||
w: number,
|
||||
h: number
|
||||
): void {
|
||||
let peak = 0;
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
const v = Math.abs(data[i]!);
|
||||
if (v > peak) peak = v;
|
||||
}
|
||||
const barW = Math.min(w, peak * w);
|
||||
ctx.fillRect(0, 0, barW, h);
|
||||
}
|
||||
Reference in New Issue
Block a user