/ proof-of-concept / toy-instruments
Sign in
proof-of-concept / toy-instruments
toy-instruments / src / embed.ts
304 lines · 12.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';
6import { Timeline } from './ui/timeline';
7import { encodeWav } from './ui/wav';
8import { fmtNum } from './ui/plot';
9import { CPU_RATE, parseSettings, runCost, simOptions } from './ui/settings';
10import { runInWorker, type Running } from './sim/run';
11import { checkPasscode, getPasscode, loadFrames, loadLight, probeFrames, resultKey, setPasscode, storeResult, type Backend, type LightResult } from './sim/cache';
12import type { Built, SimParams } from './ui/instrumentPage';
13import type { SimResult } from './sim/types';
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.
26interface Found {
27 key: string;
28 backend: Backend;
29 light: LightResult;
30 stored?: Date;
33class 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)[] = [];
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 }
67 disconnectedCallback() {
68 this.running?.cancel();
69 this.abort?.abort();
70 for (const f of this.cleanup) f();
71 this.cleanup = [];
72 }
74 private q<T extends HTMLElement>(r: string): T {
75 return this.root.querySelector(`[data-r="${r}"]`) as T;
76 }
78 private status(text: string) {
79 this.q('status').textContent = text;
80 }
82 private fail(text: string) {
83 const box = this.q('error');
84 box.textContent = text;
85 box.hidden = false;
86 }
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 }
106 const timeline = new Timeline();
107 const scene = new BeadScene(this.q('scene'), timeline);
108 scene.showSetup(built.scene);
109 this.followTheme(scene);
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'];
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);
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 }
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 }
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 }
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 }
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 }
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 }
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 }
298function 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`;
304if (!customElements.get('toy-instrument-sim')) customElements.define('toy-instrument-sim', ToyInstrumentSim);
↑↓ move↵openescclose