// Vendored from simulated_instruments/instrument-format/ts/bundle.ts, the // format's canonical definition; keep the two in sync. export interface BundleKnob { key: string; label: string; min: number; max: number; default: number; step?: number; scale?: 'log'; } export interface BundleFile { name: string; // e.g. "render_note.m"; unique within the bundle content: string; // full source, MATLAB syntax, executed by numbl } export interface InstrumentBundle { format: 'numbl-instrument-v1'; /** Stable identifier, lowercase letters, digits, hyphens. Hosts key * caches and track references on it, so it should not change between * versions of the same instrument. */ id: string; /** Display name. */ name: string; description?: string; /** Provenance, e.g. "simulated_instruments/bass". Informational. */ source?: string; /** Curated user-editable parameters. Hosts clamp values to [min, max] * and fill missing ones with the default before every call. */ knobs: BundleKnob[]; /** Every .m source the drivers need, drivers included. Must contain * render_note.m; prepare.m is optional. */ files: BundleFile[]; } const ID_RE = /^[a-z0-9][a-z0-9-]*$/; function fail(id: string, msg: string): never { throw new Error(`instrument bundle ${id}: ${msg}`); } export function parseBundle(text: string): InstrumentBundle { let j: unknown; try { j = JSON.parse(text); } catch (err) { throw new Error(`instrument bundle is not valid JSON: ${err instanceof Error ? err.message : String(err)}`); } const b = j as InstrumentBundle; const id = typeof b.id === 'string' ? b.id : '(no id)'; if (b.format !== 'numbl-instrument-v1') fail(id, `unknown format '${String(b.format)}'`); if (typeof b.id !== 'string' || !ID_RE.test(b.id)) fail(id, `'id' must match ${ID_RE}`); if (typeof b.name !== 'string' || b.name === '') fail(id, `missing string field 'name'`); for (const k of ['description', 'source'] as const) { if (b[k] !== undefined && typeof b[k] !== 'string') fail(id, `'${k}' must be a string when present`); } if (!Array.isArray(b.knobs)) fail(id, `missing 'knobs' array`); for (const k of b.knobs) { if (typeof k.key !== 'string' || typeof k.label !== 'string') fail(id, `knobs need key and label`); if (typeof k.min !== 'number' || typeof k.max !== 'number' || typeof k.default !== 'number' || !(k.min < k.max)) { fail(id, `knob '${k.key}' needs min < max and a default`); } if (k.default < k.min || k.default > k.max) fail(id, `knob '${k.key}' default outside its range`); if (k.step !== undefined && (typeof k.step !== 'number' || !(k.step > 0))) fail(id, `knob '${k.key}' step must be > 0`); if (k.scale !== undefined && k.scale !== 'log') fail(id, `knob '${k.key}' has unknown scale`); if (k.scale === 'log' && k.min <= 0) fail(id, `knob '${k.key}' log scale needs min > 0`); } if (!Array.isArray(b.files) || b.files.length === 0) fail(id, `missing 'files' array`); const seen = new Set(); for (const f of b.files) { if (typeof f.name !== 'string' || !f.name.endsWith('.m') || f.name.includes('/')) { fail(id, `file '${String(f.name)}' must be a bare .m filename`); } if (typeof f.content !== 'string' || f.content === '') fail(id, `file '${f.name}' has no content`); if (seen.has(f.name)) fail(id, `duplicate file '${f.name}'`); seen.add(f.name); } if (!seen.has('render_note.m')) fail(id, `no render_note.m among the files`); return b; } /** Whether the bundle declares the optional prepare stage. */ export function hasPrepare(b: InstrumentBundle): boolean { return b.files.some((f) => f.name === 'prepare.m'); }