/ proof-of-concept / toy-instruments
Sign in
proof-of-concept / toy-instruments
toy-instruments / src / ui / instrumentPage.ts
566 lines · 25.9 KBCodeBlameHistory
2cd36faBead drum: hex-lattice round head, felt mallet contact, top view and cross-section; shared instrument pageJeremy Magland 1import { renderControls, type Section } from './controls';
2import { LinePlot, css, fmtNum, type Marker } from './plot';
3import { Spectrogram } from './spectrogram';
4import { spectrum } from './fft';
5import { encodeWav } from './wav';
6import { BeadScene, type SceneSpec } from './scene3d';
7import { Timeline } from './timeline';
b51a77aShared result cache on artifact-cache: offer a cached result for the current settings next to Run; store runs when a passcode is setJeremy Magland 10import { checkPasscode, getPasscode, loadResult, probeResult, resultKey, setPasscode, storeResult, type Backend } from '../sim/cache';
2cd36faBead drum: hex-lattice round head, felt mallet contact, top view and cross-section; shared instrument pageJeremy Magland 11import type { BeadSystem, Excitation, SimResult } from '../sim/types';
13// The page every instrument shares: presets and control panel, 3D model,
14// setup summary, run summary, audio, waveform, spectrogram, spectrum, energy.
15// An instrument adds its own setup rows, extra result cards and views.
17export interface SimParams {
18 duration: number;
19 fs: number;
20 cfl: number;
21 frameRate: number;
24export interface Built {
25 sys: BeadSystem;
26 exc: Excitation;
27 pickup: Float64Array;
32export interface Preset<P> {
33 name: string;
34 description: string;
35 params: P;
38export interface Extras<P> {
39 show(p: P, built: Built, r: SimResult): void;
42export interface InstrumentConfig<P extends SimParams> {
43 title: string;
44 wavName: string;
c696b9aCymbal: plate bending via per-triangle shape operators, felt centre support, stick and mallet with angle and friction, far-field microphoneJeremy Magland 45 audioQuantity?: { label: string; unit: string }; // default: acceleration, m/s²
47 sections: Section<P>[];
a78afe0Add WebGPU solver with a toggle; faster CPU solverJeremy Magland 48 // WebGPU on by default (default true); off where the systems are too small to gain from it
49 webgpuDefault?: boolean;
2cd36faBead drum: hex-lattice round head, felt mallet contact, top view and cross-section; shared instrument pageJeremy Magland 50 // instrument-specific summary of the setup; built = null when the parameters are invalid
51 setup(p: P): { rows: [string, string][]; warnings: string[]; built: Built | null };
52 summaryRows?(p: P, built: Built, r: SimResult): [string, string][];
53 spectrumMarkers?(p: P): { markers: Marker[]; legend: string };
54 extraCards?: string; // cards with data-r="out" hidden, shown after a run
55 mountExtras?(q: <T extends HTMLElement>(r: string) => T, timeline: Timeline): Extras<P>;
59 makeWorker: MakeWorker; // starts a solver worker (src/sim/worker.ts)
60 // Mounted inside another page (the embedded widget's full-app view): start from these
61 // settings, leave the address alone, and leave out the back link and "Copy link".
62 embedded?: { settings: Settings<P> };
2cd36faBead drum: hex-lattice round head, felt mallet contact, top view and cross-section; shared instrument pageJeremy Magland 65const statRows = (rows: [string, string][]) => rows.map(([k, v]) => `<div><span>${k}</span><b>${v}</b></div>`).join('');
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 67export function mountInstrumentPage<P extends SimParams>(root: HTMLElement, cfg: InstrumentConfig<P>, opts: PageOptions<P>) {
69 <div class="instrument">
70 <aside class="panel">
73 <label class="ctl preset"><span class="ctl-name">Preset</span><select data-r="preset"></select></label>
74 <p class="sub" data-r="preset-desc"></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 75 <div class="share-row"${opts.embedded ? ' hidden' : ''}><button class="btn-small" data-r="share" title="Copy a link that opens this page with the current settings">Copy link to these settings</button><span class="note" data-r="share-msg"></span></div>
decd1b2Copy link to these settings: a shareable link with every parameter, applied on load and then removed from the addressJeremy Magland 76 <input class="share-url" data-r="share-url" readonly hidden aria-label="link to these settings">
2cd36faBead drum: hex-lattice round head, felt mallet contact, top view and cross-section; shared instrument pageJeremy Magland 78 <button class="btn" data-r="run">Run simulation</button>
79 <button class="btn-small" data-r="cancel" hidden>Cancel</button>
b51a77aShared result cache on artifact-cache: offer a cached result for the current settings next to Run; store runs when a passcode is setJeremy Magland 80 <button class="btn-small" data-r="load-cached" hidden>Load cached</button>
a78afe0Add WebGPU solver with a toggle; faster CPU solverJeremy Magland 81 <label class="inline gpu-toggle" data-r="gpu-label"><input type="checkbox" data-r="gpu"> WebGPU</label>
2cd36faBead drum: hex-lattice round head, felt mallet contact, top view and cross-section; shared instrument pageJeremy Magland 84 <progress data-r="progress" max="1" value="0" hidden></progress>
85 <p class="error" data-r="error" hidden></p>
87 <summary data-r="cache-state"></summary>
88 <p class="note">Results are shared: when someone has already computed a run with these settings, you can load it instead of running it again. With the passcode, this browser also stores its runs there.</p>
89 <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>
90 <p class="note" data-r="cache-msg"></p>
91 </details>
94 <main class="results">
95 <section class="card"><h2>Model</h2><div data-r="scene"></div></section>
96 <section class="card"><h2>Setup</h2><div data-r="derived" class="stats"></div><ul data-r="warnings" class="warnings"></ul></section>
97 <section class="card" data-r="out" hidden>
98 <h2>Run</h2><div data-r="summary" class="stats"></div>
2cd36faBead drum: hex-lattice round head, felt mallet contact, top view and cross-section; shared instrument pageJeremy Magland 100 <div class="audio-row"><audio controls data-r="audio"></audio><a class="btn-small" data-r="download" download="${cfg.wavName}">Download .wav</a></div>
101 <p class="note" data-r="audio-note"></p>
102 </section>
103 <section class="card" data-r="out" hidden>
c696b9aCymbal: plate bending via per-triangle shape operators, felt centre support, stick and mallet with angle and friction, far-field microphoneJeremy Magland 104 <div class="card-head"><h2>${cap((cfg.audioQuantity ?? { label: 'pickup acceleration' }).label)}</h2>
2cd36faBead drum: hex-lattice round head, felt mallet contact, top view and cross-section; shared instrument pageJeremy Magland 105 <div class="seg" data-r="wave-range"><button data-v="0" class="on">Full</button><button data-v="0.05">First 50 ms</button><button data-v="0.005">First 5 ms</button></div></div>
106 <div class="canvas-wrap"><canvas data-r="wave"></canvas></div>
107 </section>
108 <section class="card" data-r="out" hidden>
109 <div class="card-head"><h2>Spectrogram</h2>
110 <label class="inline">up to <select data-r="fmax"><option>1000</option><option>2500</option><option selected>5000</option><option>10000</option><option>24000</option></select> Hz</label></div>
111 <div class="canvas-wrap tall"><canvas data-r="spec"></canvas></div>
112 </section>
113 <section class="card" data-r="out" hidden>
114 <div class="card-head"><h2>Spectrum</h2><div class="legend" data-r="spec-legend"></div></div>
115 <div class="canvas-wrap"><canvas data-r="spectrum"></canvas></div>
116 </section>
117 ${cfg.extraCards ?? ''}
119 <h2>Contact force</h2>
120 <div class="canvas-wrap short"><canvas data-r="contact"></canvas></div>
121 <p class="note" data-r="contact-note"></p>
122 </section>
124 <h2>Energy</h2>
125 <div class="canvas-wrap short"><canvas data-r="energy"></canvas></div>
126 <p class="note">Kinetic plus spring energy of the instrument, minus the energy stored by pre-tension at equilibrium. Without damping or forcing it stays flat; the run summary gives the error of the energy balance (energy + dissipated − work in − initial, mallet included).</p>
127 </section>
128 </main>
129 </div>`;
130 const q = <T extends HTMLElement>(r: string) => root.querySelector(`[data-r="${r}"]`) as T;
131 const presetSel = q<HTMLSelectElement>('preset');
132 cfg.presets.forEach((p, i) => presetSel.add(new Option(p.name, String(i))));
133 const presetDesc = q<HTMLParagraphElement>('preset-desc');
134 const runBtn = q<HTMLButtonElement>('run');
135 const cancelBtn = q<HTMLButtonElement>('cancel');
136 const progress = q<HTMLProgressElement>('progress');
137 const errorBox = q<HTMLParagraphElement>('error');
a78afe0Add WebGPU solver with a toggle; faster CPU solverJeremy Magland 138 const gpuBox = q<HTMLInputElement>('gpu');
140 const availNote = q<HTMLParagraphElement>('cached-avail');
141 const cacheNote = q<HTMLParagraphElement>('cache-note');
142 const instrumentId = cfg.wavName.replace(/\.wav$/, ''); // e.g. bead-cymbal
a78afe0Add WebGPU solver with a toggle; faster CPU solverJeremy Magland 143 const hasGpu = 'gpu' in navigator;
144 const gpuKey = `toy-instruments.webgpu.${cfg.wavName}`;
145 let gpuPref: string | null = null;
146 try {
147 gpuPref = localStorage.getItem(gpuKey);
148 } catch {
149 /* storage unavailable */
150 }
151 gpuBox.checked = hasGpu && (gpuPref ? gpuPref === 'on' : cfg.webgpuDefault !== false);
152 gpuBox.disabled = !hasGpu;
153 q('gpu-label').title = hasGpu
154 ? 'Run the solver on the GPU (single precision). Faster for meshes of a few hundred beads or more; slower for small systems. Falls back to the CPU if the GPU cannot run it.'
155 : 'This browser does not offer WebGPU; the solver runs on the CPU.';
156 gpuBox.addEventListener('change', () => {
157 try {
158 localStorage.setItem(gpuKey, gpuBox.checked ? 'on' : 'off');
159 } catch {
160 /* storage unavailable */
161 }
165 const wave = new LinePlot(q('wave'));
166 const spec = new Spectrogram(q('spec'));
167 const specPlot = new LinePlot(q('spectrum'));
168 const energyPlot = new LinePlot(q('energy'));
170 const quantity = cfg.audioQuantity ?? { label: 'acceleration', unit: 'm/s²' };
172 const scene = new BeadScene(q('scene'), timeline);
173 const extras = cfg.mountExtras?.(q, timeline);
174 let waveRange = 0;
175 let last: { p: P; r: SimResult } | null = null;
b51a77aShared result cache on artifact-cache: offer a cached result for the current settings next to Run; store runs when a passcode is setJeremy Magland 176 let cancelRun: (() => void) | null = null; // set while a run (or a cache download) is in progress
177 // the cached result for the current settings, if there is one
178 let cached: { p: P; built: Built; key: string; stored?: Date } | null = null;
179 let probeSeq = 0;
180 let probeTimer = 0;
87d896eShow partial results while a run is in progress: plots, replay and audio update every 2 s (CPU and WebGPU); a cancelled run keeps its last partial resultJeremy Magland 182 let pendingAudio: Blob | null = null; // a newer partial result's audio, held back while the player plays
decd1b2Copy link to these settings: a shareable link with every parameter, applied on load and then removed from the addressJeremy Magland 184 // settings from a shared link, applied once and then removed from the address
187 presetSel.value = String(shared.preset);
188 if (shared.gpu !== undefined && hasGpu) gpuBox.checked = shared.gpu;
189 }
190 const controls = renderControls(q('controls'), cfg.sections, shared?.params ?? cfg.presets[0].params, () => updateSetup());
191 presetDesc.textContent = cfg.presets[Number(presetSel.value)].description;
193 const pr = cfg.presets[Number(presetSel.value)];
194 presetDesc.textContent = pr.description;
197 });
200 const shareUrl = q<HTMLInputElement>('share-url');
203 const url = sharedLink(controls.get(), Number(presetSel.value), gpuBox.checked);
204 shareUrl.hidden = true;
205 try {
206 await navigator.clipboard.writeText(url);
207 shareMsg.textContent = 'Link copied.';
208 } catch {
209 // no clipboard access: show the link to copy by hand
210 shareMsg.textContent = 'Copy this link:';
211 shareUrl.value = url;
212 shareUrl.hidden = false;
213 shareUrl.select();
214 }
215 });
218 const p = controls.get();
219 const { rows, warnings, built } = cfg.setup(p);
220 if (built) {
221 scene.showSetup(built.scene);
a78afe0Add WebGPU solver with a toggle; faster CPU solverJeremy Magland 223 rows.push(['Highest frequency', `${fmtNum(wmax / (2 * Math.PI))} Hz`]);
2cd36faBead drum: hex-lattice round head, felt mallet contact, top view and cross-section; shared instrument pageJeremy Magland 224 rows.push(['Time step', `${fmtNum(1 / (p.fs * spp))} s (${spp} per sample)`]);
2cd36faBead drum: hex-lattice round head, felt mallet contact, top view and cross-section; shared instrument pageJeremy Magland 226 const frameMB = (Math.round(p.duration * p.frameRate) * 3 * built.sys.n * 4) / 1e6;
227 if (frameMB > 400) warnings.push(`Replay frames would take ${fmtNum(frameMB)} MB; lower the replay frame rate or the duration.`);
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 228 if (work > 3e9) warnings.push(`This run is large (${fmtNum(work)} element updates): expect roughly ${fmtNum(work / CPU_RATE / 60)} min on a desktop CPU (WebGPU is usually much faster).`);
230 q('derived').innerHTML = statRows(rows);
231 q('warnings').innerHTML = warnings.map((w) => `<li>${w}</li>`).join('');
233 checkCache();
234 }
236 // what the solver gets for parameters p; null when they are invalid
237 function inputs(p: P) {
238 const { built } = cfg.setup(p);
239 if (!built) return null;
243 // Look up (debounced) whether the shared cache has a result for the current settings,
244 // and offer to load it next to Run.
245 function checkCache() {
246 const seq = ++probeSeq;
247 cached = null;
248 loadBtn.hidden = availNote.hidden = true;
249 clearTimeout(probeTimer);
250 probeTimer = window.setTimeout(async () => {
251 const p = controls.get();
252 const inp = inputs(p);
253 if (!inp) return;
254 try {
255 const key = await resultKey(instrumentId, inp.built.sys, inp.built.exc, inp.opt, inp.backend);
256 const hit = await probeResult(key);
257 if (seq !== probeSeq || !hit) return;
258 cached = { p, built: inp.built, key, stored: hit.stored };
259 const when = hit.stored ? `computed ${hit.stored.toISOString().slice(0, 10)}, ` : '';
260 availNote.textContent = `A cached result exists for these settings (${when}${fmtNum(hit.bytes / 1e6)} MB). Load it, or run the simulation again.`;
261 loadBtn.hidden = availNote.hidden = false;
262 loadBtn.disabled = !!cancelRun;
263 } catch {
264 /* cache unreachable: just don't offer it */
265 }
266 }, 250);
268 updateSetup();
270 q('wave-range').addEventListener('click', (e) => {
271 const b = (e.target as HTMLElement).closest('button');
272 if (!b) return;
273 waveRange = Number(b.dataset.v);
274 q('wave-range').querySelectorAll('button').forEach((x) => x.classList.toggle('on', x === b));
275 drawWave();
276 });
277 q<HTMLSelectElement>('fmax').addEventListener('change', (e) => {
278 spec.fmax = Number((e.target as HTMLSelectElement).value);
279 spec.draw();
280 drawSpectrum();
281 });
283 function drawWave() {
284 if (!last) return;
287 for (let i = 0; i < t.length; i++) t[i] = i / r.fs;
288 wave.set([{ x: t, y: r.audio, color: css('--series-1'), label: 'a' }], {
289 xLabel: 'time (s)',
292 xRange: [0, waveRange ? Math.min(waveRange, p.duration) : Math.max(p.duration, t[t.length - 1])],
294 }
296 function drawSpectrum() {
297 if (!last) return;
298 const { p, r } = last;
299 const s = spectrum(r.audio, r.fs);
300 let top = -Infinity;
301 for (const v of s.db) top = Math.max(top, v);
302 const db = s.db.map((v) => Math.max(v - top, -100));
303 const m = cfg.spectrumMarkers?.(p);
304 q('spec-legend').innerHTML =
305 `<span><i class="sw" style="background:var(--series-1)"></i>pickup signal</span>` + (m?.legend ?? '');
306 specPlot.set([{ x: s.freq, y: db, color: css('--series-1'), label: 'level (dB)' }], {
307 xLabel: 'frequency (Hz)',
308 yLabel: 'level (dB re max)',
309 xRange: [0, Math.min(spec.fmax, r.fs / 2)],
310 yRange: [-100, 5],
311 markers: m?.markers,
312 });
313 }
316 // extends the result already shown (same run), so the replay position, the
317 // exaggeration and the audio player's position are kept.
318 function show(p: P, built: Built, r: SimResult, partial = false, update = false) {
321 cacheNote.textContent = partial
322 ? `Partial result: ${fmtNum(simulated)} s of ${fmtNum(p.duration)} s simulated so far. It updates every few seconds while the run continues; the audio player picks up the newer sound when it is paused.`
323 : '';
2cd36faBead drum: hex-lattice round head, felt mallet contact, top view and cross-section; shared instrument pageJeremy Magland 324 root.querySelectorAll<HTMLElement>('[data-r="out"]').forEach((e) => (e.hidden = false));
325 let peak = 0;
326 for (const v of r.audio) peak = Math.max(peak, Math.abs(v));
327 const emax = Math.max(...r.energy);
328 let bal = 0;
329 for (const b of r.balance) bal = Math.max(bal, Math.abs(b));
87d896eShow partial results while a run is in progress: plots, replay and audio update every 2 s (CPU and WebGPU); a cancelled run keeps its last partial resultJeremy Magland 332 ...(partial ? ([['Simulated so far', `${fmtNum(simulated)} s of ${fmtNum(p.duration)} s`]] as [string, string][]) : []),
a78afe0Add WebGPU solver with a toggle; faster CPU solverJeremy Magland 333 ['Solver', (r.backend ?? 'CPU') + (r.fallback ? ` (WebGPU failed: ${r.fallback})` : '')],
2cd36faBead drum: hex-lattice round head, felt mallet contact, top view and cross-section; shared instrument pageJeremy Magland 334 ['Time step', `${fmtNum(r.dt)} s, ${r.stepsPerSample} per sample`],
87d896eShow partial results while a run is in progress: plots, replay and audio update every 2 s (CPU and WebGPU); a cancelled run keeps its last partial resultJeremy Magland 335 [partial ? 'Wall time so far' : 'Wall time', `${fmtNum(r.wallSeconds)} s (${rt >= 1 ? fmtNum(rt) + '× faster than' : fmtNum(1 / rt) + '× slower than'} real time)`],
2cd36faBead drum: hex-lattice round head, felt mallet contact, top view and cross-section; shared instrument pageJeremy Magland 336 ['Energy balance error', emax > 0 ? `${fmtNum((bal / emax) * 100)}% of peak energy` : '–'],
337 ...(cfg.summaryRows?.(p, built, r) ?? []),
339 [`Peak ${quantity.label}`, `${fmtNum(peak)} ${quantity.unit}`],
ae42884Anti-aliased pickup: solve at R fs and low-pass filter the pickup down to fs (both solvers); CACHE_VERSION 3; chaos-check analysis scriptsJeremy Magland 342 const aa = (r.oversampling ?? 1) > 1 ? ` Anti-aliased: the pickup was computed at ${r.oversampling}× the sample rate and low-pass filtered below ${fmtNum(r.fs / 2000)} kHz.` : '';
343 q('audio-note').textContent = `16-bit mono, ${r.fs} Hz, normalized so the peak is −1 dBFS${partial ? ' (of the sound so far)' : ''}.${aa}`;
345 spec.setSignal(r.audio, r.fs);
346 drawSpectrum();
348 else timeline.setTimes(r.frameTimes);
349 scene.showReplay({ ...built.scene, frames: r.frames, malletPos: r.malletPos }, update);
2cd36faBead drum: hex-lattice round head, felt mallet contact, top view and cross-section; shared instrument pageJeremy Magland 352 energyPlot.set([{ x: r.frameTimes, y: r.energy, color: css('--series-1'), label: 'energy (J)' }], {
353 xLabel: 'time (s)',
356 yRange: [0, emax * 1.05 || 1],
357 });
358 }
361 // until it pauses or ends (swapping the source would stop it); when it is
362 // paused partway, the new audio resumes from the same point.
363 const player = q<HTMLAudioElement>('audio');
364 function setAudio(wav: Blob, update: boolean) {
365 pendingAudio = null;
366 if (update && audioUrl && !player.paused && !player.ended) {
367 pendingAudio = wav;
368 return;
369 }
370 const at = update && !player.ended ? player.currentTime : 0;
371 if (audioUrl) URL.revokeObjectURL(audioUrl);
372 audioUrl = URL.createObjectURL(wav);
373 player.src = audioUrl;
374 q<HTMLAnchorElement>('download').href = audioUrl;
375 if (at > 0) player.addEventListener('loadedmetadata', () => (player.currentTime = Math.min(at, player.duration)), { once: true });
376 }
377 const flushAudio = () => {
378 if (pendingAudio) setAudio(pendingAudio, true);
379 };
380 player.addEventListener('pause', flushAudio);
381 player.addEventListener('ended', flushAudio);
384 const card = q('out-contact');
385 card.hidden = !r.contactForce;
386 if (!r.contactForce) return;
387 const cf = r.contactForce;
388 const c = contacts(cf, r.fs);
389 const end = c.length ? c[c.length - 1].start + c[c.length - 1].ms / 1000 : 0.01;
390 const nShow = Math.min(cf.length, Math.ceil((end * 1.3 + 0.002) * r.fs));
391 const t = new Float64Array(nShow);
392 for (let i = 0; i < nShow; i++) t[i] = (i / r.fs) * 1000;
393 contactPlot.set([{ x: t, y: cf.subarray(0, nShow), color: css('--series-2'), label: 'force (N)' }], {
394 xLabel: 'time (ms)',
395 yLabel: 'contact force (N)',
396 yRange: [0, Math.max(...cf.subarray(0, nShow)) * 1.08 || 1],
397 });
398 q('contact-note').textContent = c.length
399 ? `${c.length} contact${c.length > 1 ? 's' : ''}: ` +
400 c.map((k) => `${fmtNum(k.ms)} ms from ${fmtNum(k.start * 1000)} ms, peak ${fmtNum(k.peak)} N`).join('; ') +
401 '. During contact the instrument takes energy from the mallet and gives part of it back as the mallet rebounds, so the energy plot can spike first; the mallet then leaves at constant speed (no gravity).'
402 : 'The mallet never touched the instrument.';
403 }
406 cancelRun = cancel;
407 runBtn.disabled = loadBtn.disabled = !!cancel;
408 cancelBtn.hidden = !cancel;
409 progress.hidden = !cancel;
412 }
414 // Run the simulation and, if this browser has the passcode, store the result.
415 async function run() {
416 const p = controls.get();
417 const inp = inputs(p);
418 if (!inp) return;
419 const { built, opt, backend } = inp;
421 // page draws them, only the newest is drawn.
422 let shown = false; // a partial result of this run is on the page
423 let latest: SimResult | null = null;
424 let finished = false;
425 const onPartial = (r: SimResult) => {
426 if (!latest)
427 setTimeout(() => {
428 if (!latest || finished) return;
429 show(p, built, latest, true, shown);
430 shown = true;
431 latest = null;
432 });
433 latest = r;
434 };
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 435 const running = runInWorker(opts.makeWorker, built.sys, built.exc, opt, backend === 'gpu', (f) => (progress.value = f), onPartial);
440 show(p, built, r, false, shown);
442 // a WebGPU run that fell back to the CPU is stored as a CPU result
443 const actual: Backend = r.backend?.startsWith('WebGPU') ? 'gpu' : 'cpu';
444 const key = await resultKey(instrumentId, built.sys, built.exc, opt, actual);
445 cacheNote.textContent = 'Storing in the shared cache…';
446 storeResult(key, r, { instrument: instrumentId, backend: r.backend, wallSeconds: r.wallSeconds })
447 .then(() => {
448 if (last?.r === r) cacheNote.textContent = 'Stored in the shared cache.';
449 checkCache();
450 })
451 .catch((e) => {
452 if (last?.r === r) cacheNote.textContent = `Not stored in the shared cache: ${(e as Error).message}`;
453 });
454 }
457 if (shown) cacheNote.textContent = `The run ${(e as Error).message === 'cancelled' ? 'was cancelled' : 'stopped'}; this is the partial result up to ${fmtNum(last!.r.audio.length / last!.r.fs)} s.`;
459 errorBox.textContent = `The simulation failed: ${(e as Error).message}`;
460 errorBox.hidden = false;
461 }
462 } finally {
467 // Download and show the cached result for the current settings.
468 async function loadCached() {
469 if (!cached) return;
470 const { p, built, key } = cached;
471 const abort = new AbortController();
472 setBusy(() => abort.abort());
473 try {
474 const hit = await loadResult(key, (f) => (progress.value = f), abort.signal);
475 if (!hit) throw new Error('the entry is no longer in the cache');
476 show(p, built, hit.result);
477 const when = hit.stored ? ` on ${hit.stored.toISOString().slice(0, 10)}` : '';
478 cacheNote.textContent = `Loaded from the shared cache (computed${when}; the wall time is that of the original run).`;
479 } catch (e) {
480 if (!abort.signal.aborted) {
481 errorBox.textContent = `Could not load the cached result: ${(e as Error).message}`;
482 errorBox.hidden = false;
483 checkCache();
484 }
485 } finally {
486 setBusy(null);
487 }
488 }
489 runBtn.addEventListener('click', run);
490 loadBtn.addEventListener('click', loadCached);
491 cancelBtn.addEventListener('click', () => cancelRun?.());
493 // passcode for writing to the shared cache
494 const passInput = q<HTMLInputElement>('cache-pass');
495 const cacheMsg = q<HTMLParagraphElement>('cache-msg');
496 const showCacheState = () =>
497 (q('cache-state').textContent = getPasscode() ? 'Shared cache: runs are stored' : 'Shared cache: read only');
498 showCacheState();
499 q('cache-save').addEventListener('click', async () => {
500 const pass = passInput.value.trim();
501 if (!pass) return;
502 cacheMsg.textContent = 'Checking…';
503 try {
504 const { valid, reason } = await checkPasscode(pass);
505 if (!valid) {
506 cacheMsg.textContent = `Not saved: ${reason ?? 'invalid passcode'}.`;
507 return;
508 }
509 setPasscode(pass);
510 passInput.value = '';
511 cacheMsg.textContent = 'Saved in this browser. Runs from now on are stored in the cache.';
512 } catch (e) {
513 cacheMsg.textContent = `Could not check the passcode: ${(e as Error).message}`;
514 }
515 showCacheState();
516 });
517 q('cache-forget').addEventListener('click', () => {
518 setPasscode(null);
519 cacheMsg.textContent = 'Passcode removed from this browser.';
520 showCacheState();
decd1b2Copy link to these settings: a shareable link with every parameter, applied on load and then removed from the addressJeremy Magland 525function sharedLink<P extends SimParams>(p: P, preset: number, gpu: boolean): string {
526 const route = location.hash.split('?')[0];
531function readSharedLink<P extends SimParams>(cfg: InstrumentConfig<P>): Settings<P> | null {
533 if (query === undefined) return null;
534 history.replaceState(history.state, '', `${location.pathname}${location.search}${route}`);
539function contacts(cf: Float32Array, fs: number): { start: number; ms: number; peak: number }[] {
540 const out: { start: number; ms: number; peak: number }[] = [];
541 let i = 0;
542 while (i < cf.length) {
543 if (cf[i] > 0) {
544 const a = i;
545 let peak = 0;
546 while (i < cf.length && cf[i] > 0) peak = Math.max(peak, cf[i++]);
547 out.push({ start: a / fs, ms: ((i - a) / fs) * 1000, peak });
548 } else i++;
549 }
550 return out;
553function contactRows(r: SimResult): [string, string][] {
554 if (!r.contactForce) return [];
555 const c = contacts(r.contactForce, r.fs);
556 return [
557 [
558 'Contact',
559 c.length
560 ? `${fmtNum(c[0].ms)} ms, peak ${fmtNum(c[0].peak)} N${c.length > 1 ? ` (+${c.length - 1} more contact${c.length > 2 ? 's' : ''})` : ''}`
561 : 'none',
562 ],
563 ];
566const cap = (s: string) => s[0].toUpperCase() + s.slice(1);
↑↓ move↵openescclose