1import baseCss from './style.css?inline';
2import embedCss from './embed.css?inline';
3import SolverWorker from './sim/worker.ts?worker&inline';
4import { CONFIGS, type AnyConfig } from './instruments/configs';
5import { BeadScene } from './ui/scene3d';
6import { RATES, Timeline } from './ui/timeline';
7import { encodeWav } from './ui/wav';
8import { fmtNum, setStyleRoot } from './ui/plot';
9import { mountInstrumentPage } from './ui/instrumentPage';
10import { CPU_RATE, parseSettings, runCost, simOptions, type Settings } from './ui/settings';
11import { runInWorker, type Running } from './sim/run';
12import { checkPasscode, getPasscode, loadFrames, loadLight, probeFrames, probeResult, resultKey, setPasscode, storeResult, type Backend, type LightResult } from './sim/cache';
13import type { Built, SimParams } from './ui/instrumentPage';
14import type { SimResult } from './sim/types';
16// <toy-instrument-sim settings="…" exaggeration="5" name="…">: one simulation of a toy
17// instrument, for embedding in other pages (blog posts).
18//
19// - settings: a link from "Copy link to these settings" (or just its #/<instrument>?… part).
20// - exaggeration: how much the motion is magnified in the replay (default 5); the reader
21// can change it in the motion bar. It only affects the drawing, so it is not part of the
22// cache key.
23// - name: file name of the downloaded .wav (default toy-instrument).
24//
25// The reader sees the 3D model with "Play sound" and "Play motion" buttons; each downloads
26// what it needs from the shared cache on first press (the small light entry for the sound,
27// the frames for the motion, played in slow motion). When the cache has no result, the
28// widget offers to run the simulation in the browser; with the cache passcode set, the run
29// is stored under the same key, so the next reader gets it from the cache. "Open the full
30// app" shows the whole instrument page (all parameters, plots, runs) over the host page,
31// starting from these settings; it is this bundle's copy of the app, so it computes the
32// same cache keys as the widget.
33//
34// The key hashes what this bundle's model code builds from the settings, so a page that
35// keeps this exact bundle (jeremy.magland.org freezes each upload) always asks for, and
36// fills, the same entry, however the app changes later.
38interface Found {
39 key: string;
40 stored?: Date;
41 framesBytes: number | null;
42}
44class ToyInstrumentSim extends HTMLElement {
45 private root!: ShadowRoot;
46 private running: Running | null = null;
47 private abort = new AbortController();
48 private audioUrl: string | null = null;
49 private cleanup: (() => void)[] = [];
50 private p!: SimParams;
51 private built!: Built;
52 private scene!: BeadScene;
53 private timeline = new Timeline();
54 private found: Found | null = null;
55 private light: Promise<LightResult> | null = null;
56 private exaggeration = 5;
57 private cfg!: AnyConfig;
58 private settings!: Settings<SimParams>;
59 private overlayMounted = false;
61 connectedCallback() {
62 if (this.shadowRoot) return;
63 this.root = this.attachShadow({ mode: 'open' });
64 this.root.innerHTML = `
65 <style>${baseCss}${embedCss}</style>
66 <div class="tis">
67 <div data-r="scene"></div>
68 <div class="players">
69 <div class="player">
70 <button class="btn play" data-r="sound-btn" hidden>▶ Play sound</button>
71 <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>
72 </div>
73 <div class="player">
74 <button class="btn play" data-r="motion-btn" hidden>▶ Play motion</button>
75 <div class="viewer-bar" data-r="motion-bar" hidden>
76 <button class="btn-small" data-r="m-play">Play</button>
77 <input type="range" min="0" max="0" value="0" data-r="m-slider" aria-label="time">
78 <span class="mono" data-r="m-label"></span>
79 <label class="inline">exaggeration <select data-r="m-exag"></select></label>
80 <label class="inline">speed <select data-r="m-rate">${RATES.map(([v, l]) => `<option value="${v}">${l}</option>`).join('')}</select></label>
81 </div>
82 </div>
83 </div>
84 <p class="status" data-r="status">Looking for a stored result…</p>
85 <div class="actions">
86 <button class="btn" data-r="run" hidden></button>
87 <button class="btn-small" data-r="cancel" hidden>Cancel</button>
88 </div>
89 <progress data-r="progress" max="1" value="0" hidden></progress>
90 <p class="error" data-r="error" hidden></p>
91 <div class="footer">
92 <button class="btn-small" data-r="open-app" hidden>Open the full app</button>
93 <details class="cache-ctl">
94 <summary data-r="cache-state"></summary>
95 <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>
96 <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>
97 <p class="note" data-r="cache-msg"></p>
98 </details>
99 </div>
100 <div class="overlay" data-r="overlay" role="dialog" aria-modal="true" aria-label="Full app" hidden>
101 <div class="overlay-bar"><span>Toy instruments</span><button class="btn-small" data-r="close-app">Close ✕</button></div>
102 <div data-r="app"></div>
103 </div>
104 </div>`;
105 this.mountPasscode();
106 this.mountMotionBar();
107 this.start().catch((e) => this.fail(`Something went wrong: ${(e as Error).message}`));
108 }
110 disconnectedCallback() {
111 this.running?.cancel();
112 this.abort.abort();
113 this.timeline.pause();
114 this.closeApp();
115 for (const f of this.cleanup) f();
116 this.cleanup = [];
117 }
119 private q<T extends HTMLElement>(r: string): T {
120 return this.root.querySelector(`[data-r="${r}"]`) as T;
121 }
123 private status(text: string) {
124 this.q('status').textContent = text;
125 }
127 private fail(text: string) {
128 const box = this.q('error');
129 box.textContent = text;
130 box.hidden = false;
131 }
133 private async start() {
134 const spec = this.getAttribute('settings') ?? '';
135 const hash = spec.includes('#') ? spec.slice(spec.indexOf('#') + 1) : spec;
136 const [route, query = ''] = hash.split('?');
137 const id = route.replace(/^\//, '');
138 const cfg: AnyConfig | undefined = CONFIGS[id];
139 if (!cfg) {
140 this.status('');
141 return this.fail(`Unknown instrument "${id}" in the settings attribute.`);
142 }
143 const settings = parseSettings(cfg, query);
144 this.cfg = cfg;
145 this.settings = settings;
146 this.p = settings.params;
147 this.mountOverlay();
148 const built = cfg.setup(this.p).built;
149 if (!built) {
150 this.status('');
151 return this.fail('These settings do not give a valid instrument.');
152 }
153 this.built = built;
155 this.scene = new BeadScene(this.q('scene'), this.timeline, { simple: true, exaggeration: this.exaggeration });
156 this.scene.showSetup(built.scene);
157 this.followTheme();
159 const wanted: Backend = (settings.gpu ?? cfg.webgpuDefault !== false) ? 'gpu' : 'cpu';
160 const opt = simOptions(this.p, built);
161 const order: Backend[] = wanted === 'gpu' ? ['gpu', 'cpu'] : ['cpu', 'gpu'];
163 // the result for the settings' backend, or else for the other one (HEAD requests only)
164 let unreachable = false;
165 for (const backend of order) {
166 const key = await resultKey(id, built.sys, built.exc, opt, backend);
167 try {
168 const hit = await probeResult(key, this.abort.signal);
169 if (hit) {
170 this.found = { key, stored: hit.stored, framesBytes: await probeFrames(key, this.abort.signal) };
171 break;
172 }
173 } catch {
174 unreachable = true;
175 }
176 }
177 if (this.found) return this.offerPlayback();
179 // nothing stored: offer to run it here
180 const gpu = wanted === 'gpu' && 'gpu' in navigator;
181 const cpuSeconds = runCost(this.p, built).work / CPU_RATE;
182 this.status(
183 `${unreachable ? 'The shared cache could not be reached' : 'No stored result for these settings yet'}. ` +
184 `You can run the simulation in this browser: roughly ${duration(cpuSeconds)} on a desktop CPU` +
185 (gpu ? ', usually much less with WebGPU.' : wanted === 'gpu' ? ' (this browser has no WebGPU, so it runs on the CPU).' : '.'),
186 );
187 const runBtn = this.q<HTMLButtonElement>('run');
188 runBtn.textContent = `Run simulation${gpu ? ' (WebGPU)' : ''}`;
189 runBtn.hidden = false;
190 runBtn.addEventListener('click', () => this.run(id, gpu));
191 }
193 // A stored result: buttons that download the sound and the motion when first pressed.
194 private offerPlayback() {
195 const f = this.found!;
196 this.status(f.stored ? `Computed on ${f.stored.toISOString().slice(0, 10)}.` : '');
197 const soundBtn = this.q<HTMLButtonElement>('sound-btn');
198 soundBtn.hidden = false;
199 soundBtn.addEventListener('click', async () => {
200 soundBtn.disabled = true;
201 soundBtn.textContent = 'Loading…';
202 try {
203 const light = await this.loadLight();
204 soundBtn.hidden = true;
205 this.setAudio(light.audio, light.fs);
206 this.q<HTMLAudioElement>('audio').play().catch(() => {});
207 } catch (e) {
208 soundBtn.disabled = false;
209 soundBtn.textContent = '▶ Play sound';
210 this.fail(`Could not load the sound: ${(e as Error).message}`);
211 }
212 });
213 if (f.framesBytes === null) return;
214 const motionBtn = this.q<HTMLButtonElement>('motion-btn');
215 const label = `▶ Play motion (${fmtNum(Math.round(f.framesBytes / 1e5) / 10)} MB)`;
216 motionBtn.textContent = label;
217 motionBtn.hidden = false;
218 motionBtn.addEventListener('click', async () => {
219 motionBtn.disabled = true;
220 motionBtn.textContent = 'Loading…';
221 try {
222 const light = await this.loadLight();
223 const frames = await loadFrames(f.key, (x) => (motionBtn.textContent = `Loading… ${Math.round(x * 100)}%`), this.abort.signal);
224 if (!frames) throw new Error('it is no longer in the cache');
225 motionBtn.hidden = true;
226 this.showMotion({ ...light, frames });
227 this.timeline.play();
228 } catch (e) {
229 if (this.abort.signal.aborted) return;
230 motionBtn.disabled = false;
231 motionBtn.textContent = label;
232 this.fail(`Could not load the motion: ${(e as Error).message}`);
233 }
234 });
235 }
237 // the light entry (audio, frame times, mallet path), downloaded once
238 private loadLight(): Promise<LightResult> {
239 this.light ??= loadLight(this.found!.key, this.abort.signal).then((hit) => {
240 if (!hit) throw new Error('it is no longer in the cache');
241 return hit.light;
242 });
243 this.light.catch(() => (this.light = null));
244 return this.light;
245 }
247 private async run(id: string, gpu: boolean) {
248 const { p, built } = this;
249 const runBtn = this.q<HTMLButtonElement>('run');
250 const cancelBtn = this.q<HTMLButtonElement>('cancel');
251 const progress = this.q<HTMLProgressElement>('progress');
252 const opt = simOptions(p, built);
253 runBtn.disabled = true;
254 cancelBtn.hidden = progress.hidden = false;
255 progress.value = 0;
256 this.q('error').hidden = true;
257 this.status('Simulating…');
258 const running = runInWorker(
259 () => new SolverWorker(),
260 built.sys,
261 built.exc,
262 opt,
263 gpu,
264 (x) => (progress.value = x),
265 (r) => this.status(`Simulating… ${fmtNum(r.audio.length / r.fs)} s of ${fmtNum(p.duration)} s done.`),
266 );
267 this.running = running;
268 cancelBtn.onclick = () => running.cancel();
269 try {
270 const r = await running.promise;
271 runBtn.hidden = true;
272 this.setAudio(r.audio, r.fs);
273 this.showMotion(r);
274 const ran = `Simulated in this browser with ${r.backend ?? 'CPU'}${r.fallback ? ` (WebGPU failed: ${r.fallback})` : ''} in ${duration(r.wallSeconds)}.`;
275 if (!getPasscode()) return this.status(`${ran} Not stored: this browser has no cache passcode.`);
276 this.status(`${ran} Storing in the shared cache…`);
277 // a WebGPU run that fell back to the CPU is stored as a CPU result
278 const backend: Backend = r.backend?.startsWith('WebGPU') ? 'gpu' : 'cpu';
279 const key = await resultKey(id, built.sys, built.exc, opt, backend);
280 try {
281 await storeResult(key, r, { instrument: id, backend: r.backend, wallSeconds: r.wallSeconds, from: location.href });
282 this.status(`${ran} Stored in the shared cache.`);
283 } catch (e) {
284 this.status(`${ran} Not stored in the shared cache: ${(e as Error).message}`);
285 }
286 } catch (e) {
287 runBtn.disabled = false;
288 if ((e as Error).message === 'cancelled') this.status('The run was cancelled.');
289 else this.fail(`The simulation failed: ${(e as Error).message}`);
290 } finally {
291 this.running = null;
292 cancelBtn.hidden = progress.hidden = true;
293 }
294 }
296 private showMotion(r: SimResult) {
297 this.timeline.setTimes(r.frameTimes);
298 this.scene.showReplay({ ...this.built.scene, frames: r.frames, malletPos: r.malletPos });
299 this.q('motion-bar').hidden = false;
300 this.q('motion-bar').parentElement!.classList.add('wide');
301 }
303 // Play/pause, time slider, time label and speed for the replay.
304 private mountMotionBar() {
305 const tl = this.timeline;
306 const play = this.q<HTMLButtonElement>('m-play');
307 const slider = this.q<HTMLInputElement>('m-slider');
308 const label = this.q('m-label');
309 const rate = this.q<HTMLSelectElement>('m-rate');
310 play.addEventListener('click', () => tl.toggle());
311 slider.addEventListener('input', () => tl.seek(Number(slider.value)));
312 rate.addEventListener('change', () => tl.setRate(Number(rate.value)));
313 // exaggeration choices, including the attribute's value
314 const exag = this.q<HTMLSelectElement>('m-exag');
315 const ex = Number(this.getAttribute('exaggeration') ?? 5);
316 if (ex > 0) this.exaggeration = ex;
317 const choices = [...new Set([...EXAGGERATIONS, this.exaggeration])].sort((a, b) => a - b);
318 exag.innerHTML = choices.map((v) => `<option value="${v}">×${fmtNum(v)}</option>`).join('');
319 exag.value = String(this.exaggeration);
320 exag.addEventListener('change', () => {
321 this.exaggeration = Number(exag.value);
322 this.scene?.setExaggeration(this.exaggeration);
323 });
324 const sync = () => {
325 play.textContent = tl.playing ? 'Pause' : 'Play';
326 slider.max = String(tl.times.length - 1);
327 slider.value = String(tl.frame);
328 label.textContent = `t = ${(tl.times[tl.frame] * 1000).toFixed(1)} ms`;
329 rate.value = String(tl.rate);
330 };
331 tl.onChange(sync);
332 sync();
333 }
335 private setAudio(audio: Float32Array, fs: number) {
336 if (this.audioUrl) URL.revokeObjectURL(this.audioUrl);
337 this.audioUrl = URL.createObjectURL(encodeWav(audio, fs));
338 this.q<HTMLAudioElement>('audio').src = this.audioUrl;
339 const a = this.q<HTMLAnchorElement>('download');
340 a.href = this.audioUrl;
341 a.download = `${this.getAttribute('name') ?? 'toy-instrument'}.wav`;
342 a.title = `16-bit mono, ${fs} Hz, normalized so the peak is −1 dBFS`;
343 this.q('sound-btn').hidden = true;
344 this.q('audio-row').hidden = false;
345 this.q('audio-row').parentElement!.classList.add('wide');
346 }
348 // The full instrument page in a full-window overlay, mounted on first open and kept
349 // (with its state) while closed.
350 private mountOverlay() {
351 const open = this.q<HTMLButtonElement>('open-app');
352 open.hidden = false;
353 open.addEventListener('click', () => this.openApp());
354 this.q('close-app').addEventListener('click', () => this.closeApp());
355 this.q('overlay').addEventListener('keydown', (e) => {
356 if (e.key === 'Escape') this.closeApp();
357 });
358 }
360 private bodyOverflow: string | null = null;
362 private openApp() {
363 const overlay = this.q('overlay');
364 this.timeline.pause();
365 this.q<HTMLAudioElement>('audio').pause();
366 setStyleRoot(overlay);
367 if (!this.overlayMounted) {
368 this.overlayMounted = true;
369 mountInstrumentPage(this.q('app'), this.cfg, { makeWorker: () => new SolverWorker(), embedded: { settings: this.settings } });
370 }
371 overlay.hidden = false;
372 this.bodyOverflow = document.body.style.overflow;
373 document.body.style.overflow = 'hidden';
374 this.q('close-app').focus();
375 }
377 private closeApp() {
378 const overlay = this.q('overlay');
379 if (overlay.hidden) return;
380 overlay.hidden = true;
381 document.body.style.overflow = this.bodyOverflow ?? '';
382 setStyleRoot(null);
383 this.q('open-app').focus();
384 }
386 // Dark or light series colors from the host page's color-scheme, and a redraw when the
387 // page switches theme (jeremy.magland.org sets data-theme on <html>).
388 private followTheme() {
389 const box = this.root.querySelector('.tis')!;
390 const media = window.matchMedia('(prefers-color-scheme: dark)');
391 const apply = () => {
392 const scheme = getComputedStyle(this).colorScheme;
393 const dark = scheme.includes('dark') && (!scheme.includes('light') || media.matches);
394 box.classList.toggle('dark', dark);
395 this.scene.restyle();
396 };
397 apply();
398 const obs = new MutationObserver(apply);
399 obs.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme', 'class', 'style'] });
400 media.addEventListener('change', apply);
401 this.cleanup.push(() => obs.disconnect(), () => media.removeEventListener('change', apply));
402 }
404 private mountPasscode() {
405 const input = this.q<HTMLInputElement>('cache-pass');
406 const msg = this.q('cache-msg');
407 const show = () => (this.q('cache-state').textContent = getPasscode() ? 'Shared cache: runs here are stored' : 'Shared cache');
408 show();
409 this.q('cache-save').addEventListener('click', async () => {
410 const pass = input.value.trim();
411 if (!pass) return;
412 msg.textContent = 'Checking…';
413 try {
414 const { valid, reason } = await checkPasscode(pass);
415 if (!valid) {
416 msg.textContent = `Not saved: ${reason ?? 'invalid passcode'}.`;
417 return;
418 }
419 setPasscode(pass);
420 input.value = '';
421 msg.textContent = 'Saved in this browser.';
422 } catch (e) {
423 msg.textContent = `Could not check the passcode: ${(e as Error).message}`;
424 }
425 show();
426 });
427 this.q('cache-forget').addEventListener('click', () => {
428 setPasscode(null);
429 msg.textContent = 'Passcode removed from this browser.';
430 show();
431 });
432 }
433}
435// choices in the motion bar's exaggeration dropdown
436const EXAGGERATIONS = [1, 2, 5, 10, 20, 50, 100];
438function duration(s: number): string {
439 if (s < 90) return `${fmtNum(Math.max(1, Math.round(s)))} s`;
440 if (s < 5400) return `${fmtNum(Math.round(s / 60))} min`;
441 return `${fmtNum(Math.round((s / 3600) * 10) / 10)} h`;
442}
444if (!customElements.get('toy-instrument-sim')) customElements.define('toy-instrument-sim', ToyInstrumentSim);