import { executeCode } from 'numbl'; import { hasPrepare, type InstrumentBundle } from './bundle.ts'; /** * Rendering notes through a bundle's numbl drivers. A batch of notes * becomes one generated program: the knob struct is built, prepare (if * the bundle has one) runs once, and render_note runs per note, so * pitch-independent setup is amortized across the batch. Pure with * respect to the environment (no DOM, no worker), so the browser worker * and the node tests share it. */ export interface BatchNote { pitch: number; // MIDI note number velocity: number; // 0..1 durationSec: number; } export interface BatchOpts { sampleRate: number; quality: 'preview' | 'full'; settings: Record; // knob values; unknown keys are ignored } interface RuntimeTensorLike { kind: 'tensor'; data: Float64Array; } function lit(name: string, value: number): string { if (!Number.isFinite(value)) throw new Error(`parameter ${name} is not finite`); return String(value); } /** The generated host program, exposed for tests. */ export function buildProgram(bundle: InstrumentBundle, notes: BatchNote[], opts: BatchOpts): string { const preview = opts.quality === 'preview' ? 1 : 0; const lines = [ 'params = struct();', `params.sample_rate = ${lit('sample_rate', opts.sampleRate)};`, `params.preview = ${preview};`, ]; for (const k of bundle.knobs) { const raw = opts.settings[k.key]; const v = raw === undefined ? k.default : Math.max(k.min, Math.min(k.max, raw)); lines.push(`params.${k.key} = ${lit(k.key, v)};`); } lines.push('note = struct();', 'note.params = params;'); if (hasPrepare(bundle)) lines.push('state = prepare(params);', 'note.state = state;'); lines.push(`note.sample_rate = ${lit('sample_rate', opts.sampleRate)};`, `note.preview = ${preview};`); notes.forEach((n, i) => { lines.push( `note.pitch = ${lit('pitch', n.pitch)};`, `note.velocity = ${lit('velocity', n.velocity)};`, `note.duration_s = ${lit('duration_s', Math.max(1e-3, n.durationSec))};`, `y_${i} = render_note(note);`, ); }); return lines.join('\n'); } export function renderNoteBatch(bundle: InstrumentBundle, notes: BatchNote[], opts: BatchOpts): Float32Array[] { if (notes.length === 0) return []; const code = buildProgram(bundle, notes, opts); const mfiles = bundle.files.map((f) => ({ name: f.name, source: f.content })); const r = executeCode(code, { displayResults: false }, mfiles, 'midiboot_render.m'); return notes.map((_, i) => { const y = r.variableValues[`y_${i}`] as unknown as RuntimeTensorLike; if (!y || y.kind !== 'tensor') throw new Error(`${bundle.id}: render_note did not return a vector`); const out = new Float32Array(y.data.length); for (let j = 0; j < y.data.length; j++) out[j] = y.data[j]; return out; }); } /** 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. The host cannot know whether a * driver reads duration, so it is always part of the cache key. */ 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( bundle: InstrumentBundle, quality: 'preview' | 'full', sampleRate: number, settings: Record, pitch: number, velocityBucket: number, durationBucket: number, ): string { const s = bundle.knobs.map((k) => `${k.key}=${settings[k.key] ?? k.default}`).join(','); return `${bundle.id}|${quality}|${sampleRate}|${s}|${pitch}|${velocityBucket}|${durationBucket}`; }