Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 additions and 0 deletions

View File

@@ -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);
}