/ proof-of-concept / toy-instruments
Sign in
proof-of-concept / toy-instruments
toy-instruments / src / embed.ts
374 lines · 15.8 KBCodeBlameHistory
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';
8import { fmtNum } from './ui/plot';
9import { CPU_RATE, parseSettings, runCost, simOptions } from './ui/settings';
10import { runInWorker, type Running } from './sim/run';
4e56865Embedded widget for readers: Play sound and Play motion buttons that download on first press, no setup/replay tabs or view controls; exaggeration attribute (default 5, not in the cache key)Jeremy Magland 11import { checkPasscode, getPasscode, loadFrames, loadLight, probeFrames, probeResult, resultKey, setPasscode, storeResult, type Backend, type LightResult } from './sim/cache';
13import type { SimResult } from './sim/types';
16// instrument, for embedding in other pages (blog posts).
17//
18// - settings: a link from "Copy link to these settings" (or just its #/<instrument>?… part).
19// - exaggeration: how much the motion is magnified in the replay (default 5). It only
20// affects the drawing, so it is not part of the cache key.
21// - name: file name of the downloaded .wav (default toy-instrument).
22//
23// The reader sees the 3D model with "Play sound" and "Play motion" buttons; each downloads
24// what it needs from the shared cache on first press (the small light entry for the sound,
25// the frames for the motion, played in slow motion). When the cache has no result, the
26// widget offers to run the simulation in the browser; with the cache passcode set, the run
27// is stored under the same key, so the next reader gets it from the cache.
29// The key hashes what this bundle's model code builds from the settings, so a page that
30// keeps this exact bundle (jeremy.magland.org freezes each upload) always asks for, and
31// fills, the same entry, however the app changes later.
33interface Found {
34 key: string;
35 stored?: Date;
39class ToyInstrumentSim extends HTMLElement {
40 private root!: ShadowRoot;
41 private running: Running | null = null;
44 private cleanup: (() => void)[] = [];
46 private built!: Built;
47 private scene!: BeadScene;
48 private timeline = new Timeline();
49 private found: Found | null = null;
50 private light: Promise<LightResult> | null = null;
51 private exaggeration = 5;
53 connectedCallback() {
54 if (this.shadowRoot) return;
55 this.root = this.attachShadow({ mode: 'open' });
56 this.root.innerHTML = `
57 <style>${baseCss}${embedCss}</style>
58 <div class="tis">
59 <div data-r="scene"></div>
61 <div class="player">
62 <button class="btn play" data-r="sound-btn" hidden>▶ Play sound</button>
63 <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>
64 </div>
65 <div class="player">
66 <button class="btn play" data-r="motion-btn" hidden>▶ Play motion</button>
67 <div class="viewer-bar" data-r="motion-bar" hidden>
68 <button class="btn-small" data-r="m-play">Play</button>
69 <input type="range" min="0" max="0" value="0" data-r="m-slider" aria-label="time">
70 <span class="mono" data-r="m-label"></span>
71 <label class="inline">speed <select data-r="m-rate">${RATES.map(([v, l]) => `<option value="${v}">${l}</option>`).join('')}</select></label>
72 </div>
73 </div>
74 </div>
76 <div class="actions">
77 <button class="btn" data-r="run" hidden></button>
78 <button class="btn-small" data-r="cancel" hidden>Cancel</button>
79 </div>
80 <progress data-r="progress" max="1" value="0" hidden></progress>
81 <p class="error" data-r="error" hidden></p>
82 <details class="cache-ctl">
83 <summary data-r="cache-state"></summary>
84 <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>
85 <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>
86 <p class="note" data-r="cache-msg"></p>
87 </details>
88 </div>`;
89 this.mountPasscode();
92 }
94 disconnectedCallback() {
95 this.running?.cancel();
97 this.timeline.pause();
99 this.cleanup = [];
100 }
102 private q<T extends HTMLElement>(r: string): T {
103 return this.root.querySelector(`[data-r="${r}"]`) as T;
104 }
106 private status(text: string) {
107 this.q('status').textContent = text;
108 }
110 private fail(text: string) {
111 const box = this.q('error');
112 box.textContent = text;
113 box.hidden = false;
114 }
116 private async start() {
117 const spec = this.getAttribute('settings') ?? '';
118 const hash = spec.includes('#') ? spec.slice(spec.indexOf('#') + 1) : spec;
119 const [route, query = ''] = hash.split('?');
120 const id = route.replace(/^\//, '');
121 const cfg: AnyConfig | undefined = CONFIGS[id];
122 if (!cfg) {
123 this.status('');
124 return this.fail(`Unknown instrument "${id}" in the settings attribute.`);
125 }
126 const settings = parseSettings(cfg, query);
128 const built = cfg.setup(this.p).built;
130 this.status('');
131 return this.fail('These settings do not give a valid instrument.');
132 }
136 if (ex > 0) this.exaggeration = ex;
137 this.scene = new BeadScene(this.q('scene'), this.timeline, { simple: true, exaggeration: this.exaggeration });
138 this.scene.showSetup(built.scene);
139 this.followTheme();
141 const wanted: Backend = (settings.gpu ?? cfg.webgpuDefault !== false) ? 'gpu' : 'cpu';
147 for (const backend of order) {
154 }
155 } catch {
156 unreachable = true;
157 }
158 }
161 // nothing stored: offer to run it here
163 const cpuSeconds = runCost(this.p, built).work / CPU_RATE;
165 `${unreachable ? 'The shared cache could not be reached' : 'No stored result for these settings yet'}. ` +
166 `You can run the simulation in this browser: roughly ${duration(cpuSeconds)} on a desktop CPU` +
167 (gpu ? ', usually much less with WebGPU.' : wanted === 'gpu' ? ' (this browser has no WebGPU, so it runs on the CPU).' : '.'),
168 );
169 const runBtn = this.q<HTMLButtonElement>('run');
170 runBtn.textContent = `Run simulation${gpu ? ' (WebGPU)' : ''}`;
171 runBtn.hidden = false;
176 private offerPlayback() {
177 const f = this.found!;
178 this.status(f.stored ? `Computed on ${f.stored.toISOString().slice(0, 10)}.` : '');
179 const soundBtn = this.q<HTMLButtonElement>('sound-btn');
180 soundBtn.hidden = false;
181 soundBtn.addEventListener('click', async () => {
182 soundBtn.disabled = true;
183 soundBtn.textContent = 'Loading…';
186 soundBtn.hidden = true;
187 this.setAudio(light.audio, light.fs);
188 this.q<HTMLAudioElement>('audio').play().catch(() => {});
189 } catch (e) {
190 soundBtn.disabled = false;
191 soundBtn.textContent = '▶ Play sound';
192 this.fail(`Could not load the sound: ${(e as Error).message}`);
193 }
194 });
195 if (f.framesBytes === null) return;
196 const motionBtn = this.q<HTMLButtonElement>('motion-btn');
197 const label = `▶ Play motion (${fmtNum(Math.round(f.framesBytes / 1e5) / 10)} MB)`;
198 motionBtn.textContent = label;
199 motionBtn.hidden = false;
200 motionBtn.addEventListener('click', async () => {
201 motionBtn.disabled = true;
202 motionBtn.textContent = 'Loading…';
203 try {
204 const light = await this.loadLight();
205 const frames = await loadFrames(f.key, (x) => (motionBtn.textContent = `Loading… ${Math.round(x * 100)}%`), this.abort.signal);
206 if (!frames) throw new Error('it is no longer in the cache');
207 motionBtn.hidden = true;
208 this.showMotion({ ...light, frames });
209 this.timeline.play();
211 if (this.abort.signal.aborted) return;
213 motionBtn.textContent = label;
215 }
216 });
217 }
220 private loadLight(): Promise<LightResult> {
221 this.light ??= loadLight(this.found!.key, this.abort.signal).then((hit) => {
222 if (!hit) throw new Error('it is no longer in the cache');
223 return hit.light;
224 });
225 this.light.catch(() => (this.light = null));
226 return this.light;
227 }
229 private async run(id: string, gpu: boolean) {
230 const { p, built } = this;
232 const cancelBtn = this.q<HTMLButtonElement>('cancel');
233 const progress = this.q<HTMLProgressElement>('progress');
234 const opt = simOptions(p, built);
235 runBtn.disabled = true;
236 cancelBtn.hidden = progress.hidden = false;
237 progress.value = 0;
238 this.q('error').hidden = true;
239 this.status('Simulating…');
240 const running = runInWorker(
241 () => new SolverWorker(),
242 built.sys,
243 built.exc,
244 opt,
245 gpu,
246 (x) => (progress.value = x),
247 (r) => this.status(`Simulating… ${fmtNum(r.audio.length / r.fs)} s of ${fmtNum(p.duration)} s done.`),
248 );
249 this.running = running;
250 cancelBtn.onclick = () => running.cancel();
251 try {
252 const r = await running.promise;
253 runBtn.hidden = true;
255 this.showMotion(r);
257 if (!getPasscode()) return this.status(`${ran} Not stored: this browser has no cache passcode.`);
258 this.status(`${ran} Storing in the shared cache…`);
259 // a WebGPU run that fell back to the CPU is stored as a CPU result
260 const backend: Backend = r.backend?.startsWith('WebGPU') ? 'gpu' : 'cpu';
261 const key = await resultKey(id, built.sys, built.exc, opt, backend);
262 try {
263 await storeResult(key, r, { instrument: id, backend: r.backend, wallSeconds: r.wallSeconds, from: location.href });
264 this.status(`${ran} Stored in the shared cache.`);
265 } catch (e) {
266 this.status(`${ran} Not stored in the shared cache: ${(e as Error).message}`);
267 }
268 } catch (e) {
269 runBtn.disabled = false;
270 if ((e as Error).message === 'cancelled') this.status('The run was cancelled.');
271 else this.fail(`The simulation failed: ${(e as Error).message}`);
272 } finally {
273 this.running = null;
274 cancelBtn.hidden = progress.hidden = true;
275 }
276 }
279 this.timeline.setTimes(r.frameTimes);
280 this.scene.showReplay({ ...this.built.scene, frames: r.frames, malletPos: r.malletPos });
281 this.q('motion-bar').hidden = false;
282 this.q('motion-bar').parentElement!.classList.add('wide');
283 }
285 // Play/pause, time slider, time label and speed for the replay.
286 private mountMotionBar() {
287 const tl = this.timeline;
288 const play = this.q<HTMLButtonElement>('m-play');
289 const slider = this.q<HTMLInputElement>('m-slider');
290 const label = this.q('m-label');
291 const rate = this.q<HTMLSelectElement>('m-rate');
292 play.addEventListener('click', () => tl.toggle());
293 slider.addEventListener('input', () => tl.seek(Number(slider.value)));
294 rate.addEventListener('change', () => tl.setRate(Number(rate.value)));
295 const sync = () => {
296 play.textContent = tl.playing ? 'Pause' : 'Play';
297 slider.max = String(tl.times.length - 1);
298 slider.value = String(tl.frame);
299 label.textContent = `t = ${(tl.times[tl.frame] * 1000).toFixed(1)} ms, motion drawn ×${fmtNum(this.exaggeration)}`;
300 rate.value = String(tl.rate);
301 };
302 tl.onChange(sync);
303 sync();
308 this.audioUrl = URL.createObjectURL(encodeWav(audio, fs));
309 this.q<HTMLAudioElement>('audio').src = this.audioUrl;
310 const a = this.q<HTMLAnchorElement>('download');
311 a.href = this.audioUrl;
312 a.download = `${this.getAttribute('name') ?? 'toy-instrument'}.wav`;
314 this.q('sound-btn').hidden = true;
319 // Dark or light series colors from the host page's color-scheme, and a redraw when the
320 // page switches theme (jeremy.magland.org sets data-theme on <html>).
323 const media = window.matchMedia('(prefers-color-scheme: dark)');
324 const apply = () => {
325 const scheme = getComputedStyle(this).colorScheme;
326 const dark = scheme.includes('dark') && (!scheme.includes('light') || media.matches);
327 box.classList.toggle('dark', dark);
330 apply();
331 const obs = new MutationObserver(apply);
332 obs.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme', 'class', 'style'] });
333 media.addEventListener('change', apply);
334 this.cleanup.push(() => obs.disconnect(), () => media.removeEventListener('change', apply));
335 }
337 private mountPasscode() {
338 const input = this.q<HTMLInputElement>('cache-pass');
339 const msg = this.q('cache-msg');
340 const show = () => (this.q('cache-state').textContent = getPasscode() ? 'Shared cache: runs here are stored' : 'Shared cache');
341 show();
342 this.q('cache-save').addEventListener('click', async () => {
343 const pass = input.value.trim();
344 if (!pass) return;
345 msg.textContent = 'Checking…';
346 try {
347 const { valid, reason } = await checkPasscode(pass);
348 if (!valid) {
349 msg.textContent = `Not saved: ${reason ?? 'invalid passcode'}.`;
350 return;
351 }
352 setPasscode(pass);
353 input.value = '';
354 msg.textContent = 'Saved in this browser.';
355 } catch (e) {
356 msg.textContent = `Could not check the passcode: ${(e as Error).message}`;
357 }
358 show();
359 });
360 this.q('cache-forget').addEventListener('click', () => {
361 setPasscode(null);
362 msg.textContent = 'Passcode removed from this browser.';
363 show();
364 });
365 }
368function duration(s: number): string {
369 if (s < 90) return `${fmtNum(Math.max(1, Math.round(s)))} s`;
370 if (s < 5400) return `${fmtNum(Math.round(s / 60))} min`;
371 return `${fmtNum(Math.round((s / 3600) * 10) / 10)} h`;
374if (!customElements.get('toy-instrument-sim')) customElements.define('toy-instrument-sim', ToyInstrumentSim);
↑↓ move↵openescclose