1// Time-frequency view of the render, cropped to the band the bass actually
2// occupies; per-partial decay is visible as horizontal streaks, and a choked
3// walking-bass note as a streak that ends where the next note lands.
5import { stft } from '../../lib/fft';
6import { useCanvas, drawFrame, PLOT_COLORS, colormap } from './canvasUtils';
8interface Props {
9 y: Float32Array;
10 sampleRate: number;
11}
13const F_TOP = 4000; // display band, Hz
15export default function SpectrogramPlot({ y, sampleRate }: Props) {
16 const ref = useCanvas(
17 (ctx, w, h) => {
18 drawFrame(ctx, w, h);
19 const padL = 34;
20 const padB = 16;
21 const pw = w - padL - 6;
22 const ph = h - padB - 6;
24 const win = 2048; // 23 Hz bins at 48 kHz: enough to separate the low partials
25 const { frames, bins, db, floorDb } = stft(y, win, 512);
26 const nyq = sampleRate / 2;
27 const binsShown = Math.min(bins, Math.ceil((F_TOP / nyq) * bins));
29 const img = ctx.createImageData(frames, binsShown);
30 for (let f = 0; f < frames; f++) {
31 for (let b = 0; b < binsShown; b++) {
32 const v = db[f * bins + b];
33 const t = Math.min(1, Math.max(0, (v - floorDb) / -floorDb));
34 const [r, g, bl] = colormap(t);
35 // flip vertically: low frequency at the bottom
36 const row = binsShown - 1 - b;
37 const idx = (row * frames + f) * 4;
38 img.data[idx] = r;
39 img.data[idx + 1] = g;
40 img.data[idx + 2] = bl;
41 img.data[idx + 3] = 255;
42 }
43 }
44 const off = new OffscreenCanvas(frames, binsShown);
45 const octx = off.getContext('2d')!;
46 octx.putImageData(img, 0, 0);
47 ctx.imageSmoothingEnabled = true;
48 ctx.drawImage(off, padL, 6, pw, ph);
50 // axes
51 ctx.fillStyle = PLOT_COLORS.axis;
52 ctx.font = '10px system-ui';
53 ctx.textAlign = 'right';
54 const fTop = (binsShown / bins) * nyq;
55 for (let kf = 0; kf <= fTop; kf += 1000) {
56 const yy = 6 + ph - (kf / fTop) * ph;
57 ctx.fillText(kf >= 1000 ? `${kf / 1000}k` : `${kf}`, padL - 4, yy + 3);
58 }
59 ctx.textAlign = 'center';
60 const durS = y.length / sampleRate;
61 const tStep = durS > 8 ? 2.0 : durS > 4 ? 1.0 : durS > 1.5 ? 0.5 : 0.1;
62 for (let t = 0; t <= durS + 1e-9; t += tStep) {
63 const x = padL + (t / durS) * pw;
64 ctx.fillText(`${t.toFixed(1)}s`, x, h - 4);
65 }
66 },
67 [y, sampleRate],
68 200,
69 );
70 return <canvas ref={ref} />;
71}