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