/** * The render worker: numbl runs here so a seconds-long eigensolve or jet * integration never blocks the UI. One request renders one track through * one bundle (sent with the request; the worker holds no catalog). Notes * are grouped by cache key so each distinct sound renders once, cache * misses go through renderNoteBatch in small chunks (prepare amortized, * progress between chunks), and the mixed buffer is transferred back. * The per-note cache is an LRU bounded by total samples. */ import type { InstrumentBundle } from './bundle.ts'; import { bucketDuration, bucketVelocity, noteCacheKey, renderNoteBatch } from './render.ts'; export interface WorkerNote { pitch: number; velocity: number; // 0..1 durationSec: number; timeSec: number; } export interface WorkerRenderMsg { kind: 'render'; id: number; bundle: InstrumentBundle; 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 CACHE_MAX_SAMPLES = 48e6; // ~192 MB of Float32 note renders const CHUNK = 4; // cache misses rendered per numbl program 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 b = msg.bundle; 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(b, 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] }); } const misses = [...groups.entries()].filter(([key]) => !cache.has(key)); for (let c = 0; c < misses.length; c += CHUNK) { const chunk = misses.slice(c, c + CHUNK); post({ kind: 'status', text: `${b.name}: rendering notes ${c + 1}-${Math.min(c + CHUNK, misses.length)}/${misses.length} (${msg.quality})`, }); const bufs = renderNoteBatch( b, chunk.map(([, g]) => ({ pitch: g.pitch, velocity: g.velB, durationSec: g.durB })), { sampleRate: msg.sampleRate, quality: msg.quality, settings: msg.settings }, ); chunk.forEach(([key], i) => cachePut(key, bufs[i])); } for (const [key, g] of groups) { const buf = cacheGet(key)!; 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]; } } 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) }); } };