// The cymbal (Samejima 2021 nonlinear FD shell) behind the generic // track-render instrument interface, for direct import by midiboot and by // the cymbal2 web app. The MATLAB model in ../matlab is executed in a // lazily spawned worker that this module owns: setup and output run // through numbl, and the time loop runs on WebGPU when the browser // exposes it (a WGSL port of cymbal2_sim.m), falling back to the numbl // interpreter otherwise. The worker is kept alive across renders so its // caches (the mode tables and finished stroke renders) persist. Aborting // a request is cooperative, checked between stages and between GPU // chunks; a CPU integration cannot be interrupted mid-call, so there the // grace period elapses and the worker is torn down (and the caches with // it) and respawned. import type { Instrument, TrackRenderRequest, TrackRenderResult } from './types.ts'; import { CYMBAL2_KNOBS, type Cymbal2Variant } from './knobs.ts'; import type { WorkerCancelMsg, WorkerRenderMsg, WorkerResponse } from './worker.ts'; const CANCEL_GRACE_MS = 5000; interface Pending { resolve: (r: TrackRenderResult) => void; reject: (e: Error) => void; onProgress?: (p: { frac: number; stage: string }) => void; } let worker: Worker | null = null; let nextId = 1; const pending = new Map(); function ensureWorker(): Worker { if (worker) return worker; worker = new Worker(new URL('./worker.ts', import.meta.url), { type: 'module' }); worker.onmessage = (ev: MessageEvent) => { const msg = ev.data; const p = pending.get(msg.id); if (!p) return; if (msg.kind === 'progress') { p.onProgress?.({ frac: msg.frac, stage: msg.stage }); return; } pending.delete(msg.id); if (msg.kind === 'result') p.resolve({ audio: msg.audio, diag: msg.diag }); else if (msg.kind === 'cancelled') p.reject(new DOMException('render cancelled', 'AbortError')); else p.reject(Object.assign(new Error(msg.message), { file: msg.file, line: msg.line })); }; worker.onerror = (ev) => { const err = new Error(`cymbal2 instrument worker failed: ${ev.message}`); for (const p of pending.values()) p.reject(err); pending.clear(); }; return worker; } function teardownWorker(reason: Error): void { if (worker) worker.terminate(); worker = null; for (const p of pending.values()) p.reject(reason); pending.clear(); } function renderOnWorker(variant: Cymbal2Variant, req: TrackRenderRequest): Promise { if (req.signal?.aborted) return Promise.reject(new DOMException('render cancelled', 'AbortError')); const w = ensureWorker(); const id = nextId++; const msg: WorkerRenderMsg = { kind: 'render', id, variant, notes: req.notes.map((n) => ({ timeSec: n.timeSec, durationSec: n.durationSec, pitch: n.pitch, velocity: n.velocity })), sampleRate: req.sampleRate, lengthSamples: req.lengthSamples, quality: req.quality, settings: req.settings ?? {}, params: req.params, mfiles: req.mfiles, wantDiagnostics: req.wantDiagnostics, }; return new Promise((resolve, reject) => { let graceTimer: ReturnType | null = null; const onAbort = () => { const cancel: WorkerCancelMsg = { kind: 'cancel', id }; worker?.postMessage(cancel); // Escalation: a stage that never yields (or a hung worker) is torn // down after the grace period, losing the caches. graceTimer = setTimeout(() => { if (pending.has(id)) teardownWorker(new DOMException('render cancelled', 'AbortError')); }, CANCEL_GRACE_MS); }; req.signal?.addEventListener('abort', onAbort, { once: true }); pending.set(id, { resolve: (r) => { req.signal?.removeEventListener('abort', onAbort); if (graceTimer !== null) clearTimeout(graceTimer); resolve(r); }, reject: (e) => { req.signal?.removeEventListener('abort', onAbort); if (graceTimer !== null) clearTimeout(graceTimer); reject(e); }, onProgress: req.onProgress, }); w.postMessage(msg); }); } class Cymbal2Instrument implements Instrument { readonly id = 'cymbal2'; readonly name = 'Cymbal2'; readonly knobs = CYMBAL2_KNOBS; renderTrack(req: TrackRenderRequest): Promise { return renderOnWorker('cymbal2', req); } } export const cymbal2Instrument: Instrument = new Cymbal2Instrument(); export const cymbal2Instruments: Instrument[] = [cymbal2Instrument];