/ simulated_instruments / woodblock
Sign in
simulated_instruments / woodblock
woodblock / webapp / src / components / plots / SpectrumPlot.tsx
111 lines · 3.7 KBBlameHistoryRaw
1// Magnitude spectrum of the rendered strike, log-frequency axis, with the
2// modal partial frequencies and the Helmholtz resonance overlaid.
4import { magnitudeSpectrum } from '../../lib/fft';
5import { useCanvas, drawFrame, PLOT_COLORS } from './canvasUtils';
7interface Props {
8 y: Float32Array;
9 sampleRate: number;
10 partialsHz: number[];
11 helmholtzHz: number;
14const F_LO = 100;
15const F_HI = 24000;
16const DB_RANGE = 80;
18export default function SpectrumPlot({ y, sampleRate, partialsHz, helmholtzHz }: Props) {
19 const ref = useCanvas(
20 (ctx, w, h) => {
21 drawFrame(ctx, w, h);
22 const padL = 34;
23 const padB = 18;
24 const pw = w - padL - 6;
25 const ph = h - padB - 8;
26 const fx = (f: number) =>
27 padL + ((Math.log10(f) - Math.log10(F_LO)) / (Math.log10(F_HI) - Math.log10(F_LO))) * pw;
29 // frequency gridlines
30 ctx.font = '10px system-ui';
31 ctx.textAlign = 'center';
32 for (const f of [100, 200, 500, 1000, 2000, 5000, 10000, 20000]) {
33 const x = fx(f);
34 ctx.strokeStyle = PLOT_COLORS.grid;
35 ctx.beginPath();
36 ctx.moveTo(x, 8);
37 ctx.lineTo(x, 8 + ph);
38 ctx.stroke();
39 ctx.fillStyle = PLOT_COLORS.axis;
40 ctx.fillText(f >= 1000 ? `${f / 1000}k` : `${f}`, x, h - 5);
41 }
42 ctx.textAlign = 'right';
43 for (let db = 0; db >= -DB_RANGE; db -= 20) {
44 const yy = 8 + (-db / DB_RANGE) * ph;
45 ctx.strokeStyle = PLOT_COLORS.grid;
46 ctx.beginPath();
47 ctx.moveTo(padL, yy);
48 ctx.lineTo(padL + pw, yy);
49 ctx.stroke();
50 ctx.fillStyle = PLOT_COLORS.axis;
51 ctx.fillText(`${db}`, padL - 4, yy + 3);
52 }
54 const { mag, size } = magnitudeSpectrum(y, 16384);
55 let peak = 0;
56 for (let i = 1; i < mag.length; i++) peak = Math.max(peak, mag[i]);
57 if (peak <= 0) return;
59 // overlays first so the trace draws on top
60 ctx.strokeStyle = PLOT_COLORS.good;
61 ctx.setLineDash([4, 3]);
62 if (helmholtzHz >= F_LO && helmholtzHz <= F_HI) {
63 ctx.beginPath();
64 ctx.moveTo(fx(helmholtzHz), 8);
65 ctx.lineTo(fx(helmholtzHz), 8 + ph);
66 ctx.stroke();
67 ctx.fillStyle = PLOT_COLORS.good;
68 ctx.textAlign = 'left';
69 ctx.fillText(`H ${helmholtzHz.toFixed(0)}`, fx(helmholtzHz) + 3, 18);
70 }
71 ctx.strokeStyle = PLOT_COLORS.accent;
72 for (const f of partialsHz) {
73 if (f < F_LO || f > F_HI) continue;
74 ctx.beginPath();
75 ctx.moveTo(fx(f), 8);
76 ctx.lineTo(fx(f), 8 + ph);
77 ctx.stroke();
78 }
79 ctx.setLineDash([]);
81 ctx.strokeStyle = PLOT_COLORS.trace;
82 ctx.beginPath();
83 let started = false;
84 for (let px = 0; px <= pw; px++) {
85 const f = 10 ** (Math.log10(F_LO) + (px / pw) * (Math.log10(F_HI) - Math.log10(F_LO)));
86 const bin = Math.min(mag.length - 1, Math.round((f / sampleRate) * size));
87 // take the max over the bins this pixel spans, so narrow partials survive
88 const fNext = 10 ** (Math.log10(F_LO) + ((px + 1) / pw) * (Math.log10(F_HI) - Math.log10(F_LO)));
89 const binNext = Math.min(mag.length - 1, Math.max(bin + 1, Math.round((fNext / sampleRate) * size)));
90 let m = 0;
91 for (let b = bin; b < binNext; b++) m = Math.max(m, mag[b]);
92 const db = 20 * Math.log10(m / peak + 1e-12);
93 const yy = 8 + Math.min(ph, (-db / DB_RANGE) * ph);
94 if (!started) {
95 ctx.moveTo(padL + px, yy);
96 started = true;
97 } else {
98 ctx.lineTo(padL + px, yy);
99 }
100 }
101 ctx.stroke();
103 ctx.fillStyle = PLOT_COLORS.accent;
104 ctx.textAlign = 'left';
105 ctx.fillText('partials', padL + 4, 18);
106 },
107 [y, sampleRate, partialsHz, helmholtzHz],
108 200,
109 );
110 return <canvas ref={ref} />;
↑↓ move↵openescclose