1// Magnitude spectrum of the rendered stroke, log-frequency axis, with the
2// retained plate modes overlaid and colored by nodal-diameter order. A
3// cymbal's spectrum is far denser than the sibling triangle's: below a
4// kilohertz the partials are still resolvable, above it the modal overlap
5// exceeds one and the spectrum fills in, which is what a wash is.
7import { magnitudeSpectrum } from '../../lib/fft';
8import { useCanvas, drawFrame, PLOT_COLORS, modeColor } from './canvasUtils';
10interface Props {
11 y: Float32Array;
12 sampleRate: number;
13 modeF: Float32Array;
14 modeN: Float32Array;
15}
17const F_LO = 60;
18const F_HI = 24000;
19const DB_RANGE = 80;
21export default function SpectrumPlot({ y, sampleRate, modeF, modeN }: Props) {
22 let maxN = 1;
23 for (let i = 0; i < modeN.length; i++) maxN = Math.max(maxN, modeN[i]);
24 const ref = useCanvas(
25 (ctx, w, h) => {
26 drawFrame(ctx, w, h);
27 const padL = 34;
28 const padB = 18;
29 const pw = w - padL - 6;
30 const ph = h - padB - 8;
31 const fx = (f: number) =>
32 padL + ((Math.log10(f) - Math.log10(F_LO)) / (Math.log10(F_HI) - Math.log10(F_LO))) * pw;
34 ctx.font = '10px system-ui';
35 ctx.textAlign = 'center';
36 for (const f of [100, 200, 500, 1000, 2000, 5000, 10000, 20000]) {
37 const x = fx(f);
38 ctx.strokeStyle = PLOT_COLORS.grid;
39 ctx.beginPath();
40 ctx.moveTo(x, 8);
41 ctx.lineTo(x, 8 + ph);
42 ctx.stroke();
43 ctx.fillStyle = PLOT_COLORS.axis;
44 ctx.fillText(f >= 1000 ? `${f / 1000}k` : `${f}`, x, h - 5);
45 }
46 ctx.textAlign = 'right';
47 for (let db = 0; db >= -DB_RANGE; db -= 20) {
48 const yy = 8 + (-db / DB_RANGE) * ph;
49 ctx.strokeStyle = PLOT_COLORS.grid;
50 ctx.beginPath();
51 ctx.moveTo(padL, yy);
52 ctx.lineTo(padL + pw, yy);
53 ctx.stroke();
54 ctx.fillStyle = PLOT_COLORS.axis;
55 ctx.fillText(`${db}`, padL - 4, yy + 3);
56 }
58 const { mag, size } = magnitudeSpectrum(y, 32768);
59 let peak = 0;
60 for (let i = 1; i < mag.length; i++) peak = Math.max(peak, mag[i]);
61 if (peak <= 0) return;
63 // mode lines first so the trace draws on top
64 for (let i = 0; i < modeF.length; i++) {
65 const f = modeF[i];
66 if (f < F_LO || f > F_HI) continue;
67 ctx.strokeStyle = modeColor(modeN[i], maxN, 0.35);
68 ctx.beginPath();
69 ctx.moveTo(fx(f), 8 + ph * 0.45);
70 ctx.lineTo(fx(f), 8 + ph);
71 ctx.stroke();
72 }
74 ctx.strokeStyle = PLOT_COLORS.trace;
75 ctx.beginPath();
76 let started = false;
77 for (let px = 0; px <= pw; px++) {
78 const f = 10 ** (Math.log10(F_LO) + (px / pw) * (Math.log10(F_HI) - Math.log10(F_LO)));
79 const bin = Math.min(mag.length - 1, Math.round((f / sampleRate) * size));
80 const fNext =
81 10 ** (Math.log10(F_LO) + ((px + 1) / pw) * (Math.log10(F_HI) - Math.log10(F_LO)));
82 const binNext = Math.min(
83 mag.length - 1,
84 Math.max(bin + 1, Math.round((fNext / sampleRate) * size)),
85 );
86 let m = 0;
87 for (let b = bin; b < binNext; b++) m = Math.max(m, mag[b]);
88 const db = 20 * Math.log10(m / peak + 1e-12);
89 const yy = 8 + Math.min(ph, (-db / DB_RANGE) * ph);
90 if (!started) {
91 ctx.moveTo(padL + px, yy);
92 started = true;
93 } else {
94 ctx.lineTo(padL + px, yy);
95 }
96 }
97 ctx.stroke();
99 ctx.textAlign = 'left';
100 ctx.fillStyle = PLOT_COLORS.axis;
101 ctx.fillText('mode lines colored by nodal diameters, 0', padL + 4, 18);
102 const x0 = padL + 200;
103 for (let n = 0; n <= maxN; n++) {
104 ctx.fillStyle = modeColor(n, maxN, 0.95);
105 ctx.fillRect(x0 + (n / maxN) * 70, 11, 70 / (maxN + 1) + 1, 8);
106 }
107 ctx.fillStyle = PLOT_COLORS.axis;
108 ctx.fillText(`${maxN}`, x0 + 74, 18);
109 },
110 [y, sampleRate, modeF, modeN, maxN],
111 200,
112 );
113 return <canvas ref={ref} />;
114}