/ proof-of-concept / bead-percussion
Sign in
proof-of-concept / bead-percussion
bead-percussion / src / App.tsx
395 lines · 15.3 KBBlameHistoryRaw
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, 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={runId} p={payload} fmax={fmax} />
281 <h3>Energy by linear mode (the cascade)</h3>
282 <ModeEnergy key={runId} p={payload} />
283 <h3>Energy</h3>
284 <Energy p={payload} />
285 <h3>Beads</h3>
286 <BeadView key={runId} p={payload} onTime={setViewTime} />
287 </>
288 )}
289 </section>
290 </div>
291 </div>
292 );
295function Report({ p }: { p: RunPayload }) {
296 const r = p.result;
297 const nPinned = p.sys.pinned.reduce((a, b) => a + b, 0);
298 const rows: [string, string][] = [
299 ["beads", `${p.sys.n} (${nPinned} pinned)`],
300 ["springs, dashpots", `${p.sys.si.length}, ${p.sys.nDashpots}`],
301 ["method", p.sys.method],
302 ["dt", `${r.dt.toExponential(3)} s (${r.substeps} per audio sample)`],
303 ["highest linear frequency", `${(r.omegaMax / (2 * Math.PI)).toFixed(0)} Hz`],
304 ["frequency error", r.freqErrors.map(e => `${e.cents.toFixed(e.cents < 0.1 ? 3 : 2)}¢ at ${e.hz / 1000} kHz`).join(", ")],
305 ["energy drift after excitation", r.energyDrift.toExponential(2)],
306 ["run time", `${(r.elapsedMs / 1000).toFixed(1)} s (${(r.elapsedMs / 1000 / p.sys.duration).toFixed(1)}x real time)`],
307 ];
308 if (p.modeFreqs) rows.push(["lowest modes", Array.from(p.modeFreqs.slice(0, 5)).map(f => f.toFixed(1)).join(", ") + " Hz"]);
309 return (
310 <div className="report">
311 {r.aborted && <div className="error">{r.aborted}</div>}
312 {[...p.notes, ...r.warnings].map((w, i) => (
313 <div key={i} className="warn">
314 {w}
315 </div>
316 ))}
317 <table>
318 <tbody>
319 {rows.map(([k, v]) => (
320 <tr key={k}>
321 <td>{k}</td>
322 <td>{v}</td>
323 </tr>
324 ))}
325 </tbody>
326 </table>
327 {p.twin && (
328 <div className="caption">
329 Perturbed twin (hits changed by 1 part in 10^9):{" "}
330 {p.twin.rate !== null
331 ? `separation grows exponentially, e-folding time ${(1000 / p.twin.rate).toPrecision(2)} ms (rate ${p.twin.rate.toPrecision(3)} /s). Chaotic.`
332 : `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.`}
333 </div>
334 )}
335 {p.linear && (
336 <>
337 <div className="caption">
338 Listener signal vs the small-strike reference (how nonlinear the run is): relative RMS difference over [0, t]
339 </div>
340 <table>
341 <tbody>
342 {p.linear.windows.map(w => (
343 <tr key={w.t}>
344 <td>t = {w.t} s</td>
345 <td>{w.rel.toExponential(2)}</td>
346 </tr>
347 ))}
348 </tbody>
349 </table>
350 </>
351 )}
352 {p.check && (
353 <>
354 <div className="caption">
355 Listener signal, dt vs dt/2 ({p.check.dt.toExponential(2)} s): relative RMS difference over [0, t]
356 </div>
357 <table>
358 <tbody>
359 {p.check.windows.map(w => (
360 <tr key={w.t}>
361 <td>t = {w.t} s</td>
362 <td>{w.rel.toExponential(2)}</td>
363 </tr>
364 ))}
365 </tbody>
366 </table>
367 </>
368 )}
369 </div>
370 );
373const SYS_DOC = `Required
374 sys.pos n-by-3 bead positions (m)
375 sys.mass scalar or n-by-1 (kg)
376 sys.listen struct: beads (index or indices, averaged), dir (default [0 0 1])
377Elements
378 sys.springs rows [i j k L0] or [i j k L0 k3]; force k*e + k3*e^3, e = length - L0
379 sys.dashpots rows [i j c]; force c*(v_j - v_i) on bead i
380 sys.friction scalar or n-by-1; force -c0*v
381 sys.pinned bead indices (or logical mask) held fixed
382Excitation
383 sys.hits struct or struct array, each with
384 beads, t (default 0), and either
385 velocity [vx vy vz]: added instantly at t, or
386 force [fx fy fz] + duration: raised-cosine pulse per bead
387Solver (optional)
388 sys.duration seconds (default 2)
389 sys.method 'verlet' (default) or 'yoshida4'
390 sys.safety fraction of the stability limit for automatic dt (default 0.5)
391 sys.dt fixed step (rounded to divide the audio sample interval)
392 sys.sampleRate audio rate (default 48000)
393 sys.frameRate energy/mode/animation records per second (default 500)
394 sys.modes compute linear transverse modes (default true; flat systems)
395Bead indices are 1-based.`;
↑↓ move↵openescclose