/ proof-of-concept / bead-percussion
Sign in
proof-of-concept / bead-percussion
bead-percussion / src / App.tsx
408 lines · 16.2 KBBlameHistoryRaw
1import { useCallback, useEffect, useRef, useState } from "react";
2import { wavBlob } from "./sim/analysis";
3import { BeadView } from "./ui/BeadView";
4import { Editor } from "./ui/Editor";
5import { DecayTimes, Energy, ModeEnergy, Separation, 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 [twin, setTwin] = useState(false);
45 const [playing, setPlaying] = useState<"main" | "linear" | "twin" | null>(null);
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);
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));
89 setRunning(false);
90 w.terminate();
91 }
92 };
93 w.onerror = e => {
94 setError(e.message || "Worker error");
95 setRunning(false);
96 };
97 const req: RunRequest = { type: "run", script, dtCheck, linear: linearRef, twin };
98 w.postMessage(req);
99 },
100 [script, stopAudio, dtCheck, linearRef, twin]
101 );
103 const stop = () => {
104 worker.current?.terminate();
105 worker.current = null;
106 setRunning(false);
107 setStage("Stopped");
108 };
110 const play = (which: "main" | "linear" | "twin") => {
111 if (!payload) return;
112 stopAudio();
113 const { sampleRate } = payload.result;
114 const audio =
115 which === "linear" && payload.linear
116 ? payload.linear.audio
117 : which === "twin" && payload.twin
118 ? payload.twin.audio
119 : payload.result.audio;
120 const ctx = audioCtx.current ?? new AudioContext();
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>
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>
208 </div>
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 )}
249 {payload.twin && (
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 )}
254 <button onClick={download}>Download WAV</button>
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} />
271 {payload.twin && (
272 <>
273 <h3>Separation from the perturbed twin (chaos test)</h3>
274 <Separation p={payload} />
275 </>
276 )}
277 <h3>Spectrogram</h3>
278 <Spectrogram p={payload} fmax={fmax} />
279 <h3>Spectrum, two windows (detuning shows as peaks shifting)</h3>
280 <Spectrum key={`spectrum-${runId}`} p={payload} fmax={fmax} />
281 <h3>Energy by linear mode (the cascade)</h3>
282 <ModeEnergy key={`modes-${runId}`} p={payload} />
283 {payload.modeFreqs && payload.modeDecay && (
284 <>
285 <h3>Decay time by mode</h3>
286 <DecayTimes p={payload} />
287 </>
288 )}
289 <h3>Energy</h3>
290 <Energy p={payload} />
291 <h3>Beads</h3>
292 <BeadView key={`beads-${runId}`} p={payload} onTime={setViewTime} />
293 </>
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)`],
306 ["springs, bends, dashpots", `${p.sys.si.length}, ${p.sys.nBends}, ${p.sys.nDashpots}`],
307 ["method", p.sys.method],
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>
333 {p.twin && (
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 )}
341 {p.linear && (
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)
382 sys.listen struct: beads (index or indices, averaged), dir (default [0 0 1])
383Elements
384 sys.springs rows [i j k L0] or [i j k L0 k3]; force k*e + k3*e^3, e = length - L0
385 sys.bends rows [i j k kb] or [i j k kb cb], j the middle bead: bending,
386 energy kb/2 |x_i - 2x_j + x_k|^2 (straight, evenly spaced rest);
387 cb damps (v_i - 2v_j + v_k). Triplets come from bp_hexdisk, bp_chain
388 sys.dashpots rows [i j c]; force c*(v_j - v_i) on bead i
389 sys.friction scalar or n-by-1; force -c0*v
390 (if neither friction nor dashpots is given: every mode falls 60 dB in 2.5 s)
391 [sys.friction, sys.dashpots, sys.bends] = bp_decay(pos, mass, sys.springs, [f1 T1; f2 T2], sys.bends)
392 sets them from decay times (T60, s) at two frequencies (Hz)
393 sys.pinned bead indices (or logical mask) held fixed; for a soft hold
394 (cord, felt) use bp_anchor
395Excitation
396 sys.hits struct or struct array, each with
397 beads, t (default 0), and either
398 velocity [vx vy vz]: added instantly at t, or
399 force [fx fy fz] + duration: raised-cosine pulse per bead
400Solver (optional)
401 sys.duration seconds (default 2)
402 sys.method 'verlet' (default) or 'yoshida4'
403 sys.safety fraction of the stability limit for automatic dt (default 0.5)
404 sys.dt fixed step (rounded to divide the audio sample interval)
405 sys.sampleRate audio rate (default 48000)
406 sys.frameRate energy/mode/animation records per second (default 500)
407 sys.modes compute linear transverse modes (default true; flat systems)
408Bead indices are 1-based.`;
↑↓ move↵openescclose