Embedded 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)
5 changed files+171−81
README.mdmodified+5−4View file
@@ -71,14 +71,15 @@ Runs are cached on [artifact-cache](https://artifact-cache.tempory.net) (app `to
7171
7272 ## Embedding
7373
74-`src/embed.ts` defines a web component for other pages (posts on jeremy.magland.org): one simulation, with the 3D model and its replay and the sound.
74+`src/embed.ts` defines a web component for other pages (posts on jeremy.magland.org): one simulation, with the 3D model, its motion and its sound.
7575
7676 ```html
77-<toy-instrument-sim name="crash" settings="https://magland.github.io/toy-instruments/#/bead-cymbal?…&preset=0&gpu=1"></toy-instrument-sim>
77+<toy-instrument-sim name="crash" exaggeration="5" settings="https://magland.github.io/toy-instruments/#/bead-cymbal?…&preset=0&gpu=1"></toy-instrument-sim>
7878 ```
7979
80-- `settings` is a link from "Copy link to these settings" (or its `#/<instrument>?…` part); `name` names the downloaded `.wav`.
81-- The widget builds the instrument from the settings and shows the setup at once. It then looks in the shared cache, for the link's backend first and then the other: if there is a result, it loads the light entry (the audio player appears) and offers to load the motion (the frames, for the replay). If there is none, or the cache cannot be reached, it offers to run the simulation in the browser, with a rough time estimate. With the cache passcode saved in that browser (under "Shared cache" in the widget), the run is stored under the same key.
80+- `settings` is a link from "Copy link to these settings" (or its `#/<instrument>?…` part). `exaggeration` magnifies the drawn motion (default 5); it is not part of the cache key. `name` names the downloaded `.wav`.
81+- The reader sees the model at rest (the 3D view without its tabs, view buttons or exaggeration input; drag to rotate) and two buttons. "Play sound" downloads the light entry and plays it in an audio player; "Play motion (N MB)" downloads the frames and plays the replay in slow motion, with pause, a time slider and a speed choice. Each row expands in place, so the other button does not move. On load the widget only sends HEAD requests (for the link's backend first, then the other).
82+- If the cache has no result, or cannot be reached, the widget offers to run the simulation in the browser, with a rough time estimate, and then shows both players. With the cache passcode saved in that browser (under "Shared cache" in the widget), the run is stored under the same key.
8283 - The key hashes what the bundle's own model code builds, so a page that keeps one build of the bundle asks for, and can always refill, the same entries, whatever later versions of the app do. jeremy.magland.org uploads each build under a content hash and never replaces it.
8384 - It takes the host page's colors and font from jeremy.magland.org's CSS variables (`--bg`, `--text`, `--link`, ...), with this app's colors otherwise, and redraws when the page changes theme.
8485 - Build: `npm run build:embed` writes `embed-dist/toy-instruments-embed.js` (one ES module, about 1 MB, 230 kB gzipped), with three.js, the styles and the solver worker inlined; a worker must come from the page's origin, so it starts from a blob URL. The `prepare` script runs the same build, so the package can be installed as a git dependency (`import 'toy-instruments/embed'`); `embed-dist/` is not committed.
src/embed.cssmodified+4−1View file
@@ -43,7 +43,10 @@
4343 .status { margin: 10px 0 0; color: var(--text-secondary); font-size: 13px; }
4444 .actions { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; margin-top: 10px; }
4545 .actions:empty { display: none; }
46-.audio-row { margin-top: 10px; }
46+/* one row each for sound and motion, so expanding one never moves the other */
47+.players { display: grid; gap: 10px; justify-items: start; margin-top: 10px; }
48+.player.wide { justify-self: stretch; }
49+.player .audio-row, .player .viewer-bar { margin: 0; }
4750 .error { color: #b42d2c; font-size: 13px; margin: 8px 0 0; }
4851 .tis.dark .error { color: #e66767; }
4952 .cache-ctl { font-size: 12px; margin-top: 10px; }
src/embed.tsmodified+141−71View file
@@ -3,21 +3,28 @@ import embedCss from './embed.css?inline';
33 import SolverWorker from './sim/worker.ts?worker&inline';
44 import { CONFIGS, type AnyConfig } from './instruments/configs';
55 import { BeadScene } from './ui/scene3d';
6-import { Timeline } from './ui/timeline';
6+import { RATES, Timeline } from './ui/timeline';
77 import { encodeWav } from './ui/wav';
88 import { fmtNum } from './ui/plot';
99 import { CPU_RATE, parseSettings, runCost, simOptions } from './ui/settings';
1010 import { runInWorker, type Running } from './sim/run';
11-import { checkPasscode, getPasscode, loadFrames, loadLight, probeFrames, resultKey, setPasscode, storeResult, type Backend, type LightResult } from './sim/cache';
11+import { checkPasscode, getPasscode, loadFrames, loadLight, probeFrames, probeResult, resultKey, setPasscode, storeResult, type Backend, type LightResult } from './sim/cache';
1212 import type { Built, SimParams } from './ui/instrumentPage';
1313 import type { SimResult } from './sim/types';
1414
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.
15+// <toy-instrument-sim settings="…" exaggeration="5" name="…">: one simulation of a toy
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.
2128 //
2229 // The key hashes what this bundle's model code builds from the settings, so a page that
2330 // keeps this exact bundle (jeremy.magland.org freezes each upload) always asks for, and
@@ -25,17 +32,23 @@ import type { SimResult } from './sim/types';
2532
2633 interface Found {
2734 key: string;
28- backend: Backend;
29- light: LightResult;
3035 stored?: Date;
36+ framesBytes: number | null;
3137 }
3238
3339 class ToyInstrumentSim extends HTMLElement {
3440 private root!: ShadowRoot;
3541 private running: Running | null = null;
36- private abort: AbortController | null = null;
42+ private abort = new AbortController();
3743 private audioUrl: string | null = null;
3844 private cleanup: (() => void)[] = [];
45+ private p!: SimParams;
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;
3952
4053 connectedCallback() {
4154 if (this.shadowRoot) return;
@@ -44,10 +57,23 @@ class ToyInstrumentSim extends HTMLElement {
4457 <style>${baseCss}${embedCss}</style>
4558 <div class="tis">
4659 <div data-r="scene"></div>
60+ <div class="players">
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>
4775 <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>
4976 <div class="actions">
50- <button class="btn-small" data-r="motion" hidden></button>
5177 <button class="btn" data-r="run" hidden></button>
5278 <button class="btn-small" data-r="cancel" hidden>Cancel</button>
5379 </div>
@@ -61,12 +87,14 @@ class ToyInstrumentSim extends HTMLElement {
6187 </details>
6288 </div>`;
6389 this.mountPasscode();
90+ this.mountMotionBar();
6491 this.start().catch((e) => this.fail(`Something went wrong: ${(e as Error).message}`));
6592 }
6693
6794 disconnectedCallback() {
6895 this.running?.cancel();
69- this.abort?.abort();
96+ this.abort.abort();
97+ this.timeline.pause();
7098 for (const f of this.cleanup) f();
7199 this.cleanup = [];
72100 }
@@ -96,46 +124,43 @@ class ToyInstrumentSim extends HTMLElement {
96124 return this.fail(`Unknown instrument "${id}" in the settings attribute.`);
97125 }
98126 const settings = parseSettings(cfg, query);
99- const p: SimParams = settings.params;
100- const built = cfg.setup(p).built;
127+ this.p = settings.params;
128+ const built = cfg.setup(this.p).built;
101129 if (!built) {
102130 this.status('');
103131 return this.fail('These settings do not give a valid instrument.');
104132 }
133+ this.built = built;
105134
106- const timeline = new Timeline();
107- const scene = new BeadScene(this.q('scene'), timeline);
108- scene.showSetup(built.scene);
109- this.followTheme(scene);
135+ const ex = Number(this.getAttribute('exaggeration') ?? 5);
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();
110140
111- const hasGpu = 'gpu' in navigator;
112141 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- };
142+ const opt = simOptions(this.p, built);
118143 const order: Backend[] = wanted === 'gpu' ? ['gpu', 'cpu'] : ['cpu', 'gpu'];
119144
120- // the result for the settings' backend, or else for the other one
121- let found: Found | null = null;
145+ // the result for the settings' backend, or else for the other one (HEAD requests only)
122146 let unreachable = false;
123147 for (const backend of order) {
148+ const key = await resultKey(id, built.sys, built.exc, opt, backend);
124149 try {
125- const hit = await loadLight(keys[backend]);
150+ const hit = await probeResult(key, this.abort.signal);
126151 if (hit) {
127- found = { key: keys[backend], backend, ...hit };
152+ this.found = { key, stored: hit.stored, framesBytes: await probeFrames(key, this.abort.signal) };
128153 break;
129154 }
130155 } catch {
131156 unreachable = true;
132157 }
133158 }
134- if (found) return this.showFound(found, p, built, scene, timeline);
159+ if (this.found) return this.offerPlayback();
135160
136161 // nothing stored: offer to run it here
137- const gpu = wanted === 'gpu' && hasGpu;
138- const cpuSeconds = runCost(p, built).work / CPU_RATE;
162+ const gpu = wanted === 'gpu' && 'gpu' in navigator;
163+ const cpuSeconds = runCost(this.p, built).work / CPU_RATE;
139164 this.status(
140165 `${unreachable ? 'The shared cache could not be reached' : 'No stored result for these settings yet'}. ` +
141166 `You can run the simulation in this browser: roughly ${duration(cpuSeconds)} on a desktop CPU` +
@@ -144,45 +169,65 @@ class ToyInstrumentSim extends HTMLElement {
144169 const runBtn = this.q<HTMLButtonElement>('run');
145170 runBtn.textContent = `Run simulation${gpu ? ' (WebGPU)' : ''}`;
146171 runBtn.hidden = false;
147- runBtn.addEventListener('click', () => this.run(id, p, built, gpu, scene, timeline));
172+ runBtn.addEventListener('click', () => this.run(id, gpu));
148173 }
149174
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();
175+ // A stored result: buttons that download the sound and the motion when first pressed.
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…';
170184 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 });
185+ const light = await this.loadLight();
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();
175210 } catch (e) {
176211 if (this.abort.signal.aborted) return;
177- motion.disabled = false;
212+ motionBtn.disabled = false;
213+ motionBtn.textContent = label;
178214 this.fail(`Could not load the motion: ${(e as Error).message}`);
179- } finally {
180- progress.hidden = true;
181215 }
182216 });
183217 }
184218
185- private async run(id: string, p: SimParams, built: Built, gpu: boolean, scene: BeadScene, timeline: Timeline) {
219+ // the light entry (audio, frame times, mallet path), downloaded once
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+ }
228+
229+ private async run(id: string, gpu: boolean) {
230+ const { p, built } = this;
186231 const runBtn = this.q<HTMLButtonElement>('run');
187232 const cancelBtn = this.q<HTMLButtonElement>('cancel');
188233 const progress = this.q<HTMLProgressElement>('progress');
@@ -206,8 +251,8 @@ class ToyInstrumentSim extends HTMLElement {
206251 try {
207252 const r = await running.promise;
208253 runBtn.hidden = true;
209- this.setAudio(r.audio, r.fs, p);
210- this.showReplay(scene, timeline, built, r);
254+ this.setAudio(r.audio, r.fs);
255+ this.showMotion(r);
211256 const ran = `Simulated in this browser with ${r.backend ?? 'CPU'}${r.fallback ? ` (WebGPU failed: ${r.fallback})` : ''} in ${duration(r.wallSeconds)}.`;
212257 if (!getPasscode()) return this.status(`${ran} Not stored: this browser has no cache passcode.`);
213258 this.status(`${ran} Storing in the shared cache…`);
@@ -230,32 +275,57 @@ class ToyInstrumentSim extends HTMLElement {
230275 }
231276 }
232277
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 });
278+ private showMotion(r: SimResult) {
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+ }
284+
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();
236304 }
237305
238- private setAudio(audio: Float32Array, fs: number, p: SimParams) {
306+ private setAudio(audio: Float32Array, fs: number) {
239307 if (this.audioUrl) URL.revokeObjectURL(this.audioUrl);
240308 this.audioUrl = URL.createObjectURL(encodeWav(audio, fs));
241309 this.q<HTMLAudioElement>('audio').src = this.audioUrl;
242310 const a = this.q<HTMLAnchorElement>('download');
243311 a.href = this.audioUrl;
244312 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`;
313+ a.title = `16-bit mono, ${fs} Hz, normalized so the peak is −1 dBFS`;
314+ this.q('sound-btn').hidden = true;
246315 this.q('audio-row').hidden = false;
316+ this.q('audio-row').parentElement!.classList.add('wide');
247317 }
248318
249319 // Dark or light series colors from the host page's color-scheme, and a redraw when the
250320 // page switches theme (jeremy.magland.org sets data-theme on <html>).
251- private followTheme(scene: BeadScene) {
321+ private followTheme() {
252322 const box = this.root.querySelector('.tis')!;
253323 const media = window.matchMedia('(prefers-color-scheme: dark)');
254324 const apply = () => {
255325 const scheme = getComputedStyle(this).colorScheme;
256326 const dark = scheme.includes('dark') && (!scheme.includes('light') || media.matches);
257327 box.classList.toggle('dark', dark);
258- scene.restyle();
328+ this.scene.restyle();
259329 };
260330 apply();
261331 const obs = new MutationObserver(apply);
src/ui/scene3d.tsmodified+20−4View file
@@ -26,6 +26,14 @@ export interface ReplaySpec extends SceneSpec {
2626 }
2727
2828 type Mode = 'setup' | 'replay';
29+
30+export interface SceneOptions {
31+ // just the model and its legend: no setup/replay tabs, view buttons, exaggeration
32+ // input, timeline bar or help note (the embedded widget has its own controls)
33+ simple?: boolean;
34+ // displacement exaggeration for replays (default: automatic, so the largest motion is visible)
35+ exaggeration?: number;
36+}
2937 type View = 'persp' | 'front' | 'top' | 'end';
3038
3139 const GLYPH = 0.12; // length of the largest excitation glyph, in units of the model size
@@ -65,7 +73,11 @@ export class BeadScene {
6573 private spec: SceneSpec | null = null;
6674 private frames: Float32Array | null = null;
6775
68- constructor(root: HTMLElement, private tl: Timeline) {
76+ constructor(
77+ root: HTMLElement,
78+ private tl: Timeline,
79+ private opts: SceneOptions = {},
80+ ) {
6981 root.innerHTML = `
7082 <div class="card-head">
7183 <div class="seg" data-r="tabs"><button data-v="setup" class="on">Setup</button><button data-v="replay" disabled>Replay</button></div>
@@ -89,6 +101,10 @@ export class BeadScene {
89101 this.bar = timelineBar(tl);
90102 this.bar.hidden = true;
91103 q('bar').replaceWith(this.bar);
104+ if (opts.simple) {
105+ (root.querySelector('.card-head') as HTMLElement).hidden = true;
106+ this.note.hidden = true;
107+ }
92108
93109 this.tabs.addEventListener('click', (e) => {
94110 const b = (e.target as HTMLElement).closest('button');
@@ -156,7 +172,8 @@ export class BeadScene {
156172 const size = modelSize(spec.sys);
157173 const wasAuto = this.exaggeration === this.autoExaggeration;
158174 this.autoExaggeration = tmax > 0 ? Math.max(1, (GLYPH * size) / tmax) : 1;
159- if (!keep || wasAuto) this.exaggeration = this.autoExaggeration;
175+ if (this.opts.exaggeration) this.exaggeration = this.opts.exaggeration;
176+ else if (!keep || wasAuto) this.exaggeration = this.autoExaggeration;
160177 this.exagInput.value = fmtNum(this.exaggeration);
161178 if (keep) {
162179 if (this.mode === 'replay') {
@@ -172,8 +189,7 @@ export class BeadScene {
172189 private setMode(m: Mode) {
173190 this.mode = m;
174191 this.tabs.querySelectorAll<HTMLButtonElement>('button').forEach((b) => b.classList.toggle('on', b.dataset.v === m));
175- this.bar.hidden = m !== 'replay';
176- this.exagRow.hidden = m !== 'replay';
192+ this.bar.hidden = this.exagRow.hidden = m !== 'replay' || !!this.opts.simple;
177193 if (m === 'setup') this.tl.pause();
178194 this.rebuild();
179195 }
src/ui/timeline.tsmodified+1−1View file
@@ -82,7 +82,7 @@ export class Timeline {
8282 };
8383 }
8484
85-const RATES: [number, string][] = [
85+export const RATES: [number, string][] = [
8686 [0.001, '1/1000 ×'],
8787 [0.01, '1/100 ×'],
8888 [0.05, '1/20 ×'],