1// The render worker: numbl (and, when available, WebGPU) runs here so a
2// minutes-long time integration never blocks the UI. Requests are
3// processed one at a time; the core yields to the macrotask queue between
4// stages and awaits the GPU between chunks, which is when queued cancel
5// messages are delivered, so cancellation is cooperative and the caches
6// survive it.
7import { RenderCancelled, renderRequest, type Cymbal2Variant } from './core.ts';
8import { DEFAULT_MFILES } from './mfiles.ts';
9import type { MFile, NoteSpec } from './types.ts';
11export interface WorkerRenderMsg {
12 kind: 'render';
13 id: number;
14 variant: Cymbal2Variant;
15 notes: NoteSpec[];
16 sampleRate: number;
17 lengthSamples: number;
18 quality: 'preview' | 'full';
19 settings?: Record<string, number>;
20 params?: Record<string, number | boolean>;
21 mfiles?: MFile[];
22 wantDiagnostics?: boolean;
23}
25export interface WorkerCancelMsg {
26 kind: 'cancel';
27 id: number;
28}
30export type WorkerResponse =
31 | { kind: 'progress'; id: number; frac: number; stage: string }
32 | { kind: 'result'; id: number; audio: Float32Array; diag?: unknown }
33 | { kind: 'cancelled'; id: number }
34 | { kind: 'error'; id: number; message: string; file?: string; line?: number };
36const cancelled = new Set<number>();
37const queue: WorkerRenderMsg[] = [];
38let running = false;
40const post = (m: WorkerResponse, transfer?: Transferable[]) =>
41 (self as unknown as Worker).postMessage(m, transfer ?? []);
43async function run(msg: WorkerRenderMsg): Promise<void> {
44 try {
45 const result = await renderRequest(msg.variant, {
46 notes: msg.notes,
47 sampleRate: msg.sampleRate,
48 lengthSamples: msg.lengthSamples,
49 quality: msg.quality,
50 settings: msg.settings,
51 params: msg.params,
52 wantDiagnostics: msg.wantDiagnostics,
53 }, {
54 mfiles: msg.mfiles ?? DEFAULT_MFILES,
55 shipped: DEFAULT_MFILES,
56 post: (frac, stage) => post({ kind: 'progress', id: msg.id, frac, stage }),
57 checkCancel: () => { if (cancelled.has(msg.id)) throw new RenderCancelled(); },
58 yieldToHost: () => new Promise((res) => setTimeout(res, 0)),
59 });
60 const transfer: Transferable[] = [result.audio.buffer];
61 if (result.diag) {
62 for (const v of Object.values(result.diag as unknown as Record<string, unknown>)) {
63 if (v instanceof Float32Array && v.buffer !== result.audio.buffer) transfer.push(v.buffer);
64 }
65 }
66 post({ kind: 'result', id: msg.id, audio: result.audio, diag: result.diag }, transfer);
67 } catch (err) {
68 if (err instanceof RenderCancelled) {
69 post({ kind: 'cancelled', id: msg.id });
70 } else {
71 // Faults in the (user-editable) .m code arrive as errors carrying a
72 // file and line, which the core's parse check has already attributed.
73 const e = err as { message?: string; file?: string; line?: number };
74 post({
75 kind: 'error',
76 id: msg.id,
77 message: e?.message ? String(e.message) : String(err),
78 file: typeof e?.file === 'string' ? e.file : undefined,
79 line: typeof e?.line === 'number' ? e.line : undefined,
80 });
81 }
82 } finally {
83 cancelled.delete(msg.id);
84 }
85}
87async function pump(): Promise<void> {
88 if (running) return;
89 running = true;
90 while (queue.length > 0) {
91 const msg = queue.shift()!;
92 if (cancelled.has(msg.id)) { post({ kind: 'cancelled', id: msg.id }); cancelled.delete(msg.id); continue; }
93 await run(msg);
94 }
95 running = false;
96}
98self.onmessage = (ev: MessageEvent<WorkerRenderMsg | WorkerCancelMsg>) => {
99 const msg = ev.data;
100 if (msg.kind === 'render') {
101 queue.push(msg);
102 void pump();
103 } else if (msg.kind === 'cancel') {
104 cancelled.add(msg.id);
105 }
106};