/ proof-of-concept / bead-percussion
Sign in
proof-of-concept / bead-percussion
bead-percussion / src / App.tsx
455 lines · 18.5 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";
185968eRevert "Dome example: chaos that keeps its pitch..."Jeremy Magland 2import { wavBlob } from "./sim/analysis";
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 }));
15const STORE_KEY = "bead-percussion:script";
17// a boolean remembered in this browser (falls back to the default if storage is unavailable)
18function usePersistent(name: string, initial: boolean): [boolean, (v: boolean) => void] {
19 const key = `bead-percussion:${name}`;
20 const [value, setValue] = useState(() => {
21 try {
22 const s = localStorage.getItem(key);
23 return s === null ? initial : s === "1";
24 } catch {
25 return initial;
26 }
27 });
28 const set = (v: boolean) => {
29 setValue(v);
30 try {
31 localStorage.setItem(key, v ? "1" : "0");
32 } catch {
33 /* storage unavailable */
34 }
35 };
36 return [value, set];
39 try {
40 return localStorage.getItem(STORE_KEY);
41 } catch {
42 return null;
43 }
44};
45const save = (s: string) => {
46 try {
47 localStorage.setItem(STORE_KEY, s);
48 } catch {
49 /* storage unavailable */
50 }
51};
53export default function App() {
54 const [script, setScript] = useState(() => load() ?? examples[0].source);
55 const [running, setRunning] = useState(false);
56 const [stage, setStage] = useState("");
57 const [progress, setProgress] = useState<number | null>(null);
58 const [log, setLog] = useState("");
59 const [error, setError] = useState<string | null>(null);
60 const [payload, setPayload] = useState<RunPayload | null>(null);
61 const [fmax, setFmax] = useState(10000);
62 const [cursor, setCursor] = useState<number | null>(null);
63 const [viewTime, setViewTime] = useState<number | null>(null);
64 const [dtCheck, setDtCheck] = useState(false);
65 const [linearRef, setLinearRef] = useState(false);
68 const [gpuOn, setGpuOn] = usePersistent("gpu", false);
69 const gpuAvailable = typeof navigator !== "undefined" && "gpu" in navigator;
95f2216Minimal chaos example and perturbed-twin chaos test with separation plot and growth-rate fitJeremy Magland 70 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 71 const [runId, setRunId] = useState(0); // remounts panels with per-run state
72 const worker = useRef<Worker | null>(null);
73 const audioCtx = useRef<AudioContext | null>(null);
74 const source = useRef<AudioBufferSourceNode | null>(null);
75 const startedAt = useRef(0);
77 useEffect(() => save(script), [script]);
79 const stopAudio = useCallback(() => {
80 source.current?.stop();
81 source.current = null;
82 setCursor(null);
83 setPlaying(null);
84 }, []);
86 const run = useCallback(
87 () => {
88 worker.current?.terminate();
89 stopAudio();
90 const w = new Worker(new URL("./worker.ts", import.meta.url), { type: "module" });
91 worker.current = w;
92 setRunning(true);
93 setError(null);
94 setLog("");
95 setProgress(null);
96 setStage("Starting");
97 w.onmessage = (ev: MessageEvent<FromWorker>) => {
98 const m = ev.data;
99 if (m.type === "log") setLog(l => l + m.text);
100 else if (m.type === "stage") {
101 setStage(m.text);
102 setProgress(null);
103 } else if (m.type === "progress") setProgress(m.t / m.duration);
104 else if (m.type === "error") {
105 setError(m.message);
106 setRunning(false);
107 w.terminate();
108 } else if (m.type === "result") {
109 setPayload(m.payload);
110 setRunId(i => i + 1);
185968eRevert "Dome example: chaos that keeps its pitch..."Jeremy Magland 111 const fsHalf = m.payload.result.sampleRate / 2;
112 const top = m.payload.modeFreqs ? m.payload.modeFreqs[m.payload.modeFreqs.length - 1] * 1.15 : 10000;
113 setFmax([1000, 2000, 5000, 10000, 20000].find(f => f >= top && f <= fsHalf) ?? Math.min(20000, fsHalf));
115 w.terminate();
116 }
117 };
118 w.onerror = e => {
119 setError(e.message || "Worker error");
120 setRunning(false);
121 };
917e5cdWebGPU toggle: single-precision GPU integration (displacement-based, multi-dispatch phases, frames read back), linear modes behind a toggle (off by default), shared prepare()Jeremy Magland 122 const req: RunRequest = { type: "run", script, dtCheck, linear: linearRef, twin, modes: modesOn, gpu: gpuOn && gpuAvailable };
124 },
128 const stop = () => {
129 worker.current?.terminate();
130 worker.current = null;
131 setRunning(false);
132 setStage("Stopped");
133 };
95f2216Minimal chaos example and perturbed-twin chaos test with separation plot and growth-rate fitJeremy Magland 135 const play = (which: "main" | "linear" | "twin") => {
137 stopAudio();
138 const { sampleRate } = payload.result;
140 which === "linear" && payload.linear
141 ? payload.linear.audio
142 : which === "twin" && payload.twin
143 ? payload.twin.audio
144 : payload.result.audio;
146 audioCtx.current = ctx;
147 // both signals share the main run's gain, so the A/B comparison keeps levels
148 let peak = 0;
149 for (const v of payload.result.audio) peak = Math.max(peak, Math.abs(v));
150 const buf = ctx.createBuffer(1, audio.length, sampleRate);
151 const ch = buf.getChannelData(0);
152 const g = peak > 0 ? 0.9 / peak : 1;
153 for (let i = 0; i < audio.length; i++) ch[i] = Math.max(-1, Math.min(1, g * audio[i]));
154 const src = ctx.createBufferSource();
155 src.buffer = buf;
156 src.connect(ctx.destination);
157 src.onended = () => {
158 if (source.current === src) {
159 source.current = null;
160 setCursor(null);
161 setPlaying(null);
162 }
163 };
164 src.start();
165 source.current = src;
166 setPlaying(which);
167 startedAt.current = ctx.currentTime;
168 const T = audio.length / sampleRate;
169 const tick = () => {
170 if (source.current !== src) return;
171 const t = ctx.currentTime - startedAt.current;
172 setCursor(Math.min(t, T));
173 if (t < T) requestAnimationFrame(tick);
174 };
175 requestAnimationFrame(tick);
176 };
178 const download = () => {
179 if (!payload) return;
180 const url = URL.createObjectURL(wavBlob(payload.result.audio, payload.result.sampleRate));
181 const a = document.createElement("a");
182 a.href = url;
183 a.download = "bead-percussion.wav";
184 a.click();
185 setTimeout(() => URL.revokeObjectURL(url), 1000);
186 };
188 const r = payload?.result;
189 return (
190 <div className="app">
191 <header>
192 <h1>bead-percussion</h1>
193 <span className="sub">beads and springs, set up in numbl, heard through one bead</span>
194 </header>
195 <div className="columns">
196 <section className="left">
197 <div className="controls">
198 <select
199 value=""
200 onChange={e => {
201 const ex = examples.find(x => x.name === e.target.value);
202 if (ex && (script === ex.source || confirm(`Replace the script with the "${ex.name}" example?`))) setScript(ex.source);
203 }}
204 >
205 <option value="" disabled>
206 Load example…
207 </option>
208 {examples.map(ex => (
209 <option key={ex.name} value={ex.name}>
210 {ex.name}
211 </option>
212 ))}
213 </select>
214 <span className="grow" />
215 {running ? (
216 <button onClick={stop}>Stop</button>
217 ) : (
218 <button className="primary" onClick={run} title="Ctrl/Cmd+Enter">
219 Run
220 </button>
221 )}
222 </div>
223 <div className="controls options">
917e5cdWebGPU toggle: single-precision GPU integration (displacement-based, multi-dispatch phases, frames read back), linear modes behind a toggle (off by default), shared prepare()Jeremy Magland 224 <label title="Compute the linear modes (eigenvalues) and track the energy in each; can take seconds for large or curved nets">
225 <input type="checkbox" checked={modesOn} onChange={e => setModesOn(e.target.checked)} /> linear modes
226 </label>
227 <label
228 title={
229 gpuAvailable
230 ? "Integrate on the GPU with WebGPU (single precision). Faster for large nets; the perturbed twin still runs on the CPU."
231 : "WebGPU is not available in this browser"
232 }
233 >
234 <input type="checkbox" disabled={!gpuAvailable} checked={gpuOn && gpuAvailable} onChange={e => setGpuOn(e.target.checked)} /> WebGPU
235 </label>
fc06d37Bead-and-spring percussion simulator: numbl-scripted setup, Verlet/Yoshida solver with exact damping, linear modes, browser appJeremy Magland 236 <label title="Run again at dt/2 and compare the listener signals">
237 <input type="checkbox" checked={dtCheck} onChange={e => setDtCheck(e.target.checked)} /> dt/2 check
238 </label>
239 <label title="Run again with every hit scaled by 1e-3 (then scaled back up): what the system would do if it were linear">
240 <input type="checkbox" checked={linearRef} onChange={e => setLinearRef(e.target.checked)} /> small-strike reference
241 </label>
95f2216Minimal chaos example and perturbed-twin chaos test with separation plot and growth-rate fitJeremy Magland 242 <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)">
243 <input type="checkbox" checked={twin} onChange={e => setTwin(e.target.checked)} /> perturbed twin
244 </label>
246 <Editor value={script} onChange={setScript} onRun={() => !running && run()} />
247 {running && (
248 <div className="status">
249 {stage}
250 {progress !== null && ` ${(100 * progress).toFixed(0)}%`}
251 <div className="bar">
252 <div style={{ width: `${100 * (progress ?? 0)}%` }} />
253 </div>
254 </div>
255 )}
256 {error && <pre className="error">{error}</pre>}
257 {log && <pre className="log">{log}</pre>}
258 {payload && <Report p={payload} />}
259 <details className="helpers">
260 <summary>Helper functions (bp_*.m)</summary>
261 {helpers.map(h => (
262 <div key={h.name}>
263 <h4>{h.name}</h4>
264 <pre>{h.source}</pre>
265 </div>
266 ))}
267 </details>
268 <details className="helpers">
269 <summary>sys fields</summary>
270 <pre>{SYS_DOC}</pre>
271 </details>
272 </section>
273 <section className="right">
274 {!payload && <div className="empty">Run a script to hear and see the result.</div>}
275 {payload && r && (
276 <>
277 <div className="controls">
278 <button className="primary" onClick={() => (playing === "main" ? stopAudio() : play("main"))}>
279 {playing === "main" ? "Stop" : "Play"}
280 </button>
281 {payload.linear && (
282 <button onClick={() => (playing === "linear" ? stopAudio() : play("linear"))} title="The same strike scaled down 1000x, then scaled back up">
283 {playing === "linear" ? "Stop" : "Play small-strike reference"}
284 </button>
285 )}
287 <button onClick={() => (playing === "twin" ? stopAudio() : play("twin"))} title="The run with the strike changed by one part in 1e9">
288 {playing === "twin" ? "Stop" : "Play twin"}
289 </button>
290 )}
292 <span className="grow" />
293 <label>
294 freq range{" "}
295 <select value={fmax} onChange={e => setFmax(+e.target.value)}>
296 {[1000, 2000, 5000, 10000, 20000]
297 .filter(f => f <= r.sampleRate / 2)
298 .map(f => (
299 <option key={f} value={f}>
300 {f / 1000} kHz
301 </option>
302 ))}
303 </select>
304 </label>
305 </div>
306 <h3>Listener signal</h3>
307 <Waveform p={payload} cursor={cursor ?? viewTime} />
309 <>
310 <h3>Separation from the perturbed twin (chaos test)</h3>
311 <Separation p={payload} />
312 </>
313 )}
315 <Spectrogram p={payload} fmax={fmax} />
316 <h3>Spectrum, two windows (detuning shows as peaks shifting)</h3>
85eb848Fix duplicated plots during playback: give per-run panels distinct keysJeremy Magland 317 <Spectrum key={`spectrum-${runId}`} p={payload} fmax={fmax} />
85eb848Fix duplicated plots during playback: give per-run panels distinct keysJeremy Magland 319 <ModeEnergy key={`modes-${runId}`} p={payload} />
0330c41Damping by decay times: bp_decay helper, per-mode decay-time plot, examples convertedJeremy Magland 320 {payload.modeFreqs && payload.modeDecay && (
321 <>
322 <h3>Decay time by mode</h3>
323 <DecayTimes p={payload} />
324 </>
325 )}
327 <Energy p={payload} />
328 <h3>Beads</h3>
85eb848Fix duplicated plots during playback: give per-run panels distinct keysJeremy Magland 329 <BeadView key={`beads-${runId}`} p={payload} onTime={setViewTime} />
331 )}
332 </section>
333 </div>
334 </div>
335 );
338function Report({ p }: { p: RunPayload }) {
339 const r = p.result;
340 const nPinned = p.sys.pinned.reduce((a, b) => a + b, 0);
341 const rows: [string, string][] = [
342 ["beads", `${p.sys.n} (${nPinned} pinned)`],
fc06d37Bead-and-spring percussion simulator: numbl-scripted setup, Verlet/Yoshida solver with exact damping, linear modes, browser appJeremy Magland 345 ["dt", `${r.dt.toExponential(3)} s (${r.substeps} per audio sample)`],
346 ["highest linear frequency", `${(r.omegaMax / (2 * Math.PI)).toFixed(0)} Hz`],
347 ["frequency error", r.freqErrors.map(e => `${e.cents.toFixed(e.cents < 0.1 ? 3 : 2)}¢ at ${e.hz / 1000} kHz`).join(", ")],
348 ["energy drift after excitation", r.energyDrift.toExponential(2)],
349 ["run time", `${(r.elapsedMs / 1000).toFixed(1)} s (${(r.elapsedMs / 1000 / p.sys.duration).toFixed(1)}x real time)`],
350 ];
351 if (p.modeFreqs) rows.push(["lowest modes", Array.from(p.modeFreqs.slice(0, 5)).map(f => f.toFixed(1)).join(", ") + " Hz"]);
352 return (
353 <div className="report">
354 {r.aborted && <div className="error">{r.aborted}</div>}
355 {[...p.notes, ...r.warnings].map((w, i) => (
356 <div key={i} className="warn">
357 {w}
358 </div>
359 ))}
360 <table>
361 <tbody>
362 {rows.map(([k, v]) => (
363 <tr key={k}>
364 <td>{k}</td>
365 <td>{v}</td>
366 </tr>
367 ))}
368 </tbody>
369 </table>
371 <div className="caption">
372 Perturbed twin (hits changed by 1 part in 10^9):{" "}
373 {p.twin.rate !== null
374 ? `separation grows exponentially, e-folding time ${(1000 / p.twin.rate).toPrecision(2)} ms (rate ${p.twin.rate.toPrecision(3)} /s). Chaotic.`
375 : `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.`}
376 </div>
377 )}
379 <>
380 <div className="caption">
381 Listener signal vs the small-strike reference (how nonlinear the run is): relative RMS difference over [0, t]
382 </div>
383 <table>
384 <tbody>
385 {p.linear.windows.map(w => (
386 <tr key={w.t}>
387 <td>t = {w.t} s</td>
388 <td>{w.rel.toExponential(2)}</td>
389 </tr>
390 ))}
391 </tbody>
392 </table>
393 </>
394 )}
395 {p.check && (
396 <>
397 <div className="caption">
398 Listener signal, dt vs dt/2 ({p.check.dt.toExponential(2)} s): relative RMS difference over [0, t]
399 </div>
400 <table>
401 <tbody>
402 {p.check.windows.map(w => (
403 <tr key={w.t}>
404 <td>t = {w.t} s</td>
405 <td>{w.rel.toExponential(2)}</td>
406 </tr>
407 ))}
408 </tbody>
409 </table>
410 </>
411 )}
412 </div>
413 );
416const SYS_DOC = `Required
417 sys.pos n-by-3 bead positions (m)
418 sys.mass scalar or n-by-1 (kg)
420 quantity: 'acceleration' (default), 'velocity' or 'displacement'
421 (sound in air follows acceleration more closely; displacement
422 favors low frequencies by 1/f^2)
424 sys.springs rows [i j k L0] or [i j k L0 k3]; force k*e + k3*e^3, e = length - L0
426 energy kb/2 |x_i - 2x_j + x_k|^2 (straight, evenly spaced rest);
427 cb damps (v_i - 2v_j + v_k). Triplets come from bp_hexdisk, bp_chain
429 shared by triangles (i,j,k) and (l,k,j); energy kh/2 (theta-theta0)^2,
430 theta0 the angle at sys.pos (so any rest shape, e.g. a dome, is
431 unstressed). Rows come from bp_hinges(tri)
432 sys.dashpots rows [i j c] or [i j c ca]; force c*(v_j - v_i) on bead i, plus
433 ca*(d.(v_j - v_i))*d along the direction d between the beads
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 435 (if neither friction nor dashpots is given: every mode falls 60 dB in 2.5 s)
437 damping columns of bends and hinges from decay times (T60, s) at
438 two frequencies (Hz); call it after the elements are set
440 (cord, felt) use bp_anchor
442 sys.hits struct or struct array, each with
443 beads, t (default 0), and either
444 velocity [vx vy vz]: added instantly at t, or
445 force [fx fy fz] + duration: raised-cosine pulse per bead
446Solver (optional)
447 sys.duration seconds (default 2)
448 sys.method 'verlet' (default) or 'yoshida4'
449 sys.safety fraction of the stability limit for automatic dt (default 0.5)
450 sys.dt fixed step (rounded to divide the audio sample interval)
451 sys.sampleRate audio rate (default 48000)
452 sys.frameRate energy/mode/animation records per second (default 500)
454 "linear modes" toggle (off by default)
↑↓ move↵openescclose