/ proof-of-concept / bead-percussion
Sign in
proof-of-concept / bead-percussion
bead-percussion / src / ui / Plots.tsx
359 lines · 13.8 KBBlameHistoryRaw
1import { useEffect, useMemo, useRef, useState } from "react";
2import { modeBands, spectrogram, spectrum } from "../sim/analysis";
3import type { RunPayload } from "../worker";
4import { drawAxes, fmt, heat, niceTicks, setupCanvas } from "./plot";
6function useCanvas(draw: (c: HTMLCanvasElement) => void, deps: unknown[]) {
7 const ref = useRef<HTMLCanvasElement>(null);
8 useEffect(() => {
9 const c = ref.current;
10 if (!c) return;
11 const run = () => draw(c);
12 run();
13 const ro = new ResizeObserver(run);
14 ro.observe(c);
15 const mq = window.matchMedia("(prefers-color-scheme: dark)");
16 mq.addEventListener("change", run);
17 return () => {
18 ro.disconnect();
19 mq.removeEventListener("change", run);
20 };
21 // eslint-disable-next-line react-hooks/exhaustive-deps
22 }, deps);
23 return ref;
26export function Waveform({ p, cursor }: { p: RunPayload; cursor: number | null }) {
27 const { audio, sampleRate } = p.result;
28 const ref = useCanvas(
29 c => {
30 const f = setupCanvas(c);
31 let peak = 0;
32 for (const v of audio) peak = Math.max(peak, Math.abs(v));
33 peak = peak || 1;
34 const T = audio.length / sampleRate;
35 const q = p.sys.listenQuantity;
36 const [unit, label] = q === "displacement" ? [1e3, "disp (mm)"] : q === "velocity" ? [1, "vel (m/s)"] : [1, "accel (m/s²)"];
37 drawAxes(f, [0, T], [-peak * unit, peak * unit], { xlabel: "time (s)", ylabel: label });
38 const { ctx, x0, y0, pw, ph } = f;
39 ctx.fillStyle = f.css("--series");
40 const per = audio.length / pw;
41 for (let px = 0; px < pw; px++) {
42 let lo = Infinity;
43 let hi = -Infinity;
44 const a = Math.floor(px * per);
45 const b = Math.max(a + 1, Math.floor((px + 1) * per));
46 for (let i = a; i < b && i < audio.length; i++) {
47 lo = Math.min(lo, audio[i]);
48 hi = Math.max(hi, audio[i]);
49 }
50 const yhi = y0 + ph / 2 - (hi / peak) * (ph / 2);
51 const ylo = y0 + ph / 2 - (lo / peak) * (ph / 2);
52 ctx.fillRect(x0 + px, yhi, 1, Math.max(1, ylo - yhi));
53 }
54 if (cursor !== null) {
55 ctx.fillStyle = f.css("--accent");
56 ctx.fillRect(x0 + (cursor / T) * pw, y0, 1.5, ph);
57 }
58 },
59 [audio, cursor]
60 );
61 return <canvas ref={ref} className="plot short" />;
64export function Spectrogram({ p, fmax }: { p: RunPayload; fmax: number }) {
65 const { audio, sampleRate } = p.result;
66 const sg = useMemo(() => spectrogram(audio), [audio]);
67 const ref = useCanvas(
68 c => {
69 const f = setupCanvas(c);
70 const T = audio.length / sampleRate;
71 const { ctx, x0, y0, pw, ph } = f;
72 const binHz = sampleRate / sg.win;
73 const nb = Math.min(sg.nBins, Math.ceil(fmax / binHz));
74 const img = ctx.createImageData(sg.nFrames, nb);
75 const range = 80;
76 for (let fr = 0; fr < sg.nFrames; fr++) {
77 for (let b = 0; b < nb; b++) {
78 const t = (sg.db[fr * sg.nBins + b] - (sg.maxDb - range)) / range;
79 const [r, g, bl] = heat(t);
80 const o = 4 * ((nb - 1 - b) * sg.nFrames + fr);
81 img.data[o] = r;
82 img.data[o + 1] = g;
83 img.data[o + 2] = bl;
84 img.data[o + 3] = 255;
85 }
86 }
87 const off = new OffscreenCanvas(sg.nFrames, nb);
88 off.getContext("2d")!.putImageData(img, 0, 0);
89 ctx.imageSmoothingEnabled = true;
90 const tEnd = ((sg.nFrames - 1) * sg.hop + sg.win) / sampleRate;
91 ctx.drawImage(off, x0, y0, (pw * tEnd) / T, ph);
92 drawAxes(f, [0, T], [0, (nb * binHz) / 1000], { grid: false, xlabel: "time (s)", ylabel: "kHz" });
93 },
94 [sg, fmax]
95 );
96 return <canvas ref={ref} className="plot" />;
99export function Energy({ p }: { p: RunPayload }) {
100 const { frameTimes, energy, dissipated } = p.result;
101 const ref = useCanvas(
102 c => {
103 const f = setupCanvas(c);
104 const T = p.sys.duration;
105 let emax = 0;
106 for (let i = 0; i < energy.length; i++) emax = Math.max(emax, energy[i] + dissipated[i]);
107 emax = emax || 1;
108 const unit = emax < 1e-3 ? 1e6 : emax < 1 ? 1e3 : 1;
109 const uname = unit === 1e6 ? "µJ" : unit === 1e3 ? "mJ" : "J";
110 drawAxes(f, [0, T], [0, emax * unit * 1.05], { xlabel: "time (s)", ylabel: `energy (${uname})` });
111 const { ctx, x0, y0, pw, ph } = f;
112 const line = (ys: (i: number) => number, color: string) => {
113 ctx.strokeStyle = color;
114 ctx.lineWidth = 1.5;
115 ctx.beginPath();
116 for (let i = 0; i < frameTimes.length; i++) {
117 const px = x0 + (frameTimes[i] / T) * pw;
118 const py = y0 + ph - (ys(i) / (emax * 1.05)) * ph;
119 if (i) ctx.lineTo(px, py);
120 else ctx.moveTo(px, py);
121 }
122 ctx.stroke();
123 };
124 line(i => energy[i] + dissipated[i], f.css("--series2"));
125 line(i => energy[i], f.css("--series"));
126 },
127 [p]
128 );
129 return (
130 <>
131 <canvas ref={ref} className="plot short" />
132 <div className="legend">
133 <span className="swatch s1" /> in the beads <span className="swatch s2" /> plus dissipated by damping
134 </div>
135 </>
136 );
139export function ModeEnergy({ p }: { p: RunPayload }) {
140 const { modeEnergy, frameTimes } = p.result;
141 const [tmax, setTmax] = useState(p.sys.duration);
142 const freqs = p.modeFreqs;
143 const bands = useMemo(
144 () => (freqs && modeEnergy ? modeBands(freqs, modeEnergy, frameTimes.length) : null),
145 [freqs, modeEnergy, frameTimes]
146 );
147 const ref = useCanvas(
148 c => {
149 if (!bands) return;
150 const f = setupCanvas(c);
151 const { ctx, x0, y0, pw, ph } = f;
152 let nF = frameTimes.length;
153 while (nF > 2 && frameTimes[nF - 1] > tmax) nF--;
154 const { nBands, e, edges } = bands;
155 // log energy per band, relative to the total linear energy at each time
156 const img = ctx.createImageData(nF, nBands);
157 const range = 8; // decades
158 for (let fr = 0; fr < nF; fr++) {
159 let tot = 0;
160 for (let b = 0; b < nBands; b++) tot += e[fr * nBands + b];
161 for (let b = 0; b < nBands; b++) {
162 const v = tot > 0 ? Math.log10(e[fr * nBands + b] / tot + 1e-300) : -range;
163 const [r, g, bl] = heat(1 + v / range);
164 const o = 4 * ((nBands - 1 - b) * nF + fr);
165 img.data[o] = r;
166 img.data[o + 1] = g;
167 img.data[o + 2] = bl;
168 img.data[o + 3] = e[fr * nBands + b] > 0 ? 255 : 0; // bands with no modes stay blank
169 }
170 }
171 const off = new OffscreenCanvas(nF, nBands);
172 off.getContext("2d")!.putImageData(img, 0, 0);
173 ctx.imageSmoothingEnabled = false;
174 ctx.drawImage(off, x0, y0, pw, ph);
175 const lf0 = Math.log10(edges[0]);
176 const lf1 = Math.log10(edges[nBands]);
177 const decs: number[] = [];
178 for (let d = Math.ceil(lf0); d <= lf1; d++) for (const m of [1, 2, 5]) if (Math.log10(m) + d >= lf0 && Math.log10(m) + d <= lf1) decs.push(Math.log10(m) + d);
179 drawAxes(f, [0, frameTimes[nF - 1]], [lf0, lf1], {
180 grid: false,
181 xlabel: "time (s)",
182 ylabel: "mode freq (Hz)",
183 yticks: decs.length ? decs : niceTicks(lf0, lf1, 4),
184 yfmt: v => String(Math.round(Math.pow(10, v))),
185 });
186 },
187 [bands, tmax]
188 );
189 if (!bands) return <div className="note">Mode energies are shown when linear modes are computed (see the notes above if they were skipped).</div>;
190 return (
191 <>
192 <canvas ref={ref} className="plot" />
193 <div className="legend">
194 Share of the linear (quadratic) energy in each band of {p.modeKind === "full" ? "3D" : "transverse"} modes, 8 decades of color. Show the first{" "}
195 <select value={tmax} onChange={e => setTmax(+e.target.value)}>
196 {[0.03, 0.1, 0.3, 1]
197 .filter(t => t < p.sys.duration)
198 .concat([p.sys.duration])
199 .map(t => (
200 <option key={t} value={t}>
201 {t} s
202 </option>
203 ))}
204 </select>
205 </div>
206 </>
207 );
210export function Spectrum({ p, fmax }: { p: RunPayload; fmax: number }) {
211 const { audio, sampleRate } = p.result;
212 const T = audio.length / sampleRate;
213 const [early, setEarly] = useState(0);
214 const [late, setLate] = useState(Math.max(0, T - 0.25));
215 const len = Math.round(Math.min(0.25, T) * sampleRate);
216 const sE = useMemo(() => spectrum(audio, Math.round(early * sampleRate), len), [audio, early, len, sampleRate]);
217 const sL = useMemo(() => spectrum(audio, Math.round(late * sampleRate), len), [audio, late, len, sampleRate]);
218 const ref = useCanvas(
219 c => {
220 const f = setupCanvas(c);
221 const { ctx, x0, y0, pw, ph } = f;
222 let top = -Infinity;
223 for (const s of [sE, sL]) for (const v of s.db) top = Math.max(top, v);
224 const yr: [number, number] = [top - 90, top + 3];
225 drawAxes(f, [0, fmax / 1000], [yr[0] - top, yr[1] - top], { xlabel: "kHz", ylabel: "dB" });
226 if (p.modeFreqs) {
227 ctx.strokeStyle = f.css("--grid-strong");
228 for (const fm of p.modeFreqs) {
229 if (fm > fmax) break;
230 const px = x0 + (fm / fmax) * pw;
231 ctx.beginPath();
232 ctx.moveTo(px, y0);
233 ctx.lineTo(px, y0 + 6);
234 ctx.stroke();
235 }
236 }
237 const line = (s: typeof sE, color: string) => {
238 ctx.strokeStyle = color;
239 ctx.lineWidth = 1.2;
240 ctx.beginPath();
241 const nb = Math.min(s.db.length, Math.ceil(fmax / (s.df * sampleRate)));
242 for (let b = 0; b < nb; b++) {
243 const px = x0 + ((b * s.df * sampleRate) / fmax) * pw;
244 const py = y0 + ph - ((s.db[b] - yr[0]) / (yr[1] - yr[0])) * ph;
245 if (b) ctx.lineTo(px, Math.min(py, y0 + ph));
246 else ctx.moveTo(px, Math.min(py, y0 + ph));
247 }
248 ctx.stroke();
249 };
250 line(sL, f.css("--series2"));
251 line(sE, f.css("--series"));
252 },
253 [sE, sL, fmax, p]
254 );
255 const maxStart = Math.max(0, T - len / sampleRate);
256 return (
257 <>
258 <canvas ref={ref} className="plot" />
259 <div className="legend">
260 <span className="swatch s1" /> window at{" "}
261 <input type="range" min={0} max={maxStart} step={0.005} value={early} onChange={e => setEarly(+e.target.value)} /> {early.toFixed(3)} s
262 <span className="swatch s2" /> window at{" "}
263 <input type="range" min={0} max={maxStart} step={0.005} value={late} onChange={e => setLate(+e.target.value)} /> {late.toFixed(3)} s
264 {p.modeFreqs && <span> · ticks at top: linear mode frequencies</span>}
265 </div>
266 </>
267 );
270export function Separation({ p }: { p: RunPayload }) {
271 const tw = p.twin!;
272 const ref = useCanvas(
273 c => {
274 const f = setupCanvas(c);
275 const lo = Math.floor(Math.log10(tw.eps)) - 1;
276 drawAxes(f, [0, p.sys.duration], [lo, 1], {
277 xlabel: "time (s)",
278 ylabel: "log10 separation",
279 yticks: Array.from({ length: 2 - lo }, (_, i) => lo + i).filter(v => v % 2 === 0 || 1 - lo < 8),
280 });
281 const { ctx, x0, y0, pw, ph } = f;
282 ctx.strokeStyle = f.css("--series");
283 ctx.lineWidth = 1.5;
284 ctx.beginPath();
285 let started = false;
286 for (let i = 0; i < tw.times.length; i++) {
287 if (!(tw.sep[i] > 0)) continue;
288 const px = x0 + (tw.times[i] / p.sys.duration) * pw;
289 const py = y0 + ph - ((Math.log10(tw.sep[i]) - lo) / (1 - lo)) * ph;
290 if (started) ctx.lineTo(px, py);
291 else ctx.moveTo(px, py);
292 started = true;
293 }
294 ctx.stroke();
295 },
296 [tw]
297 );
298 return (
299 <>
300 <canvas ref={ref} className="plot short" />
301 <div className="legend">
302 Largest difference in any bead coordinate between the run and its twin, relative to the largest displacement over the preceding 50 ms. A straight rise
303 means exponential growth (chaos); regular motion drifts apart slowly. It levels off near 1 when the runs are unrelated.
304 </div>
305 </>
306 );
309export function DecayTimes({ p }: { p: RunPayload }) {
310 const freqs = p.modeFreqs!;
311 const sig = p.modeDecay!;
312 const ref = useCanvas(
313 c => {
314 const f = setupCanvas(c);
315 const fTop = freqs[freqs.length - 1];
316 const shown = (k: number) => freqs[k] > 1e-6 * fTop; // skip free rigid motion at 0 Hz
317 const T60 = Array.from(sig, (s, k) => (s > 0 && shown(k) ? Math.log(1000) / s : Infinity));
318 const finite = T60.filter(Number.isFinite);
319 const fx0 = Math.log10((freqs.find(f => f > 1e-6 * fTop) ?? fTop) * 0.9);
320 const fx1 = Math.log10(freqs[freqs.length - 1] * 1.1);
321 let ty0 = Math.floor(Math.log10(Math.min(...finite, 1)) * 2) / 2;
322 let ty1 = Math.ceil(Math.log10(Math.max(...finite, 1)) * 2) / 2;
323 if (ty1 - ty0 < 1) (ty0 -= 0.5), (ty1 += 0.5);
324 const logTicks = (lo: number, hi: number) => {
325 const out: number[] = [];
326 for (let d = Math.floor(lo); d <= hi; d++) for (const m of [1, 2, 5]) if (d + Math.log10(m) >= lo && d + Math.log10(m) <= hi) out.push(d + Math.log10(m));
327 return out;
328 };
329 drawAxes(f, [fx0, fx1], [ty0, ty1], {
330 xlabel: "mode frequency (Hz)",
331 ylabel: "T60 (s)",
332 xticks: logTicks(fx0, fx1),
333 yticks: logTicks(ty0, ty1),
334 xfmt: v => fmt(Math.pow(10, v)),
335 yfmt: v => fmt(Math.pow(10, v)),
336 });
337 const { ctx, x0, y0, pw, ph } = f;
338 ctx.fillStyle = f.css("--series");
339 for (let k = 0; k < freqs.length; k++) {
340 if (!Number.isFinite(T60[k])) continue;
341 const px = x0 + ((Math.log10(freqs[k]) - fx0) / (fx1 - fx0)) * pw;
342 const py = y0 + ph - ((Math.log10(T60[k]) - ty0) / (ty1 - ty0)) * ph;
343 ctx.fillRect(px - 1.5, py - 1.5, 3, 3);
344 }
345 },
346 [p]
347 );
348 const undamped = Array.from(sig).every(s => s === 0);
349 if (undamped) return <div className="note">No damping: every mode rings indefinitely.</div>;
350 return (
351 <>
352 <canvas ref={ref} className="plot short" />
353 <div className="legend">
354 Time for each mode to fall 60 dB at small amplitude, from the friction, dashpots and bending and hinge damping. Use bp_decay to set it from two
355 (frequency, T60) pairs.
356 </div>
357 </>
358 );
↑↓ move↵openescclose