import baseCss from './style.css?inline'; import embedCss from './embed.css?inline'; import SolverWorker from './sim/worker.ts?worker&inline'; import { CONFIGS, type AnyConfig } from './instruments/configs'; import { BeadScene } from './ui/scene3d'; import { RATES, Timeline } from './ui/timeline'; import { encodeWav } from './ui/wav'; import { fmtNum } from './ui/plot'; import { CPU_RATE, parseSettings, runCost, simOptions } from './ui/settings'; import { runInWorker, type Running } from './sim/run'; import { checkPasscode, getPasscode, loadFrames, loadLight, probeFrames, probeResult, resultKey, setPasscode, storeResult, type Backend, type LightResult } from './sim/cache'; import type { Built, SimParams } from './ui/instrumentPage'; import type { SimResult } from './sim/types'; // : one simulation of a toy // instrument, for embedding in other pages (blog posts). // // - settings: a link from "Copy link to these settings" (or just its #/?… part). // - exaggeration: how much the motion is magnified in the replay (default 5). It only // affects the drawing, so it is not part of the cache key. // - name: file name of the downloaded .wav (default toy-instrument). // // The reader sees the 3D model with "Play sound" and "Play motion" buttons; each downloads // what it needs from the shared cache on first press (the small light entry for the sound, // the frames for the motion, played in slow motion). When the cache has no result, the // widget offers to run the simulation in the browser; with the cache passcode set, the run // is stored under the same key, so the next reader gets it from the cache. // // The key hashes what this bundle's model code builds from the settings, so a page that // keeps this exact bundle (jeremy.magland.org freezes each upload) always asks for, and // fills, the same entry, however the app changes later. interface Found { key: string; stored?: Date; framesBytes: number | null; } class ToyInstrumentSim extends HTMLElement { private root!: ShadowRoot; private running: Running | null = null; private abort = new AbortController(); private audioUrl: string | null = null; private cleanup: (() => void)[] = []; private p!: SimParams; private built!: Built; private scene!: BeadScene; private timeline = new Timeline(); private found: Found | null = null; private light: Promise | null = null; private exaggeration = 5; connectedCallback() { if (this.shadowRoot) return; this.root = this.attachShadow({ mode: 'open' }); this.root.innerHTML = `

Looking for a stored result…

Results come from a shared cache. With its passcode, simulations run here are stored there, for everyone who opens this page.

`; this.mountPasscode(); this.mountMotionBar(); this.start().catch((e) => this.fail(`Something went wrong: ${(e as Error).message}`)); } disconnectedCallback() { this.running?.cancel(); this.abort.abort(); this.timeline.pause(); for (const f of this.cleanup) f(); this.cleanup = []; } private q(r: string): T { return this.root.querySelector(`[data-r="${r}"]`) as T; } private status(text: string) { this.q('status').textContent = text; } private fail(text: string) { const box = this.q('error'); box.textContent = text; box.hidden = false; } private async start() { const spec = this.getAttribute('settings') ?? ''; const hash = spec.includes('#') ? spec.slice(spec.indexOf('#') + 1) : spec; const [route, query = ''] = hash.split('?'); const id = route.replace(/^\//, ''); const cfg: AnyConfig | undefined = CONFIGS[id]; if (!cfg) { this.status(''); return this.fail(`Unknown instrument "${id}" in the settings attribute.`); } const settings = parseSettings(cfg, query); this.p = settings.params; const built = cfg.setup(this.p).built; if (!built) { this.status(''); return this.fail('These settings do not give a valid instrument.'); } this.built = built; const ex = Number(this.getAttribute('exaggeration') ?? 5); if (ex > 0) this.exaggeration = ex; this.scene = new BeadScene(this.q('scene'), this.timeline, { simple: true, exaggeration: this.exaggeration }); this.scene.showSetup(built.scene); this.followTheme(); const wanted: Backend = (settings.gpu ?? cfg.webgpuDefault !== false) ? 'gpu' : 'cpu'; const opt = simOptions(this.p, built); const order: Backend[] = wanted === 'gpu' ? ['gpu', 'cpu'] : ['cpu', 'gpu']; // the result for the settings' backend, or else for the other one (HEAD requests only) let unreachable = false; for (const backend of order) { const key = await resultKey(id, built.sys, built.exc, opt, backend); try { const hit = await probeResult(key, this.abort.signal); if (hit) { this.found = { key, stored: hit.stored, framesBytes: await probeFrames(key, this.abort.signal) }; break; } } catch { unreachable = true; } } if (this.found) return this.offerPlayback(); // nothing stored: offer to run it here const gpu = wanted === 'gpu' && 'gpu' in navigator; const cpuSeconds = runCost(this.p, built).work / CPU_RATE; this.status( `${unreachable ? 'The shared cache could not be reached' : 'No stored result for these settings yet'}. ` + `You can run the simulation in this browser: roughly ${duration(cpuSeconds)} on a desktop CPU` + (gpu ? ', usually much less with WebGPU.' : wanted === 'gpu' ? ' (this browser has no WebGPU, so it runs on the CPU).' : '.'), ); const runBtn = this.q('run'); runBtn.textContent = `Run simulation${gpu ? ' (WebGPU)' : ''}`; runBtn.hidden = false; runBtn.addEventListener('click', () => this.run(id, gpu)); } // A stored result: buttons that download the sound and the motion when first pressed. private offerPlayback() { const f = this.found!; this.status(f.stored ? `Computed on ${f.stored.toISOString().slice(0, 10)}.` : ''); const soundBtn = this.q('sound-btn'); soundBtn.hidden = false; soundBtn.addEventListener('click', async () => { soundBtn.disabled = true; soundBtn.textContent = 'Loading…'; try { const light = await this.loadLight(); soundBtn.hidden = true; this.setAudio(light.audio, light.fs); this.q('audio').play().catch(() => {}); } catch (e) { soundBtn.disabled = false; soundBtn.textContent = '▶ Play sound'; this.fail(`Could not load the sound: ${(e as Error).message}`); } }); if (f.framesBytes === null) return; const motionBtn = this.q('motion-btn'); const label = `▶ Play motion (${fmtNum(Math.round(f.framesBytes / 1e5) / 10)} MB)`; motionBtn.textContent = label; motionBtn.hidden = false; motionBtn.addEventListener('click', async () => { motionBtn.disabled = true; motionBtn.textContent = 'Loading…'; try { const light = await this.loadLight(); const frames = await loadFrames(f.key, (x) => (motionBtn.textContent = `Loading… ${Math.round(x * 100)}%`), this.abort.signal); if (!frames) throw new Error('it is no longer in the cache'); motionBtn.hidden = true; this.showMotion({ ...light, frames }); this.timeline.play(); } catch (e) { if (this.abort.signal.aborted) return; motionBtn.disabled = false; motionBtn.textContent = label; this.fail(`Could not load the motion: ${(e as Error).message}`); } }); } // the light entry (audio, frame times, mallet path), downloaded once private loadLight(): Promise { this.light ??= loadLight(this.found!.key, this.abort.signal).then((hit) => { if (!hit) throw new Error('it is no longer in the cache'); return hit.light; }); this.light.catch(() => (this.light = null)); return this.light; } private async run(id: string, gpu: boolean) { const { p, built } = this; const runBtn = this.q('run'); const cancelBtn = this.q('cancel'); const progress = this.q('progress'); const opt = simOptions(p, built); runBtn.disabled = true; cancelBtn.hidden = progress.hidden = false; progress.value = 0; this.q('error').hidden = true; this.status('Simulating…'); const running = runInWorker( () => new SolverWorker(), built.sys, built.exc, opt, gpu, (x) => (progress.value = x), (r) => this.status(`Simulating… ${fmtNum(r.audio.length / r.fs)} s of ${fmtNum(p.duration)} s done.`), ); this.running = running; cancelBtn.onclick = () => running.cancel(); try { const r = await running.promise; runBtn.hidden = true; this.setAudio(r.audio, r.fs); this.showMotion(r); const ran = `Simulated in this browser with ${r.backend ?? 'CPU'}${r.fallback ? ` (WebGPU failed: ${r.fallback})` : ''} in ${duration(r.wallSeconds)}.`; if (!getPasscode()) return this.status(`${ran} Not stored: this browser has no cache passcode.`); this.status(`${ran} Storing in the shared cache…`); // a WebGPU run that fell back to the CPU is stored as a CPU result const backend: Backend = r.backend?.startsWith('WebGPU') ? 'gpu' : 'cpu'; const key = await resultKey(id, built.sys, built.exc, opt, backend); try { await storeResult(key, r, { instrument: id, backend: r.backend, wallSeconds: r.wallSeconds, from: location.href }); this.status(`${ran} Stored in the shared cache.`); } catch (e) { this.status(`${ran} Not stored in the shared cache: ${(e as Error).message}`); } } catch (e) { runBtn.disabled = false; if ((e as Error).message === 'cancelled') this.status('The run was cancelled.'); else this.fail(`The simulation failed: ${(e as Error).message}`); } finally { this.running = null; cancelBtn.hidden = progress.hidden = true; } } private showMotion(r: SimResult) { this.timeline.setTimes(r.frameTimes); this.scene.showReplay({ ...this.built.scene, frames: r.frames, malletPos: r.malletPos }); this.q('motion-bar').hidden = false; this.q('motion-bar').parentElement!.classList.add('wide'); } // Play/pause, time slider, time label and speed for the replay. private mountMotionBar() { const tl = this.timeline; const play = this.q('m-play'); const slider = this.q('m-slider'); const label = this.q('m-label'); const rate = this.q('m-rate'); play.addEventListener('click', () => tl.toggle()); slider.addEventListener('input', () => tl.seek(Number(slider.value))); rate.addEventListener('change', () => tl.setRate(Number(rate.value))); const sync = () => { play.textContent = tl.playing ? 'Pause' : 'Play'; slider.max = String(tl.times.length - 1); slider.value = String(tl.frame); label.textContent = `t = ${(tl.times[tl.frame] * 1000).toFixed(1)} ms, motion drawn ×${fmtNum(this.exaggeration)}`; rate.value = String(tl.rate); }; tl.onChange(sync); sync(); } private setAudio(audio: Float32Array, fs: number) { if (this.audioUrl) URL.revokeObjectURL(this.audioUrl); this.audioUrl = URL.createObjectURL(encodeWav(audio, fs)); this.q('audio').src = this.audioUrl; const a = this.q('download'); a.href = this.audioUrl; a.download = `${this.getAttribute('name') ?? 'toy-instrument'}.wav`; a.title = `16-bit mono, ${fs} Hz, normalized so the peak is −1 dBFS`; this.q('sound-btn').hidden = true; this.q('audio-row').hidden = false; this.q('audio-row').parentElement!.classList.add('wide'); } // Dark or light series colors from the host page's color-scheme, and a redraw when the // page switches theme (jeremy.magland.org sets data-theme on ). private followTheme() { const box = this.root.querySelector('.tis')!; const media = window.matchMedia('(prefers-color-scheme: dark)'); const apply = () => { const scheme = getComputedStyle(this).colorScheme; const dark = scheme.includes('dark') && (!scheme.includes('light') || media.matches); box.classList.toggle('dark', dark); this.scene.restyle(); }; apply(); const obs = new MutationObserver(apply); obs.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme', 'class', 'style'] }); media.addEventListener('change', apply); this.cleanup.push(() => obs.disconnect(), () => media.removeEventListener('change', apply)); } private mountPasscode() { const input = this.q('cache-pass'); const msg = this.q('cache-msg'); const show = () => (this.q('cache-state').textContent = getPasscode() ? 'Shared cache: runs here are stored' : 'Shared cache'); show(); this.q('cache-save').addEventListener('click', async () => { const pass = input.value.trim(); if (!pass) return; msg.textContent = 'Checking…'; try { const { valid, reason } = await checkPasscode(pass); if (!valid) { msg.textContent = `Not saved: ${reason ?? 'invalid passcode'}.`; return; } setPasscode(pass); input.value = ''; msg.textContent = 'Saved in this browser.'; } catch (e) { msg.textContent = `Could not check the passcode: ${(e as Error).message}`; } show(); }); this.q('cache-forget').addEventListener('click', () => { setPasscode(null); msg.textContent = 'Passcode removed from this browser.'; show(); }); } } function duration(s: number): string { if (s < 90) return `${fmtNum(Math.max(1, Math.round(s)))} s`; if (s < 5400) return `${fmtNum(Math.round(s / 60))} min`; return `${fmtNum(Math.round((s / 3600) * 10) / 10)} h`; } if (!customElements.get('toy-instrument-sim')) customElements.define('toy-instrument-sim', ToyInstrumentSim);