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). It only
21// affects the drawing, so it is not part of the cache key.
22// - name: file name of the downloaded .wav (default toy-instrument).
23//
24// The reader sees the 3D model with "Play sound" and "Play motion" buttons; each downloads
25// what it needs from the shared cache on first press (the small light entry for the sound,
26// the frames for the motion, played in slow motion). When the cache has no result, the
27// widget offers to run the simulation in the browser; with the cache passcode set, the run
28// is stored under the same key, so the next reader gets it from the cache. "Open the full
29// app" shows the whole instrument page (all parameters, plots, runs) over the host page,
30// starting from these settings; it is this bundle's copy of the app, so it computes the
31// same cache keys as the widget.
32//
33// The key hashes what this bundle's model code builds from the settings, so a page that
34// keeps this exact bundle (jeremy.magland.org freezes each upload) always asks for, and
35// fills, the same entry, however the app changes later.
37interface Found {
38 key: string;
39 stored?: Date;
40 framesBytes: number | null;
41}
43class ToyInstrumentSim extends HTMLElement {
44 private root!: ShadowRoot;
45 private running: Running | null = null;
46 private abort = new AbortController();
47 private audioUrl: string | null = null;
48 private cleanup: (() => void)[] = [];
49 private p!: SimParams;
50 private built!: Built;
51 private scene!: BeadScene;
52 private timeline = new Timeline();
53 private found: Found | null = null;
54 private light: Promise<LightResult> | null = null;
55 private exaggeration = 5;
56 private cfg!: AnyConfig;
57 private settings!: Settings<SimParams>;
58 private overlayMounted = false;
60 connectedCallback() {
61 if (this.shadowRoot) return;
62 this.root = this.attachShadow({ mode: 'open' });
63 this.root.innerHTML = `
64 <style>${baseCss}${embedCss}</style>
65 <div class="tis">
66 <div data-r="scene"></div>
67 <div class="players">
68 <div class="player">
69 <button class="btn play" data-r="sound-btn" hidden>▶ Play sound</button>
70 <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>
71 </div>
72 <div class="player">
73 <button class="btn play" data-r="motion-btn" hidden>▶ Play motion</button>
74 <div class="viewer-bar" data-r="motion-bar" hidden>
75 <button class="btn-small" data-r="m-play">Play</button>
76 <input type="range" min="0" max="0" value="0" data-r="m-slider" aria-label="time">
77 <span class="mono" data-r="m-label"></span>
78 <label class="inline">speed <select data-r="m-rate">${RATES.map(([v, l]) => `<option value="${v}">${l}</option>`).join('')}</select></label>
79 </div>
80 </div>
81 </div>
82 <p class="status" data-r="status">Looking for a stored result…</p>
83 <div class="actions">
84 <button class="btn" data-r="run" hidden></button>
85 <button class="btn-small" data-r="cancel" hidden>Cancel</button>
86 </div>
87 <progress data-r="progress" max="1" value="0" hidden></progress>
88 <p class="error" data-r="error" hidden></p>
89 <div class="footer">
90 <button class="btn-small" data-r="open-app" hidden>Open the full app</button>
91 <details class="cache-ctl">
92 <summary data-r="cache-state"></summary>
93 <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>
94 <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>
95 <p class="note" data-r="cache-msg"></p>
96 </details>
97 </div>
98 <div class="overlay" data-r="overlay" role="dialog" aria-modal="true" aria-label="Full app" hidden>
99 <div class="overlay-bar"><span>Toy instruments</span><button class="btn-small" data-r="close-app">Close ✕</button></div>
100 <div data-r="app"></div>
101 </div>
102 </div>`;
103 this.mountPasscode();
104 this.mountMotionBar();
105 this.start().catch((e) => this.fail(`Something went wrong: ${(e as Error).message}`));
106 }
108 disconnectedCallback() {
109 this.running?.cancel();
110 this.abort.abort();
111 this.timeline.pause();
112 this.closeApp();
113 for (const f of this.cleanup) f();
114 this.cleanup = [];
115 }
117 private q<T extends HTMLElement>(r: string): T {
118 return this.root.querySelector(`[data-r="${r}"]`) as T;
119 }
121 private status(text: string) {
122 this.q('status').textContent = text;
123 }
125 private fail(text: string) {
126 const box = this.q('error');
127 box.textContent = text;
128 box.hidden = false;
129 }
131 private async start() {
132 const spec = this.getAttribute('settings') ?? '';
133 const hash = spec.includes('#') ? spec.slice(spec.indexOf('#') + 1) : spec;
134 const [route, query = ''] = hash.split('?');
135 const id = route.replace(/^\//, '');
136 const cfg: AnyConfig | undefined = CONFIGS[id];
137 if (!cfg) {
138 this.status('');
139 return this.fail(`Unknown instrument "${id}" in the settings attribute.`);
140 }
141 const settings = parseSettings(cfg, query);
142 this.cfg = cfg;
143 this.settings = settings;
144 this.p = settings.params;
145 this.mountOverlay();
146 const built = cfg.setup(this.p).built;
147 if (!built) {
148 this.status('');
149 return this.fail('These settings do not give a valid instrument.');
150 }
151 this.built = built;
153 const ex = Number(this.getAttribute('exaggeration') ?? 5);
154 if (ex > 0) this.exaggeration = ex;
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 const sync = () => {
314 play.textContent = tl.playing ? 'Pause' : 'Play';
315 slider.max = String(tl.times.length - 1);
316 slider.value = String(tl.frame);
317 label.textContent = `t = ${(tl.times[tl.frame] * 1000).toFixed(1)} ms, motion drawn ×${fmtNum(this.exaggeration)}`;
318 rate.value = String(tl.rate);
319 };
320 tl.onChange(sync);
321 sync();
322 }
324 private setAudio(audio: Float32Array, fs: number) {
325 if (this.audioUrl) URL.revokeObjectURL(this.audioUrl);
326 this.audioUrl = URL.createObjectURL(encodeWav(audio, fs));
327 this.q<HTMLAudioElement>('audio').src = this.audioUrl;
328 const a = this.q<HTMLAnchorElement>('download');
329 a.href = this.audioUrl;
330 a.download = `${this.getAttribute('name') ?? 'toy-instrument'}.wav`;
331 a.title = `16-bit mono, ${fs} Hz, normalized so the peak is −1 dBFS`;
332 this.q('sound-btn').hidden = true;
333 this.q('audio-row').hidden = false;
334 this.q('audio-row').parentElement!.classList.add('wide');
335 }
337 // The full instrument page in a full-window overlay, mounted on first open and kept
338 // (with its state) while closed.
339 private mountOverlay() {
340 const open = this.q<HTMLButtonElement>('open-app');
341 open.hidden = false;
342 open.addEventListener('click', () => this.openApp());
343 this.q('close-app').addEventListener('click', () => this.closeApp());
344 this.q('overlay').addEventListener('keydown', (e) => {
345 if (e.key === 'Escape') this.closeApp();
346 });
347 }
349 private bodyOverflow: string | null = null;
351 private openApp() {
352 const overlay = this.q('overlay');
353 this.timeline.pause();
354 this.q<HTMLAudioElement>('audio').pause();
355 setStyleRoot(overlay);
356 if (!this.overlayMounted) {
357 this.overlayMounted = true;
358 mountInstrumentPage(this.q('app'), this.cfg, { makeWorker: () => new SolverWorker(), embedded: { settings: this.settings } });
359 }
360 overlay.hidden = false;
361 this.bodyOverflow = document.body.style.overflow;
362 document.body.style.overflow = 'hidden';
363 this.q('close-app').focus();
364 }
366 private closeApp() {
367 const overlay = this.q('overlay');
368 if (overlay.hidden) return;
369 overlay.hidden = true;
370 document.body.style.overflow = this.bodyOverflow ?? '';
371 setStyleRoot(null);
372 this.q('open-app').focus();
373 }
375 // Dark or light series colors from the host page's color-scheme, and a redraw when the
376 // page switches theme (jeremy.magland.org sets data-theme on <html>).
377 private followTheme() {
378 const box = this.root.querySelector('.tis')!;
379 const media = window.matchMedia('(prefers-color-scheme: dark)');
380 const apply = () => {
381 const scheme = getComputedStyle(this).colorScheme;
382 const dark = scheme.includes('dark') && (!scheme.includes('light') || media.matches);
383 box.classList.toggle('dark', dark);
384 this.scene.restyle();
385 };
386 apply();
387 const obs = new MutationObserver(apply);
388 obs.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme', 'class', 'style'] });
389 media.addEventListener('change', apply);
390 this.cleanup.push(() => obs.disconnect(), () => media.removeEventListener('change', apply));
391 }
393 private mountPasscode() {
394 const input = this.q<HTMLInputElement>('cache-pass');
395 const msg = this.q('cache-msg');
396 const show = () => (this.q('cache-state').textContent = getPasscode() ? 'Shared cache: runs here are stored' : 'Shared cache');
397 show();
398 this.q('cache-save').addEventListener('click', async () => {
399 const pass = input.value.trim();
400 if (!pass) return;
401 msg.textContent = 'Checking…';
402 try {
403 const { valid, reason } = await checkPasscode(pass);
404 if (!valid) {
405 msg.textContent = `Not saved: ${reason ?? 'invalid passcode'}.`;
406 return;
407 }
408 setPasscode(pass);
409 input.value = '';
410 msg.textContent = 'Saved in this browser.';
411 } catch (e) {
412 msg.textContent = `Could not check the passcode: ${(e as Error).message}`;
413 }
414 show();
415 });
416 this.q('cache-forget').addEventListener('click', () => {
417 setPasscode(null);
418 msg.textContent = 'Passcode removed from this browser.';
419 show();
420 });
421 }
422}
424function duration(s: number): string {
425 if (s < 90) return `${fmtNum(Math.max(1, Math.round(s)))} s`;
426 if (s < 5400) return `${fmtNum(Math.round(s / 60))} min`;
427 return `${fmtNum(Math.round((s / 3600) * 10) / 10)} h`;
428}
430if (!customElements.get('toy-instrument-sim')) customElements.define('toy-instrument-sim', ToyInstrumentSim);