1/**
2 * Exported numbl instruments behind the generic Instrument interface:
3 * self-contained bundles (format numbl-instrument-v1, see
4 * simulated_instruments/instrument-format) whose own MATLAB-syntax
5 * drivers map each MIDI note onto the model and render it, executed by
6 * numbl in a worker and mixed per track. Bundles arrive two ways: the
7 * committed exports of the sibling simulated_instruments repos, inlined
8 * at build time, and files imported at runtime, persisted in IndexedDB.
9 * An imported bundle with a catalog bundle's id shadows it, which is how
10 * a newer export is dropped in without rebuilding the app. There is no
11 * live noteOn voice yet, so monitoring while recording falls back to the
12 * player's beep.
13 */
14import type { Instrument, RenderRequest } from '../types.ts';
15import { registerInstrument, unregisterInstrument } from '../registry.ts';
16import { loadCatalog } from './catalog.ts';
17import { parseBundle, type InstrumentBundle } from './bundle.ts';
18import * as db from '../../storage/db.ts';
19import type { WorkerRenderMsg, WorkerResponse } from './worker.ts';
21type StatusFn = (msg: string) => void;
22let onStatus: StatusFn = () => {};
23export function setNumblStatus(cb: StatusFn): void {
24 onStatus = cb;
25}
27let worker: Worker | null = null;
28let nextId = 1;
29const pending = new Map<number, { resolve: (v: Float32Array) => void; reject: (e: Error) => void }>();
31function ensureWorker(): Worker {
32 if (worker) return worker;
33 worker = new Worker(new URL('./worker.ts', import.meta.url), { type: 'module' });
34 worker.onmessage = (ev: MessageEvent<WorkerResponse>) => {
35 const msg = ev.data;
36 if (msg.kind === 'status') {
37 onStatus(msg.text);
38 return;
39 }
40 const p = pending.get(msg.id);
41 if (!p) return;
42 pending.delete(msg.id);
43 if (msg.kind === 'result') p.resolve(msg.samples);
44 else p.reject(new Error(msg.message));
45 };
46 worker.onerror = (ev) => {
47 const err = new Error(`numbl instrument worker failed: ${ev.message}`);
48 for (const p of pending.values()) p.reject(err);
49 pending.clear();
50 };
51 return worker;
52}
54class NumblInstrument implements Instrument {
55 readonly id: string;
56 readonly name: string;
57 readonly knobs;
59 constructor(readonly bundle: InstrumentBundle, imported: boolean) {
60 this.id = `nb-${bundle.id}`;
61 this.name = imported ? `${bundle.name} (imported)` : bundle.name;
62 this.knobs = bundle.knobs;
63 }
65 render(req: RenderRequest): Promise<Float32Array> {
66 if (req.notes.length === 0) return Promise.resolve(new Float32Array(req.lengthSamples));
67 const w = ensureWorker();
68 const id = nextId++;
69 const msg: WorkerRenderMsg = {
70 kind: 'render',
71 id,
72 bundle: this.bundle,
73 notes: req.notes.map((n) => ({ pitch: n.pitch, velocity: n.velocity, durationSec: n.durationSec, timeSec: n.timeSec })),
74 sampleRate: req.sampleRate,
75 lengthSamples: req.lengthSamples,
76 quality: req.quality ?? 'preview',
77 settings: req.settings ?? {},
78 };
79 return new Promise<Float32Array>((resolve, reject) => {
80 pending.set(id, { resolve, reject });
81 w.postMessage(msg);
82 });
83 }
84}
86const catalogById = new Map<string, InstrumentBundle>();
87const importedIds = new Set<string>();
89export function registerCatalogInstruments(): number {
90 for (const bundle of loadCatalog()) {
91 catalogById.set(bundle.id, bundle);
92 registerInstrument(new NumblInstrument(bundle, false));
93 }
94 return catalogById.size;
95}
97/** Re-register persisted imports at startup; call after the catalog. */
98export async function loadImportedInstruments(): Promise<number> {
99 const recs = await db.listImportedInstruments();
100 let n = 0;
101 for (const rec of recs) {
102 try {
103 const bundle = parseBundle(rec.text);
104 registerInstrument(new NumblInstrument(bundle, true));
105 importedIds.add(bundle.id);
106 n++;
107 } catch (err) {
108 console.warn(`skipping imported instrument '${rec.id}': ${err instanceof Error ? err.message : String(err)}`);
109 }
110 }
111 return n;
112}
114/** Validate, persist, and register one bundle file's text. */
115export async function importInstrumentText(text: string): Promise<InstrumentBundle> {
116 const bundle = parseBundle(text);
117 await db.putImportedInstrument({ id: bundle.id, text, importedAt: Date.now() });
118 registerInstrument(new NumblInstrument(bundle, true));
119 importedIds.add(bundle.id);
120 return bundle;
121}
123export function listImportedIds(): string[] {
124 return [...importedIds].sort();
125}
127/** Remove an import; a shadowed catalog bundle of the same id comes back. */
128export async function removeImportedInstrument(bundleId: string): Promise<void> {
129 await db.deleteImportedInstrument(bundleId);
130 importedIds.delete(bundleId);
131 const cat = catalogById.get(bundleId);
132 if (cat) registerInstrument(new NumblInstrument(cat, false));
133 else unregisterInstrument(`nb-${bundleId}`);
134}