/ proof-of-concept / bead-percussion
Sign in
proof-of-concept / bead-percussion
bead-percussion / src / App.tsx
421 lines · 16.4 KBCodeBlameHistory
fc06d37Bead-and-spring percussion simulator: numbl-scripted setup, Verlet/Yoshida solver with exact damping, linear modes, browser appJeremy Magland 1import { useCallback, useEffect, useRef, useState } from "react";
4import { Editor } from "./ui/Editor";
0330c41Damping by decay times: bp_decay helper, per-mode decay-time plot, examples convertedJeremy Magland 5import { DecayTimes, Energy, ModeEnergy, Separation, Spectrogram, Spectrum, Waveform } from "./ui/Plots";
fc06d37Bead-and-spring percussion simulator: numbl-scripted setup, Verlet/Yoshida solver with exact damping, linear modes, browser appJeremy Magland 6import type { FromWorker, RunPayload, RunRequest } from "./worker";
8const exampleSources = import.meta.glob("../matlab/examples/*.m", { query: "?raw", import: "default", eager: true }) as Record<string, string>;
9const helperSources = import.meta.glob("../matlab/helpers/*.m", { query: "?raw", import: "default", eager: true }) as Record<string, string>;
10const examples = Object.entries(exampleSources)
11 .map(([path, source]) => ({ name: path.split("/").pop()!.replace(/\.m$/, ""), source }))
12 .sort((a, b) => (a.name === "drum" ? -1 : b.name === "drum" ? 1 : a.name.localeCompare(b.name)));
13const helpers = Object.entries(helperSources).map(([path, source]) => ({ name: path.split("/").pop()!, source }));
16function defaultFmax(audio: Float32Array, fs: number): number {
17 const s = spectrum(audio, 0, Math.min(audio.length, Math.round(0.5 * fs)));
18 const p = Array.from(s.db, d => 10 ** (d / 10));
19 const total = p.reduce((a, b) => a + b, 0);
20 let acc = 0;
21 let f99 = fs / 2;
22 for (let b = 0; b < p.length; b++) {
23 acc += p[b];
24 if (acc >= 0.999 * total) {
25 f99 = b * s.df * fs;
26 break;
27 }
28 }
29 return [1000, 2000, 5000, 10000, 20000].find(f => f >= 1.2 * f99 && f <= fs / 2) ?? Math.min(20000, fs / 2);
33const load = () => {
34 try {
35 return localStorage.getItem(STORE_KEY);
36 } catch {
37 return null;
38 }
39};
40const save = (s: string) => {
41 try {
42 localStorage.setItem(STORE_KEY, s);
43 } catch {
44 /* storage unavailable */
45 }
46};
48export default function App() {
49 const [script, setScript] = useState(() => load() ?? examples[0].source);
50 const [running, setRunning] = useState(false);
51 const [stage, setStage] = useState("");
52 const [progress, setProgress] = useState<number | null>(null);
53 const [log, setLog] = useState("");
54 const [error, setError] = useState<string | null>(null);
55 const [payload, setPayload] = useState<RunPayload | null>(null);
56 const [fmax, setFmax] = useState(10000);
57 const [cursor, setCursor] = useState<number | null>(null);
58 const [viewTime, setViewTime] = useState<number | null>(null);
59 const [dtCheck, setDtCheck] = useState(false);
60 const [linearRef, setLinearRef] = useState(false);
62 const [playing, setPlaying] = useState<"main" | "linear" | "twin" | null>(null);
fc06d37Bead-and-spring percussion simulator: numbl-scripted setup, Verlet/Yoshida solver with exact damping, linear modes, browser appJeremy Magland 63 const [runId, setRunId] = useState(0); // remounts panels with per-run state
64 const worker = useRef<Worker | null>(null);
65 const audioCtx = useRef<AudioContext | null>(null);
66 const source = useRef<AudioBufferSourceNode | null>(null);
67 const startedAt = useRef(0);
69 useEffect(() => save(script), [script]);
71 const stopAudio = useCallback(() => {
72 source.current?.stop();
73 source.current = null;
74 setCursor(null);
75 setPlaying(null);
76 }, []);
78 const run = useCallback(
79 () => {
80 worker.current?.terminate();
81 stopAudio();
82 const w = new Worker(new URL("./worker.ts", import.meta.url), { type: "module" });
83 worker.current = w;
84 setRunning(true);
85 setError(null);
86 setLog("");
87 setProgress(null);
88 setStage("Starting");
89 w.onmessage = (ev: MessageEvent<FromWorker>) => {
90 const m = ev.data;
91 if (m.type === "log") setLog(l => l + m.text);
92 else if (m.type === "stage") {
93 setStage(m.text);
94 setProgress(null);
95 } else if (m.type === "progress") setProgress(m.t / m.duration);
96 else if (m.type === "error") {
97 setError(m.message);
98 setRunning(false);
99 w.terminate();
100 } else if (m.type === "result") {
101 setPayload(m.payload);
102 setRunId(i => i + 1);
105 w.terminate();
106 }
107 };
108 w.onerror = e => {
109 setError(e.message || "Worker error");
110 setRunning(false);
111 };
95f2216Minimal chaos example and perturbed-twin chaos test with separation plot and growth-rate fitJeremy Magland 112 const req: RunRequest = { type: "run", script, dtCheck, linear: linearRef, twin };
114 },
95f2216Minimal chaos example and perturbed-twin chaos test with separation plot and growth-rate fitJeremy Magland 115 [script, stopAudio, dtCheck, linearRef, twin]
118 const stop = () => {
119 worker.current?.terminate();
120 worker.current = null;
121 setRunning(false);
122 setStage("Stopped");
123 };
95f2216Minimal chaos example and perturbed-twin chaos test with separation plot and growth-rate fitJeremy Magland 125 const play = (which: "main" | "linear" | "twin") => {
127 stopAudio();
128 const { sampleRate } = payload.result;
130 which === "linear" && payload.linear
131 ? payload.linear.audio
132 : which === "twin" && payload.twin
133 ? payload.twin.audio
134 : payload.result.audio;
136 audioCtx.current = ctx;
137 // both signals share the main run's gain, so the A/B comparison keeps levels
138 let peak = 0;
139 for (const v of payload.result.audio) peak = Math.max(peak, Math.abs(v));
140 const buf = ctx.createBuffer(1, audio.length, sampleRate);
141 const ch = buf.getChannelData(0);
142 const g = peak > 0 ? 0.9 / peak : 1;
143 for (let i = 0; i < audio.length; i++) ch[i] = Math.max(-1, Math.min(1, g * audio[i]));
144 const src = ctx.createBufferSource();
145 src.buffer = buf;
146 src.connect(ctx.destination);
147 src.onended = () => {
148 if (source.current === src) {
149 source.current = null;
150 setCursor(null);
151 setPlaying(null);
152 }
153 };
154 src.start();
155 source.current = src;
156 setPlaying(which);
157 startedAt.current = ctx.currentTime;
158 const T = audio.length / sampleRate;
159 const tick = () => {
160 if (source.current !== src) return;
161 const t = ctx.currentTime - startedAt.current;
162 setCursor(Math.min(t, T));
163 if (t < T) requestAnimationFrame(tick);
164 };
165 requestAnimationFrame(tick);
166 };
168 const download = () => {
169 if (!payload) return;
170 const url = URL.createObjectURL(wavBlob(payload.result.audio, payload.result.sampleRate));
171 const a = document.createElement("a");
172 a.href = url;
173 a.download = "bead-percussion.wav";
174 a.click();
175 setTimeout(() => URL.revokeObjectURL(url), 1000);
176 };
178 const r = payload?.result;
179 return (
180 <div className="app">
181 <header>
182 <h1>bead-percussion</h1>
183 <span className="sub">beads and springs, set up in numbl, heard through one bead</span>
184 </header>
185 <div className="columns">
186 <section className="left">
187 <div className="controls">
188 <select
189 value=""
190 onChange={e => {
191 const ex = examples.find(x => x.name === e.target.value);
192 if (ex && (script === ex.source || confirm(`Replace the script with the "${ex.name}" example?`))) setScript(ex.source);
193 }}
194 >
195 <option value="" disabled>
196 Load example…
197 </option>
198 {examples.map(ex => (
199 <option key={ex.name} value={ex.name}>
200 {ex.name}
201 </option>
202 ))}
203 </select>
204 <span className="grow" />
205 {running ? (
206 <button onClick={stop}>Stop</button>
207 ) : (
208 <button className="primary" onClick={run} title="Ctrl/Cmd+Enter">
209 Run
210 </button>
211 )}
212 </div>
213 <div className="controls options">
214 <label title="Run again at dt/2 and compare the listener signals">
215 <input type="checkbox" checked={dtCheck} onChange={e => setDtCheck(e.target.checked)} /> dt/2 check
216 </label>
217 <label title="Run again with every hit scaled by 1e-3 (then scaled back up): what the system would do if it were linear">
218 <input type="checkbox" checked={linearRef} onChange={e => setLinearRef(e.target.checked)} /> small-strike reference
219 </label>
95f2216Minimal chaos example and perturbed-twin chaos test with separation plot and growth-rate fitJeremy Magland 220 <label title="Run again with every hit changed by one part in 1e9 and track how fast the two runs separate (exponential growth means chaos)">
221 <input type="checkbox" checked={twin} onChange={e => setTwin(e.target.checked)} /> perturbed twin
222 </label>
224 <Editor value={script} onChange={setScript} onRun={() => !running && run()} />
225 {running && (
226 <div className="status">
227 {stage}
228 {progress !== null && ` ${(100 * progress).toFixed(0)}%`}
229 <div className="bar">
230 <div style={{ width: `${100 * (progress ?? 0)}%` }} />
231 </div>
232 </div>
233 )}
234 {error && <pre className="error">{error}</pre>}
235 {log && <pre className="log">{log}</pre>}
236 {payload && <Report p={payload} />}
237 <details className="helpers">
238 <summary>Helper functions (bp_*.m)</summary>
239 {helpers.map(h => (
240 <div key={h.name}>
241 <h4>{h.name}</h4>
242 <pre>{h.source}</pre>
243 </div>
244 ))}
245 </details>
246 <details className="helpers">
247 <summary>sys fields</summary>
248 <pre>{SYS_DOC}</pre>
249 </details>
250 </section>
251 <section className="right">
252 {!payload && <div className="empty">Run a script to hear and see the result.</div>}
253 {payload && r && (
254 <>
255 <div className="controls">
256 <button className="primary" onClick={() => (playing === "main" ? stopAudio() : play("main"))}>
257 {playing === "main" ? "Stop" : "Play"}
258 </button>
259 {payload.linear && (
260 <button onClick={() => (playing === "linear" ? stopAudio() : play("linear"))} title="The same strike scaled down 1000x, then scaled back up">
261 {playing === "linear" ? "Stop" : "Play small-strike reference"}
262 </button>
263 )}
265 <button onClick={() => (playing === "twin" ? stopAudio() : play("twin"))} title="The run with the strike changed by one part in 1e9">
266 {playing === "twin" ? "Stop" : "Play twin"}
267 </button>
268 )}
270 <span className="grow" />
271 <label>
272 freq range{" "}
273 <select value={fmax} onChange={e => setFmax(+e.target.value)}>
274 {[1000, 2000, 5000, 10000, 20000]
275 .filter(f => f <= r.sampleRate / 2)
276 .map(f => (
277 <option key={f} value={f}>
278 {f / 1000} kHz
279 </option>
280 ))}
281 </select>
282 </label>
283 </div>
284 <h3>Listener signal</h3>
285 <Waveform p={payload} cursor={cursor ?? viewTime} />
287 <>
288 <h3>Separation from the perturbed twin (chaos test)</h3>
289 <Separation p={payload} />
290 </>
291 )}
293 <Spectrogram p={payload} fmax={fmax} />
294 <h3>Spectrum, two windows (detuning shows as peaks shifting)</h3>
85eb848Fix duplicated plots during playback: give per-run panels distinct keysJeremy Magland 295 <Spectrum key={`spectrum-${runId}`} p={payload} fmax={fmax} />
85eb848Fix duplicated plots during playback: give per-run panels distinct keysJeremy Magland 297 <ModeEnergy key={`modes-${runId}`} p={payload} />
0330c41Damping by decay times: bp_decay helper, per-mode decay-time plot, examples convertedJeremy Magland 298 {payload.modeFreqs && payload.modeDecay && (
299 <>
300 <h3>Decay time by mode</h3>
301 <DecayTimes p={payload} />
302 </>
303 )}
305 <Energy p={payload} />
306 <h3>Beads</h3>
85eb848Fix duplicated plots during playback: give per-run panels distinct keysJeremy Magland 307 <BeadView key={`beads-${runId}`} p={payload} onTime={setViewTime} />
309 )}
310 </section>
311 </div>
312 </div>
313 );
316function Report({ p }: { p: RunPayload }) {
317 const r = p.result;
318 const nPinned = p.sys.pinned.reduce((a, b) => a + b, 0);
319 const rows: [string, string][] = [
320 ["beads", `${p.sys.n} (${nPinned} pinned)`],
321 ["springs, dashpots", `${p.sys.si.length}, ${p.sys.nDashpots}`],
322 ["method", p.sys.method],
323 ["dt", `${r.dt.toExponential(3)} s (${r.substeps} per audio sample)`],
324 ["highest linear frequency", `${(r.omegaMax / (2 * Math.PI)).toFixed(0)} Hz`],
325 ["frequency error", r.freqErrors.map(e => `${e.cents.toFixed(e.cents < 0.1 ? 3 : 2)}¢ at ${e.hz / 1000} kHz`).join(", ")],
326 ["energy drift after excitation", r.energyDrift.toExponential(2)],
327 ["run time", `${(r.elapsedMs / 1000).toFixed(1)} s (${(r.elapsedMs / 1000 / p.sys.duration).toFixed(1)}x real time)`],
328 ];
329 if (p.modeFreqs) rows.push(["lowest modes", Array.from(p.modeFreqs.slice(0, 5)).map(f => f.toFixed(1)).join(", ") + " Hz"]);
330 return (
331 <div className="report">
332 {r.aborted && <div className="error">{r.aborted}</div>}
333 {[...p.notes, ...r.warnings].map((w, i) => (
334 <div key={i} className="warn">
335 {w}
336 </div>
337 ))}
338 <table>
339 <tbody>
340 {rows.map(([k, v]) => (
341 <tr key={k}>
342 <td>{k}</td>
343 <td>{v}</td>
344 </tr>
345 ))}
346 </tbody>
347 </table>
349 <div className="caption">
350 Perturbed twin (hits changed by 1 part in 10^9):{" "}
351 {p.twin.rate !== null
352 ? `separation grows exponentially, e-folding time ${(1000 / p.twin.rate).toPrecision(2)} ms (rate ${p.twin.rate.toPrecision(3)} /s). Chaotic.`
353 : `separation reached ${p.twin.sep.reduce((a, b) => Math.max(a, b), 0).toExponential(1)} of the motion's size; no exponential growth to 1e-2 within the run.`}
354 </div>
355 )}
357 <>
358 <div className="caption">
359 Listener signal vs the small-strike reference (how nonlinear the run is): relative RMS difference over [0, t]
360 </div>
361 <table>
362 <tbody>
363 {p.linear.windows.map(w => (
364 <tr key={w.t}>
365 <td>t = {w.t} s</td>
366 <td>{w.rel.toExponential(2)}</td>
367 </tr>
368 ))}
369 </tbody>
370 </table>
371 </>
372 )}
373 {p.check && (
374 <>
375 <div className="caption">
376 Listener signal, dt vs dt/2 ({p.check.dt.toExponential(2)} s): relative RMS difference over [0, t]
377 </div>
378 <table>
379 <tbody>
380 {p.check.windows.map(w => (
381 <tr key={w.t}>
382 <td>t = {w.t} s</td>
383 <td>{w.rel.toExponential(2)}</td>
384 </tr>
385 ))}
386 </tbody>
387 </table>
388 </>
389 )}
390 </div>
391 );
394const SYS_DOC = `Required
395 sys.pos n-by-3 bead positions (m)
396 sys.mass scalar or n-by-1 (kg)
397 sys.listen struct: beads (index or indices, averaged), dir (default [0 0 1])
398Elements
399 sys.springs rows [i j k L0] or [i j k L0 k3]; force k*e + k3*e^3, e = length - L0
400 sys.dashpots rows [i j c]; force c*(v_j - v_i) on bead i
401 sys.friction scalar or n-by-1; force -c0*v
ac09b0fDefault damping: every mode falls 60 dB in 2.5 s; examples at 2.5 s; twin separation relative to recent displacement; stronger chaos strikeJeremy Magland 402 (if neither friction nor dashpots is given: every mode falls 60 dB in 2.5 s)
0330c41Damping by decay times: bp_decay helper, per-mode decay-time plot, examples convertedJeremy Magland 403 [sys.friction, sys.dashpots] = bp_decay(pos, mass, sys.springs, [f1 T1; f2 T2])
404 sets both from decay times (T60, s) at two frequencies (Hz)
407 sys.relax move beads to static equilibrium first (default: true when there is a load)
409 sys.hits struct or struct array, each with
410 beads, t (default 0), and either
411 velocity [vx vy vz]: added instantly at t, or
412 force [fx fy fz] + duration: raised-cosine pulse per bead
413Solver (optional)
414 sys.duration seconds (default 2)
415 sys.method 'verlet' (default) or 'yoshida4'
416 sys.safety fraction of the stability limit for automatic dt (default 0.5)
417 sys.dt fixed step (rounded to divide the audio sample interval)
418 sys.sampleRate audio rate (default 48000)
419 sys.frameRate energy/mode/animation records per second (default 500)
420 sys.modes compute linear transverse modes (default true; flat systems)
421Bead indices are 1-based.`;
↑↓ move↵openescclose