1// Magnitude spectrum of the render, log-frequency axis, with the coupled
2// modes of the first note overlaid, colored by how much of each mode's
3// energy lives in the body. A pizzicato bass spectrum is a comb of slightly
4// stretched partials standing on the body resonances; where a string
5// partial meets a body mode the overlay shows the pair trading color.
7import { magnitudeSpectrum } from '../../lib/fft';
8import { useCanvas, drawFrame, PLOT_COLORS } from './canvasUtils';
10interface Props {
11 y: Float32Array;
12 sampleRate: number;
13 modeF: Float32Array;
14 modeBodyFrac: Float32Array;
15}
17const F_LO = 30;
18const F_HI = 8000;
19const DB_RANGE = 80;
21function mixColor(t: number, alpha: number): string {
22 // cyan (string) -> amber (body)
23 const r = Math.round(77 + t * (232 - 77));
24 const g = Math.round(208 + t * (163 - 208));
25 const b = Math.round(225 + t * (61 - 225));
26 return `rgba(${r}, ${g}, ${b}, ${alpha})`;
27}
29export default function SpectrumPlot({ y, sampleRate, modeF, modeBodyFrac }: Props) {
30 const ref = useCanvas(
31 (ctx, w, h) => {
32 drawFrame(ctx, w, h);
33 const padL = 34;
34 const padB = 18;
35 const pw = w - padL - 6;
36 const ph = h - padB - 8;
37 const fx = (f: number) =>
38 padL + ((Math.log10(f) - Math.log10(F_LO)) / (Math.log10(F_HI) - Math.log10(F_LO))) * pw;
40 ctx.font = '10px system-ui';
41 ctx.textAlign = 'center';
42 for (const f of [30, 50, 100, 200, 500, 1000, 2000, 5000]) {
43 const x = fx(f);
44 ctx.strokeStyle = PLOT_COLORS.grid;
45 ctx.beginPath();
46 ctx.moveTo(x, 8);
47 ctx.lineTo(x, 8 + ph);
48 ctx.stroke();
49 ctx.fillStyle = PLOT_COLORS.axis;
50 ctx.fillText(f >= 1000 ? `${f / 1000}k` : `${f}`, x, h - 5);
51 }
52 ctx.textAlign = 'right';
53 for (let db = 0; db >= -DB_RANGE; db -= 20) {
54 const yy = 8 + (-db / DB_RANGE) * ph;
55 ctx.strokeStyle = PLOT_COLORS.grid;
56 ctx.beginPath();
57 ctx.moveTo(padL, yy);
58 ctx.lineTo(padL + pw, yy);
59 ctx.stroke();
60 ctx.fillStyle = PLOT_COLORS.axis;
61 ctx.fillText(`${db}`, padL - 4, yy + 3);
62 }
64 const { mag, size } = magnitudeSpectrum(y, 65536);
65 let peak = 0;
66 for (let i = 1; i < mag.length; i++) peak = Math.max(peak, mag[i]);
67 if (peak <= 0) return;
69 // overlays first so the trace draws on top
70 ctx.setLineDash([4, 3]);
71 for (let i = 0; i < modeF.length; i++) {
72 const f = modeF[i];
73 if (f < F_LO || f > F_HI) continue;
74 ctx.strokeStyle = mixColor(Math.min(1, modeBodyFrac[i]), 0.45);
75 ctx.beginPath();
76 ctx.moveTo(fx(f), 8);
77 ctx.lineTo(fx(f), 8 + ph);
78 ctx.stroke();
79 }
80 ctx.setLineDash([]);
82 ctx.strokeStyle = PLOT_COLORS.trace;
83 ctx.beginPath();
84 let started = false;
85 for (let px = 0; px <= pw; px++) {
86 const f = 10 ** (Math.log10(F_LO) + (px / pw) * (Math.log10(F_HI) - Math.log10(F_LO)));
87 const bin = Math.min(mag.length - 1, Math.round((f / sampleRate) * size));
88 const fNext =
89 10 ** (Math.log10(F_LO) + ((px + 1) / pw) * (Math.log10(F_HI) - Math.log10(F_LO)));
90 const binNext = Math.min(
91 mag.length - 1,
92 Math.max(bin + 1, Math.round((fNext / sampleRate) * size)),
93 );
94 let m = 0;
95 for (let b = bin; b < binNext; b++) m = Math.max(m, mag[b]);
96 const db = 20 * Math.log10(m / peak + 1e-12);
97 const yy = 8 + Math.min(ph, (-db / DB_RANGE) * ph);
98 if (!started) {
99 ctx.moveTo(padL + px, yy);
100 started = true;
101 } else {
102 ctx.lineTo(padL + px, yy);
103 }
104 }
105 ctx.stroke();
107 ctx.textAlign = 'left';
108 ctx.fillStyle = mixColor(0, 0.9);
109 ctx.fillText('string-dominated modes', padL + 4, 18);
110 ctx.fillStyle = mixColor(1, 0.9);
111 ctx.fillText('body-dominated modes', padL + 134, 18);
112 ctx.fillStyle = PLOT_COLORS.axis;
113 ctx.fillText('(first note)', padL + 258, 18);
114 },
115 [y, sampleRate, modeF, modeBodyFrac],
116 200,
117 );
118 return <canvas ref={ref} />;
119}