Embeddable <toy-instrument-sim> widget (src/embed.ts): 3D model, replay and audio from the shared cache, or run in the browser and stored with the passcode; cache results as light and frames entries (CACHE_VERSION 2); instrument configs and link settings shared by the page and the widget
19 changed files+830−302
.gitignoremodified+1−0View file
@@ -1,2 +1,3 @@
11 node_modules
22 dist
3+embed-dist
README.mdmodified+19−3View file
@@ -61,7 +61,7 @@ Both solvers integrate the same scheme (`src/sim/solver.ts` on the CPU in double
6161 Runs are cached on [artifact-cache](https://artifact-cache.tempory.net) (app `toy-instruments`, `src/sim/cache.ts`). Reads are public, so anyone opening the site gets a result someone has already computed; writes need the app's passcode, entered once per browser under "Shared cache" in the panel.
6262
6363 - Key: `v<CACHE_VERSION>/<instrument>/<cpu|gpu>/<SHA-256>`, hashed over everything the solver receives (bead system, excitation, options), not the UI parameters. A model change that alters the built system therefore misses old entries on its own; bump `CACHE_VERSION` when the solver's output changes for the same inputs. CPU and WebGPU results are cached separately; a WebGPU run that fell back to the CPU is stored as a CPU result.
64-- Value: the whole `SimResult` (audio, replay frames, energy, contact force, scalars) in a small binary format, so a cached result shows everything a fresh run does. The default cymbal is about 35 MB; results over the 100 MB entry limit are not stored.
64+- Value: the whole `SimResult` in a small binary format, so a cached result shows everything a fresh run does, split into two entries: `<key>/light` (audio, energy, contact force, scalars; a few hundred kB) and `<key>/frames` (the replay frames; the default cymbal's are about 35 MB). The frames are stored first, so a light entry always has its frames. Results whose frames are over the 100 MB entry limit are not stored. Version 1 kept both in one entry.
6565 - As the settings (or the WebGPU toggle) change, the page checks whether the cache has a result for them (a HEAD request, debounced) and, if so, shows its date and size with a Load cached button next to Run. Run always simulates; when the passcode is set, every completed run is stored, replacing any existing entry.
6666 - Because the key hashes floating-point inputs, a browser whose `Math` functions round differently in the last bit (possible in Safari) builds a different key and misses entries computed elsewhere. It never gets a wrong result.
6767
@@ -69,9 +69,25 @@ Runs are cached on [artifact-cache](https://artifact-cache.tempory.net) (app `to
6969
7070 "Copy link to these settings" (under the preset) copies `…/#/<instrument>?<param>=<value>&…&preset=<index>&gpu=<0|1>`: every parameter in SI units, the preset the settings started from (its description is shown) and the WebGPU toggle (CPU and WebGPU results are cached separately). Opening the link applies the settings and then removes them from the address bar (`history.replaceState`), so reloading or sharing the plain address shows the page as usual; the WebGPU setting from a link is not saved as this browser's preference. Parameters missing from a link, invalid or not among a select's options keep the values of the link's preset, and unknown ones are ignored, so links survive added or removed parameters. When the shared cache has a result for the linked settings, the usual Load cached button appears.
7171
72+## Embedding
73+
74+`src/embed.ts` defines a web component for other pages (posts on jeremy.magland.org): one simulation, with the 3D model and its replay and the sound.
75+
76+```html
77+<toy-instrument-sim name="crash" settings="https://magland.github.io/toy-instruments/#/bead-cymbal?…&preset=0&gpu=1"></toy-instrument-sim>
78+```
79+
80+- `settings` is a link from "Copy link to these settings" (or its `#/<instrument>?…` part); `name` names the downloaded `.wav`.
81+- The widget builds the instrument from the settings and shows the setup at once. It then looks in the shared cache, for the link's backend first and then the other: if there is a result, it loads the light entry (the audio player appears) and offers to load the motion (the frames, for the replay). If there is none, or the cache cannot be reached, it offers to run the simulation in the browser, with a rough time estimate. With the cache passcode saved in that browser (under "Shared cache" in the widget), the run is stored under the same key.
82+- The key hashes what the bundle's own model code builds, so a page that keeps one build of the bundle asks for, and can always refill, the same entries, whatever later versions of the app do. jeremy.magland.org uploads each build under a content hash and never replaces it.
83+- It takes the host page's colors and font from jeremy.magland.org's CSS variables (`--bg`, `--text`, `--link`, ...), with this app's colors otherwise, and redraws when the page changes theme.
84+- Build: `npm run build:embed` writes `embed-dist/toy-instruments-embed.js` (one ES module, about 1 MB, 230 kB gzipped), with three.js, the styles and the solver worker inlined; a worker must come from the page's origin, so it starts from a blob URL. The `prepare` script runs the same build, so the package can be installed as a git dependency (`import 'toy-instruments/embed'`); `embed-dist/` is not committed.
85+- `embed.html` (dev server only, `npm run dev` then `/embed.html`) shows two widgets on a page styled like jeremy.magland.org, with a theme toggle. `/embed.html?cache=local` sends the cache's requests to a local artifact-cache (`wrangler dev` on port 8797), to test storing without the real passcode.
86+
7287 ## Layout
7388
7489 - `src/sim/` generic 3D bead-spring system, CPU solver (`solver.ts`), WebGPU solver (`gpu.ts`), worker.
75-- `src/instruments/<id>/` model (parameters to system), presets, page. Register new instruments in `src/instruments/registry.ts`.
76-- `src/ui/instrumentPage.ts` the shared page (controls, 3D model, setup, run, audio, plots); an instrument supplies setup rows and extra cards.
90+- `src/instruments/<id>/` model (parameters to system), presets, `config.ts` (controls, setup summary, built system, extra views). `src/instruments/configs.ts` lists the configs by id; `src/instruments/registry.ts` lists the gallery. Register new instruments in both.
91+- `src/ui/instrumentPage.ts` the shared page (controls, 3D model, setup, run, audio, plots); an instrument's config supplies setup rows and extra cards. `src/ui/settings.ts` reads and writes the settings of shared links.
92+- `src/embed.ts` the embeddable widget (see Embedding).
7793 - `src/ui/` reusable controls form, line plot, spectrogram, chain displacement viewer, 3D scene, shared playback timeline, FFT, WAV.
embed.htmladded+30−0View file
@@ -0,0 +1,30 @@
1+<!doctype html>
2+<html lang="en">
3+ <head>
4+ <meta charset="UTF-8" />
5+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6+ <title>Embed test · Toy instruments</title>
7+ <!-- Dev server only (npm run dev, then /embed.html): <toy-instrument-sim> on a page
8+ styled like jeremy.magland.org, with a light and a dark theme. -->
9+ <style>
10+ :root { --bg: #fff; --bg-code: #f5f5f5; --text: #1a1a1a; --text-muted: #666; --link: #1a5276; --border: #e0e0e0; color-scheme: light; }
11+ :root[data-theme='dark'] { --bg: #14161a; --bg-code: #1f2329; --text: #d7dade; --text-muted: #9199a3; --link: #7cb8ff; --border: #2b3037; color-scheme: dark; }
12+ body { background: var(--bg); color: var(--text); font: 17px/1.65 Georgia, serif; max-width: 42rem; margin: 2rem auto; padding: 0 1.25rem; }
13+ </style>
14+ <script>
15+ // ?cache=local: send the shared cache's requests to a local artifact-cache (wrangler dev on port 8797)
16+ if (new URLSearchParams(location.search).get('cache') === 'local') {
17+ const fetch0 = window.fetch;
18+ window.fetch = (url, init) => fetch0(String(url).replace('https://artifact-cache.tempory.net', 'http://localhost:8797'), init);
19+ }
20+ </script>
21+ <script type="module" src="/src/embed.ts"></script>
22+ </head>
23+ <body>
24+ <p><button onclick="document.documentElement.dataset.theme = document.documentElement.dataset.theme === 'dark' ? 'light' : 'dark'">Toggle theme</button></p>
25+ <p>A string of beads (default preset):</p>
26+ <toy-instrument-sim name="string" settings="#/bead-string?preset=0&gpu=0"></toy-instrument-sim>
27+ <p>The default cymbal:</p>
28+ <toy-instrument-sim name="cymbal" settings="#/bead-cymbal?preset=0&gpu=1"></toy-instrument-sim>
29+ </body>
30+</html>
package.jsonmodified+8−0View file
@@ -3,9 +3,17 @@
33 "private": true,
44 "version": "0.1.0",
55 "type": "module",
6+ "files": [
7+ "embed-dist"
8+ ],
9+ "exports": {
10+ "./embed": "./embed-dist/toy-instruments-embed.js"
11+ },
612 "scripts": {
713 "dev": "vite",
814 "build": "tsc --noEmit && vite build",
15+ "build:embed": "vite build -c vite.embed.config.ts",
16+ "prepare": "vite build -c vite.embed.config.ts",
917 "preview": "vite preview",
1018 "test": "vitest run",
1119 "typecheck": "tsc --noEmit"
src/embed.cssadded+50−0View file
@@ -0,0 +1,50 @@
1+/* Styles of the embedded widget (src/embed.ts), on top of style.css. The widget takes the
2+ host page's colors and font when it defines jeremy.magland.org's variables (--bg, --text,
3+ --text-muted, --border, --link, --bg-code, --font-ui), with the app's colors otherwise.
4+ Names the two share (--border, --text-muted) go through --tis-* on the host first, since a
5+ property cannot refer to itself. */
6+
7+:host {
8+ display: block;
9+ margin: 1.5em 0;
10+ --tis-bg: var(--bg, #fcfcfb);
11+ --tis-code: var(--bg-code, #f4f3f0);
12+ --tis-text: var(--text, #0b0b0b);
13+ --tis-muted: var(--text-muted, #77756f);
14+ --tis-border: var(--border, #e2e0da);
15+ --tis-link: var(--link, #2a78d6);
16+}
17+.tis {
18+ --surface-1: var(--tis-bg);
19+ --page: var(--tis-code);
20+ --text-primary: var(--tis-text);
21+ --text-secondary: var(--tis-muted);
22+ --text-muted: var(--tis-muted);
23+ --border: var(--tis-border);
24+ --accent: var(--tis-link);
25+ --accent-ink: var(--tis-bg);
26+ --series-1: #2a78d6;
27+ --series-2: #eb6834;
28+ --series-3: #1baf7a;
29+ font: 14px/1.45 var(--font-ui, system-ui, -apple-system, 'Segoe UI', sans-serif);
30+ color: var(--text-primary);
31+ background: var(--surface-1);
32+ border: 1px solid var(--border);
33+ border-radius: 10px;
34+ padding: 12px;
35+}
36+.tis.dark {
37+ --series-1: #3987e5;
38+ --series-2: #d95926;
39+ --series-3: #199e70;
40+}
41+.scene-wrap { height: 340px; }
42+@media (max-width: 600px) { .scene-wrap { height: 260px; } }
43+.status { margin: 10px 0 0; color: var(--text-secondary); font-size: 13px; }
44+.actions { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; margin-top: 10px; }
45+.actions:empty { display: none; }
46+.audio-row { margin-top: 10px; }
47+.error { color: #b42d2c; font-size: 13px; margin: 8px 0 0; }
48+.tis.dark .error { color: #e66767; }
49+.cache-ctl { font-size: 12px; margin-top: 10px; }
50+.cache-ctl input { width: auto; flex: 1; }
src/embed.tsadded+304−0View file
@@ -0,0 +1,304 @@
1+import baseCss from './style.css?inline';
2+import embedCss from './embed.css?inline';
3+import SolverWorker from './sim/worker.ts?worker&inline';
4+import { CONFIGS, type AnyConfig } from './instruments/configs';
5+import { BeadScene } from './ui/scene3d';
6+import { Timeline } from './ui/timeline';
7+import { encodeWav } from './ui/wav';
8+import { fmtNum } from './ui/plot';
9+import { CPU_RATE, parseSettings, runCost, simOptions } from './ui/settings';
10+import { runInWorker, type Running } from './sim/run';
11+import { checkPasscode, getPasscode, loadFrames, loadLight, probeFrames, resultKey, setPasscode, storeResult, type Backend, type LightResult } from './sim/cache';
12+import type { Built, SimParams } from './ui/instrumentPage';
13+import type { SimResult } from './sim/types';
14+
15+// <toy-instrument-sim settings="…">: one simulation of a toy instrument, for embedding in
16+// other pages (blog posts). settings is a link from "Copy link to these settings" (or just
17+// its #/<instrument>?… part). The widget shows the 3D model, loads the result from the
18+// shared cache (audio at once, the replay frames on request) and, when there is none, offers
19+// to run the simulation in the browser; with the cache passcode set, the run is stored
20+// under the same key, so the next reader gets it from the cache.
21+//
22+// The key hashes what this bundle's model code builds from the settings, so a page that
23+// keeps this exact bundle (jeremy.magland.org freezes each upload) always asks for, and
24+// fills, the same entry, however the app changes later.
25+
26+interface Found {
27+ key: string;
28+ backend: Backend;
29+ light: LightResult;
30+ stored?: Date;
31+}
32+
33+class ToyInstrumentSim extends HTMLElement {
34+ private root!: ShadowRoot;
35+ private running: Running | null = null;
36+ private abort: AbortController | null = null;
37+ private audioUrl: string | null = null;
38+ private cleanup: (() => void)[] = [];
39+
40+ connectedCallback() {
41+ if (this.shadowRoot) return;
42+ this.root = this.attachShadow({ mode: 'open' });
43+ this.root.innerHTML = `
44+ <style>${baseCss}${embedCss}</style>
45+ <div class="tis">
46+ <div data-r="scene"></div>
47+ <p class="status" data-r="status">Looking for a stored result…</p>
48+ <div class="audio-row" data-r="audio-row" hidden><audio controls data-r="audio"></audio><a class="btn-small" data-r="download">Download .wav</a></div>
49+ <div class="actions">
50+ <button class="btn-small" data-r="motion" hidden></button>
51+ <button class="btn" data-r="run" hidden></button>
52+ <button class="btn-small" data-r="cancel" hidden>Cancel</button>
53+ </div>
54+ <progress data-r="progress" max="1" value="0" hidden></progress>
55+ <p class="error" data-r="error" hidden></p>
56+ <details class="cache-ctl">
57+ <summary data-r="cache-state"></summary>
58+ <p class="note">Results come from a shared cache. With its passcode, simulations run here are stored there, for everyone who opens this page.</p>
59+ <div class="cache-form"><input type="password" data-r="cache-pass" placeholder="Passcode" autocomplete="off"><button class="btn-small" data-r="cache-save">Save</button><button class="btn-small" data-r="cache-forget">Forget</button></div>
60+ <p class="note" data-r="cache-msg"></p>
61+ </details>
62+ </div>`;
63+ this.mountPasscode();
64+ this.start().catch((e) => this.fail(`Something went wrong: ${(e as Error).message}`));
65+ }
66+
67+ disconnectedCallback() {
68+ this.running?.cancel();
69+ this.abort?.abort();
70+ for (const f of this.cleanup) f();
71+ this.cleanup = [];
72+ }
73+
74+ private q<T extends HTMLElement>(r: string): T {
75+ return this.root.querySelector(`[data-r="${r}"]`) as T;
76+ }
77+
78+ private status(text: string) {
79+ this.q('status').textContent = text;
80+ }
81+
82+ private fail(text: string) {
83+ const box = this.q('error');
84+ box.textContent = text;
85+ box.hidden = false;
86+ }
87+
88+ private async start() {
89+ const spec = this.getAttribute('settings') ?? '';
90+ const hash = spec.includes('#') ? spec.slice(spec.indexOf('#') + 1) : spec;
91+ const [route, query = ''] = hash.split('?');
92+ const id = route.replace(/^\//, '');
93+ const cfg: AnyConfig | undefined = CONFIGS[id];
94+ if (!cfg) {
95+ this.status('');
96+ return this.fail(`Unknown instrument "${id}" in the settings attribute.`);
97+ }
98+ const settings = parseSettings(cfg, query);
99+ const p: SimParams = settings.params;
100+ const built = cfg.setup(p).built;
101+ if (!built) {
102+ this.status('');
103+ return this.fail('These settings do not give a valid instrument.');
104+ }
105+
106+ const timeline = new Timeline();
107+ const scene = new BeadScene(this.q('scene'), timeline);
108+ scene.showSetup(built.scene);
109+ this.followTheme(scene);
110+
111+ const hasGpu = 'gpu' in navigator;
112+ const wanted: Backend = (settings.gpu ?? cfg.webgpuDefault !== false) ? 'gpu' : 'cpu';
113+ const opt = simOptions(p, built);
114+ const keys = {
115+ cpu: await resultKey(id, built.sys, built.exc, opt, 'cpu'),
116+ gpu: await resultKey(id, built.sys, built.exc, opt, 'gpu'),
117+ };
118+ const order: Backend[] = wanted === 'gpu' ? ['gpu', 'cpu'] : ['cpu', 'gpu'];
119+
120+ // the result for the settings' backend, or else for the other one
121+ let found: Found | null = null;
122+ let unreachable = false;
123+ for (const backend of order) {
124+ try {
125+ const hit = await loadLight(keys[backend]);
126+ if (hit) {
127+ found = { key: keys[backend], backend, ...hit };
128+ break;
129+ }
130+ } catch {
131+ unreachable = true;
132+ }
133+ }
134+ if (found) return this.showFound(found, p, built, scene, timeline);
135+
136+ // nothing stored: offer to run it here
137+ const gpu = wanted === 'gpu' && hasGpu;
138+ const cpuSeconds = runCost(p, built).work / CPU_RATE;
139+ this.status(
140+ `${unreachable ? 'The shared cache could not be reached' : 'No stored result for these settings yet'}. ` +
141+ `You can run the simulation in this browser: roughly ${duration(cpuSeconds)} on a desktop CPU` +
142+ (gpu ? ', usually much less with WebGPU.' : wanted === 'gpu' ? ' (this browser has no WebGPU, so it runs on the CPU).' : '.'),
143+ );
144+ const runBtn = this.q<HTMLButtonElement>('run');
145+ runBtn.textContent = `Run simulation${gpu ? ' (WebGPU)' : ''}`;
146+ runBtn.hidden = false;
147+ runBtn.addEventListener('click', () => this.run(id, p, built, gpu, scene, timeline));
148+ }
149+
150+ private async showFound(f: Found, p: SimParams, built: Built, scene: BeadScene, timeline: Timeline) {
151+ const when = f.stored ? ` on ${f.stored.toISOString().slice(0, 10)}` : '';
152+ this.status(`Computed${when} with ${f.light.backend ?? (f.backend === 'gpu' ? 'WebGPU' : 'CPU')} in ${duration(f.light.wallSeconds)}.`);
153+ this.setAudio(f.light.audio, f.light.fs, p);
154+ const motion = this.q<HTMLButtonElement>('motion');
155+ let bytes: number | null = null;
156+ try {
157+ bytes = await probeFrames(f.key);
158+ } catch {
159+ /* shown as missing below */
160+ }
161+ if (bytes === null) return;
162+ motion.textContent = `Load the motion (${fmtNum(bytes / 1e6)} MB)`;
163+ motion.hidden = false;
164+ motion.addEventListener('click', async () => {
165+ motion.disabled = true;
166+ const progress = this.q<HTMLProgressElement>('progress');
167+ progress.hidden = false;
168+ progress.value = 0;
169+ this.abort = new AbortController();
170+ try {
171+ const frames = await loadFrames(f.key, (x) => (progress.value = x), this.abort.signal);
172+ if (!frames) throw new Error('they are no longer in the cache');
173+ motion.hidden = true;
174+ this.showReplay(scene, timeline, built, { ...f.light, frames });
175+ } catch (e) {
176+ if (this.abort.signal.aborted) return;
177+ motion.disabled = false;
178+ this.fail(`Could not load the motion: ${(e as Error).message}`);
179+ } finally {
180+ progress.hidden = true;
181+ }
182+ });
183+ }
184+
185+ private async run(id: string, p: SimParams, built: Built, gpu: boolean, scene: BeadScene, timeline: Timeline) {
186+ const runBtn = this.q<HTMLButtonElement>('run');
187+ const cancelBtn = this.q<HTMLButtonElement>('cancel');
188+ const progress = this.q<HTMLProgressElement>('progress');
189+ const opt = simOptions(p, built);
190+ runBtn.disabled = true;
191+ cancelBtn.hidden = progress.hidden = false;
192+ progress.value = 0;
193+ this.q('error').hidden = true;
194+ this.status('Simulating…');
195+ const running = runInWorker(
196+ () => new SolverWorker(),
197+ built.sys,
198+ built.exc,
199+ opt,
200+ gpu,
201+ (x) => (progress.value = x),
202+ (r) => this.status(`Simulating… ${fmtNum(r.audio.length / r.fs)} s of ${fmtNum(p.duration)} s done.`),
203+ );
204+ this.running = running;
205+ cancelBtn.onclick = () => running.cancel();
206+ try {
207+ const r = await running.promise;
208+ runBtn.hidden = true;
209+ this.setAudio(r.audio, r.fs, p);
210+ this.showReplay(scene, timeline, built, r);
211+ const ran = `Simulated in this browser with ${r.backend ?? 'CPU'}${r.fallback ? ` (WebGPU failed: ${r.fallback})` : ''} in ${duration(r.wallSeconds)}.`;
212+ if (!getPasscode()) return this.status(`${ran} Not stored: this browser has no cache passcode.`);
213+ this.status(`${ran} Storing in the shared cache…`);
214+ // a WebGPU run that fell back to the CPU is stored as a CPU result
215+ const backend: Backend = r.backend?.startsWith('WebGPU') ? 'gpu' : 'cpu';
216+ const key = await resultKey(id, built.sys, built.exc, opt, backend);
217+ try {
218+ await storeResult(key, r, { instrument: id, backend: r.backend, wallSeconds: r.wallSeconds, from: location.href });
219+ this.status(`${ran} Stored in the shared cache.`);
220+ } catch (e) {
221+ this.status(`${ran} Not stored in the shared cache: ${(e as Error).message}`);
222+ }
223+ } catch (e) {
224+ runBtn.disabled = false;
225+ if ((e as Error).message === 'cancelled') this.status('The run was cancelled.');
226+ else this.fail(`The simulation failed: ${(e as Error).message}`);
227+ } finally {
228+ this.running = null;
229+ cancelBtn.hidden = progress.hidden = true;
230+ }
231+ }
232+
233+ private showReplay(scene: BeadScene, timeline: Timeline, built: Built, r: SimResult) {
234+ timeline.setTimes(r.frameTimes);
235+ scene.showReplay({ ...built.scene, frames: r.frames, malletPos: r.malletPos });
236+ }
237+
238+ private setAudio(audio: Float32Array, fs: number, p: SimParams) {
239+ if (this.audioUrl) URL.revokeObjectURL(this.audioUrl);
240+ this.audioUrl = URL.createObjectURL(encodeWav(audio, fs));
241+ this.q<HTMLAudioElement>('audio').src = this.audioUrl;
242+ const a = this.q<HTMLAnchorElement>('download');
243+ a.href = this.audioUrl;
244+ a.download = `${this.getAttribute('name') ?? 'toy-instrument'}.wav`;
245+ a.title = `16-bit mono, ${fs} Hz, ${fmtNum(p.duration)} s, normalized so the peak is −1 dBFS`;
246+ this.q('audio-row').hidden = false;
247+ }
248+
249+ // Dark or light series colors from the host page's color-scheme, and a redraw when the
250+ // page switches theme (jeremy.magland.org sets data-theme on <html>).
251+ private followTheme(scene: BeadScene) {
252+ const box = this.root.querySelector('.tis')!;
253+ const media = window.matchMedia('(prefers-color-scheme: dark)');
254+ const apply = () => {
255+ const scheme = getComputedStyle(this).colorScheme;
256+ const dark = scheme.includes('dark') && (!scheme.includes('light') || media.matches);
257+ box.classList.toggle('dark', dark);
258+ scene.restyle();
259+ };
260+ apply();
261+ const obs = new MutationObserver(apply);
262+ obs.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme', 'class', 'style'] });
263+ media.addEventListener('change', apply);
264+ this.cleanup.push(() => obs.disconnect(), () => media.removeEventListener('change', apply));
265+ }
266+
267+ private mountPasscode() {
268+ const input = this.q<HTMLInputElement>('cache-pass');
269+ const msg = this.q('cache-msg');
270+ const show = () => (this.q('cache-state').textContent = getPasscode() ? 'Shared cache: runs here are stored' : 'Shared cache');
271+ show();
272+ this.q('cache-save').addEventListener('click', async () => {
273+ const pass = input.value.trim();
274+ if (!pass) return;
275+ msg.textContent = 'Checking…';
276+ try {
277+ const { valid, reason } = await checkPasscode(pass);
278+ if (!valid) {
279+ msg.textContent = `Not saved: ${reason ?? 'invalid passcode'}.`;
280+ return;
281+ }
282+ setPasscode(pass);
283+ input.value = '';
284+ msg.textContent = 'Saved in this browser.';
285+ } catch (e) {
286+ msg.textContent = `Could not check the passcode: ${(e as Error).message}`;
287+ }
288+ show();
289+ });
290+ this.q('cache-forget').addEventListener('click', () => {
291+ setPasscode(null);
292+ msg.textContent = 'Passcode removed from this browser.';
293+ show();
294+ });
295+ }
296+}
297+
298+function duration(s: number): string {
299+ if (s < 90) return `${fmtNum(Math.max(1, Math.round(s)))} s`;
300+ if (s < 5400) return `${fmtNum(Math.round(s / 60))} min`;
301+ return `${fmtNum(Math.round((s / 3600) * 10) / 10)} h`;
302+}
303+
304+if (!customElements.get('toy-instrument-sim')) customElements.define('toy-instrument-sim', ToyInstrumentSim);
src/instruments/bead-cymbal/page.ts →src/instruments/bead-cymbal/config.tsrenamed+74−76View file
@@ -1,7 +1,7 @@
11 import type { Section } from '../../ui/controls';
22 import { fmtNum } from '../../ui/plot';
33 import { MembraneView } from '../../ui/membraneview';
4-import { mountInstrumentPage, type Built } from '../../ui/instrumentPage';
4+import type { Built, InstrumentConfig } from '../../ui/instrumentPage';
55 import {
66 bendingLimitHz,
77 buildCymbal,
@@ -97,85 +97,83 @@ const SECTIONS: Section<CymbalParams>[] = [
9797 },
9898 ];
9999
100-export function mountBeadCymbal(root: HTMLElement) {
101- const build = (p: CymbalParams): Built | null => {
102- const d = deriveCymbal(p);
103- if (d.n < 3 || !(p.thickness > 0)) return null;
104- const sys = buildCymbal(d);
105- const exc = buildCymbalExcitation(p, d);
106- const mic = buildMic(p, d);
107- return {
100+const build = (p: CymbalParams): Built | null => {
101+ const d = deriveCymbal(p);
102+ if (d.n < 3 || !(p.thickness > 0)) return null;
103+ const sys = buildCymbal(d);
104+ const exc = buildCymbalExcitation(p, d);
105+ const mic = buildMic(p, d);
106+ return {
107+ sys,
108+ exc,
109+ pickup: mic.weights,
110+ pickupDelay: mic.delay,
111+ scene: {
108112 sys,
109113 exc,
114+ excKind: p.excitation,
110115 pickup: mic.weights,
111- pickupDelay: mic.delay,
112- scene: {
113- sys,
114- exc,
115- excKind: p.excitation,
116- pickup: mic.weights,
117- pickupLabel: 'microphone',
118- note: 'free plate: nothing holds it, rigid motion removed',
119- mic: {
120- dir: micDirection(p),
121- from: [0, profileHeight(p, 0), 0],
122- label: `microphone direction (${fmtNum(p.micDistance)} m away, ${fmtNum(p.micElevation)}° up)`,
123- },
116+ pickupLabel: 'microphone',
117+ note: 'free plate: nothing holds it, rigid motion removed',
118+ mic: {
119+ dir: micDirection(p),
120+ from: [0, profileHeight(p, 0), 0],
121+ label: `microphone direction (${fmtNum(p.micDistance)} m away, ${fmtNum(p.micElevation)}° up)`,
124122 },
125- };
123+ },
126124 };
125+};
127126
128- mountInstrumentPage<CymbalParams>(root, {
129- title: 'Cymbal',
130- wavName: 'bead-cymbal.wav',
131- audioQuantity: { label: 'pressure at the microphone', unit: 'Pa' },
132- presets: CYMBAL_PRESETS,
133- sections: SECTIONS,
134- setup(p) {
135- const d = deriveCymbal(p);
136- const R = p.diameter / 2;
137- const rows: [string, string][] = [
138- ['Beads', `${d.n}`],
139- ['Support', 'none: a free plate with its rigid-body motion removed'],
140- ['Lattice spacing', `${fmtNum(d.mesh.a * 1000)} mm`],
141- ['Mass', `${fmtNum(d.mass.reduce((s, m) => s + m, 0) * 1000)} g`],
142- ['Bending stiffness D', `${fmtNum(d.D)} N·m`],
143- ['In-plane wave speed', `${fmtNum(d.waveSpeedP)} m/s${p.soften > 1 ? ` (softened ${fmtNum(p.soften)}×)` : ''}`],
144- ['Free flat plate (2,0) mode', `${fmtNum((5.253 / (2 * Math.PI * R * R)) * d.bendingCoef)} Hz (ideal)`],
145- ['Bending resolved to', `~${fmtNum(bendingLimitHz(d))} Hz`],
146- ];
147- const warnings = [...d.warnings];
148- const fmax = bendingLimitHz(d);
149- if (fmax < 3000)
150- warnings.push(
151- `Bending waves are only resolved up to about ${fmtNum(fmax)} Hz: above that the lattice is too coarse and the sound is not cymbal-like. More rings help, at a steep cost.`,
127+export const CYMBAL_CONFIG: InstrumentConfig<CymbalParams> = {
128+ title: 'Cymbal',
129+ wavName: 'bead-cymbal.wav',
130+ audioQuantity: { label: 'pressure at the microphone', unit: 'Pa' },
131+ presets: CYMBAL_PRESETS,
132+ sections: SECTIONS,
133+ setup(p) {
134+ const d = deriveCymbal(p);
135+ const R = p.diameter / 2;
136+ const rows: [string, string][] = [
137+ ['Beads', `${d.n}`],
138+ ['Support', 'none: a free plate with its rigid-body motion removed'],
139+ ['Lattice spacing', `${fmtNum(d.mesh.a * 1000)} mm`],
140+ ['Mass', `${fmtNum(d.mass.reduce((s, m) => s + m, 0) * 1000)} g`],
141+ ['Bending stiffness D', `${fmtNum(d.D)} N·m`],
142+ ['In-plane wave speed', `${fmtNum(d.waveSpeedP)} m/s${p.soften > 1 ? ` (softened ${fmtNum(p.soften)}×)` : ''}`],
143+ ['Free flat plate (2,0) mode', `${fmtNum((5.253 / (2 * Math.PI * R * R)) * d.bendingCoef)} Hz (ideal)`],
144+ ['Bending resolved to', `~${fmtNum(bendingLimitHz(d))} Hz`],
145+ ];
146+ const warnings = [...d.warnings];
147+ const fmax = bendingLimitHz(d);
148+ if (fmax < 3000)
149+ warnings.push(
150+ `Bending waves are only resolved up to about ${fmtNum(fmax)} Hz: above that the lattice is too coarse and the sound is not cymbal-like. More rings help, at a steep cost.`,
151+ );
152+ if (Math.abs(p.micElevation) > 80) warnings.push('The microphone is almost straight above: modes with nodal diameters cancel there.');
153+ if (p.hitAngle >= 85) warnings.push('A hit angle near 90° barely touches the surface.');
154+ return { rows, warnings, built: build(p) };
155+ },
156+ summaryRows: (_p, _b, r) => [['Largest strain in the plate', `${fmtNum(r.maxStretch * 100)}%`]],
157+ extraCards: '<section class="card" data-r="out" hidden><h2>Cymbal (top view and cross-section)</h2><div data-r="membrane"></div></section>',
158+ mountExtras(q, timeline) {
159+ const view = new MembraneView(q('membrane'), timeline);
160+ return {
161+ show(p, _built, r) {
162+ const d = deriveCymbal(p);
163+ view.set(
164+ {
165+ pos: d.mesh.pos,
166+ radius: p.diameter / 2,
167+ spacing: d.mesh.a,
168+ strike: hitPoint(p),
169+ pickup: null,
170+ pickupNote:
171+ 'Sound: far-field microphone (Rayleigh integral: each bead’s normal acceleration × area, delayed by its distance; assumes a baffled plate, so sound bending around the rim is ignored).',
172+ sectionAngle: p.excR > 0.01 ? p.excAngle : 0,
173+ },
174+ r.frames,
152175 );
153- if (Math.abs(p.micElevation) > 80) warnings.push('The microphone is almost straight above: modes with nodal diameters cancel there.');
154- if (p.hitAngle >= 85) warnings.push('A hit angle near 90° barely touches the surface.');
155- return { rows, warnings, built: build(p) };
156- },
157- summaryRows: (_p, _b, r) => [['Largest strain in the plate', `${fmtNum(r.maxStretch * 100)}%`]],
158- extraCards: '<section class="card" data-r="out" hidden><h2>Cymbal (top view and cross-section)</h2><div data-r="membrane"></div></section>',
159- mountExtras(q, timeline) {
160- const view = new MembraneView(q('membrane'), timeline);
161- return {
162- show(p, _built, r) {
163- const d = deriveCymbal(p);
164- view.set(
165- {
166- pos: d.mesh.pos,
167- radius: p.diameter / 2,
168- spacing: d.mesh.a,
169- strike: hitPoint(p),
170- pickup: null,
171- pickupNote:
172- 'Sound: far-field microphone (Rayleigh integral: each bead’s normal acceleration × area, delayed by its distance; assumes a baffled plate, so sound bending around the rim is ignored).',
173- sectionAngle: p.excR > 0.01 ? p.excAngle : 0,
174- },
175- r.frames,
176- );
177- },
178- };
179- },
180- });
181-}
176+ },
177+ };
178+ },
179+};
src/instruments/bead-drum/page.ts →src/instruments/bead-drum/config.tsrenamed+61−63View file
@@ -1,7 +1,7 @@
11 import type { Section } from '../../ui/controls';
22 import { fmtNum } from '../../ui/plot';
33 import { MembraneView } from '../../ui/membraneview';
4-import { mountInstrumentPage, type Built } from '../../ui/instrumentPage';
4+import type { Built, InstrumentConfig } from '../../ui/instrumentPage';
55 import {
66 buildDrum,
77 buildDrumExcitation,
@@ -87,70 +87,68 @@ const SECTIONS: Section<DrumParams>[] = [
8787 },
8888 ];
8989
90-export function mountBeadDrum(root: HTMLElement) {
91- const build = (p: DrumParams): Built | null => {
92- const d = deriveDrum(p);
93- if (!(d.restLength > 0) || d.n < 1) return null;
94- const sys = buildDrum(d);
95- const exc = buildDrumExcitation(p, d);
96- const pickup = buildDrumPickup(p, d);
97- return {
90+const build = (p: DrumParams): Built | null => {
91+ const d = deriveDrum(p);
92+ if (!(d.restLength > 0) || d.n < 1) return null;
93+ const sys = buildDrum(d);
94+ const exc = buildDrumExcitation(p, d);
95+ const pickup = buildDrumPickup(p, d);
96+ return {
97+ sys,
98+ exc,
99+ pickup,
100+ scene: {
98101 sys,
99102 exc,
103+ excKind: p.excitation,
100104 pickup,
101- scene: {
102- sys,
103- exc,
104- excKind: p.excitation,
105- pickup,
106- pickupLabel: `${p.pickupMode === 'sum' ? 'sum over all beads' : 'one bead'}, ${p.pickupAxis} acceleration`,
107- hoop: { radius: p.diameter / 2 },
108- },
109- };
105+ pickupLabel: `${p.pickupMode === 'sum' ? 'sum over all beads' : 'one bead'}, ${p.pickupAxis} acceleration`,
106+ hoop: { radius: p.diameter / 2 },
107+ },
110108 };
109+};
111110
112- mountInstrumentPage<DrumParams>(root, {
113- title: 'Bead drum',
114- wavName: 'bead-drum.wav',
115- presets: DRUM_PRESETS,
116- sections: SECTIONS,
117- setup(p) {
118- const d = deriveDrum(p);
119- const rows: [string, string][] = [
120- ['Beads', `${d.n} (rim points ${d.lattice.rim.length})`],
121- ['Lattice spacing', `${fmtNum(d.lattice.a * 1000)} mm`],
122- ['Bead mass', `${fmtNum(d.beadMass * 1000)} g`],
123- ['Spring stiffness, tension', `${fmtNum(d.springK)} N/m, ${fmtNum(d.springTension)} N`],
124- ['Rest length L0', `${fmtNum(d.restLength * 1000)} mm (pre-stretch ${fmtNum(d.prestretch * 100)}%)`],
125- ['Wave speed (transverse, in-plane)', `${fmtNum(d.waveSpeedT)}, ${fmtNum(d.waveSpeedP)} m/s`],
126- ['Ideal membrane fundamental', `${fmtNum(d.idealF01)} Hz`],
127- ];
128- const warnings = [...d.warnings];
129- if (p.excR >= 0.97) warnings.push('The excitation point is at the rim, where the head barely moves.');
130- if (d.n > 3000) warnings.push(`${d.n} beads: expect a slow run.`);
131- return { rows, warnings, built: build(p) };
132- },
133- summaryRows: (p, _b, r) => [['Largest spring strain', `${fmtNum(r.maxStretch * 100)}% (pre-stretch ${fmtNum(deriveDrum(p).prestretch * 100)}%)`]],
134- extraCards: `
135- <section class="card" data-r="out" hidden><h2>Drumhead (top view and cross-section)</h2><div data-r="membrane"></div></section>`,
136- mountExtras(q, timeline) {
137- const view = new MembraneView(q('membrane'), timeline);
138- return {
139- show(p, _built, r) {
140- const d = deriveDrum(p);
141- view.set(
142- {
143- pos: d.lattice.pos,
144- radius: p.diameter / 2,
145- spacing: d.lattice.a,
146- strike: excitationPoint(p),
147- pickup: p.pickupMode === 'bead' ? nearestBead(d, (p.diameter / 2) * p.pickupR * Math.cos((p.pickupAngle * Math.PI) / 180), (p.diameter / 2) * p.pickupR * Math.sin((p.pickupAngle * Math.PI) / 180)) : null,
148- sectionAngle: p.excR > 0.01 ? p.excAngle : 0,
149- },
150- r.frames,
151- );
152- },
153- };
154- },
155- });
156-}
111+export const DRUM_CONFIG: InstrumentConfig<DrumParams> = {
112+ title: 'Bead drum',
113+ wavName: 'bead-drum.wav',
114+ presets: DRUM_PRESETS,
115+ sections: SECTIONS,
116+ setup(p) {
117+ const d = deriveDrum(p);
118+ const rows: [string, string][] = [
119+ ['Beads', `${d.n} (rim points ${d.lattice.rim.length})`],
120+ ['Lattice spacing', `${fmtNum(d.lattice.a * 1000)} mm`],
121+ ['Bead mass', `${fmtNum(d.beadMass * 1000)} g`],
122+ ['Spring stiffness, tension', `${fmtNum(d.springK)} N/m, ${fmtNum(d.springTension)} N`],
123+ ['Rest length L0', `${fmtNum(d.restLength * 1000)} mm (pre-stretch ${fmtNum(d.prestretch * 100)}%)`],
124+ ['Wave speed (transverse, in-plane)', `${fmtNum(d.waveSpeedT)}, ${fmtNum(d.waveSpeedP)} m/s`],
125+ ['Ideal membrane fundamental', `${fmtNum(d.idealF01)} Hz`],
126+ ];
127+ const warnings = [...d.warnings];
128+ if (p.excR >= 0.97) warnings.push('The excitation point is at the rim, where the head barely moves.');
129+ if (d.n > 3000) warnings.push(`${d.n} beads: expect a slow run.`);
130+ return { rows, warnings, built: build(p) };
131+ },
132+ summaryRows: (p, _b, r) => [['Largest spring strain', `${fmtNum(r.maxStretch * 100)}% (pre-stretch ${fmtNum(deriveDrum(p).prestretch * 100)}%)`]],
133+ extraCards: `
134+ <section class="card" data-r="out" hidden><h2>Drumhead (top view and cross-section)</h2><div data-r="membrane"></div></section>`,
135+ mountExtras(q, timeline) {
136+ const view = new MembraneView(q('membrane'), timeline);
137+ return {
138+ show(p, _built, r) {
139+ const d = deriveDrum(p);
140+ view.set(
141+ {
142+ pos: d.lattice.pos,
143+ radius: p.diameter / 2,
144+ spacing: d.lattice.a,
145+ strike: excitationPoint(p),
146+ pickup: p.pickupMode === 'bead' ? nearestBead(d, (p.diameter / 2) * p.pickupR * Math.cos((p.pickupAngle * Math.PI) / 180), (p.diameter / 2) * p.pickupR * Math.sin((p.pickupAngle * Math.PI) / 180)) : null,
147+ sectionAngle: p.excR > 0.01 ? p.excAngle : 0,
148+ },
149+ r.frames,
150+ );
151+ },
152+ };
153+ },
154+};
src/instruments/bead-string/page.ts →src/instruments/bead-string/config.tsrenamed+78−80View file
@@ -1,7 +1,7 @@
11 import type { Section } from '../../ui/controls';
22 import { css, fmtNum } from '../../ui/plot';
33 import { ChainView } from '../../ui/chainview';
4-import { mountInstrumentPage, type Built } from '../../ui/instrumentPage';
4+import type { Built, InstrumentConfig } from '../../ui/instrumentPage';
55 import { buildExcitation, buildPickup, buildSystem, derive, parsePins, type StringParams } from './model';
66 import { PRESETS } from './presets';
77
@@ -86,87 +86,85 @@ const SECTIONS: Section<StringParams>[] = [
8686 },
8787 ];
8888
89-export function mountBeadString(root: HTMLElement) {
90- const build = (p: StringParams): Built | null => {
91- const d = derive(p);
92- if (!(d.restLength > 0) || !(p.n >= 1)) return null;
93- const sys = buildSystem(p, d);
94- const exc = buildExcitation(p);
95- const pickup = buildPickup(p);
96- return {
89+const build = (p: StringParams): Built | null => {
90+ const d = derive(p);
91+ if (!(d.restLength > 0) || !(p.n >= 1)) return null;
92+ const sys = buildSystem(p, d);
93+ const exc = buildExcitation(p);
94+ const pickup = buildPickup(p);
95+ return {
96+ sys,
97+ exc,
98+ pickup,
99+ scene: {
97100 sys,
98101 exc,
102+ excKind: p.excitation,
99103 pickup,
100- scene: {
101- sys,
102- exc,
103- excKind: p.excitation,
104- pickup,
105- pickupLabel: `${p.pickupMode === 'sum' ? 'sum over all beads' : `bead ${Math.round(p.pickupBead)}`}, ${p.pickupAxis} acceleration`,
106- },
107- };
104+ pickupLabel: `${p.pickupMode === 'sum' ? 'sum over all beads' : `bead ${Math.round(p.pickupBead)}`}, ${p.pickupAxis} acceleration`,
105+ },
108106 };
107+};
109108
110- mountInstrumentPage<StringParams>(root, {
111- title: 'String of beads',
112- wavName: 'bead-string.wav',
113- webgpuDefault: false, // a few dozen beads: the CPU is faster
114- presets: PRESETS,
115- sections: SECTIONS,
116- setup(p) {
117- const d = derive(p);
118- const tr = d.transverseHz;
119- const rows: [string, string][] = [
120- ['Length (wall to wall)', `${fmtNum(d.length)} m`],
121- ['Rest length L0', `${fmtNum(d.restLength * 1000)} mm (pre-stretch ${fmtNum(d.prestretch * 100)}%)`],
122- ['Linear density', `${fmtNum(d.linearDensity * 1000)} g/m`],
123- ['Wave speed (transverse, longitudinal)', `${fmtNum(d.waveSpeedT)}, ${fmtNum(d.waveSpeedL)} m/s`],
124- ['Lowest transverse mode', tr.length ? `${fmtNum(tr[0])} Hz` : 'none'],
125- ['Overtone ratios', tr.slice(1, 7).map((f) => fmtNum(f / tr[0])).join(', ') || '–'],
126- ['Lowest longitudinal mode', d.longitudinalHz.length ? `${fmtNum(d.longitudinalHz[0])} Hz` : 'none'],
127- ];
128- const warnings = [...d.warnings];
129- const pins = parsePins(p.pins, p.n);
130- if (pins.includes(Math.round(p.excBead) - 1)) warnings.push('The excited bead is pinned.');
131- if (p.pickupMode === 'bead' && pins.includes(Math.round(p.pickupBead) - 1)) warnings.push('The pickup bead is pinned: its acceleration is zero.');
132- if (tr.length && tr[0] < 20) warnings.push(`The lowest mode (${fmtNum(tr[0])} Hz) is below hearing; you will mostly hear overtones.`);
133- return { rows, warnings, built: build(p) };
134- },
135- summaryRows: (p, _b, r) => [['Largest spring strain', `${fmtNum(r.maxStretch * 100)}% (pre-stretch ${fmtNum(derive(p).prestretch * 100)}%)`]],
136- spectrumMarkers(p) {
137- const d = derive(p);
138- return {
139- markers: [
140- ...d.transverseHz.map((f, i) => ({ x: f, color: css('--series-1'), label: `transverse mode ${i + 1}: ${fmtNum(f)} Hz` })),
141- ...d.longitudinalHz.map((f, i) => ({ x: f, color: css('--series-2'), label: `longitudinal mode ${i + 1}: ${fmtNum(f)} Hz` })),
142- ],
143- legend:
144- '<span><i class="dash" style="border-color:var(--series-1)"></i>linear transverse modes</span>' +
145- '<span><i class="dash" style="border-color:var(--series-2)"></i>linear longitudinal modes</span>',
146- };
147- },
148- extraCards: '<section class="card" data-r="out" hidden><h2>Displacements (2D)</h2><div data-r="viewer"></div></section>',
149- mountExtras(q, timeline) {
150- const viewer = new ChainView(q('viewer'), timeline);
151- return {
152- show(p, built, r) {
153- const { sys } = built;
154- const rest = new Float64Array(p.n);
155- for (let i = 0; i < p.n; i++) rest[i] = sys.rest[3 * i];
156- viewer.set(
157- {
158- n: p.n,
159- restX: rest,
160- leftWall: p.leftEnd === 'free' ? null : 0,
161- rightWall: p.rightEnd === 'free' ? null : (p.n + 1) * p.spacing,
162- slideLeft: p.leftEnd === 'sliding',
163- slideRight: p.rightEnd === 'sliding',
164- pinned: sys.pinned,
165- },
166- { frames: r.frames, times: r.frameTimes },
167- );
168- },
169- };
170- },
171- });
172-}
109+export const STRING_CONFIG: InstrumentConfig<StringParams> = {
110+ title: 'String of beads',
111+ wavName: 'bead-string.wav',
112+ webgpuDefault: false, // a few dozen beads: the CPU is faster
113+ presets: PRESETS,
114+ sections: SECTIONS,
115+ setup(p) {
116+ const d = derive(p);
117+ const tr = d.transverseHz;
118+ const rows: [string, string][] = [
119+ ['Length (wall to wall)', `${fmtNum(d.length)} m`],
120+ ['Rest length L0', `${fmtNum(d.restLength * 1000)} mm (pre-stretch ${fmtNum(d.prestretch * 100)}%)`],
121+ ['Linear density', `${fmtNum(d.linearDensity * 1000)} g/m`],
122+ ['Wave speed (transverse, longitudinal)', `${fmtNum(d.waveSpeedT)}, ${fmtNum(d.waveSpeedL)} m/s`],
123+ ['Lowest transverse mode', tr.length ? `${fmtNum(tr[0])} Hz` : 'none'],
124+ ['Overtone ratios', tr.slice(1, 7).map((f) => fmtNum(f / tr[0])).join(', ') || '–'],
125+ ['Lowest longitudinal mode', d.longitudinalHz.length ? `${fmtNum(d.longitudinalHz[0])} Hz` : 'none'],
126+ ];
127+ const warnings = [...d.warnings];
128+ const pins = parsePins(p.pins, p.n);
129+ if (pins.includes(Math.round(p.excBead) - 1)) warnings.push('The excited bead is pinned.');
130+ if (p.pickupMode === 'bead' && pins.includes(Math.round(p.pickupBead) - 1)) warnings.push('The pickup bead is pinned: its acceleration is zero.');
131+ if (tr.length && tr[0] < 20) warnings.push(`The lowest mode (${fmtNum(tr[0])} Hz) is below hearing; you will mostly hear overtones.`);
132+ return { rows, warnings, built: build(p) };
133+ },
134+ summaryRows: (p, _b, r) => [['Largest spring strain', `${fmtNum(r.maxStretch * 100)}% (pre-stretch ${fmtNum(derive(p).prestretch * 100)}%)`]],
135+ spectrumMarkers(p) {
136+ const d = derive(p);
137+ return {
138+ markers: [
139+ ...d.transverseHz.map((f, i) => ({ x: f, color: css('--series-1'), label: `transverse mode ${i + 1}: ${fmtNum(f)} Hz` })),
140+ ...d.longitudinalHz.map((f, i) => ({ x: f, color: css('--series-2'), label: `longitudinal mode ${i + 1}: ${fmtNum(f)} Hz` })),
141+ ],
142+ legend:
143+ '<span><i class="dash" style="border-color:var(--series-1)"></i>linear transverse modes</span>' +
144+ '<span><i class="dash" style="border-color:var(--series-2)"></i>linear longitudinal modes</span>',
145+ };
146+ },
147+ extraCards: '<section class="card" data-r="out" hidden><h2>Displacements (2D)</h2><div data-r="viewer"></div></section>',
148+ mountExtras(q, timeline) {
149+ const viewer = new ChainView(q('viewer'), timeline);
150+ return {
151+ show(p, built, r) {
152+ const { sys } = built;
153+ const rest = new Float64Array(p.n);
154+ for (let i = 0; i < p.n; i++) rest[i] = sys.rest[3 * i];
155+ viewer.set(
156+ {
157+ n: p.n,
158+ restX: rest,
159+ leftWall: p.leftEnd === 'free' ? null : 0,
160+ rightWall: p.rightEnd === 'free' ? null : (p.n + 1) * p.spacing,
161+ slideLeft: p.leftEnd === 'sliding',
162+ slideRight: p.rightEnd === 'sliding',
163+ pinned: sys.pinned,
164+ },
165+ { frames: r.frames, times: r.frameTimes },
166+ );
167+ },
168+ };
169+ },
170+};
src/instruments/configs.tsadded+16−0View file
@@ -0,0 +1,16 @@
1+import type { InstrumentConfig } from '../ui/instrumentPage';
2+import { STRING_CONFIG } from './bead-string/config';
3+import { DRUM_CONFIG } from './bead-drum/config';
4+import { CYMBAL_CONFIG } from './bead-cymbal/config';
5+
6+// Every built instrument's configuration, by id (the instrument page and the
7+// embedded widget both start from these).
8+
9+export type AnyConfig = InstrumentConfig<any>;
10+
11+export const CONFIGS: Record<string, AnyConfig> = {
12+ 'bead-string': STRING_CONFIG,
13+ 'bead-drum': DRUM_CONFIG,
14+ 'bead-cymbal': CYMBAL_CONFIG,
15+};
16+
src/instruments/registry.tsmodified+7−6View file
@@ -1,6 +1,7 @@
1-import { mountBeadString } from './bead-string/page';
2-import { mountBeadDrum } from './bead-drum/page';
3-import { mountBeadCymbal } from './bead-cymbal/page';
1+import { mountInstrumentPage } from '../ui/instrumentPage';
2+import { CONFIGS } from './configs';
3+
4+const mount = (id: string) => (root: HTMLElement) => mountInstrumentPage(root, CONFIGS[id]);
45
56 export interface Instrument {
67 id: string;
@@ -14,19 +15,19 @@ export const INSTRUMENTS: Instrument[] = [
1415 id: 'bead-string',
1516 name: 'String of beads',
1617 blurb: 'Point masses joined by pre-tensioned springs, moving in 3D. Pluck, strike or hammer it; clamp, slide or free the ends; pin beads.',
17- mount: mountBeadString,
18+ mount: mount('bead-string'),
1819 },
1920 {
2021 id: 'bead-drum',
2122 name: 'Bead drum',
2223 blurb: 'A round drumhead: a tensioned hex net of beads in a clamped rim. Hit it with a felt mallet, strike, push or press it.',
23- mount: mountBeadDrum,
24+ mount: mount('bead-drum'),
2425 },
2526 {
2627 id: 'bead-cymbal',
2728 name: 'Cymbal',
2829 blurb: 'A thin bronze plate on felts at its centre, flat or with bow and bell. Hit it with a stick or mallet, at any angle; listen through a microphone.',
29- mount: mountBeadCymbal,
30+ mount: mount('bead-cymbal'),
3031 },
3132 { id: 'bead-bar', name: 'Bar', blurb: 'A stiff chain with bending springs, free ends: xylophone-like overtones.' },
3233 ];
src/sim/cache.tsmodified+68−21View file
@@ -2,11 +2,14 @@ import type { BeadSystem, Excitation, SimOptions, SimResult } from './types';
22
33 // Shared cache of simulation results on artifact-cache (https://artifact-cache.tempory.net,
44 // app "toy-instruments"). Reads are public; writes need the app's passcode, kept in
5-// localStorage. Keys: v<CACHE_VERSION>/<instrument>/<cpu|gpu>/<SHA-256 of sys, exc, opt>.
6-// The hash covers everything the solver sees, so model changes miss old entries by
7-// themselves; bump CACHE_VERSION when the solver's output changes for the same inputs.
8-
9-export const CACHE_VERSION = 1;
5+// localStorage. A result's key is v<CACHE_VERSION>/<instrument>/<cpu|gpu>/<SHA-256 of
6+// sys, exc, opt>, stored as two entries: <key>/light (audio, energy, contact force and
7+// scalars: everything but the replay frames, small) and <key>/frames (the replay frames,
8+// usually most of the size). The hash covers everything the solver sees, so model
9+// changes miss old entries by themselves; bump CACHE_VERSION when the solver's output
10+// changes for the same inputs.
11+
12+export const CACHE_VERSION = 2;
1013 const BASE = 'https://artifact-cache.tempory.net/api/apps/toy-instruments';
1114 const PASS_KEY = 'toy-instruments.cache-passcode';
1215 export const MAX_ENTRY_BYTES = 100 * 1024 * 1024;
@@ -46,15 +49,15 @@ function canon(v: unknown, parts: Uint8Array[]): string {
4649 return `{${keys.map((k) => `${JSON.stringify(k)}:${canon(o[k], parts)}`).join(',')}}`;
4750 }
4851
49-// Binary layout: "TIR1", uint32 header length, header JSON, then each array's bytes,
50-// every section starting on an 8-byte boundary.
52+// Binary layout of an entry: "TIR1", uint32 header length, header JSON (scalars and
53+// the list of arrays), then each array's bytes, every section starting on an 8-byte boundary.
5154 const MAGIC = 'TIR1';
5255 const ARRAYS = { Float32Array, Float64Array };
5356 type ArrayType = keyof typeof ARRAYS;
5457
5558 const pad8 = (n: number) => (n + 7) & ~7;
5659
57-export function encodeResult(r: SimResult): Uint8Array<ArrayBuffer> {
60+export function encodeRecord(r: object): Uint8Array<ArrayBuffer> {
5861 const scalars: Record<string, unknown> = {};
5962 const arrays: { name: string; type: ArrayType; length: number }[] = [];
6063 const data: Uint8Array[] = [];
@@ -79,7 +82,7 @@ export function encodeResult(r: SimResult): Uint8Array<ArrayBuffer> {
7982 return out;
8083 }
8184
82-export function decodeResult(buf: ArrayBuffer): SimResult {
85+export function decodeRecord<T>(buf: ArrayBuffer): T {
8386 if (new TextDecoder().decode(new Uint8Array(buf, 0, 4)) !== MAGIC) throw new Error('not a cached result');
8487 const hl = new DataView(buf).getUint32(4, true);
8588 const { scalars, arrays } = JSON.parse(new TextDecoder().decode(new Uint8Array(buf, 8, hl)));
@@ -90,26 +93,63 @@ export function decodeResult(buf: ArrayBuffer): SimResult {
9093 r[a.name] = new A(buf, o, a.length);
9194 o += pad8(a.length * A.BYTES_PER_ELEMENT);
9295 }
93- return r as unknown as SimResult;
96+ return r as T;
9497 }
9598
96-export interface CacheHit {
97- result: SimResult;
98- stored?: Date;
99+// A result without its replay frames.
100+export type LightResult = Omit<SimResult, 'frames'>;
101+
102+export function splitResult(r: SimResult): { light: LightResult; frames: Float32Array } {
103+ const { frames, ...light } = r;
104+ return { light, frames };
99105 }
100106
101-// Whether a result is cached (HEAD, no download): its size and when it was stored, or null.
107+// Whether a result is cached (HEAD on its light entry, no download): the light entry's
108+// size and when it was stored, or null.
102109 export async function probeResult(key: string, signal?: AbortSignal): Promise<{ bytes: number; stored?: Date } | null> {
103- const res = await fetch(`${BASE}/entries/${key}`, { method: 'HEAD', signal });
110+ return probe(`${key}/light`, signal);
111+}
112+
113+// Size of a cached result's replay frames, or null if they are not cached.
114+export async function probeFrames(key: string, signal?: AbortSignal): Promise<number | null> {
115+ return (await probe(`${key}/frames`, signal))?.bytes ?? null;
116+}
117+
118+async function probe(entry: string, signal?: AbortSignal): Promise<{ bytes: number; stored?: Date } | null> {
119+ const res = await fetch(`${BASE}/entries/${entry}`, { method: 'HEAD', signal });
104120 if (res.status === 404) return null;
105121 if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
106122 const lm = res.headers.get('last-modified');
107123 return { bytes: Number(res.headers.get('content-length')) || 0, stored: lm ? new Date(lm) : undefined };
108124 }
109125
110-// Fetch a cached result, or null if there is none. onProgress gets the fraction downloaded.
126+// A cached result's light entry, or null if there is none.
127+export async function loadLight(key: string, signal?: AbortSignal): Promise<{ light: LightResult; stored?: Date } | null> {
128+ const got = await download(`${key}/light`, () => {}, signal);
129+ return got && { light: decodeRecord<LightResult>(got.buf), stored: got.stored };
130+}
131+
132+// A cached result's replay frames, or null if there are none. onProgress gets the fraction downloaded.
133+export async function loadFrames(key: string, onProgress: (f: number) => void, signal?: AbortSignal): Promise<Float32Array | null> {
134+ const got = await download(`${key}/frames`, onProgress, signal);
135+ return got && decodeRecord<{ frames: Float32Array }>(got.buf).frames;
136+}
137+
138+export interface CacheHit {
139+ result: SimResult;
140+ stored?: Date;
141+}
142+
143+// A whole cached result (light entry, then frames), or null if either is missing.
111144 export async function loadResult(key: string, onProgress: (f: number) => void, signal?: AbortSignal): Promise<CacheHit | null> {
112- const res = await fetch(`${BASE}/entries/${key}`, { signal });
145+ const hit = await loadLight(key, signal);
146+ if (!hit) return null;
147+ const frames = await loadFrames(key, onProgress, signal);
148+ return frames && { result: { ...hit.light, frames }, stored: hit.stored };
149+}
150+
151+async function download(entry: string, onProgress: (f: number) => void, signal?: AbortSignal): Promise<{ buf: ArrayBuffer; stored?: Date } | null> {
152+ const res = await fetch(`${BASE}/entries/${entry}`, { signal });
113153 if (res.status === 404) return null;
114154 if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
115155 // Content-Length is used only for progress (it is the compressed size if the response is encoded)
@@ -135,15 +175,22 @@ export async function loadResult(key: string, onProgress: (f: number) => void, s
135175 buf = bytes.buffer;
136176 } else buf = await res.arrayBuffer();
137177 const lm = res.headers.get('last-modified');
138- return { result: decodeResult(buf), stored: lm ? new Date(lm) : undefined };
178+ return { buf, stored: lm ? new Date(lm) : undefined };
139179 }
140180
181+// Store a result: the frames first, so a light entry (what readers look for) always has its frames.
141182 export async function storeResult(key: string, r: SimResult, meta: Record<string, unknown>): Promise<void> {
142183 const pass = getPasscode();
143184 if (!pass) throw new Error('no passcode');
144- const body = encodeResult(r);
145- if (body.length > MAX_ENTRY_BYTES) throw new Error(`result is ${Math.round(body.length / 1e6)} MB, over the 100 MB limit`);
146- const res = await fetch(`${BASE}/entries/${key}`, {
185+ const { light, frames } = splitResult(r);
186+ const framesBody = encodeRecord({ frames });
187+ if (framesBody.length > MAX_ENTRY_BYTES) throw new Error(`the replay frames are ${Math.round(framesBody.length / 1e6)} MB, over the 100 MB limit`);
188+ await put(`${key}/frames`, framesBody, meta, pass);
189+ await put(`${key}/light`, encodeRecord(light), meta, pass);
190+}
191+
192+async function put(entry: string, body: Uint8Array<ArrayBuffer>, meta: Record<string, unknown>, pass: string) {
193+ const res = await fetch(`${BASE}/entries/${entry}`, {
147194 method: 'PUT',
148195 headers: { 'X-Passcode': pass, 'Content-Type': 'application/octet-stream', 'X-Artifact-Meta': encodeURIComponent(JSON.stringify(meta)) },
149196 body,
src/sim/run.tsmodified+6−1View file
@@ -5,8 +5,13 @@ export interface Running {
55 cancel: () => void;
66 }
77
8+// Starts a solver worker (src/sim/worker.ts). The page loads it from its own file; the
9+// embedded widget, which runs on other sites, has it inlined (a worker must be same-origin).
10+export type MakeWorker = () => Worker;
11+
812 // onPartial gets the result so far every few seconds while the run is in progress
913 export function runInWorker(
14+ makeWorker: MakeWorker,
1015 sys: BeadSystem,
1116 exc: Excitation,
1217 opt: SimOptions,
@@ -14,7 +19,7 @@ export function runInWorker(
1419 onProgress: (f: number) => void,
1520 onPartial?: (r: SimResult) => void,
1621 ): Running {
17- const worker = new Worker(new URL('./worker.ts', import.meta.url), { type: 'module' });
22+ const worker = makeWorker();
1823 let reject!: (e: Error) => void;
1924 const promise = new Promise<SimResult>((res, rej) => {
2025 reject = rej;
src/ui/instrumentPage.tsmodified+12−41View file
@@ -5,8 +5,8 @@ import { spectrum } from './fft';
55 import { encodeWav } from './wav';
66 import { BeadScene, type SceneSpec } from './scene3d';
77 import { Timeline } from './timeline';
8-import { timeStep } from '../sim/solver';
98 import { runInWorker } from '../sim/run';
9+import { CPU_RATE, parseSettings, runCost, settingsQuery, simOptions, type Settings } from './settings';
1010 import { checkPasscode, getPasscode, loadResult, probeResult, resultKey, setPasscode, storeResult, type Backend } from '../sim/cache';
1111 import type { BeadSystem, Excitation, SimResult } from '../sim/types';
1212
@@ -212,16 +212,13 @@ export function mountInstrumentPage<P extends SimParams>(root: HTMLElement, cfg:
212212 const { rows, warnings, built } = cfg.setup(p);
213213 if (built) {
214214 scene.showSetup(built.scene);
215- const { omegaMax: wmax, stepsPerSample: spp } = timeStep(built.sys, built.exc.mallet, p.fs, p.cfl);
215+ const { omegaMax: wmax, stepsPerSample: spp, work } = runCost(p, built);
216216 rows.push(['Highest frequency', `${fmtNum(wmax / (2 * Math.PI))} Hz`]);
217217 rows.push(['Time step', `${fmtNum(1 / (p.fs * spp))} s (${spp} per sample)`]);
218- // a bending hinge costs about four springs
219- const elements = built.sys.si.length + 4 * (built.sys.bending?.w0.length ?? 0);
220- const work = spp * p.fs * p.duration * elements;
221218 rows.push(['Element updates', fmtNum(work)]);
222219 const frameMB = (Math.round(p.duration * p.frameRate) * 3 * built.sys.n * 4) / 1e6;
223220 if (frameMB > 400) warnings.push(`Replay frames would take ${fmtNum(frameMB)} MB; lower the replay frame rate or the duration.`);
224- if (work > 3e9) warnings.push(`This run is large (${fmtNum(work)} element updates): expect roughly ${fmtNum(work / 2.2e7 / 60)} min on a desktop CPU (WebGPU is usually much faster).`);
221+ if (work > 3e9) warnings.push(`This run is large (${fmtNum(work)} element updates): expect roughly ${fmtNum(work / CPU_RATE / 60)} min on a desktop CPU (WebGPU is usually much faster).`);
225222 }
226223 q('derived').innerHTML = statRows(rows);
227224 q('warnings').innerHTML = warnings.map((w) => `<li>${w}</li>`).join('');
@@ -233,8 +230,7 @@ export function mountInstrumentPage<P extends SimParams>(root: HTMLElement, cfg:
233230 function inputs(p: P) {
234231 const { built } = cfg.setup(p);
235232 if (!built) return null;
236- const opt = { duration: p.duration, fs: p.fs, cfl: p.cfl, frameRate: p.frameRate, pickup: built.pickup, pickupDelay: built.pickupDelay };
237- return { built, opt, backend: (gpuBox.checked ? 'gpu' : 'cpu') as Backend };
233+ return { built, opt: simOptions(p, built), backend: (gpuBox.checked ? 'gpu' : 'cpu') as Backend };
238234 }
239235
240236 // Look up (debounced) whether the shared cache has a result for the current settings,
@@ -428,7 +424,7 @@ export function mountInstrumentPage<P extends SimParams>(root: HTMLElement, cfg:
428424 });
429425 latest = r;
430426 };
431- const running = runInWorker(built.sys, built.exc, opt, backend === 'gpu', (f) => (progress.value = f), onPartial);
427+ const running = runInWorker(pageWorker, built.sys, built.exc, opt, backend === 'gpu', (f) => (progress.value = f), onPartial);
432428 setBusy(running.cancel);
433429 try {
434430 const r = await running.promise;
@@ -517,47 +513,22 @@ export function mountInstrumentPage<P extends SimParams>(root: HTMLElement, cfg:
517513 });
518514 }
519515
520-// A link to this page with settings p: every parameter, the preset they started
521-// from (for its description) and the WebGPU toggle (CPU and WebGPU results are cached separately).
516+// A link to this page with settings p (see settings.ts).
522517 function sharedLink<P extends SimParams>(p: P, preset: number, gpu: boolean): string {
523- const qs = new URLSearchParams();
524- for (const [k, v] of Object.entries(p)) qs.set(k, String(v));
525- qs.set('preset', String(preset));
526- qs.set('gpu', gpu ? '1' : '0');
527518 const route = location.hash.split('?')[0];
528- return `${location.origin}${location.pathname}${route}?${qs}`;
519+ return `${location.origin}${location.pathname}${route}?${settingsQuery(p, preset, gpu)}`;
529520 }
530521
531-// Settings from a shared link in the address (#/<instrument>?...), which is
532-// then removed. Parameters missing from the link (or invalid) keep the values
533-// of the link's preset; unknown ones are ignored, so old links still open.
534-function readSharedLink<P extends SimParams>(cfg: InstrumentConfig<P>): { params: P; preset: number; gpu?: boolean } | null {
522+// Settings from a shared link in the address (#/<instrument>?...), which is then removed.
523+function readSharedLink<P extends SimParams>(cfg: InstrumentConfig<P>): Settings<P> | null {
535524 const [route, query] = location.hash.split('?');
536525 if (query === undefined) return null;
537526 history.replaceState(history.state, '', `${location.pathname}${location.search}${route}`);
538- const qs = new URLSearchParams(query);
539- const n = Number(qs.get('preset'));
540- const preset = Number.isInteger(n) && n >= 0 && n < cfg.presets.length ? n : 0;
541- const base = cfg.presets[preset].params;
542- const params = { ...base } as Record<string, unknown>;
543- const options = new Map<string, string[]>();
544- for (const sec of cfg.sections) for (const s of sec.params) if (s.options) options.set(s.key, s.options.map(([v]) => v));
545- for (const [k, v0] of Object.entries(base)) {
546- const v = qs.get(k);
547- if (v === null) continue;
548- if (typeof v0 === 'number') {
549- const x = Number(v);
550- if (v.trim() !== '' && Number.isFinite(x) && (!options.has(k) || options.get(k)!.includes(String(x)))) params[k] = x;
551- } else if (typeof v0 === 'string' && (!options.has(k) || options.get(k)!.includes(v))) params[k] = v;
552- }
553- const gpu = qs.get('gpu');
554- return {
555- params: params as P,
556- preset,
557- gpu: gpu === null ? undefined : gpu === '1',
558- };
527+ return parseSettings(cfg, query);
559528 }
560529
530+const pageWorker = () => new Worker(new URL('../sim/worker.ts', import.meta.url), { type: 'module' });
531+
561532 // separate contact intervals in the contact-force record
562533 function contacts(cf: Float32Array, fs: number): { start: number; ms: number; peak: number }[] {
563534 const out: { start: number; ms: number; peak: number }[] = [];
src/ui/plot.tsmodified+3−1View file
@@ -1,7 +1,9 @@
11 // Small canvas plotting helpers: one axis per chart, recessive grid,
22 // min/max decimation for long signals, crosshair + tooltip on hover.
33
4-export const css = (name: string) => getComputedStyle(document.documentElement).getPropertyValue(name).trim();
4+// a CSS custom property's value, as seen by element el (the page root by default; an
5+// embedded widget passes an element inside its shadow root, where its own tokens apply)
6+export const css = (name: string, el: Element = document.documentElement) => getComputedStyle(el).getPropertyValue(name).trim();
57
68 export interface Series {
79 x: ArrayLike<number>; // increasing
src/ui/scene3d.tsmodified+12−7View file
@@ -136,6 +136,11 @@ export class BeadScene {
136136 window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => this.rebuild());
137137 }
138138
139+ // redraw with the current colors (after a theme change the view cannot see by itself)
140+ restyle() {
141+ this.rebuild();
142+ }
143+
139144 showSetup(spec: SceneSpec) {
140145 this.setupSpec = spec;
141146 if (this.mode === 'setup') this.rebuild();
@@ -231,7 +236,7 @@ export class BeadScene {
231236 this.spec = spec;
232237 this.frames = this.mode === 'replay' && this.replaySpec ? this.replaySpec.frames : null;
233238 if (!spec) return this.render();
234- if (this.renderer) this.renderer.setClearColor(new THREE.Color(css('--surface-1')));
239+ if (this.renderer) this.renderer.setClearColor(new THREE.Color(css('--surface-1', this.wrap)));
235240 const { sys, exc, pickup } = spec;
236241 const n = sys.n;
237242
@@ -252,11 +257,11 @@ export class BeadScene {
252257 if (!Number.isFinite(minLen)) minLen = 0.05;
253258 const r = Math.min(0.32 * minLen, 0.022);
254259
255- const cBead = new THREE.Color(css('--series-1'));
256- const cExc = new THREE.Color(css('--series-2'));
257- const cPick = new THREE.Color(css('--series-3'));
258- const cInk = new THREE.Color(css('--text-primary'));
259- const cMuted = new THREE.Color(css('--text-muted'));
260+ const cBead = new THREE.Color(css('--series-1', this.wrap));
261+ const cExc = new THREE.Color(css('--series-2', this.wrap));
262+ const cPick = new THREE.Color(css('--series-3', this.wrap));
263+ const cInk = new THREE.Color(css('--text-primary', this.wrap));
264+ const cMuted = new THREE.Color(css('--text-muted', this.wrap));
260265
261266 // grid floor for depth cues
262267 const grid = new THREE.GridHelper(1.4, 14, cMuted, cMuted);
@@ -311,7 +316,7 @@ export class BeadScene {
311316 }
312317 if (spec.mic) {
313318 const o = new THREE.Vector3(...spec.mic.from).sub(this.center).multiplyScalar(this.scale);
314- this.model.add(new THREE.ArrowHelper(new THREE.Vector3(...spec.mic.dir).normalize(), o, 0.42, new THREE.Color(css('--series-3')), 0.05, 0.025));
319+ this.model.add(new THREE.ArrowHelper(new THREE.Vector3(...spec.mic.dir).normalize(), o, 0.42, new THREE.Color(css('--series-3', this.wrap)), 0.05, 0.025));
315320 }
316321 if (!spec.hoop) sys.anchors.forEach((a) => {
317322 const at = this.nodePos(sys, sys.n + sys.anchors.indexOf(a), new THREE.Vector3());
src/ui/settings.tsadded+58−0View file
@@ -0,0 +1,58 @@
1+import { timeStep } from '../sim/solver';
2+import type { SimOptions } from '../sim/types';
3+import type { Built, InstrumentConfig, SimParams } from './instrumentPage';
4+
5+// Settings in a shared link (#/<instrument>?<param>=<value>&…&preset=<i>&gpu=<0|1>),
6+// read by the instrument page and by the embedded widget.
7+
8+export interface Settings<P> {
9+ params: P;
10+ preset: number;
11+ gpu?: boolean;
12+}
13+
14+// Settings p as a query string: every parameter, the preset they started from
15+// (for its description) and the WebGPU toggle (CPU and WebGPU results are cached separately).
16+export function settingsQuery<P extends SimParams>(p: P, preset: number, gpu: boolean): string {
17+ const qs = new URLSearchParams();
18+ for (const [k, v] of Object.entries(p)) qs.set(k, String(v));
19+ qs.set('preset', String(preset));
20+ qs.set('gpu', gpu ? '1' : '0');
21+ return qs.toString();
22+}
23+
24+// Parameters missing from the query (or invalid) keep the values of its preset;
25+// unknown ones are ignored, so old links still open.
26+export function parseSettings<P extends SimParams>(cfg: InstrumentConfig<P>, query: string): Settings<P> {
27+ const qs = new URLSearchParams(query);
28+ const n = Number(qs.get('preset'));
29+ const preset = Number.isInteger(n) && n >= 0 && n < cfg.presets.length ? n : 0;
30+ const base = cfg.presets[preset].params;
31+ const params = { ...base } as Record<string, unknown>;
32+ const options = new Map<string, string[]>();
33+ for (const sec of cfg.sections) for (const s of sec.params) if (s.options) options.set(s.key, s.options.map(([v]) => v));
34+ for (const [k, v0] of Object.entries(base)) {
35+ const v = qs.get(k);
36+ if (v === null) continue;
37+ if (typeof v0 === 'number') {
38+ const x = Number(v);
39+ if (v.trim() !== '' && Number.isFinite(x) && (!options.has(k) || options.get(k)!.includes(String(x)))) params[k] = x;
40+ } else if (typeof v0 === 'string' && (!options.has(k) || options.get(k)!.includes(v))) params[k] = v;
41+ }
42+ const gpu = qs.get('gpu');
43+ return { params: params as P, preset, gpu: gpu === null ? undefined : gpu === '1' };
44+}
45+
46+// The solver options for parameters p of a built instrument.
47+export function simOptions(p: SimParams, built: Built): SimOptions {
48+ return { duration: p.duration, fs: p.fs, cfl: p.cfl, frameRate: p.frameRate, pickup: built.pickup, pickupDelay: built.pickupDelay };
49+}
50+
51+// Size of a run: time step and element updates (a bending hinge costs about four
52+// springs). A desktop CPU does roughly CPU_RATE updates per second.
53+export const CPU_RATE = 2.2e7;
54+export function runCost(p: SimParams, built: Built) {
55+ const { omegaMax, stepsPerSample } = timeStep(built.sys, built.exc.mallet, p.fs, p.cfl);
56+ const elements = built.sys.si.length + 4 * (built.sys.bending?.w0.length ?? 0);
57+ return { omegaMax, stepsPerSample, work: stepsPerSample * p.fs * p.duration * elements };
58+}
test/cache.test.tsmodified+10−3View file
@@ -1,5 +1,5 @@
11 import { describe, expect, it } from 'vitest';
2-import { decodeResult, digest, encodeResult } from '../src/sim/cache';
2+import { decodeRecord, digest, encodeRecord, splitResult } from '../src/sim/cache';
33 import type { SimResult } from '../src/sim/types';
44
55 describe('result cache encoding', () => {
@@ -19,12 +19,19 @@ describe('result cache encoding', () => {
1919 contactForce: new Float32Array([0, 4, 2]),
2020 backend: 'CPU',
2121 };
22- const bytes = encodeResult(r);
22+ const bytes = encodeRecord(r);
2323 expect(bytes.length % 8).toBe(0);
24- const back = decodeResult(bytes.buffer);
24+ const back = decodeRecord<SimResult>(bytes.buffer);
2525 expect(back).toEqual(r);
2626 expect(back.frameTimes).toBeInstanceOf(Float64Array);
2727 expect(back.malletPos).toBeUndefined();
28+
29+ // stored as a light entry and a frames entry
30+ const { light, frames } = splitResult(r);
31+ expect(light).not.toHaveProperty('frames');
32+ const l = decodeRecord<Omit<SimResult, 'frames'>>(encodeRecord(light).buffer);
33+ const f = decodeRecord<{ frames: Float32Array }>(encodeRecord({ frames }).buffer).frames;
34+ expect({ ...l, frames: f }).toEqual(r);
2835 });
2936
3037 it('hashes inputs canonically: key order does not matter, values and array types do', async () => {
vite.embed.config.tsadded+13−0View file
@@ -0,0 +1,13 @@
1+import { defineConfig } from 'vite';
2+
3+// The embeddable widget (src/embed.ts): one ES module that defines <toy-instrument-sim>,
4+// with three.js, the styles and the solver worker inlined, so it runs from any origin.
5+export default defineConfig({
6+ worker: { format: 'es' },
7+ build: {
8+ outDir: 'embed-dist',
9+ emptyOutDir: true,
10+ copyPublicDir: false,
11+ lib: { entry: 'src/embed.ts', formats: ['es'], fileName: () => 'toy-instruments-embed.js' },
12+ },
13+});