1// Time-frequency view of the rendered strike; per-partial decay is visible as
2// horizontal streaks of different lengths.
4import { stft } from '../../lib/fft';
5import { useCanvas, drawFrame, PLOT_COLORS, colormap } from './canvasUtils';
7interface Props {
8 y: Float32Array;
9 sampleRate: number;
10}
12export default function SpectrogramPlot({ y, sampleRate }: Props) {
13 const ref = useCanvas(
14 (ctx, w, h) => {
15 drawFrame(ctx, w, h);
16 const padL = 34;
17 const padB = 16;
18 const pw = w - padL - 6;
19 const ph = h - padB - 6;
21 const { frames, bins, db, floorDb } = stft(y, 512, 128);
23 const img = ctx.createImageData(frames, bins);
24 for (let f = 0; f < frames; f++) {
25 for (let b = 0; b < bins; b++) {
26 const v = db[f * bins + b];
27 const t = Math.min(1, Math.max(0, (v - floorDb) / -floorDb));
28 const [r, g, bl] = colormap(t);
29 // flip vertically: low frequency at the bottom
30 const row = bins - 1 - b;
31 const idx = (row * frames + f) * 4;
32 img.data[idx] = r;
33 img.data[idx + 1] = g;
34 img.data[idx + 2] = bl;
35 img.data[idx + 3] = 255;
36 }
37 }
38 const off = new OffscreenCanvas(frames, bins);
39 const octx = off.getContext('2d')!;
40 octx.putImageData(img, 0, 0);
41 ctx.imageSmoothingEnabled = true;
42 ctx.drawImage(off, padL, 6, pw, ph);
44 // axes
45 ctx.fillStyle = PLOT_COLORS.axis;
46 ctx.font = '10px system-ui';
47 ctx.textAlign = 'right';
48 const nyq = sampleRate / 2;
49 for (let kf = 0; kf <= nyq; kf += 4000) {
50 const yy = 6 + ph - (kf / nyq) * ph;
51 ctx.fillText(kf >= 1000 ? `${kf / 1000}k` : `${kf}`, padL - 4, yy + 3);
52 }
53 ctx.textAlign = 'center';
54 const durS = y.length / sampleRate;
55 for (let t = 0; t <= durS + 1e-9; t += 0.1) {
56 const x = padL + (t / durS) * pw;
57 ctx.fillText(`${t.toFixed(1)}s`, x, h - 4);
58 }
59 },
60 [y, sampleRate],
61 200,
62 );
63 return <canvas ref={ref} />;
64}