1// The maraca behind the generic track-render instrument interface, for
2// direct import by midiboot and by the maraca web app. The MATLAB model
3// in ../matlab is executed by numbl in a lazily spawned worker that this
4// module owns; the worker is kept alive across renders so its caches (the
5// explorer stage tiers and finished stroke renders) persist. Aborting a
6// request is cooperative, checked between render stages; if the worker
7// does not respond within a grace period it is torn down (and the caches
8// with it) and respawned.
9import type { Instrument, TrackRenderRequest, TrackRenderResult } from './types.ts';
10import { MARACA_KNOBS, type MaracaVariant } from './knobs.ts';
11import type { WorkerCancelMsg, WorkerRenderMsg, WorkerResponse } from './worker.ts';
13const CANCEL_GRACE_MS = 5000;
15interface Pending {
16 resolve: (r: TrackRenderResult) => void;
17 reject: (e: Error) => void;
18 onProgress?: (p: { frac: number; stage: string }) => void;
19}
21let worker: Worker | null = null;
22let nextId = 1;
23const pending = new Map<number, Pending>();
25function ensureWorker(): Worker {
26 if (worker) return worker;
27 worker = new Worker(new URL('./worker.ts', import.meta.url), { type: 'module' });
28 worker.onmessage = (ev: MessageEvent<WorkerResponse>) => {
29 const msg = ev.data;
30 const p = pending.get(msg.id);
31 if (!p) return;
32 if (msg.kind === 'progress') {
33 p.onProgress?.({ frac: msg.frac, stage: msg.stage });
34 return;
35 }
36 pending.delete(msg.id);
37 if (msg.kind === 'result') p.resolve({ audio: msg.audio, diag: msg.diag });
38 else if (msg.kind === 'cancelled') p.reject(new DOMException('render cancelled', 'AbortError'));
39 else p.reject(Object.assign(new Error(msg.message), { file: msg.file, line: msg.line }));
40 };
41 worker.onerror = (ev) => {
42 const err = new Error(`maraca instrument worker failed: ${ev.message}`);
43 for (const p of pending.values()) p.reject(err);
44 pending.clear();
45 };
46 return worker;
47}
49function teardownWorker(reason: Error): void {
50 if (worker) worker.terminate();
51 worker = null;
52 for (const p of pending.values()) p.reject(reason);
53 pending.clear();
54}
56function renderOnWorker(variant: MaracaVariant, req: TrackRenderRequest): Promise<TrackRenderResult> {
57 if (req.signal?.aborted) return Promise.reject(new DOMException('render cancelled', 'AbortError'));
58 const w = ensureWorker();
59 const id = nextId++;
60 const msg: WorkerRenderMsg = {
61 kind: 'render',
62 id,
63 variant,
64 notes: req.notes.map((n) => ({ timeSec: n.timeSec, durationSec: n.durationSec, pitch: n.pitch, velocity: n.velocity })),
65 sampleRate: req.sampleRate,
66 lengthSamples: req.lengthSamples,
67 quality: req.quality,
68 settings: req.settings ?? {},
69 params: req.params,
70 mfiles: req.mfiles,
71 wantDiagnostics: req.wantDiagnostics,
72 };
73 return new Promise<TrackRenderResult>((resolve, reject) => {
74 let graceTimer: ReturnType<typeof setTimeout> | null = null;
75 const onAbort = () => {
76 const cancel: WorkerCancelMsg = { kind: 'cancel', id };
77 worker?.postMessage(cancel);
78 // Escalation: a stage that never yields (or a hung worker) is torn
79 // down after the grace period, losing the caches.
80 graceTimer = setTimeout(() => {
81 if (pending.has(id)) teardownWorker(new DOMException('render cancelled', 'AbortError'));
82 }, CANCEL_GRACE_MS);
83 };
84 req.signal?.addEventListener('abort', onAbort, { once: true });
85 pending.set(id, {
86 resolve: (r) => {
87 req.signal?.removeEventListener('abort', onAbort);
88 if (graceTimer !== null) clearTimeout(graceTimer);
89 resolve(r);
90 },
91 reject: (e) => {
92 req.signal?.removeEventListener('abort', onAbort);
93 if (graceTimer !== null) clearTimeout(graceTimer);
94 reject(e);
95 },
96 onProgress: req.onProgress,
97 });
98 w.postMessage(msg);
99 });
100}
102class MaracaInstrument implements Instrument {
103 readonly id = 'maraca';
104 readonly name = 'Maraca';
105 readonly knobs = MARACA_KNOBS;
106 renderTrack(req: TrackRenderRequest): Promise<TrackRenderResult> {
107 return renderOnWorker('maraca', req);
108 }
109}
111export const maracaInstrument: Instrument = new MaracaInstrument();
112export const maracaInstruments: Instrument[] = [maracaInstrument];