Shared result cache on artifact-cache: offer a cached result for the current settings next to Run; store runs when a passcode is set
5 changed files+384−20
README.mdmodified+9−0View file
@@ -55,6 +55,15 @@ Both solvers integrate the same scheme (`src/sim/solver.ts` on the CPU in double
5555 - Speed, WebGPU vs CPU on an Intel Iris Xe laptop (Chromium): the default cymbal (366 beads) about 2x, a 20-ring cymbal (1444 beads) 4x, 30 rings (3188 beads) 6x; strings (3 to 50 beads) 4 to 80x slower in 50 ms test runs, where the GPU's start-up (about half a second) dominates. A single persistent workgroup running whole time steps per dispatch was tried first; on this GPU its large kernel made every barrier cost several microseconds, and it used only one of the GPU's subslices.
5656 - `bench.html` (dev server only, `npm run dev` then `/bench.html?dur=0.05&only=cymbal&rings=20`) runs every preset on both solvers and prints the differences and timings.
5757
58+## Shared result cache
59+
60+Runs are cached on [artifact-cache](https://artifact-cache.tempory.net) (app `toy-instruments`, `src/sim/cache.ts`). Reads are public, so anyone opening the site gets a result someone has already computed; writes need the app's passcode, entered once per browser under "Shared cache" in the panel.
61+
62+- Key: `v<CACHE_VERSION>/<instrument>/<cpu|gpu>/<SHA-256>`, hashed over everything the solver receives (bead system, excitation, options), not the UI parameters. A model change that alters the built system therefore misses old entries on its own; bump `CACHE_VERSION` when the solver's output changes for the same inputs. CPU and WebGPU results are cached separately; a WebGPU run that fell back to the CPU is stored as a CPU result.
63+- Value: the whole `SimResult` (audio, replay frames, energy, contact force, scalars) in a small binary format, so a cached result shows everything a fresh run does. The default cymbal is about 35 MB; results over the 100 MB entry limit are not stored.
64+- As the settings (or the WebGPU toggle) change, the page checks whether the cache has a result for them (a HEAD request, debounced) and, if so, shows its date and size with a Load cached button next to Run. Run always simulates; when the passcode is set, every completed run is stored, replacing any existing entry.
65+- Because the key hashes floating-point inputs, a browser whose `Math` functions round differently in the last bit (possible in Safari) builds a different key and misses entries computed elsewhere. It never gets a wrong result.
66+
5867 ## Layout
5968
6069 - `src/sim/` generic 3D bead-spring system, CPU solver (`solver.ts`), WebGPU solver (`gpu.ts`), worker.
src/sim/cache.tsadded+185−0View file
@@ -0,0 +1,185 @@
1+import type { BeadSystem, Excitation, SimOptions, SimResult } from './types';
2+
3+// Shared cache of simulation results on artifact-cache (https://artifact-cache.tempory.net,
4+// app "toy-instruments"). Reads are public; writes need the app's passcode, kept in
5+// localStorage. Keys: v<CACHE_VERSION>/<instrument>/<cpu|gpu>/<SHA-256 of sys, exc, opt>.
6+// The hash covers everything the solver sees, so model changes miss old entries by
7+// themselves; bump CACHE_VERSION when the solver's output changes for the same inputs.
8+
9+export const CACHE_VERSION = 1;
10+const BASE = 'https://artifact-cache.tempory.net/api/apps/toy-instruments';
11+const PASS_KEY = 'toy-instruments.cache-passcode';
12+export const MAX_ENTRY_BYTES = 100 * 1024 * 1024;
13+
14+export type Backend = 'cpu' | 'gpu';
15+
16+export async function resultKey(instrument: string, sys: BeadSystem, exc: Excitation, opt: SimOptions, backend: Backend): Promise<string> {
17+ return `v${CACHE_VERSION}/${instrument}/${backend}/${await digest({ sys, exc, opt })}`;
18+}
19+
20+// SHA-256 of a canonical encoding: JSON with sorted keys, typed arrays replaced by
21+// their type and byte length, followed by the arrays' bytes in order.
22+export async function digest(value: unknown): Promise<string> {
23+ const parts: Uint8Array[] = [];
24+ const head = new TextEncoder().encode(canon(value, parts));
25+ const all = new Uint8Array(head.length + parts.reduce((s, p) => s + p.length, 0));
26+ all.set(head);
27+ let o = head.length;
28+ for (const p of parts) {
29+ all.set(p, o);
30+ o += p.length;
31+ }
32+ const h = new Uint8Array(await crypto.subtle.digest('SHA-256', all));
33+ return [...h].map((b) => b.toString(16).padStart(2, '0')).join('');
34+}
35+
36+function canon(v: unknown, parts: Uint8Array[]): string {
37+ if (ArrayBuffer.isView(v)) {
38+ const b = new Uint8Array(v.buffer, v.byteOffset, v.byteLength);
39+ parts.push(b);
40+ return `{"$":"${v.constructor.name}","n":${b.length}}`;
41+ }
42+ if (v === null || typeof v !== 'object') return JSON.stringify(v) ?? 'null';
43+ if (Array.isArray(v)) return `[${v.map((x) => canon(x, parts)).join(',')}]`;
44+ const o = v as Record<string, unknown>;
45+ const keys = Object.keys(o).filter((k) => o[k] !== undefined).sort();
46+ return `{${keys.map((k) => `${JSON.stringify(k)}:${canon(o[k], parts)}`).join(',')}}`;
47+}
48+
49+// Binary layout: "TIR1", uint32 header length, header JSON, then each array's bytes,
50+// every section starting on an 8-byte boundary.
51+const MAGIC = 'TIR1';
52+const ARRAYS = { Float32Array, Float64Array };
53+type ArrayType = keyof typeof ARRAYS;
54+
55+const pad8 = (n: number) => (n + 7) & ~7;
56+
57+export function encodeResult(r: SimResult): Uint8Array<ArrayBuffer> {
58+ const scalars: Record<string, unknown> = {};
59+ const arrays: { name: string; type: ArrayType; length: number }[] = [];
60+ const data: Uint8Array[] = [];
61+ for (const [name, v] of Object.entries(r)) {
62+ if (v instanceof Float32Array || v instanceof Float64Array) {
63+ arrays.push({ name, type: v.constructor.name as ArrayType, length: v.length });
64+ data.push(new Uint8Array(v.buffer, v.byteOffset, v.byteLength));
65+ } else if (v !== undefined) scalars[name] = v;
66+ }
67+ const head = new TextEncoder().encode(JSON.stringify({ scalars, arrays }));
68+ let size = pad8(8 + head.length);
69+ for (const d of data) size += pad8(d.length);
70+ const out = new Uint8Array(size);
71+ out.set(new TextEncoder().encode(MAGIC));
72+ new DataView(out.buffer).setUint32(4, head.length, true);
73+ out.set(head, 8);
74+ let o = pad8(8 + head.length);
75+ for (const d of data) {
76+ out.set(d, o);
77+ o += pad8(d.length);
78+ }
79+ return out;
80+}
81+
82+export function decodeResult(buf: ArrayBuffer): SimResult {
83+ if (new TextDecoder().decode(new Uint8Array(buf, 0, 4)) !== MAGIC) throw new Error('not a cached result');
84+ const hl = new DataView(buf).getUint32(4, true);
85+ const { scalars, arrays } = JSON.parse(new TextDecoder().decode(new Uint8Array(buf, 8, hl)));
86+ const r: Record<string, unknown> = { ...scalars };
87+ let o = pad8(8 + hl);
88+ for (const a of arrays as { name: string; type: ArrayType; length: number }[]) {
89+ const A = ARRAYS[a.type];
90+ r[a.name] = new A(buf, o, a.length);
91+ o += pad8(a.length * A.BYTES_PER_ELEMENT);
92+ }
93+ return r as unknown as SimResult;
94+}
95+
96+export interface CacheHit {
97+ result: SimResult;
98+ stored?: Date;
99+}
100+
101+// Whether a result is cached (HEAD, no download): its size and when it was stored, or null.
102+export async function probeResult(key: string, signal?: AbortSignal): Promise<{ bytes: number; stored?: Date } | null> {
103+ const res = await fetch(`${BASE}/entries/${key}`, { method: 'HEAD', signal });
104+ if (res.status === 404) return null;
105+ if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
106+ const lm = res.headers.get('last-modified');
107+ return { bytes: Number(res.headers.get('content-length')) || 0, stored: lm ? new Date(lm) : undefined };
108+}
109+
110+// Fetch a cached result, or null if there is none. onProgress gets the fraction downloaded.
111+export async function loadResult(key: string, onProgress: (f: number) => void, signal?: AbortSignal): Promise<CacheHit | null> {
112+ const res = await fetch(`${BASE}/entries/${key}`, { signal });
113+ if (res.status === 404) return null;
114+ if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
115+ // Content-Length is used only for progress (it is the compressed size if the response is encoded)
116+ const total = Number(res.headers.get('content-length')) || 0;
117+ let buf: ArrayBuffer;
118+ if (res.body) {
119+ const chunks: Uint8Array[] = [];
120+ const reader = res.body.getReader();
121+ let got = 0;
122+ for (;;) {
123+ const { done, value } = await reader.read();
124+ if (done) break;
125+ chunks.push(value);
126+ got += value.length;
127+ if (total) onProgress(Math.min(got / total, 1));
128+ }
129+ const bytes = new Uint8Array(got);
130+ let o = 0;
131+ for (const c of chunks) {
132+ bytes.set(c, o);
133+ o += c.length;
134+ }
135+ buf = bytes.buffer;
136+ } else buf = await res.arrayBuffer();
137+ const lm = res.headers.get('last-modified');
138+ return { result: decodeResult(buf), stored: lm ? new Date(lm) : undefined };
139+}
140+
141+export async function storeResult(key: string, r: SimResult, meta: Record<string, unknown>): Promise<void> {
142+ const pass = getPasscode();
143+ if (!pass) throw new Error('no passcode');
144+ const body = encodeResult(r);
145+ if (body.length > MAX_ENTRY_BYTES) throw new Error(`result is ${Math.round(body.length / 1e6)} MB, over the 100 MB limit`);
146+ const res = await fetch(`${BASE}/entries/${key}`, {
147+ method: 'PUT',
148+ headers: { 'X-Passcode': pass, 'Content-Type': 'application/octet-stream', 'X-Artifact-Meta': encodeURIComponent(JSON.stringify(meta)) },
149+ body,
150+ });
151+ if (!res.ok) throw new Error(await errorText(res));
152+}
153+
154+export async function checkPasscode(pass: string): Promise<{ valid: boolean; reason?: string }> {
155+ const res = await fetch(`${BASE}/check-passcode`, { method: 'POST', headers: { 'X-Passcode': pass } });
156+ if (!res.ok) throw new Error(await errorText(res));
157+ return res.json();
158+}
159+
160+export function getPasscode(): string | null {
161+ try {
162+ return localStorage.getItem(PASS_KEY);
163+ } catch {
164+ return null;
165+ }
166+}
167+
168+export function setPasscode(pass: string | null) {
169+ try {
170+ if (pass) localStorage.setItem(PASS_KEY, pass);
171+ else localStorage.removeItem(PASS_KEY);
172+ } catch {
173+ /* storage unavailable */
174+ }
175+}
176+
177+async function errorText(res: Response): Promise<string> {
178+ try {
179+ const b = await res.json();
180+ if (b?.error) return `${res.status}: ${b.error}`;
181+ } catch {
182+ /* not JSON */
183+ }
184+ return `${res.status} ${res.statusText}`;
185+}
src/style.cssmodified+6−1View file
@@ -115,7 +115,8 @@ input:focus, select:focus { outline: 2px solid var(--accent); outline-offset: -1
115115 .btn { background: var(--accent); color: var(--accent-ink); border: none; padding: 8px 16px; font-weight: 600; }
116116 .btn:disabled { opacity: 0.5; cursor: default; }
117117 .btn-small { background: transparent; color: var(--text-primary); border: 1px solid var(--border); padding: 4px 10px; font-size: 13px; }
118-.run-row { display: flex; gap: 8px; align-items: center; margin-top: 14px; }
118+.run-row { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; margin-top: 14px; }
119+.run-row > * { white-space: nowrap; }
119120 .run-row.top { margin-top: 4px; }
120121 .gpu-toggle { margin-left: auto; }
121122 progress { width: 100%; margin-top: 8px; accent-color: var(--accent); }
@@ -171,3 +172,7 @@ audio { max-width: 100%; }
171172 .map-wrap { position: relative; aspect-ratio: 1; max-width: 100%; max-height: 420px; }
172173 .map-wrap canvas { width: 100%; height: 100%; display: block; }
173174 .profile-wrap { height: 260px; }
175+
176+.cache-ctl { margin-top: 10px; font-size: 13px; }
177+.cache-ctl summary { cursor: pointer; color: var(--text-secondary); }
178+.cache-form { display: flex; gap: 6px; margin-top: 6px; }
src/ui/instrumentPage.tsmodified+147−19View file
@@ -6,7 +6,8 @@ import { encodeWav } from './wav';
66 import { BeadScene, type SceneSpec } from './scene3d';
77 import { Timeline } from './timeline';
88 import { timeStep } from '../sim/solver';
9-import { runInWorker, type Running } from '../sim/run';
9+import { runInWorker } from '../sim/run';
10+import { checkPasscode, getPasscode, loadResult, probeResult, resultKey, setPasscode, storeResult, type Backend } from '../sim/cache';
1011 import type { BeadSystem, Excitation, SimResult } from '../sim/types';
1112
1213 // The page every instrument shares: presets and control panel, 3D model,
@@ -67,10 +68,18 @@ export function mountInstrumentPage<P extends SimParams>(root: HTMLElement, cfg:
6768 <div class="run-row top">
6869 <button class="btn" data-r="run">Run simulation</button>
6970 <button class="btn-small" data-r="cancel" hidden>Cancel</button>
71+ <button class="btn-small" data-r="load-cached" hidden>Load cached</button>
7072 <label class="inline gpu-toggle" data-r="gpu-label"><input type="checkbox" data-r="gpu"> WebGPU</label>
7173 </div>
74+ <p class="note" data-r="cached-avail" hidden></p>
7275 <progress data-r="progress" max="1" value="0" hidden></progress>
7376 <p class="error" data-r="error" hidden></p>
77+ <details class="cache-ctl">
78+ <summary data-r="cache-state"></summary>
79+ <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>
80+ <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>
81+ <p class="note" data-r="cache-msg"></p>
82+ </details>
7483 <div data-r="controls"></div>
7584 </aside>
7685 <main class="results">
@@ -78,6 +87,7 @@ export function mountInstrumentPage<P extends SimParams>(root: HTMLElement, cfg:
7887 <section class="card"><h2>Setup</h2><div data-r="derived" class="stats"></div><ul data-r="warnings" class="warnings"></ul></section>
7988 <section class="card" data-r="out" hidden>
8089 <h2>Run</h2><div data-r="summary" class="stats"></div>
90+ <p class="note" data-r="cache-note"></p>
8191 <div class="audio-row"><audio controls data-r="audio"></audio><a class="btn-small" data-r="download" download="${cfg.wavName}">Download .wav</a></div>
8292 <p class="note" data-r="audio-note"></p>
8393 </section>
@@ -117,6 +127,10 @@ export function mountInstrumentPage<P extends SimParams>(root: HTMLElement, cfg:
117127 const progress = q<HTMLProgressElement>('progress');
118128 const errorBox = q<HTMLParagraphElement>('error');
119129 const gpuBox = q<HTMLInputElement>('gpu');
130+ const loadBtn = q<HTMLButtonElement>('load-cached');
131+ const availNote = q<HTMLParagraphElement>('cached-avail');
132+ const cacheNote = q<HTMLParagraphElement>('cache-note');
133+ const instrumentId = cfg.wavName.replace(/\.wav$/, ''); // e.g. bead-cymbal
120134 const hasGpu = 'gpu' in navigator;
121135 const gpuKey = `toy-instruments.webgpu.${cfg.wavName}`;
122136 let gpuPref: string | null = null;
@@ -136,6 +150,7 @@ export function mountInstrumentPage<P extends SimParams>(root: HTMLElement, cfg:
136150 } catch {
137151 /* storage unavailable */
138152 }
153+ checkCache();
139154 });
140155
141156 const wave = new LinePlot(q('wave'));
@@ -149,7 +164,11 @@ export function mountInstrumentPage<P extends SimParams>(root: HTMLElement, cfg:
149164 const extras = cfg.mountExtras?.(q, timeline);
150165 let waveRange = 0;
151166 let last: { p: P; r: SimResult } | null = null;
152- let running: Running | null = null;
167+ let cancelRun: (() => void) | null = null; // set while a run (or a cache download) is in progress
168+ // the cached result for the current settings, if there is one
169+ let cached: { p: P; built: Built; key: string; stored?: Date } | null = null;
170+ let probeSeq = 0;
171+ let probeTimer = 0;
153172 let audioUrl: string | null = null;
154173
155174 const controls = renderControls(q('controls'), cfg.sections, cfg.presets[0].params, () => updateSetup());
@@ -178,7 +197,42 @@ export function mountInstrumentPage<P extends SimParams>(root: HTMLElement, cfg:
178197 }
179198 q('derived').innerHTML = statRows(rows);
180199 q('warnings').innerHTML = warnings.map((w) => `<li>${w}</li>`).join('');
181- runBtn.disabled = !built || !!running;
200+ runBtn.disabled = !built || !!cancelRun;
201+ checkCache();
202+ }
203+
204+ // what the solver gets for parameters p; null when they are invalid
205+ function inputs(p: P) {
206+ const { built } = cfg.setup(p);
207+ if (!built) return null;
208+ const opt = { duration: p.duration, fs: p.fs, cfl: p.cfl, frameRate: p.frameRate, pickup: built.pickup, pickupDelay: built.pickupDelay };
209+ return { built, opt, backend: (gpuBox.checked ? 'gpu' : 'cpu') as Backend };
210+ }
211+
212+ // Look up (debounced) whether the shared cache has a result for the current settings,
213+ // and offer to load it next to Run.
214+ function checkCache() {
215+ const seq = ++probeSeq;
216+ cached = null;
217+ loadBtn.hidden = availNote.hidden = true;
218+ clearTimeout(probeTimer);
219+ probeTimer = window.setTimeout(async () => {
220+ const p = controls.get();
221+ const inp = inputs(p);
222+ if (!inp) return;
223+ try {
224+ const key = await resultKey(instrumentId, inp.built.sys, inp.built.exc, inp.opt, inp.backend);
225+ const hit = await probeResult(key);
226+ if (seq !== probeSeq || !hit) return;
227+ cached = { p, built: inp.built, key, stored: hit.stored };
228+ const when = hit.stored ? `computed ${hit.stored.toISOString().slice(0, 10)}, ` : '';
229+ availNote.textContent = `A cached result exists for these settings (${when}${fmtNum(hit.bytes / 1e6)} MB). Load it, or run the simulation again.`;
230+ loadBtn.hidden = availNote.hidden = false;
231+ loadBtn.disabled = !!cancelRun;
232+ } catch {
233+ /* cache unreachable: just don't offer it */
234+ }
235+ }, 250);
182236 }
183237 updateSetup();
184238
@@ -228,6 +282,7 @@ export function mountInstrumentPage<P extends SimParams>(root: HTMLElement, cfg:
228282
229283 function show(p: P, built: Built, r: SimResult) {
230284 last = { p, r };
285+ cacheNote.textContent = '';
231286 root.querySelectorAll<HTMLElement>('[data-r="out"]').forEach((e) => (e.hidden = false));
232287 let peak = 0;
233288 for (const v of r.audio) peak = Math.max(peak, Math.abs(v));
@@ -285,32 +340,105 @@ export function mountInstrumentPage<P extends SimParams>(root: HTMLElement, cfg:
285340 : 'The mallet never touched the instrument.';
286341 }
287342
288- runBtn.addEventListener('click', async () => {
289- const p = controls.get();
290- const { built } = cfg.setup(p);
291- if (!built) return;
292- const opt = { duration: p.duration, fs: p.fs, cfl: p.cfl, frameRate: p.frameRate, pickup: built.pickup, pickupDelay: built.pickupDelay };
293- runBtn.disabled = true;
294- errorBox.hidden = true;
295- cancelBtn.hidden = false;
296- progress.hidden = false;
343+ function setBusy(cancel: (() => void) | null) {
344+ cancelRun = cancel;
345+ runBtn.disabled = loadBtn.disabled = !!cancel;
346+ cancelBtn.hidden = !cancel;
347+ progress.hidden = !cancel;
297348 progress.value = 0;
298- running = runInWorker(built.sys, built.exc, opt, gpuBox.checked, (f) => (progress.value = f));
349+ if (cancel) errorBox.hidden = true;
350+ }
351+
352+ // Run the simulation and, if this browser has the passcode, store the result.
353+ async function run() {
354+ const p = controls.get();
355+ const inp = inputs(p);
356+ if (!inp) return;
357+ const { built, opt, backend } = inp;
358+ const running = runInWorker(built.sys, built.exc, opt, backend === 'gpu', (f) => (progress.value = f));
359+ setBusy(running.cancel);
299360 try {
300- show(p, built, await running.promise);
361+ const r = await running.promise;
362+ show(p, built, r);
363+ if (getPasscode()) {
364+ // a WebGPU run that fell back to the CPU is stored as a CPU result
365+ const actual: Backend = r.backend?.startsWith('WebGPU') ? 'gpu' : 'cpu';
366+ const key = await resultKey(instrumentId, built.sys, built.exc, opt, actual);
367+ cacheNote.textContent = 'Storing in the shared cache…';
368+ storeResult(key, r, { instrument: instrumentId, backend: r.backend, wallSeconds: r.wallSeconds })
369+ .then(() => {
370+ if (last?.r === r) cacheNote.textContent = 'Stored in the shared cache.';
371+ checkCache();
372+ })
373+ .catch((e) => {
374+ if (last?.r === r) cacheNote.textContent = `Not stored in the shared cache: ${(e as Error).message}`;
375+ });
376+ }
301377 } catch (e) {
302378 if ((e as Error).message !== 'cancelled') {
303379 errorBox.textContent = `The simulation failed: ${(e as Error).message}`;
304380 errorBox.hidden = false;
305381 }
306382 } finally {
307- running = null;
308- runBtn.disabled = false;
309- cancelBtn.hidden = true;
310- progress.hidden = true;
383+ setBusy(null);
311384 }
385+ }
386+
387+ // Download and show the cached result for the current settings.
388+ async function loadCached() {
389+ if (!cached) return;
390+ const { p, built, key } = cached;
391+ const abort = new AbortController();
392+ setBusy(() => abort.abort());
393+ try {
394+ const hit = await loadResult(key, (f) => (progress.value = f), abort.signal);
395+ if (!hit) throw new Error('the entry is no longer in the cache');
396+ show(p, built, hit.result);
397+ const when = hit.stored ? ` on ${hit.stored.toISOString().slice(0, 10)}` : '';
398+ cacheNote.textContent = `Loaded from the shared cache (computed${when}; the wall time is that of the original run).`;
399+ } catch (e) {
400+ if (!abort.signal.aborted) {
401+ errorBox.textContent = `Could not load the cached result: ${(e as Error).message}`;
402+ errorBox.hidden = false;
403+ checkCache();
404+ }
405+ } finally {
406+ setBusy(null);
407+ }
408+ }
409+ runBtn.addEventListener('click', run);
410+ loadBtn.addEventListener('click', loadCached);
411+ cancelBtn.addEventListener('click', () => cancelRun?.());
412+
413+ // passcode for writing to the shared cache
414+ const passInput = q<HTMLInputElement>('cache-pass');
415+ const cacheMsg = q<HTMLParagraphElement>('cache-msg');
416+ const showCacheState = () =>
417+ (q('cache-state').textContent = getPasscode() ? 'Shared cache: runs are stored' : 'Shared cache: read only');
418+ showCacheState();
419+ q('cache-save').addEventListener('click', async () => {
420+ const pass = passInput.value.trim();
421+ if (!pass) return;
422+ cacheMsg.textContent = 'Checking…';
423+ try {
424+ const { valid, reason } = await checkPasscode(pass);
425+ if (!valid) {
426+ cacheMsg.textContent = `Not saved: ${reason ?? 'invalid passcode'}.`;
427+ return;
428+ }
429+ setPasscode(pass);
430+ passInput.value = '';
431+ cacheMsg.textContent = 'Saved in this browser. Runs from now on are stored in the cache.';
432+ } catch (e) {
433+ cacheMsg.textContent = `Could not check the passcode: ${(e as Error).message}`;
434+ }
435+ showCacheState();
436+ });
437+ q('cache-forget').addEventListener('click', () => {
438+ setPasscode(null);
439+ cacheMsg.textContent = 'Passcode removed from this browser.';
440+ showCacheState();
312441 });
313- cancelBtn.addEventListener('click', () => running?.cancel());
314442 }
315443
316444 // separate contact intervals in the contact-force record
test/cache.test.tsadded+37−0View file
@@ -0,0 +1,37 @@
1+import { describe, expect, it } from 'vitest';
2+import { decodeResult, digest, encodeResult } from '../src/sim/cache';
3+import type { SimResult } from '../src/sim/types';
4+
5+describe('result cache encoding', () => {
6+ it('round-trips a result, optional arrays included', () => {
7+ const r: SimResult = {
8+ audio: new Float32Array([0.5, -1, 3]),
9+ fs: 48000,
10+ frames: new Float32Array(7).map((_, i) => i * 0.1),
11+ frameTimes: new Float64Array([0, 0.5]),
12+ energy: new Float64Array([1e-3, 2e-3]),
13+ balance: new Float64Array([0, 1e-12]),
14+ dt: 1e-6,
15+ stepsPerSample: 21,
16+ omegaMax: 12345.6,
17+ wallSeconds: 3.2,
18+ maxStretch: 0.01,
19+ contactForce: new Float32Array([0, 4, 2]),
20+ backend: 'CPU',
21+ };
22+ const bytes = encodeResult(r);
23+ expect(bytes.length % 8).toBe(0);
24+ const back = decodeResult(bytes.buffer);
25+ expect(back).toEqual(r);
26+ expect(back.frameTimes).toBeInstanceOf(Float64Array);
27+ expect(back.malletPos).toBeUndefined();
28+ });
29+
30+ it('hashes inputs canonically: key order does not matter, values and array types do', async () => {
31+ const a = await digest({ x: 1, arr: new Float64Array([1, 2]), y: undefined });
32+ expect(await digest({ arr: new Float64Array([1, 2]), x: 1 })).toBe(a);
33+ expect(await digest({ x: 1, arr: new Float64Array([1, 2.0000001]) })).not.toBe(a);
34+ expect(await digest({ x: 1, arr: new Float32Array([1, 2]) })).not.toBe(a);
35+ expect(await digest({ x: 2, arr: new Float64Array([1, 2]) })).not.toBe(a);
36+ });
37+});