// The render worker: numbl (and, when available, WebGPU) runs here so a // minutes-long time integration never blocks the UI. Requests are // processed one at a time; the core yields to the macrotask queue between // stages and awaits the GPU between chunks, which is when queued cancel // messages are delivered, so cancellation is cooperative and the caches // survive it. import { RenderCancelled, renderRequest, type Cymbal2Variant } from './core.ts'; import { DEFAULT_MFILES } from './mfiles.ts'; import type { MFile, NoteSpec } from './types.ts'; export interface WorkerRenderMsg { kind: 'render'; id: number; variant: Cymbal2Variant; notes: NoteSpec[]; sampleRate: number; lengthSamples: number; quality: 'preview' | 'full'; settings?: Record; params?: Record; mfiles?: MFile[]; wantDiagnostics?: boolean; } export interface WorkerCancelMsg { kind: 'cancel'; id: number; } export type WorkerResponse = | { kind: 'progress'; id: number; frac: number; stage: string } | { kind: 'result'; id: number; audio: Float32Array; diag?: unknown } | { kind: 'cancelled'; id: number } | { kind: 'error'; id: number; message: string; file?: string; line?: number }; const cancelled = new Set(); const queue: WorkerRenderMsg[] = []; let running = false; const post = (m: WorkerResponse, transfer?: Transferable[]) => (self as unknown as Worker).postMessage(m, transfer ?? []); async function run(msg: WorkerRenderMsg): Promise { try { const result = await renderRequest(msg.variant, { notes: msg.notes, sampleRate: msg.sampleRate, lengthSamples: msg.lengthSamples, quality: msg.quality, settings: msg.settings, params: msg.params, wantDiagnostics: msg.wantDiagnostics, }, { mfiles: msg.mfiles ?? DEFAULT_MFILES, shipped: DEFAULT_MFILES, post: (frac, stage) => post({ kind: 'progress', id: msg.id, frac, stage }), checkCancel: () => { if (cancelled.has(msg.id)) throw new RenderCancelled(); }, yieldToHost: () => new Promise((res) => setTimeout(res, 0)), }); const transfer: Transferable[] = [result.audio.buffer]; if (result.diag) { for (const v of Object.values(result.diag as unknown as Record)) { if (v instanceof Float32Array && v.buffer !== result.audio.buffer) transfer.push(v.buffer); } } post({ kind: 'result', id: msg.id, audio: result.audio, diag: result.diag }, transfer); } catch (err) { if (err instanceof RenderCancelled) { post({ kind: 'cancelled', id: msg.id }); } else { // Faults in the (user-editable) .m code arrive as errors carrying a // file and line, which the core's parse check has already attributed. const e = err as { message?: string; file?: string; line?: number }; post({ kind: 'error', id: msg.id, message: e?.message ? String(e.message) : String(err), file: typeof e?.file === 'string' ? e.file : undefined, line: typeof e?.line === 'number' ? e.line : undefined, }); } } finally { cancelled.delete(msg.id); } } async function pump(): Promise { if (running) return; running = true; while (queue.length > 0) { const msg = queue.shift()!; if (cancelled.has(msg.id)) { post({ kind: 'cancelled', id: msg.id }); cancelled.delete(msg.id); continue; } await run(msg); } running = false; } self.onmessage = (ev: MessageEvent) => { const msg = ev.data; if (msg.kind === 'render') { queue.push(msg); void pump(); } else if (msg.kind === 'cancel') { cancelled.add(msg.id); } };