// Time-frequency view of the rendered note: the attack chiff, the harmonic // comb of the locked oscillation, and the breath-noise floor between them. import { stft } from '../../lib/fft'; import { useCanvas, drawFrame, PLOT_COLORS, colormap } from './canvasUtils'; interface Props { y: Float32Array; sampleRate: number; } export default function SpectrogramPlot({ y, sampleRate }: Props) { const ref = useCanvas( (ctx, w, h) => { drawFrame(ctx, w, h); const padL = 34; const padB = 16; const pw = w - padL - 6; const ph = h - padB - 6; const { frames, bins, db, floorDb } = stft(y, 512, 128); const img = ctx.createImageData(frames, bins); for (let f = 0; f < frames; f++) { for (let b = 0; b < bins; b++) { const v = db[f * bins + b]; const t = Math.min(1, Math.max(0, (v - floorDb) / -floorDb)); const [r, g, bl] = colormap(t); // flip vertically: low frequency at the bottom const row = bins - 1 - b; const idx = (row * frames + f) * 4; img.data[idx] = r; img.data[idx + 1] = g; img.data[idx + 2] = bl; img.data[idx + 3] = 255; } } const off = new OffscreenCanvas(frames, bins); const octx = off.getContext('2d')!; octx.putImageData(img, 0, 0); ctx.imageSmoothingEnabled = true; ctx.drawImage(off, padL, 6, pw, ph); // axes ctx.fillStyle = PLOT_COLORS.axis; ctx.font = '10px system-ui'; ctx.textAlign = 'right'; const nyq = sampleRate / 2; for (let kf = 0; kf <= nyq; kf += 4000) { const yy = 6 + ph - (kf / nyq) * ph; ctx.fillText(kf >= 1000 ? `${kf / 1000}k` : `${kf}`, padL - 4, yy + 3); } ctx.textAlign = 'center'; const durS = y.length / sampleRate; for (let t = 0; t <= durS + 1e-9; t += 0.1) { const x = padL + (t / durS) * pw; ctx.fillText(`${t.toFixed(1)}s`, x, h - 4); } }, [y, sampleRate], 200, ); return ; }