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 { Spectrogram } from './ui/spectrogram'; import { encodeWav } from './ui/wav'; import { fmtNum, setStyleRoot } from './ui/plot'; import { mountInstrumentPage } from './ui/instrumentPage'; import { CPU_RATE, parseSettings, runCost, simOptions, type Settings } 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); the reader // can change it in the motion bar. It only affects the drawing, so it is not part of the // cache key. // - fmax: top of the spectrogram's frequency axis in Hz (default 24000, capped at half the // sample rate); the reader can change it. // - name: file name of the downloaded .wav (default toy-instrument). // // The reader sees the 3D model, the spectrogram of the sound, and "Play sound" and "Play // motion" buttons. The small light entry (audio and scalars) is downloaded when the widget // comes near the viewport, for the spectrogram; the frames (most of the size) only when // "Play motion" is first pressed, and 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. "Open the full // app" shows the whole instrument page (all parameters, plots, runs) over the host page, // starting from these settings; it is this bundle's copy of the app, so it computes the // same cache keys as the widget. // // 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; private spec!: Spectrogram; private cfg!: AnyConfig; private settings!: Settings; private overlayMounted = false; connectedCallback() { if (this.shadowRoot) return; this.root = this.attachShadow({ mode: 'open' }); this.root.innerHTML = `

Looking for a stored result…

`; this.mountPasscode(); this.mountMotionBar(); this.mountSpectrogram(); this.start().catch((e) => this.fail(`Something went wrong: ${(e as Error).message}`)); } disconnectedCallback() { this.running?.cancel(); this.abort.abort(); this.timeline.pause(); this.closeApp(); 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.cfg = cfg; this.settings = settings; this.p = settings.params; this.mountOverlay(); 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; 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: the spectrogram once the widget is near the viewport, and 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 near = new IntersectionObserver( (entries) => { if (!entries.some((e) => e.isIntersecting)) return; near.disconnect(); this.loadLight() .then((light) => this.showSpectrogram(light.audio, light.fs)) .catch((e) => this.abort.signal.aborted || this.fail(`Could not load the result: ${(e as Error).message}`)); }, { rootMargin: '400px' }, ); near.observe(this); this.cleanup.push(() => near.disconnect()); 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.showSpectrogram(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; } } // the spectrogram, with a choice of its top frequency (attribute fmax, default 24000 Hz) private mountSpectrogram() { this.spec = new Spectrogram(this.q('spec')); const sel = this.q('fmax'); const attr = Number(this.getAttribute('fmax')); const fmax = attr > 0 ? attr : 24000; const choices = [...new Set([...FMAX_CHOICES, fmax])].sort((a, b) => a - b); sel.innerHTML = choices.map((v) => ``).join(''); sel.value = String(fmax); this.spec.fmax = fmax; sel.addEventListener('change', () => { this.spec.fmax = Number(sel.value); this.spec.draw(); }); } private showSpectrogram(audio: Float32Array, fs: number) { this.q('spec-block').hidden = false; this.spec.setSignal(audio, fs); } 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))); // exaggeration choices, including the attribute's value const exag = this.q('m-exag'); const ex = Number(this.getAttribute('exaggeration') ?? 5); if (ex > 0) this.exaggeration = ex; const choices = [...new Set([...EXAGGERATIONS, this.exaggeration])].sort((a, b) => a - b); exag.innerHTML = choices.map((v) => ``).join(''); exag.value = String(this.exaggeration); exag.addEventListener('change', () => { this.exaggeration = Number(exag.value); this.scene?.setExaggeration(this.exaggeration); }); 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`; 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'); } // The full instrument page in a full-window overlay, mounted on first open and kept // (with its state) while closed. private mountOverlay() { const open = this.q('open-app'); open.hidden = false; open.addEventListener('click', () => this.openApp()); this.q('close-app').addEventListener('click', () => this.closeApp()); this.q('overlay').addEventListener('keydown', (e) => { if (e.key === 'Escape') this.closeApp(); }); } private bodyOverflow: string | null = null; private openApp() { const overlay = this.q('overlay'); this.timeline.pause(); this.q('audio').pause(); setStyleRoot(overlay); if (!this.overlayMounted) { this.overlayMounted = true; mountInstrumentPage(this.q('app'), this.cfg, { makeWorker: () => new SolverWorker(), embedded: { settings: this.settings } }); } overlay.hidden = false; this.bodyOverflow = document.body.style.overflow; document.body.style.overflow = 'hidden'; this.q('close-app').focus(); } private closeApp() { const overlay = this.q('overlay'); if (overlay.hidden) return; overlay.hidden = true; document.body.style.overflow = this.bodyOverflow ?? ''; setStyleRoot(null); this.q('open-app').focus(); } // 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(); this.spec.draw(); }; 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(); }); } } // choices for the spectrogram's top frequency (Hz) const FMAX_CHOICES = [1000, 2500, 5000, 10000, 24000]; // choices in the motion bar's exaggeration dropdown const EXAGGERATIONS = [1, 2, 5, 10, 20, 50, 100]; 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);