/ proof-of-concept / midiboot
Sign in
proof-of-concept / midiboot
midiboot / src / instruments / numbl / render.ts
100 lines · 3.8 KBBlameHistoryRaw
1import { executeCode } from 'numbl';
2import { hasPrepare, type InstrumentBundle } from './bundle.ts';
4/**
5 * Rendering notes through a bundle's numbl drivers. A batch of notes
6 * becomes one generated program: the knob struct is built, prepare (if
7 * the bundle has one) runs once, and render_note runs per note, so
8 * pitch-independent setup is amortized across the batch. Pure with
9 * respect to the environment (no DOM, no worker), so the browser worker
10 * and the node tests share it.
11 */
13export interface BatchNote {
14 pitch: number; // MIDI note number
15 velocity: number; // 0..1
16 durationSec: number;
19export interface BatchOpts {
20 sampleRate: number;
21 quality: 'preview' | 'full';
22 settings: Record<string, number>; // knob values; unknown keys are ignored
25interface RuntimeTensorLike {
26 kind: 'tensor';
27 data: Float64Array;
30function lit(name: string, value: number): string {
31 if (!Number.isFinite(value)) throw new Error(`parameter ${name} is not finite`);
32 return String(value);
35/** The generated host program, exposed for tests. */
36export function buildProgram(bundle: InstrumentBundle, notes: BatchNote[], opts: BatchOpts): string {
37 const preview = opts.quality === 'preview' ? 1 : 0;
38 const lines = [
39 'params = struct();',
40 `params.sample_rate = ${lit('sample_rate', opts.sampleRate)};`,
41 `params.preview = ${preview};`,
42 ];
43 for (const k of bundle.knobs) {
44 const raw = opts.settings[k.key];
45 const v = raw === undefined ? k.default : Math.max(k.min, Math.min(k.max, raw));
46 lines.push(`params.${k.key} = ${lit(k.key, v)};`);
47 }
48 lines.push('note = struct();', 'note.params = params;');
49 if (hasPrepare(bundle)) lines.push('state = prepare(params);', 'note.state = state;');
50 lines.push(`note.sample_rate = ${lit('sample_rate', opts.sampleRate)};`, `note.preview = ${preview};`);
51 notes.forEach((n, i) => {
52 lines.push(
53 `note.pitch = ${lit('pitch', n.pitch)};`,
54 `note.velocity = ${lit('velocity', n.velocity)};`,
55 `note.duration_s = ${lit('duration_s', Math.max(1e-3, n.durationSec))};`,
56 `y_${i} = render_note(note);`,
57 );
58 });
59 return lines.join('\n');
62export function renderNoteBatch(bundle: InstrumentBundle, notes: BatchNote[], opts: BatchOpts): Float32Array[] {
63 if (notes.length === 0) return [];
64 const code = buildProgram(bundle, notes, opts);
65 const mfiles = bundle.files.map((f) => ({ name: f.name, source: f.content }));
66 const r = executeCode(code, { displayResults: false }, mfiles, 'midiboot_render.m');
67 return notes.map((_, i) => {
68 const y = r.variableValues[`y_${i}`] as unknown as RuntimeTensorLike;
69 if (!y || y.kind !== 'tensor') throw new Error(`${bundle.id}: render_note did not return a vector`);
70 const out = new Float32Array(y.data.length);
71 for (let j = 0; j < y.data.length; j++) out[j] = y.data[j];
72 return out;
73 });
76/** Velocity quantized to a few levels so identical notes share a cached
77 * render; 8 levels is inaudible next to the models' own dynamics. */
78export function bucketVelocity(velocity01: number, levels = 8): number {
79 return Math.ceil(Math.max(0.001, Math.min(1, velocity01)) * levels) / levels;
82/** Duration quantized to 0.25 s steps. The host cannot know whether a
83 * driver reads duration, so it is always part of the cache key. */
84export function bucketDuration(durationSec: number): number {
85 return Math.max(0.25, Math.round(durationSec / 0.25) * 0.25);
88/** Everything that identifies one cached note render. */
89export function noteCacheKey(
90 bundle: InstrumentBundle,
91 quality: 'preview' | 'full',
92 sampleRate: number,
93 settings: Record<string, number>,
94 pitch: number,
95 velocityBucket: number,
96 durationBucket: number,
97): string {
98 const s = bundle.knobs.map((k) => `${k.key}=${settings[k.key] ?? k.default}`).join(',');
99 return `${bundle.id}|${quality}|${sampleRate}|${s}|${pitch}|${velocityBucket}|${durationBucket}`;
↑↓ move↵openescclose