// Persistent cache for finished note renders: an in-memory LRU in front of // an IndexedDB store, so a rendered note survives page reloads, worker // teardowns, and browser restarts. Keys already carry everything that // identifies a sound (the .m code hash, quality tier, sample rate, knob // values, pitch, bucketed velocity/duration), so a stale entry from older // code is simply never asked for again and ages out. // // Eviction is by budget and by age. The memory tier is bounded by total // samples and evicts least-recently-used, as before. The IndexedDB tier // has its own larger sample budget, trimmed least-recently-used after // each write, and entries unused for 30 days are dropped when the store // is opened. Sample data and bookkeeping live in separate object stores // ('data' and 'meta'), so the eviction scan never loads audio. // // Everything degrades to memory-only: no indexedDB global (the node // tests), a blocked or failing database, or a private-mode quota all just // disable persistence. interface MetaRec { key: string; sampleCount: number; lastUsedAt: number; } interface DataRec { key: string; samples: Float32Array; } const MEM_MAX_SAMPLES = 48e6; // ~192 MB of Float32 renders in memory const DB_MAX_SAMPLES = 96e6; // ~384 MB on disk const MAX_AGE_MS = 30 * 24 * 3600e3; function reqDone(req: IDBRequest): Promise { return new Promise((resolve, reject) => { req.onsuccess = () => resolve(req.result); req.onerror = () => reject(req.error); }); } export class NoteCache { private mem = new Map(); private memSamples = 0; private dbPromise: Promise | null = null; /** Bookkeeping mirror of the 'meta' store, loaded once at open. */ private meta = new Map(); private dbSamples = 0; constructor(private readonly dbName: string) {} private open(): Promise { if (this.dbPromise) return this.dbPromise; this.dbPromise = (async () => { if (typeof indexedDB === 'undefined') return null; try { const req = indexedDB.open(this.dbName, 1); req.onupgradeneeded = () => { const db = req.result; if (!db.objectStoreNames.contains('data')) db.createObjectStore('data', { keyPath: 'key' }); if (!db.objectStoreNames.contains('meta')) db.createObjectStore('meta', { keyPath: 'key' }); }; const db = await new Promise((resolve, reject) => { req.onsuccess = () => resolve(req.result); req.onerror = () => reject(req.error); req.onblocked = () => reject(new Error('blocked')); }); db.onversionchange = () => db.close(); // Load the bookkeeping and drop entries unused for too long. const metas = (await reqDone( db.transaction('meta', 'readonly').objectStore('meta').getAll(), )) as MetaRec[]; const now = Date.now(); const stale: string[] = []; for (const m of metas) { if (now - m.lastUsedAt > MAX_AGE_MS) stale.push(m.key); else { this.meta.set(m.key, m); this.dbSamples += m.sampleCount; } } if (stale.length > 0) { const tx = db.transaction(['data', 'meta'], 'readwrite'); for (const key of stale) { tx.objectStore('data').delete(key); tx.objectStore('meta').delete(key); } } return db; } catch { return null; // persistence unavailable; memory tier still works } })(); return this.dbPromise; } private memPut(key: string, samples: Float32Array): void { const old = this.mem.get(key); if (old) this.memSamples -= old.length; this.mem.delete(key); this.mem.set(key, samples); this.memSamples += samples.length; while (this.memSamples > MEM_MAX_SAMPLES && this.mem.size > 1) { const oldest = this.mem.keys().next().value as string; this.memSamples -= this.mem.get(oldest)!.length; this.mem.delete(oldest); } } /** Record a use; fire-and-forget so hits never wait on a write. */ private touch(key: string): void { const m = this.meta.get(key); if (!m) return; m.lastUsedAt = Date.now(); void this.open().then((db) => { if (!db) return; try { db.transaction('meta', 'readwrite').objectStore('meta').put(m); } catch { /* persistence unavailable */ } }); } async get(key: string): Promise { const hit = this.mem.get(key); if (hit) { this.mem.delete(key); this.mem.set(key, hit); // LRU refresh this.touch(key); return hit; } const db = await this.open(); if (!db) return undefined; try { const rec = (await reqDone( db.transaction('data', 'readonly').objectStore('data').get(key), )) as DataRec | undefined; if (!rec) return undefined; const samples = rec.samples instanceof Float32Array ? rec.samples : new Float32Array(rec.samples as ArrayLike); this.memPut(key, samples); this.touch(key); return samples; } catch { return undefined; } } /** Store a fresh render: memory now, disk in the background. */ put(key: string, samples: Float32Array): void { this.memPut(key, samples); void this.persist(key, samples); } private async persist(key: string, samples: Float32Array): Promise { const db = await this.open(); if (!db) return; try { const prev = this.meta.get(key); if (prev) this.dbSamples -= prev.sampleCount; const m: MetaRec = { key, sampleCount: samples.length, lastUsedAt: Date.now() }; const tx = db.transaction(['data', 'meta'], 'readwrite'); tx.objectStore('data').put({ key, samples } satisfies DataRec); tx.objectStore('meta').put(m); this.meta.set(key, m); this.dbSamples += m.sampleCount; // Trim least-recently-used until the disk tier is under budget. if (this.dbSamples > DB_MAX_SAMPLES) { const byAge = [...this.meta.values()].sort((a, b) => a.lastUsedAt - b.lastUsedAt); for (const victim of byAge) { if (this.dbSamples <= DB_MAX_SAMPLES) break; if (victim.key === key) continue; // never evict what was just written tx.objectStore('data').delete(victim.key); tx.objectStore('meta').delete(victim.key); this.meta.delete(victim.key); this.dbSamples -= victim.sampleCount; } } } catch { /* persistence unavailable; the memory tier holds it */ } } }