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(null); useEffect(() => { // React 19 + JSX guarantees canvasRef.current is populated by the time // this effect runs (the 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 ( ); } /** @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); }