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