/ proof-of-concept / midiboot
Sign in
proof-of-concept / midiboot
midiboot / src / instruments / numbl / worker.ts
107 lines · 3.9 KBBlameHistoryRaw
1/**
2 * The render worker: numbl runs here so a seconds-long eigensolve or jet
3 * integration never blocks the UI. One request renders one track through
4 * one bundle (sent with the request; the worker holds no catalog). Notes
5 * are grouped by cache key so each distinct sound renders once, cache
6 * misses go through renderNoteBatch in small chunks (prepare amortized,
7 * progress between chunks), and the mixed buffer is transferred back.
8 * The per-note cache is an LRU bounded by total samples.
9 */
10import type { InstrumentBundle } from './bundle.ts';
11import { bucketDuration, bucketVelocity, noteCacheKey, renderNoteBatch } from './render.ts';
13export interface WorkerNote {
14 pitch: number;
15 velocity: number; // 0..1
16 durationSec: number;
17 timeSec: number;
20export interface WorkerRenderMsg {
21 kind: 'render';
22 id: number;
23 bundle: InstrumentBundle;
24 notes: WorkerNote[];
25 sampleRate: number;
26 lengthSamples: number;
27 quality: 'preview' | 'full';
28 settings: Record<string, number>;
31export type WorkerResponse =
32 | { kind: 'result'; id: number; samples: Float32Array }
33 | { kind: 'error'; id: number; message: string }
34 | { kind: 'status'; text: string };
36const CACHE_MAX_SAMPLES = 48e6; // ~192 MB of Float32 note renders
37const CHUNK = 4; // cache misses rendered per numbl program
38const cache = new Map<string, Float32Array>();
39let cacheSamples = 0;
41function cacheGet(key: string): Float32Array | undefined {
42 const v = cache.get(key);
43 if (v) {
44 cache.delete(key);
45 cache.set(key, v);
46 }
47 return v;
50function cachePut(key: string, buf: Float32Array): void {
51 cache.set(key, buf);
52 cacheSamples += buf.length;
53 while (cacheSamples > CACHE_MAX_SAMPLES && cache.size > 1) {
54 const oldest = cache.keys().next().value as string;
55 cacheSamples -= cache.get(oldest)!.length;
56 cache.delete(oldest);
57 }
60const post = (m: WorkerResponse, transfer?: Transferable[]) =>
61 (self as unknown as Worker).postMessage(m, transfer ?? []);
63self.onmessage = (ev: MessageEvent<WorkerRenderMsg>) => {
64 const msg = ev.data;
65 if (msg.kind !== 'render') return;
66 try {
67 const b = msg.bundle;
68 const out = new Float32Array(msg.lengthSamples);
69 // Group notes by cache key so each distinct sound renders once.
70 const groups = new Map<string, { velB: number; durB: number; pitch: number; times: number[] }>();
71 for (const n of msg.notes) {
72 const velB = bucketVelocity(n.velocity);
73 const durB = bucketDuration(n.durationSec);
74 const key = noteCacheKey(b, msg.quality, msg.sampleRate, msg.settings, n.pitch, velB, durB);
75 const g = groups.get(key);
76 if (g) g.times.push(n.timeSec);
77 else groups.set(key, { velB, durB, pitch: n.pitch, times: [n.timeSec] });
78 }
79 const misses = [...groups.entries()].filter(([key]) => !cache.has(key));
80 for (let c = 0; c < misses.length; c += CHUNK) {
81 const chunk = misses.slice(c, c + CHUNK);
82 post({
83 kind: 'status',
84 text: `${b.name}: rendering notes ${c + 1}-${Math.min(c + CHUNK, misses.length)}/${misses.length} (${msg.quality})`,
85 });
86 const bufs = renderNoteBatch(
87 b,
88 chunk.map(([, g]) => ({ pitch: g.pitch, velocity: g.velB, durationSec: g.durB })),
89 { sampleRate: msg.sampleRate, quality: msg.quality, settings: msg.settings },
90 );
91 chunk.forEach(([key], i) => cachePut(key, bufs[i]));
92 }
93 for (const [key, g] of groups) {
94 const buf = cacheGet(key)!;
95 for (const t of g.times) {
96 const off = Math.round(t * msg.sampleRate);
97 const end = Math.min(out.length, off + buf.length);
98 for (let i = Math.max(0, off); i < end; i++) out[i] += buf[i - off];
99 }
100 }
101 post({ kind: 'status', text: '' });
102 post({ kind: 'result', id: msg.id, samples: out }, [out.buffer]);
103 } catch (err) {
104 post({ kind: 'status', text: '' });
105 post({ kind: 'error', id: msg.id, message: err instanceof Error ? err.message : String(err) });
106 }
107};
↑↓ move↵openescclose