/ simulated_instruments / cymbal2
Sign in
simulated_instruments / cymbal2
cymbal2 / instrument / index.ts
116 lines · 4.4 KBBlameHistoryRaw
1// The cymbal (Samejima 2021 nonlinear FD shell) behind the generic
2// track-render instrument interface, for direct import by midiboot and by
3// the cymbal2 web app. The MATLAB model in ../matlab is executed in a
4// lazily spawned worker that this module owns: setup and output run
5// through numbl, and the time loop runs on WebGPU when the browser
6// exposes it (a WGSL port of cymbal2_sim.m), falling back to the numbl
7// interpreter otherwise. The worker is kept alive across renders so its
8// caches (the mode tables and finished stroke renders) persist. Aborting
9// a request is cooperative, checked between stages and between GPU
10// chunks; a CPU integration cannot be interrupted mid-call, so there the
11// grace period elapses and the worker is torn down (and the caches with
12// it) and respawned.
13import type { Instrument, TrackRenderRequest, TrackRenderResult } from './types.ts';
14import { CYMBAL2_KNOBS, type Cymbal2Variant } from './knobs.ts';
15import type { WorkerCancelMsg, WorkerRenderMsg, WorkerResponse } from './worker.ts';
17const CANCEL_GRACE_MS = 5000;
19interface Pending {
20 resolve: (r: TrackRenderResult) => void;
21 reject: (e: Error) => void;
22 onProgress?: (p: { frac: number; stage: string }) => void;
25let worker: Worker | null = null;
26let nextId = 1;
27const pending = new Map<number, Pending>();
29function ensureWorker(): Worker {
30 if (worker) return worker;
31 worker = new Worker(new URL('./worker.ts', import.meta.url), { type: 'module' });
32 worker.onmessage = (ev: MessageEvent<WorkerResponse>) => {
33 const msg = ev.data;
34 const p = pending.get(msg.id);
35 if (!p) return;
36 if (msg.kind === 'progress') {
37 p.onProgress?.({ frac: msg.frac, stage: msg.stage });
38 return;
39 }
40 pending.delete(msg.id);
41 if (msg.kind === 'result') p.resolve({ audio: msg.audio, diag: msg.diag });
42 else if (msg.kind === 'cancelled') p.reject(new DOMException('render cancelled', 'AbortError'));
43 else p.reject(Object.assign(new Error(msg.message), { file: msg.file, line: msg.line }));
44 };
45 worker.onerror = (ev) => {
46 const err = new Error(`cymbal2 instrument worker failed: ${ev.message}`);
47 for (const p of pending.values()) p.reject(err);
48 pending.clear();
49 };
50 return worker;
53function teardownWorker(reason: Error): void {
54 if (worker) worker.terminate();
55 worker = null;
56 for (const p of pending.values()) p.reject(reason);
57 pending.clear();
60function renderOnWorker(variant: Cymbal2Variant, req: TrackRenderRequest): Promise<TrackRenderResult> {
61 if (req.signal?.aborted) return Promise.reject(new DOMException('render cancelled', 'AbortError'));
62 const w = ensureWorker();
63 const id = nextId++;
64 const msg: WorkerRenderMsg = {
65 kind: 'render',
66 id,
67 variant,
68 notes: req.notes.map((n) => ({ timeSec: n.timeSec, durationSec: n.durationSec, pitch: n.pitch, velocity: n.velocity })),
69 sampleRate: req.sampleRate,
70 lengthSamples: req.lengthSamples,
71 quality: req.quality,
72 settings: req.settings ?? {},
73 params: req.params,
74 mfiles: req.mfiles,
75 wantDiagnostics: req.wantDiagnostics,
76 };
77 return new Promise<TrackRenderResult>((resolve, reject) => {
78 let graceTimer: ReturnType<typeof setTimeout> | null = null;
79 const onAbort = () => {
80 const cancel: WorkerCancelMsg = { kind: 'cancel', id };
81 worker?.postMessage(cancel);
82 // Escalation: a stage that never yields (or a hung worker) is torn
83 // down after the grace period, losing the caches.
84 graceTimer = setTimeout(() => {
85 if (pending.has(id)) teardownWorker(new DOMException('render cancelled', 'AbortError'));
86 }, CANCEL_GRACE_MS);
87 };
88 req.signal?.addEventListener('abort', onAbort, { once: true });
89 pending.set(id, {
90 resolve: (r) => {
91 req.signal?.removeEventListener('abort', onAbort);
92 if (graceTimer !== null) clearTimeout(graceTimer);
93 resolve(r);
94 },
95 reject: (e) => {
96 req.signal?.removeEventListener('abort', onAbort);
97 if (graceTimer !== null) clearTimeout(graceTimer);
98 reject(e);
99 },
100 onProgress: req.onProgress,
101 });
102 w.postMessage(msg);
103 });
106class Cymbal2Instrument implements Instrument {
107 readonly id = 'cymbal2';
108 readonly name = 'Cymbal2';
109 readonly knobs = CYMBAL2_KNOBS;
110 renderTrack(req: TrackRenderRequest): Promise<TrackRenderResult> {
111 return renderOnWorker('cymbal2', req);
112 }
115export const cymbal2Instrument: Instrument = new Cymbal2Instrument();
116export const cymbal2Instruments: Instrument[] = [cymbal2Instrument];
↑↓ move↵openescclose