import type { KnobSpec } from '../types.ts'; /** * The simulated-instrument manifest, format "simulated-instrument-v1". * * Each repo under ../simulated_instruments carries an instrument.json * describing how midiboot may play its MATLAB model: * * name / label identity and display name * entry .m function called as y = entry(P) for one note * defaults .m function returning the parameter struct P * fixed parameter overrides always applied (single-event gesture, * fixed seed, normalize off so velocity survives in pascals) * pitch optional: how MIDI pitch reaches a parameter; mode "midi" * assigns the clamped note number, mode "table" picks the * nearest entry of a {midi, value} table (fingering charts) * velocity how velocity 0..1 maps linearly onto a parameter range * duration optional: how the note's length reaches a parameter * preview parameter overrides for the fast tier (e.g. oversample 1) * knobs curated user-editable parameters, with ranges and * defaults; everything else stays at the model's defaults * * This file is the schema's one definition: parseManifest validates a * manifest and the map* helpers below are the whole pitch/velocity/duration * semantics, shared by the browser worker and the node tests. */ export interface PitchSpec { param: string; mode: 'midi' | 'table'; min?: number; max?: number; table?: { midi: number; value: number }[]; } export interface VelocitySpec { param: string; min: number; max: number; } export interface DurationSpec { param: string; min: number; max: number; } export interface SimManifest { format: 'simulated-instrument-v1'; name: string; label: string; entry: string; defaults: string; fixed: Record; pitch?: PitchSpec; velocity: VelocitySpec; duration?: DurationSpec; preview: Record; knobs: KnobSpec[]; } function fail(name: string, msg: string): never { throw new Error(`instrument manifest ${name}: ${msg}`); } export function parseManifest(text: string): SimManifest { let j: unknown; try { j = JSON.parse(text); } catch (err) { throw new Error(`instrument manifest is not valid JSON: ${err instanceof Error ? err.message : String(err)}`); } const m = j as SimManifest; const name = typeof m.name === 'string' ? m.name : '(unnamed)'; if (m.format !== 'simulated-instrument-v1') fail(name, `unknown format '${String(m.format)}'`); for (const k of ['name', 'label', 'entry', 'defaults'] as const) { if (typeof m[k] !== 'string' || m[k] === '') fail(name, `missing string field '${k}'`); } if (typeof m.fixed !== 'object' || m.fixed === null) fail(name, `missing 'fixed' overrides`); if (typeof m.preview !== 'object' || m.preview === null) fail(name, `missing 'preview' overrides`); for (const [k, v] of [...Object.entries(m.fixed), ...Object.entries(m.preview)]) { if (typeof v !== 'number' || !Number.isFinite(v)) fail(name, `override '${k}' is not a finite number`); } const vel = m.velocity; if (!vel || typeof vel.param !== 'string' || typeof vel.min !== 'number' || typeof vel.max !== 'number' || !(vel.min < vel.max)) { fail(name, `'velocity' needs {param, min < max}`); } if (m.pitch) { const p = m.pitch; if (typeof p.param !== 'string') fail(name, `'pitch' needs a param`); if (p.mode === 'midi') { if (typeof p.min !== 'number' || typeof p.max !== 'number' || !(p.min < p.max)) fail(name, `pitch mode 'midi' needs min < max`); } else if (p.mode === 'table') { if (!Array.isArray(p.table) || p.table.length === 0) fail(name, `pitch mode 'table' needs a non-empty table`); for (const row of p.table) { if (typeof row.midi !== 'number' || typeof row.value !== 'number') fail(name, `pitch table rows need {midi, value}`); } } else { fail(name, `unknown pitch mode '${String(p.mode)}'`); } } if (m.duration) { const d = m.duration; if (typeof d.param !== 'string' || typeof d.min !== 'number' || typeof d.max !== 'number' || !(d.min < d.max)) { fail(name, `'duration' needs {param, min < max}`); } } if (!Array.isArray(m.knobs)) fail(name, `missing 'knobs' array`); for (const k of m.knobs) { if (typeof k.key !== 'string' || typeof k.label !== 'string') fail(name, `knobs need key and label`); if (typeof k.min !== 'number' || typeof k.max !== 'number' || typeof k.default !== 'number' || !(k.min < k.max)) { fail(name, `knob '${k.key}' needs min < max and a default`); } if (k.default < k.min || k.default > k.max) fail(name, `knob '${k.key}' default outside its range`); if (k.scale !== undefined && k.scale !== 'log') fail(name, `knob '${k.key}' has unknown scale`); if (k.scale === 'log' && k.min <= 0) fail(name, `knob '${k.key}' log scale needs min > 0`); } return m; } /** MIDI pitch to a parameter assignment, or null for unpitched instruments. */ export function mapPitch(m: SimManifest, midiPitch: number): { key: string; value: number } | null { const p = m.pitch; if (!p) return null; if (p.mode === 'midi') { return { key: p.param, value: Math.max(p.min!, Math.min(p.max!, Math.round(midiPitch))) }; } let best = p.table![0]; for (const row of p.table!) { if (Math.abs(row.midi - midiPitch) < Math.abs(best.midi - midiPitch)) best = row; } return { key: p.param, value: best.value }; } /** Velocity 0..1 to a parameter assignment, linear across the range. */ export function mapVelocity(m: SimManifest, velocity01: number): { key: string; value: number } { const v = Math.max(0, Math.min(1, velocity01)); return { key: m.velocity.param, value: m.velocity.min + (m.velocity.max - m.velocity.min) * v }; } /** Note duration to a parameter assignment, or null when the model ignores it. */ export function mapDuration(m: SimManifest, durationSec: number): { key: string; value: number } | null { const d = m.duration; if (!d) return null; return { key: d.param, value: Math.max(d.min, Math.min(d.max, durationSec)) }; } /** Velocity quantized to a few levels so identical notes share a cached * render; 8 levels is inaudible next to the models' own dynamics. */ export function bucketVelocity(velocity01: number, levels = 8): number { return Math.ceil(Math.max(0.001, Math.min(1, velocity01)) * levels) / levels; } /** Duration quantized to 0.25 s steps, only meaningful with a duration spec. */ export function bucketDuration(durationSec: number): number { return Math.max(0.25, Math.round(durationSec / 0.25) * 0.25); } /** Everything that identifies one cached note render. */ export function noteCacheKey( m: SimManifest, quality: 'preview' | 'full', sampleRate: number, settings: Record, pitch: number, velocityBucket: number, durationBucket: number, ): string { const s = m.knobs.map((k) => `${k.key}=${settings[k.key] ?? k.default}`).join(','); const pv = mapPitch(m, pitch); return `${m.name}|${quality}|${sampleRate}|${s}|${pv ? pv.value : '-'}|${velocityBucket}|${m.duration ? durationBucket : '-'}`; }