/ proof-of-concept / midiboot
Sign in
proof-of-concept / midiboot
midiboot / src / instruments / numbl / bundle.ts
87 lines · 3.6 KBBlameHistoryRaw
1// Vendored from simulated_instruments/instrument-format/ts/bundle.ts, the
2// format's canonical definition; keep the two in sync.
4export interface BundleKnob {
5 key: string;
6 label: string;
7 min: number;
8 max: number;
9 default: number;
10 step?: number;
11 scale?: 'log';
14export interface BundleFile {
15 name: string; // e.g. "render_note.m"; unique within the bundle
16 content: string; // full source, MATLAB syntax, executed by numbl
19export interface InstrumentBundle {
20 format: 'numbl-instrument-v1';
21 /** Stable identifier, lowercase letters, digits, hyphens. Hosts key
22 * caches and track references on it, so it should not change between
23 * versions of the same instrument. */
24 id: string;
25 /** Display name. */
26 name: string;
27 description?: string;
28 /** Provenance, e.g. "simulated_instruments/bass". Informational. */
29 source?: string;
30 /** Curated user-editable parameters. Hosts clamp values to [min, max]
31 * and fill missing ones with the default before every call. */
32 knobs: BundleKnob[];
33 /** Every .m source the drivers need, drivers included. Must contain
34 * render_note.m; prepare.m is optional. */
35 files: BundleFile[];
38const ID_RE = /^[a-z0-9][a-z0-9-]*$/;
40function fail(id: string, msg: string): never {
41 throw new Error(`instrument bundle ${id}: ${msg}`);
44export function parseBundle(text: string): InstrumentBundle {
45 let j: unknown;
46 try {
47 j = JSON.parse(text);
48 } catch (err) {
49 throw new Error(`instrument bundle is not valid JSON: ${err instanceof Error ? err.message : String(err)}`);
50 }
51 const b = j as InstrumentBundle;
52 const id = typeof b.id === 'string' ? b.id : '(no id)';
53 if (b.format !== 'numbl-instrument-v1') fail(id, `unknown format '${String(b.format)}'`);
54 if (typeof b.id !== 'string' || !ID_RE.test(b.id)) fail(id, `'id' must match ${ID_RE}`);
55 if (typeof b.name !== 'string' || b.name === '') fail(id, `missing string field 'name'`);
56 for (const k of ['description', 'source'] as const) {
57 if (b[k] !== undefined && typeof b[k] !== 'string') fail(id, `'${k}' must be a string when present`);
58 }
59 if (!Array.isArray(b.knobs)) fail(id, `missing 'knobs' array`);
60 for (const k of b.knobs) {
61 if (typeof k.key !== 'string' || typeof k.label !== 'string') fail(id, `knobs need key and label`);
62 if (typeof k.min !== 'number' || typeof k.max !== 'number' || typeof k.default !== 'number' || !(k.min < k.max)) {
63 fail(id, `knob '${k.key}' needs min < max and a default`);
64 }
65 if (k.default < k.min || k.default > k.max) fail(id, `knob '${k.key}' default outside its range`);
66 if (k.step !== undefined && (typeof k.step !== 'number' || !(k.step > 0))) fail(id, `knob '${k.key}' step must be > 0`);
67 if (k.scale !== undefined && k.scale !== 'log') fail(id, `knob '${k.key}' has unknown scale`);
68 if (k.scale === 'log' && k.min <= 0) fail(id, `knob '${k.key}' log scale needs min > 0`);
69 }
70 if (!Array.isArray(b.files) || b.files.length === 0) fail(id, `missing 'files' array`);
71 const seen = new Set<string>();
72 for (const f of b.files) {
73 if (typeof f.name !== 'string' || !f.name.endsWith('.m') || f.name.includes('/')) {
74 fail(id, `file '${String(f.name)}' must be a bare .m filename`);
75 }
76 if (typeof f.content !== 'string' || f.content === '') fail(id, `file '${f.name}' has no content`);
77 if (seen.has(f.name)) fail(id, `duplicate file '${f.name}'`);
78 seen.add(f.name);
79 }
80 if (!seen.has('render_note.m')) fail(id, `no render_note.m among the files`);
81 return b;
84/** Whether the bundle declares the optional prepare stage. */
85export function hasPrepare(b: InstrumentBundle): boolean {
86 return b.files.some((f) => f.name === 'prepare.m');
↑↓ move↵openescclose