/ proof-of-concept / midiboot
Sign in
proof-of-concept / midiboot
midiboot / src / instruments / simulated / manifest.ts
172 lines · 7.1 KBBlameHistoryRaw
1import type { KnobSpec } from '../types.ts';
3/**
4 * The simulated-instrument manifest, format "simulated-instrument-v1".
5 *
6 * Each repo under ../simulated_instruments carries an instrument.json
7 * describing how midiboot may play its MATLAB model:
8 *
9 * name / label identity and display name
10 * entry .m function called as y = entry(P) for one note
11 * defaults .m function returning the parameter struct P
12 * fixed parameter overrides always applied (single-event gesture,
13 * fixed seed, normalize off so velocity survives in pascals)
14 * pitch optional: how MIDI pitch reaches a parameter; mode "midi"
15 * assigns the clamped note number, mode "table" picks the
16 * nearest entry of a {midi, value} table (fingering charts)
17 * velocity how velocity 0..1 maps linearly onto a parameter range
18 * duration optional: how the note's length reaches a parameter
19 * preview parameter overrides for the fast tier (e.g. oversample 1)
20 * knobs curated user-editable parameters, with ranges and
21 * defaults; everything else stays at the model's defaults
22 *
23 * This file is the schema's one definition: parseManifest validates a
24 * manifest and the map* helpers below are the whole pitch/velocity/duration
25 * semantics, shared by the browser worker and the node tests.
26 */
28export interface PitchSpec {
29 param: string;
30 mode: 'midi' | 'table';
31 min?: number;
32 max?: number;
33 table?: { midi: number; value: number }[];
36export interface VelocitySpec {
37 param: string;
38 min: number;
39 max: number;
42export interface DurationSpec {
43 param: string;
44 min: number;
45 max: number;
48export interface SimManifest {
49 format: 'simulated-instrument-v1';
50 name: string;
51 label: string;
52 entry: string;
53 defaults: string;
54 fixed: Record<string, number>;
55 pitch?: PitchSpec;
56 velocity: VelocitySpec;
57 duration?: DurationSpec;
58 preview: Record<string, number>;
59 knobs: KnobSpec[];
62function fail(name: string, msg: string): never {
63 throw new Error(`instrument manifest ${name}: ${msg}`);
66export function parseManifest(text: string): SimManifest {
67 let j: unknown;
68 try {
69 j = JSON.parse(text);
70 } catch (err) {
71 throw new Error(`instrument manifest is not valid JSON: ${err instanceof Error ? err.message : String(err)}`);
72 }
73 const m = j as SimManifest;
74 const name = typeof m.name === 'string' ? m.name : '(unnamed)';
75 if (m.format !== 'simulated-instrument-v1') fail(name, `unknown format '${String(m.format)}'`);
76 for (const k of ['name', 'label', 'entry', 'defaults'] as const) {
77 if (typeof m[k] !== 'string' || m[k] === '') fail(name, `missing string field '${k}'`);
78 }
79 if (typeof m.fixed !== 'object' || m.fixed === null) fail(name, `missing 'fixed' overrides`);
80 if (typeof m.preview !== 'object' || m.preview === null) fail(name, `missing 'preview' overrides`);
81 for (const [k, v] of [...Object.entries(m.fixed), ...Object.entries(m.preview)]) {
82 if (typeof v !== 'number' || !Number.isFinite(v)) fail(name, `override '${k}' is not a finite number`);
83 }
84 const vel = m.velocity;
85 if (!vel || typeof vel.param !== 'string' || typeof vel.min !== 'number' || typeof vel.max !== 'number' || !(vel.min < vel.max)) {
86 fail(name, `'velocity' needs {param, min < max}`);
87 }
88 if (m.pitch) {
89 const p = m.pitch;
90 if (typeof p.param !== 'string') fail(name, `'pitch' needs a param`);
91 if (p.mode === 'midi') {
92 if (typeof p.min !== 'number' || typeof p.max !== 'number' || !(p.min < p.max)) fail(name, `pitch mode 'midi' needs min < max`);
93 } else if (p.mode === 'table') {
94 if (!Array.isArray(p.table) || p.table.length === 0) fail(name, `pitch mode 'table' needs a non-empty table`);
95 for (const row of p.table) {
96 if (typeof row.midi !== 'number' || typeof row.value !== 'number') fail(name, `pitch table rows need {midi, value}`);
97 }
98 } else {
99 fail(name, `unknown pitch mode '${String(p.mode)}'`);
100 }
101 }
102 if (m.duration) {
103 const d = m.duration;
104 if (typeof d.param !== 'string' || typeof d.min !== 'number' || typeof d.max !== 'number' || !(d.min < d.max)) {
105 fail(name, `'duration' needs {param, min < max}`);
106 }
107 }
108 if (!Array.isArray(m.knobs)) fail(name, `missing 'knobs' array`);
109 for (const k of m.knobs) {
110 if (typeof k.key !== 'string' || typeof k.label !== 'string') fail(name, `knobs need key and label`);
111 if (typeof k.min !== 'number' || typeof k.max !== 'number' || typeof k.default !== 'number' || !(k.min < k.max)) {
112 fail(name, `knob '${k.key}' needs min < max and a default`);
113 }
114 if (k.default < k.min || k.default > k.max) fail(name, `knob '${k.key}' default outside its range`);
115 if (k.scale !== undefined && k.scale !== 'log') fail(name, `knob '${k.key}' has unknown scale`);
116 if (k.scale === 'log' && k.min <= 0) fail(name, `knob '${k.key}' log scale needs min > 0`);
117 }
118 return m;
121/** MIDI pitch to a parameter assignment, or null for unpitched instruments. */
122export function mapPitch(m: SimManifest, midiPitch: number): { key: string; value: number } | null {
123 const p = m.pitch;
124 if (!p) return null;
125 if (p.mode === 'midi') {
126 return { key: p.param, value: Math.max(p.min!, Math.min(p.max!, Math.round(midiPitch))) };
127 }
128 let best = p.table![0];
129 for (const row of p.table!) {
130 if (Math.abs(row.midi - midiPitch) < Math.abs(best.midi - midiPitch)) best = row;
131 }
132 return { key: p.param, value: best.value };
135/** Velocity 0..1 to a parameter assignment, linear across the range. */
136export function mapVelocity(m: SimManifest, velocity01: number): { key: string; value: number } {
137 const v = Math.max(0, Math.min(1, velocity01));
138 return { key: m.velocity.param, value: m.velocity.min + (m.velocity.max - m.velocity.min) * v };
141/** Note duration to a parameter assignment, or null when the model ignores it. */
142export function mapDuration(m: SimManifest, durationSec: number): { key: string; value: number } | null {
143 const d = m.duration;
144 if (!d) return null;
145 return { key: d.param, value: Math.max(d.min, Math.min(d.max, durationSec)) };
148/** Velocity quantized to a few levels so identical notes share a cached
149 * render; 8 levels is inaudible next to the models' own dynamics. */
150export function bucketVelocity(velocity01: number, levels = 8): number {
151 return Math.ceil(Math.max(0.001, Math.min(1, velocity01)) * levels) / levels;
154/** Duration quantized to 0.25 s steps, only meaningful with a duration spec. */
155export function bucketDuration(durationSec: number): number {
156 return Math.max(0.25, Math.round(durationSec / 0.25) * 0.25);
159/** Everything that identifies one cached note render. */
160export function noteCacheKey(
161 m: SimManifest,
162 quality: 'preview' | 'full',
163 sampleRate: number,
164 settings: Record<string, number>,
165 pitch: number,
166 velocityBucket: number,
167 durationBucket: number,
168): string {
169 const s = m.knobs.map((k) => `${k.key}=${settings[k.key] ?? k.default}`).join(',');
170 const pv = mapPitch(m, pitch);
171 return `${m.name}|${quality}|${sampleRate}|${s}|${pv ? pv.value : '-'}|${velocityBucket}|${m.duration ? durationBucket : '-'}`;
↑↓ move↵openescclose