/** * The render worker: numbl runs here so a seconds-long eigensolve or jet * integration never blocks the UI. One request renders one track: each * note is rendered individually (velocity and duration quantized so * repeats hit the cache), placed at its onset, and summed; the mixed * buffer is transferred back. The per-note cache is an LRU bounded by * total samples, keyed by everything that affects one note's audio. */ import { loadCatalog, type SimInstrumentDef } from './catalog.ts'; import { bucketDuration, bucketVelocity, noteCacheKey } from './manifest.ts'; import { renderSimNote } from './mrender.ts'; export interface WorkerNote { pitch: number; velocity: number; // 0..1 durationSec: number; timeSec: number; } export interface WorkerRenderMsg { kind: 'render'; id: number; name: string; // manifest name notes: WorkerNote[]; sampleRate: number; lengthSamples: number; quality: 'preview' | 'full'; settings: Record; } export type WorkerResponse = | { kind: 'result'; id: number; samples: Float32Array } | { kind: 'error'; id: number; message: string } | { kind: 'status'; text: string }; const catalog = new Map(); for (const def of loadCatalog()) catalog.set(def.manifest.name, def); const CACHE_MAX_SAMPLES = 48e6; // ~192 MB of Float32 note renders const cache = new Map(); let cacheSamples = 0; function cacheGet(key: string): Float32Array | undefined { const v = cache.get(key); if (v) { cache.delete(key); cache.set(key, v); } return v; } function cachePut(key: string, buf: Float32Array): void { cache.set(key, buf); cacheSamples += buf.length; while (cacheSamples > CACHE_MAX_SAMPLES && cache.size > 1) { const oldest = cache.keys().next().value as string; cacheSamples -= cache.get(oldest)!.length; cache.delete(oldest); } } const post = (m: WorkerResponse, transfer?: Transferable[]) => (self as unknown as Worker).postMessage(m, transfer ?? []); self.onmessage = (ev: MessageEvent) => { const msg = ev.data; if (msg.kind !== 'render') return; try { const def = catalog.get(msg.name); if (!def) throw new Error(`unknown simulated instrument '${msg.name}'`); const m = def.manifest; const out = new Float32Array(msg.lengthSamples); // Group notes by cache key so each distinct sound renders once. const groups = new Map(); for (const n of msg.notes) { const velB = bucketVelocity(n.velocity); const durB = bucketDuration(n.durationSec); const key = noteCacheKey(m, msg.quality, msg.sampleRate, msg.settings, n.pitch, velB, durB); const g = groups.get(key); if (g) g.times.push(n.timeSec); else groups.set(key, { velB, durB, pitch: n.pitch, times: [n.timeSec] }); } let done = 0; for (const [key, g] of groups) { let buf = cacheGet(key); if (!buf) { post({ kind: 'status', text: `${m.label}: rendering note ${done + 1}/${groups.size} (${msg.quality})` }); buf = renderSimNote(m, def.mfiles, { pitch: g.pitch, velocity: g.velB, durationSec: g.durB, sampleRate: msg.sampleRate, quality: msg.quality, settings: msg.settings, }); cachePut(key, buf); } for (const t of g.times) { const off = Math.round(t * msg.sampleRate); const end = Math.min(out.length, off + buf.length); for (let i = Math.max(0, off); i < end; i++) out[i] += buf[i - off]; } done++; } post({ kind: 'status', text: '' }); post({ kind: 'result', id: msg.id, samples: out }, [out.buffer]); } catch (err) { post({ kind: 'status', text: '' }); post({ kind: 'error', id: msg.id, message: err instanceof Error ? err.message : String(err) }); } };