/** * Exported numbl instruments behind the generic Instrument interface: * self-contained bundles (format numbl-instrument-v1, see * simulated_instruments/instrument-format) whose own MATLAB-syntax * drivers map each MIDI note onto the model and render it, executed by * numbl in a worker and mixed per track. Bundles arrive two ways: the * committed exports of the sibling simulated_instruments repos, inlined * at build time, and files imported at runtime, persisted in IndexedDB. * An imported bundle with a catalog bundle's id shadows it, which is how * a newer export is dropped in without rebuilding the app. There is no * live noteOn voice yet, so monitoring while recording falls back to the * player's beep. */ import type { Instrument, RenderRequest } from '../types.ts'; import { registerInstrument, unregisterInstrument } from '../registry.ts'; import { loadCatalog } from './catalog.ts'; import { parseBundle, type InstrumentBundle } from './bundle.ts'; import * as db from '../../storage/db.ts'; import type { WorkerRenderMsg, WorkerResponse } from './worker.ts'; type StatusFn = (msg: string) => void; let onStatus: StatusFn = () => {}; export function setNumblStatus(cb: StatusFn): void { onStatus = cb; } let worker: Worker | null = null; let nextId = 1; const pending = new Map void; reject: (e: Error) => void }>(); 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; if (msg.kind === 'status') { onStatus(msg.text); return; } const p = pending.get(msg.id); if (!p) return; pending.delete(msg.id); if (msg.kind === 'result') p.resolve(msg.samples); else p.reject(new Error(msg.message)); }; worker.onerror = (ev) => { const err = new Error(`numbl instrument worker failed: ${ev.message}`); for (const p of pending.values()) p.reject(err); pending.clear(); }; return worker; } class NumblInstrument implements Instrument { readonly id: string; readonly name: string; readonly knobs; constructor(readonly bundle: InstrumentBundle, imported: boolean) { this.id = `nb-${bundle.id}`; this.name = imported ? `${bundle.name} (imported)` : bundle.name; this.knobs = bundle.knobs; } render(req: RenderRequest): Promise { if (req.notes.length === 0) return Promise.resolve(new Float32Array(req.lengthSamples)); const w = ensureWorker(); const id = nextId++; const msg: WorkerRenderMsg = { kind: 'render', id, bundle: this.bundle, notes: req.notes.map((n) => ({ pitch: n.pitch, velocity: n.velocity, durationSec: n.durationSec, timeSec: n.timeSec })), sampleRate: req.sampleRate, lengthSamples: req.lengthSamples, quality: req.quality ?? 'preview', settings: req.settings ?? {}, }; return new Promise((resolve, reject) => { pending.set(id, { resolve, reject }); w.postMessage(msg); }); } } const catalogById = new Map(); const importedIds = new Set(); export function registerCatalogInstruments(): number { for (const bundle of loadCatalog()) { catalogById.set(bundle.id, bundle); registerInstrument(new NumblInstrument(bundle, false)); } return catalogById.size; } /** Re-register persisted imports at startup; call after the catalog. */ export async function loadImportedInstruments(): Promise { const recs = await db.listImportedInstruments(); let n = 0; for (const rec of recs) { try { const bundle = parseBundle(rec.text); registerInstrument(new NumblInstrument(bundle, true)); importedIds.add(bundle.id); n++; } catch (err) { console.warn(`skipping imported instrument '${rec.id}': ${err instanceof Error ? err.message : String(err)}`); } } return n; } /** Validate, persist, and register one bundle file's text. */ export async function importInstrumentText(text: string): Promise { const bundle = parseBundle(text); await db.putImportedInstrument({ id: bundle.id, text, importedAt: Date.now() }); registerInstrument(new NumblInstrument(bundle, true)); importedIds.add(bundle.id); return bundle; } export function listImportedIds(): string[] { return [...importedIds].sort(); } /** Remove an import; a shadowed catalog bundle of the same id comes back. */ export async function removeImportedInstrument(bundleId: string): Promise { await db.deleteImportedInstrument(bundleId); importedIds.delete(bundleId); const cat = catalogById.get(bundleId); if (cat) registerInstrument(new NumblInstrument(cat, false)); else unregisterInstrument(`nb-${bundleId}`); }