import type { BeadSystem, Excitation, SimOptions, SimResult } from './types'; // Shared cache of simulation results on artifact-cache (https://artifact-cache.tempory.net, // app "toy-instruments"). Reads are public; writes need the app's passcode, kept in // localStorage. A result's key is v///, stored as two entries: /light (audio, energy, contact force and // scalars: everything but the replay frames, small) and /frames (the replay frames, // usually most of the size). The hash covers everything the solver sees, so model // changes miss old entries by themselves; bump CACHE_VERSION when the solver's output // changes for the same inputs. export const CACHE_VERSION = 2; const BASE = 'https://artifact-cache.tempory.net/api/apps/toy-instruments'; const PASS_KEY = 'toy-instruments.cache-passcode'; export const MAX_ENTRY_BYTES = 100 * 1024 * 1024; export type Backend = 'cpu' | 'gpu'; export async function resultKey(instrument: string, sys: BeadSystem, exc: Excitation, opt: SimOptions, backend: Backend): Promise { return `v${CACHE_VERSION}/${instrument}/${backend}/${await digest({ sys, exc, opt })}`; } // SHA-256 of a canonical encoding: JSON with sorted keys, typed arrays replaced by // their type and byte length, followed by the arrays' bytes in order. export async function digest(value: unknown): Promise { const parts: Uint8Array[] = []; const head = new TextEncoder().encode(canon(value, parts)); const all = new Uint8Array(head.length + parts.reduce((s, p) => s + p.length, 0)); all.set(head); let o = head.length; for (const p of parts) { all.set(p, o); o += p.length; } const h = new Uint8Array(await crypto.subtle.digest('SHA-256', all)); return [...h].map((b) => b.toString(16).padStart(2, '0')).join(''); } function canon(v: unknown, parts: Uint8Array[]): string { if (ArrayBuffer.isView(v)) { const b = new Uint8Array(v.buffer, v.byteOffset, v.byteLength); parts.push(b); return `{"$":"${v.constructor.name}","n":${b.length}}`; } if (v === null || typeof v !== 'object') return JSON.stringify(v) ?? 'null'; if (Array.isArray(v)) return `[${v.map((x) => canon(x, parts)).join(',')}]`; const o = v as Record; const keys = Object.keys(o).filter((k) => o[k] !== undefined).sort(); return `{${keys.map((k) => `${JSON.stringify(k)}:${canon(o[k], parts)}`).join(',')}}`; } // Binary layout of an entry: "TIR1", uint32 header length, header JSON (scalars and // the list of arrays), then each array's bytes, every section starting on an 8-byte boundary. const MAGIC = 'TIR1'; const ARRAYS = { Float32Array, Float64Array }; type ArrayType = keyof typeof ARRAYS; const pad8 = (n: number) => (n + 7) & ~7; export function encodeRecord(r: object): Uint8Array { const scalars: Record = {}; const arrays: { name: string; type: ArrayType; length: number }[] = []; const data: Uint8Array[] = []; for (const [name, v] of Object.entries(r)) { if (v instanceof Float32Array || v instanceof Float64Array) { arrays.push({ name, type: v.constructor.name as ArrayType, length: v.length }); data.push(new Uint8Array(v.buffer, v.byteOffset, v.byteLength)); } else if (v !== undefined) scalars[name] = v; } const head = new TextEncoder().encode(JSON.stringify({ scalars, arrays })); let size = pad8(8 + head.length); for (const d of data) size += pad8(d.length); const out = new Uint8Array(size); out.set(new TextEncoder().encode(MAGIC)); new DataView(out.buffer).setUint32(4, head.length, true); out.set(head, 8); let o = pad8(8 + head.length); for (const d of data) { out.set(d, o); o += pad8(d.length); } return out; } export function decodeRecord(buf: ArrayBuffer): T { if (new TextDecoder().decode(new Uint8Array(buf, 0, 4)) !== MAGIC) throw new Error('not a cached result'); const hl = new DataView(buf).getUint32(4, true); const { scalars, arrays } = JSON.parse(new TextDecoder().decode(new Uint8Array(buf, 8, hl))); const r: Record = { ...scalars }; let o = pad8(8 + hl); for (const a of arrays as { name: string; type: ArrayType; length: number }[]) { const A = ARRAYS[a.type]; r[a.name] = new A(buf, o, a.length); o += pad8(a.length * A.BYTES_PER_ELEMENT); } return r as T; } // A result without its replay frames. export type LightResult = Omit; export function splitResult(r: SimResult): { light: LightResult; frames: Float32Array } { const { frames, ...light } = r; return { light, frames }; } // Whether a result is cached (HEAD on its light entry, no download): the light entry's // size and when it was stored, or null. export async function probeResult(key: string, signal?: AbortSignal): Promise<{ bytes: number; stored?: Date } | null> { return probe(`${key}/light`, signal); } // Size of a cached result's replay frames, or null if they are not cached. export async function probeFrames(key: string, signal?: AbortSignal): Promise { return (await probe(`${key}/frames`, signal))?.bytes ?? null; } async function probe(entry: string, signal?: AbortSignal): Promise<{ bytes: number; stored?: Date } | null> { const res = await fetch(`${BASE}/entries/${entry}`, { method: 'HEAD', signal }); if (res.status === 404) return null; if (!res.ok) throw new Error(`${res.status} ${res.statusText}`); const lm = res.headers.get('last-modified'); return { bytes: Number(res.headers.get('content-length')) || 0, stored: lm ? new Date(lm) : undefined }; } // A cached result's light entry, or null if there is none. export async function loadLight(key: string, signal?: AbortSignal): Promise<{ light: LightResult; stored?: Date } | null> { const got = await download(`${key}/light`, () => {}, signal); return got && { light: decodeRecord(got.buf), stored: got.stored }; } // A cached result's replay frames, or null if there are none. onProgress gets the fraction downloaded. export async function loadFrames(key: string, onProgress: (f: number) => void, signal?: AbortSignal): Promise { const got = await download(`${key}/frames`, onProgress, signal); return got && decodeRecord<{ frames: Float32Array }>(got.buf).frames; } export interface CacheHit { result: SimResult; stored?: Date; } // A whole cached result (light entry, then frames), or null if either is missing. export async function loadResult(key: string, onProgress: (f: number) => void, signal?: AbortSignal): Promise { const hit = await loadLight(key, signal); if (!hit) return null; const frames = await loadFrames(key, onProgress, signal); return frames && { result: { ...hit.light, frames }, stored: hit.stored }; } async function download(entry: string, onProgress: (f: number) => void, signal?: AbortSignal): Promise<{ buf: ArrayBuffer; stored?: Date } | null> { const res = await fetch(`${BASE}/entries/${entry}`, { signal }); if (res.status === 404) return null; if (!res.ok) throw new Error(`${res.status} ${res.statusText}`); // Content-Length is used only for progress (it is the compressed size if the response is encoded) const total = Number(res.headers.get('content-length')) || 0; let buf: ArrayBuffer; if (res.body) { const chunks: Uint8Array[] = []; const reader = res.body.getReader(); let got = 0; for (;;) { const { done, value } = await reader.read(); if (done) break; chunks.push(value); got += value.length; if (total) onProgress(Math.min(got / total, 1)); } const bytes = new Uint8Array(got); let o = 0; for (const c of chunks) { bytes.set(c, o); o += c.length; } buf = bytes.buffer; } else buf = await res.arrayBuffer(); const lm = res.headers.get('last-modified'); return { buf, stored: lm ? new Date(lm) : undefined }; } // Store a result: the frames first, so a light entry (what readers look for) always has its frames. export async function storeResult(key: string, r: SimResult, meta: Record): Promise { const pass = getPasscode(); if (!pass) throw new Error('no passcode'); const { light, frames } = splitResult(r); const framesBody = encodeRecord({ frames }); if (framesBody.length > MAX_ENTRY_BYTES) throw new Error(`the replay frames are ${Math.round(framesBody.length / 1e6)} MB, over the 100 MB limit`); await put(`${key}/frames`, framesBody, meta, pass); await put(`${key}/light`, encodeRecord(light), meta, pass); } async function put(entry: string, body: Uint8Array, meta: Record, pass: string) { const res = await fetch(`${BASE}/entries/${entry}`, { method: 'PUT', headers: { 'X-Passcode': pass, 'Content-Type': 'application/octet-stream', 'X-Artifact-Meta': encodeURIComponent(JSON.stringify(meta)) }, body, }); if (!res.ok) throw new Error(await errorText(res)); } export async function checkPasscode(pass: string): Promise<{ valid: boolean; reason?: string }> { const res = await fetch(`${BASE}/check-passcode`, { method: 'POST', headers: { 'X-Passcode': pass } }); if (!res.ok) throw new Error(await errorText(res)); return res.json(); } export function getPasscode(): string | null { try { return localStorage.getItem(PASS_KEY); } catch { return null; } } export function setPasscode(pass: string | null) { try { if (pass) localStorage.setItem(PASS_KEY, pass); else localStorage.removeItem(PASS_KEY); } catch { /* storage unavailable */ } } async function errorText(res: Response): Promise { try { const b = await res.json(); if (b?.error) return `${res.status}: ${b.error}`; } catch { /* not JSON */ } return `${res.status} ${res.statusText}`; }