/ proof-of-concept / bead-percussion
Sign in
proof-of-concept / bead-percussion
bead-percussion / src / App.tsx
367 lines · 13.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";
2import { wavBlob } from "./sim/analysis";
3import { BeadView } from "./ui/BeadView";
4import { Editor } from "./ui/Editor";
5import { Energy, ModeEnergy, Spectrogram, Spectrum, Waveform } from "./ui/Plots";
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);
44 const [playing, setPlaying] = useState<"main" | "linear" | null>(null);
45 const [runId, setRunId] = useState(0); // remounts panels with per-run state
46 const worker = useRef<Worker | null>(null);
47 const audioCtx = useRef<AudioContext | null>(null);
48 const source = useRef<AudioBufferSourceNode | null>(null);
49 const startedAt = useRef(0);
51 useEffect(() => save(script), [script]);
53 const stopAudio = useCallback(() => {
54 source.current?.stop();
55 source.current = null;
56 setCursor(null);
57 setPlaying(null);
58 }, []);
60 const run = useCallback(
61 () => {
62 worker.current?.terminate();
63 stopAudio();
64 const w = new Worker(new URL("./worker.ts", import.meta.url), { type: "module" });
65 worker.current = w;
66 setRunning(true);
67 setError(null);
68 setLog("");
69 setProgress(null);
70 setStage("Starting");
71 w.onmessage = (ev: MessageEvent<FromWorker>) => {
72 const m = ev.data;
73 if (m.type === "log") setLog(l => l + m.text);
74 else if (m.type === "stage") {
75 setStage(m.text);
76 setProgress(null);
77 } else if (m.type === "progress") setProgress(m.t / m.duration);
78 else if (m.type === "error") {
79 setError(m.message);
80 setRunning(false);
81 w.terminate();
82 } else if (m.type === "result") {
83 setPayload(m.payload);
84 setRunId(i => i + 1);
85 const fsHalf = m.payload.result.sampleRate / 2;
86 const top = m.payload.modeFreqs ? m.payload.modeFreqs[m.payload.modeFreqs.length - 1] * 1.15 : 10000;
87 setFmax([1000, 2000, 5000, 10000, 20000].find(f => f >= top && f <= fsHalf) ?? Math.min(20000, fsHalf));
88 setRunning(false);
89 w.terminate();
90 }
91 };
92 w.onerror = e => {
93 setError(e.message || "Worker error");
94 setRunning(false);
95 };
96 const req: RunRequest = { type: "run", script, dtCheck, linear: linearRef };
97 w.postMessage(req);
98 },
99 [script, stopAudio, dtCheck, linearRef]
100 );
102 const stop = () => {
103 worker.current?.terminate();
104 worker.current = null;
105 setRunning(false);
106 setStage("Stopped");
107 };
109 const play = (which: "main" | "linear") => {
110 if (!payload) return;
111 stopAudio();
112 const { sampleRate } = payload.result;
113 const audio = which === "linear" && payload.linear ? payload.linear.audio : payload.result.audio;
114 const ctx = audioCtx.current ?? new AudioContext();
115 audioCtx.current = ctx;
116 // both signals share the main run's gain, so the A/B comparison keeps levels
117 let peak = 0;
118 for (const v of payload.result.audio) peak = Math.max(peak, Math.abs(v));
119 const buf = ctx.createBuffer(1, audio.length, sampleRate);
120 const ch = buf.getChannelData(0);
121 const g = peak > 0 ? 0.9 / peak : 1;
122 for (let i = 0; i < audio.length; i++) ch[i] = Math.max(-1, Math.min(1, g * audio[i]));
123 const src = ctx.createBufferSource();
124 src.buffer = buf;
125 src.connect(ctx.destination);
126 src.onended = () => {
127 if (source.current === src) {
128 source.current = null;
129 setCursor(null);
130 setPlaying(null);
131 }
132 };
133 src.start();
134 source.current = src;
135 setPlaying(which);
136 startedAt.current = ctx.currentTime;
137 const T = audio.length / sampleRate;
138 const tick = () => {
139 if (source.current !== src) return;
140 const t = ctx.currentTime - startedAt.current;
141 setCursor(Math.min(t, T));
142 if (t < T) requestAnimationFrame(tick);
143 };
144 requestAnimationFrame(tick);
145 };
147 const download = () => {
148 if (!payload) return;
149 const url = URL.createObjectURL(wavBlob(payload.result.audio, payload.result.sampleRate));
150 const a = document.createElement("a");
151 a.href = url;
152 a.download = "bead-percussion.wav";
153 a.click();
154 setTimeout(() => URL.revokeObjectURL(url), 1000);
155 };
157 const r = payload?.result;
158 return (
159 <div className="app">
160 <header>
161 <h1>bead-percussion</h1>
162 <span className="sub">beads and springs, set up in numbl, heard through one bead</span>
163 </header>
164 <div className="columns">
165 <section className="left">
166 <div className="controls">
167 <select
168 value=""
169 onChange={e => {
170 const ex = examples.find(x => x.name === e.target.value);
171 if (ex && (script === ex.source || confirm(`Replace the script with the "${ex.name}" example?`))) setScript(ex.source);
172 }}
173 >
174 <option value="" disabled>
175 Load example…
176 </option>
177 {examples.map(ex => (
178 <option key={ex.name} value={ex.name}>
179 {ex.name}
180 </option>
181 ))}
182 </select>
183 <span className="grow" />
184 {running ? (
185 <button onClick={stop}>Stop</button>
186 ) : (
187 <button className="primary" onClick={run} title="Ctrl/Cmd+Enter">
188 Run
189 </button>
190 )}
191 </div>
192 <div className="controls options">
193 <label title="Run again at dt/2 and compare the listener signals">
194 <input type="checkbox" checked={dtCheck} onChange={e => setDtCheck(e.target.checked)} /> dt/2 check
195 </label>
196 <label title="Run again with every hit scaled by 1e-3 (then scaled back up): what the system would do if it were linear">
197 <input type="checkbox" checked={linearRef} onChange={e => setLinearRef(e.target.checked)} /> small-strike reference
198 </label>
199 </div>
200 <Editor value={script} onChange={setScript} onRun={() => !running && run()} />
201 {running && (
202 <div className="status">
203 {stage}
204 {progress !== null && ` ${(100 * progress).toFixed(0)}%`}
205 <div className="bar">
206 <div style={{ width: `${100 * (progress ?? 0)}%` }} />
207 </div>
208 </div>
209 )}
210 {error && <pre className="error">{error}</pre>}
211 {log && <pre className="log">{log}</pre>}
212 {payload && <Report p={payload} />}
213 <details className="helpers">
214 <summary>Helper functions (bp_*.m)</summary>
215 {helpers.map(h => (
216 <div key={h.name}>
217 <h4>{h.name}</h4>
218 <pre>{h.source}</pre>
219 </div>
220 ))}
221 </details>
222 <details className="helpers">
223 <summary>sys fields</summary>
224 <pre>{SYS_DOC}</pre>
225 </details>
226 </section>
227 <section className="right">
228 {!payload && <div className="empty">Run a script to hear and see the result.</div>}
229 {payload && r && (
230 <>
231 <div className="controls">
232 <button className="primary" onClick={() => (playing === "main" ? stopAudio() : play("main"))}>
233 {playing === "main" ? "Stop" : "Play"}
234 </button>
235 {payload.linear && (
236 <button onClick={() => (playing === "linear" ? stopAudio() : play("linear"))} title="The same strike scaled down 1000x, then scaled back up">
237 {playing === "linear" ? "Stop" : "Play small-strike reference"}
238 </button>
239 )}
240 <button onClick={download}>Download WAV</button>
241 <span className="grow" />
242 <label>
243 freq range{" "}
244 <select value={fmax} onChange={e => setFmax(+e.target.value)}>
245 {[1000, 2000, 5000, 10000, 20000]
246 .filter(f => f <= r.sampleRate / 2)
247 .map(f => (
248 <option key={f} value={f}>
249 {f / 1000} kHz
250 </option>
251 ))}
252 </select>
253 </label>
254 </div>
255 <h3>Listener signal</h3>
256 <Waveform p={payload} cursor={cursor ?? viewTime} />
257 <h3>Spectrogram</h3>
258 <Spectrogram p={payload} fmax={fmax} />
259 <h3>Spectrum, two windows (detuning shows as peaks shifting)</h3>
260 <Spectrum key={runId} p={payload} fmax={fmax} />
261 <h3>Energy by linear mode (the cascade)</h3>
262 <ModeEnergy key={runId} p={payload} />
263 <h3>Energy</h3>
264 <Energy p={payload} />
265 <h3>Beads</h3>
266 <BeadView key={runId} p={payload} onTime={setViewTime} />
267 </>
268 )}
269 </section>
270 </div>
271 </div>
272 );
275function Report({ p }: { p: RunPayload }) {
276 const r = p.result;
277 const nPinned = p.sys.pinned.reduce((a, b) => a + b, 0);
278 const rows: [string, string][] = [
279 ["beads", `${p.sys.n} (${nPinned} pinned)`],
280 ["springs, dashpots", `${p.sys.si.length}, ${p.sys.nDashpots}`],
281 ["method", p.sys.method],
282 ["dt", `${r.dt.toExponential(3)} s (${r.substeps} per audio sample)`],
283 ["highest linear frequency", `${(r.omegaMax / (2 * Math.PI)).toFixed(0)} Hz`],
284 ["frequency error", r.freqErrors.map(e => `${e.cents.toFixed(e.cents < 0.1 ? 3 : 2)}¢ at ${e.hz / 1000} kHz`).join(", ")],
285 ["energy drift after excitation", r.energyDrift.toExponential(2)],
286 ["run time", `${(r.elapsedMs / 1000).toFixed(1)} s (${(r.elapsedMs / 1000 / p.sys.duration).toFixed(1)}x real time)`],
287 ];
288 if (p.modeFreqs) rows.push(["lowest modes", Array.from(p.modeFreqs.slice(0, 5)).map(f => f.toFixed(1)).join(", ") + " Hz"]);
289 return (
290 <div className="report">
291 {r.aborted && <div className="error">{r.aborted}</div>}
292 {[...p.notes, ...r.warnings].map((w, i) => (
293 <div key={i} className="warn">
294 {w}
295 </div>
296 ))}
297 <table>
298 <tbody>
299 {rows.map(([k, v]) => (
300 <tr key={k}>
301 <td>{k}</td>
302 <td>{v}</td>
303 </tr>
304 ))}
305 </tbody>
306 </table>
307 {p.linear && (
308 <>
309 <div className="caption">
310 Listener signal vs the small-strike reference (how nonlinear the run is): relative RMS difference over [0, t]
311 </div>
312 <table>
313 <tbody>
314 {p.linear.windows.map(w => (
315 <tr key={w.t}>
316 <td>t = {w.t} s</td>
317 <td>{w.rel.toExponential(2)}</td>
318 </tr>
319 ))}
320 </tbody>
321 </table>
322 </>
323 )}
324 {p.check && (
325 <>
326 <div className="caption">
327 Listener signal, dt vs dt/2 ({p.check.dt.toExponential(2)} s): relative RMS difference over [0, t]
328 </div>
329 <table>
330 <tbody>
331 {p.check.windows.map(w => (
332 <tr key={w.t}>
333 <td>t = {w.t} s</td>
334 <td>{w.rel.toExponential(2)}</td>
335 </tr>
336 ))}
337 </tbody>
338 </table>
339 </>
340 )}
341 </div>
342 );
345const SYS_DOC = `Required
346 sys.pos n-by-3 bead positions (m)
347 sys.mass scalar or n-by-1 (kg)
348 sys.listen struct: beads (index or indices, averaged), dir (default [0 0 1])
349Elements
350 sys.springs rows [i j k L0] or [i j k L0 k3]; force k*e + k3*e^3, e = length - L0
351 sys.dashpots rows [i j c]; force c*(v_j - v_i) on bead i
352 sys.friction scalar or n-by-1; force -c0*v
353 sys.pinned bead indices (or logical mask) held fixed
354Excitation
355 sys.hits struct or struct array, each with
356 beads, t (default 0), and either
357 velocity [vx vy vz]: added instantly at t, or
358 force [fx fy fz] + duration: raised-cosine pulse per bead
359Solver (optional)
360 sys.duration seconds (default 2)
361 sys.method 'verlet' (default) or 'yoshida4'
362 sys.safety fraction of the stability limit for automatic dt (default 0.5)
363 sys.dt fixed step (rounded to divide the audio sample interval)
364 sys.sampleRate audio rate (default 48000)
365 sys.frameRate energy/mode/animation records per second (default 500)
366 sys.modes compute linear transverse modes (default true; flat systems)
367Bead indices are 1-based.`;
↑↓ move↵openescclose