/ simulated_instruments / triangle
Sign in
simulated_instruments / triangle
triangle / instrument / noteCache.ts
180 lines · 6.5 KBCodeBlameHistory
4c0259bAdd instrument/: the triangle behind the track-render interfaceJeremy Magland 1// Persistent cache for finished note renders: an in-memory LRU in front of
2// an IndexedDB store, so a rendered note survives page reloads, worker
3// teardowns, and browser restarts. Keys already carry everything that
4// identifies a sound (the .m code hash, quality tier, sample rate, knob
5// values, pitch, bucketed velocity/duration), so a stale entry from older
6// code is simply never asked for again and ages out.
7//
8// Eviction is by budget and by age. The memory tier is bounded by total
9// samples and evicts least-recently-used, as before. The IndexedDB tier
10// has its own larger sample budget, trimmed least-recently-used after
11// each write, and entries unused for 30 days are dropped when the store
12// is opened. Sample data and bookkeeping live in separate object stores
13// ('data' and 'meta'), so the eviction scan never loads audio.
14//
15// Everything degrades to memory-only: no indexedDB global (the node
16// tests), a blocked or failing database, or a private-mode quota all just
17// disable persistence.
19interface MetaRec {
20 key: string;
21 sampleCount: number;
22 lastUsedAt: number;
25interface DataRec {
26 key: string;
27 samples: Float32Array;
30const MEM_MAX_SAMPLES = 48e6; // ~192 MB of Float32 renders in memory
31const DB_MAX_SAMPLES = 96e6; // ~384 MB on disk
32const MAX_AGE_MS = 30 * 24 * 3600e3;
34function reqDone<T>(req: IDBRequest<T>): Promise<T> {
35 return new Promise((resolve, reject) => {
36 req.onsuccess = () => resolve(req.result);
37 req.onerror = () => reject(req.error);
38 });
41export class NoteCache {
42 private mem = new Map<string, Float32Array>();
43 private memSamples = 0;
44 private dbPromise: Promise<IDBDatabase | null> | null = null;
45 /** Bookkeeping mirror of the 'meta' store, loaded once at open. */
46 private meta = new Map<string, MetaRec>();
47 private dbSamples = 0;
49 constructor(private readonly dbName: string) {}
51 private open(): Promise<IDBDatabase | null> {
52 if (this.dbPromise) return this.dbPromise;
53 this.dbPromise = (async () => {
54 if (typeof indexedDB === 'undefined') return null;
55 try {
56 const req = indexedDB.open(this.dbName, 1);
57 req.onupgradeneeded = () => {
58 const db = req.result;
59 if (!db.objectStoreNames.contains('data')) db.createObjectStore('data', { keyPath: 'key' });
60 if (!db.objectStoreNames.contains('meta')) db.createObjectStore('meta', { keyPath: 'key' });
61 };
62 const db = await new Promise<IDBDatabase>((resolve, reject) => {
63 req.onsuccess = () => resolve(req.result);
64 req.onerror = () => reject(req.error);
65 req.onblocked = () => reject(new Error('blocked'));
66 });
67 db.onversionchange = () => db.close();
69 // Load the bookkeeping and drop entries unused for too long.
70 const metas = (await reqDone(
71 db.transaction('meta', 'readonly').objectStore('meta').getAll(),
72 )) as MetaRec[];
73 const now = Date.now();
74 const stale: string[] = [];
75 for (const m of metas) {
76 if (now - m.lastUsedAt > MAX_AGE_MS) stale.push(m.key);
77 else {
78 this.meta.set(m.key, m);
79 this.dbSamples += m.sampleCount;
80 }
81 }
82 if (stale.length > 0) {
83 const tx = db.transaction(['data', 'meta'], 'readwrite');
84 for (const key of stale) {
85 tx.objectStore('data').delete(key);
86 tx.objectStore('meta').delete(key);
87 }
88 }
89 return db;
90 } catch {
91 return null; // persistence unavailable; memory tier still works
92 }
93 })();
94 return this.dbPromise;
95 }
97 private memPut(key: string, samples: Float32Array): void {
98 const old = this.mem.get(key);
99 if (old) this.memSamples -= old.length;
100 this.mem.delete(key);
101 this.mem.set(key, samples);
102 this.memSamples += samples.length;
103 while (this.memSamples > MEM_MAX_SAMPLES && this.mem.size > 1) {
104 const oldest = this.mem.keys().next().value as string;
105 this.memSamples -= this.mem.get(oldest)!.length;
106 this.mem.delete(oldest);
107 }
108 }
110 /** Record a use; fire-and-forget so hits never wait on a write. */
111 private touch(key: string): void {
112 const m = this.meta.get(key);
113 if (!m) return;
114 m.lastUsedAt = Date.now();
115 void this.open().then((db) => {
116 if (!db) return;
117 try {
118 db.transaction('meta', 'readwrite').objectStore('meta').put(m);
119 } catch { /* persistence unavailable */ }
120 });
121 }
123 async get(key: string): Promise<Float32Array | undefined> {
124 const hit = this.mem.get(key);
125 if (hit) {
126 this.mem.delete(key);
127 this.mem.set(key, hit); // LRU refresh
128 this.touch(key);
129 return hit;
130 }
131 const db = await this.open();
132 if (!db) return undefined;
133 try {
134 const rec = (await reqDone(
135 db.transaction('data', 'readonly').objectStore('data').get(key),
136 )) as DataRec | undefined;
137 if (!rec) return undefined;
138 const samples = rec.samples instanceof Float32Array ? rec.samples : new Float32Array(rec.samples as ArrayLike<number>);
139 this.memPut(key, samples);
140 this.touch(key);
141 return samples;
142 } catch {
143 return undefined;
144 }
145 }
147 /** Store a fresh render: memory now, disk in the background. */
148 put(key: string, samples: Float32Array): void {
149 this.memPut(key, samples);
150 void this.persist(key, samples);
151 }
153 private async persist(key: string, samples: Float32Array): Promise<void> {
154 const db = await this.open();
155 if (!db) return;
156 try {
157 const prev = this.meta.get(key);
158 if (prev) this.dbSamples -= prev.sampleCount;
159 const m: MetaRec = { key, sampleCount: samples.length, lastUsedAt: Date.now() };
160 const tx = db.transaction(['data', 'meta'], 'readwrite');
161 tx.objectStore('data').put({ key, samples } satisfies DataRec);
162 tx.objectStore('meta').put(m);
163 this.meta.set(key, m);
164 this.dbSamples += m.sampleCount;
166 // Trim least-recently-used until the disk tier is under budget.
167 if (this.dbSamples > DB_MAX_SAMPLES) {
168 const byAge = [...this.meta.values()].sort((a, b) => a.lastUsedAt - b.lastUsedAt);
169 for (const victim of byAge) {
170 if (this.dbSamples <= DB_MAX_SAMPLES) break;
171 if (victim.key === key) continue; // never evict what was just written
172 tx.objectStore('data').delete(victim.key);
173 tx.objectStore('meta').delete(victim.key);
174 this.meta.delete(victim.key);
175 this.dbSamples -= victim.sampleCount;
176 }
177 }
178 } catch { /* persistence unavailable; the memory tier holds it */ }
179 }
↑↓ move↵openescclose