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