1// The soprano recorder as a track-render instrument: pure rendering logic
2// with no DOM or Worker dependency, shared by the browser worker
3// (worker.ts) and the node tests (test.ts). Two request shapes arrive:
4//
5// - the track path (midiboot): every note of a MIDI track. Notes are
6// grouped by cache key (fingering, bucketed velocity, and bucketed
7// duration; the recorder genuinely reads note length as blown
8// duration), each distinct sound renders once through recorder_render,
9// and copies are summed at the onsets. Pitch folds by octaves into the
10// baroque fingering chart's span (C5..A6), exactly as the retired
11// per-note export driver did.
12//
13// - the explorer path (the recorder web app, wantDiagnostics set): the
14// full parameter surface and live-edited .m sources drive one staged
15// render (basis + jet per note -> radiate/output, intermediates cached
16// per tier) with the diagnostics payload the app's plots consume.
18import { executeCode, type RuntimeValue, type RuntimeTensor } from 'numbl';
19import type { MFile, TrackRenderRequest } from './types.ts';
21import { RECORDER_KNOBS, type RecorderVariant } from './knobs.ts';
22import { NoteCache } from './noteCache.ts';
24export { RECORDER_KNOBS, type RecorderVariant };
26/** The environment the host provides: sources, progress, cancellation, and
27 * a macrotask yield so a browser worker can receive a cancel message. */
28export interface RenderEnv {
29 mfiles: MFile[];
30 post: (frac: number, stage: string) => void;
31 /** Throws to abandon the render; called between stages and notes. */
32 checkCancel: () => void;
33 yieldToHost: () => Promise<void>;
34}
36// --- diagnostics payload for the explorer path (the web app's plots) ---
38export interface RecorderExploreDiag {
39 quality: 'preview' | 'full';
40 noteIdx: Float32Array;
41 noteOn: Float32Array;
42 fPlay: Float32Array;
43 fTarget: Float32Array;
44 cents: Float32Array;
45 qPlot: Float32Array;
46 qPlotRate: number;
47 modeF: Float32Array;
48 modeQ: Float32Array;
49 gridF: Float32Array;
50 gridY: Float32Array;
51 profX: Float32Array;
52 profP: Float32Array;
53 diag: {
54 noteCount: number;
55 modesKept: number;
56 durationS: number;
57 peakPa: number;
58 peakSplDb: number;
59 listenerDistM: number;
60 warnings: string[];
61 basisMs: number;
62 jetMs: number;
63 radiateMs: number;
64 basisCached: boolean;
65 jetCached: boolean;
66 oversample: number;
67 };
68}
70// --- shared helpers ---
72export function hashMFiles(files: MFile[]): string {
73 let h = 0x811c9dc5;
74 for (const f of files) {
75 const s = f.name + ' ' + f.source;
76 for (let i = 0; i < s.length; i++) {
77 h ^= s.charCodeAt(i);
78 h = Math.imul(h, 0x01000193);
79 }
80 }
81 return (h >>> 0).toString(36);
82}
84function lruGet<T>(cache: Map<string, T>, key: string): T | undefined {
85 const v = cache.get(key);
86 if (v !== undefined) {
87 cache.delete(key);
88 cache.set(key, v);
89 }
90 return v;
91}
93function lruSet<T>(cache: Map<string, T>, key: string, value: T, max: number): void {
94 cache.delete(key);
95 cache.set(key, value);
96 while (cache.size > max) {
97 const oldest = cache.keys().next().value as string;
98 cache.delete(oldest);
99 }
100}
102type Params = Record<string, number | boolean>;
104function pLiteral(params: Params): string {
105 const lines: string[] = [];
106 for (const [k, v] of Object.entries(params)) {
107 if (typeof v === 'number') {
108 if (!Number.isFinite(v)) throw new Error(`parameter ${k} is not finite`);
109 lines.push(`P.${k} = ${v};`);
110 } else if (typeof v === 'boolean') lines.push(`P.${k} = ${v ? 1 : 0};`);
111 }
112 return lines.join('\n');
113}
115function keyFor(params: Params, keys: readonly string[]): string {
116 return keys.map((k) => String(params[k] ?? 'd')).join('|');
117}
119function runStage(mfiles: MFile[], source: string, inputs?: Record<string, RuntimeValue>) {
120 return executeCode(source, { initialVariableValues: inputs, displayResults: false }, mfiles, 'stage.m');
121}
123function asTensor(v: RuntimeValue): RuntimeTensor {
124 const t = v as RuntimeTensor;
125 if (!t || t.kind !== 'tensor') throw new Error('expected tensor result from numbl stage');
126 return t;
127}
129function asScalar(v: RuntimeValue): number {
130 if (typeof v === 'number') return v;
131 const t = v as RuntimeTensor;
132 if (t && t.kind === 'tensor' && t.data.length === 1) return t.data[0];
133 if (typeof v === 'boolean') return v ? 1 : 0;
134 throw new Error('expected scalar result from numbl stage');
135}
137function toF32(v: RuntimeValue): Float32Array {
138 const t = asTensor(v);
139 const out = new Float32Array(t.data.length);
140 for (let i = 0; i < t.data.length; i++) out[i] = t.data[i];
141 return out;
142}
144function knobValue(settings: Record<string, number>, key: string): number {
145 const spec = RECORDER_KNOBS.find((k) => k.key === key)!;
146 const raw = settings[key];
147 return raw === undefined ? spec.default : Math.max(spec.min, Math.min(spec.max, raw));
148}
150/** Velocity quantized to a few levels so identical notes share a cached
151 * render; 8 levels is inaudible next to the model's own dynamics. */
152export function bucketVelocity(velocity01: number, levels = 8): number {
153 return Math.ceil(Math.max(0.001, Math.min(1, velocity01)) * levels) / levels;
154}
156/** Duration quantized to 0.25 s steps; blown duration is real input here. */
157export function bucketDuration(durationSec: number): number {
158 return Math.max(0.25, Math.round(durationSec / 0.25) * 0.25);
159}
161// The playable pitches of the baroque fingering chart, C5..A6, and the
162// fold of an arbitrary MIDI pitch into that span by octaves.
163const CHART_MIDI = [72, 74, 76, 77, 79, 81, 83, 84, 85, 86, 88, 89, 91, 93];
165export function chartIndexForPitch(pitch: number): number {
166 let p = Math.round(pitch);
167 if (p < 72) p += 12 * Math.ceil((72 - p) / 12);
168 if (p > 93) p -= 12 * Math.ceil((p - 93) / 12);
169 let best = 0;
170 for (let i = 1; i < CHART_MIDI.length; i++) {
171 if (Math.abs(CHART_MIDI[i] - p) < Math.abs(CHART_MIDI[best] - p)) best = i;
172 }
173 return best + 1; // 1-based chart row
174}
176const BASIS_KEYS = [
177 'bore_length_m', 'bore_r_head_m', 'bore_r_foot_m', 'wall_m',
178 'hole_scale', 'thumb_pinch', 'pinch_loss',
179 'cutup_m', 'window_width_m',
180 'max_mode_hz', 'max_modes', 'grid_hz',
181 'speed_of_sound', 'air_density',
182];
184const JET_KEYS = [
185 'flue_h_m', 'flue_w_m', 'labium_offset_m',
186 'jet_gain', 'jet_growth', 'jet_conv', 'jet_stcut', 'vortex_loss',
187 'noise_level',
188 'breath_pa', 'breath_track', 'attack_ms', 'release_ms',
189 'vibrato_hz', 'vibrato_depth',
190 'note_dur_s', 'tail_s', 'seed',
191 'sample_rate',
192];
194// Basis fields that cross from the basis stage into the jet and radiate
195// stages as numbl RuntimeValues.
196const BASIS_FIELDS = ['f', 'omega', 'Q', 'g', 'B', 'apX', 'apW', 'Sm', 'fTarget'];
198interface BasisEntry {
199 vars: Record<string, RuntimeValue>;
200 modeF: Float32Array;
201 modeQ: Float32Array;
202 gridF: Float32Array;
203 gridY: Float32Array;
204 profX: Float32Array;
205 profP: Float32Array;
206}
208interface JetEntry {
209 Zr: RuntimeValue;
210 Zi: RuntimeValue;
211 Qin: RuntimeValue;
212 fPlay: number;
213 fTarget: number;
214 cents: number;
215 qPlot: Float32Array;
216 qPlotRate: number;
217 fsHi: number;
218}
220// Explorer-tier caches, keyed under the code hash so editing the .m sources
221// invalidates everything computed from the previous version.
222const basisCache = new Map<string, BasisEntry>();
223const jetCache = new Map<string, JetEntry>();
224const BASIS_CACHE_MAX = 6;
225const JET_CACHE_MAX = 4;
227// Finished per-note audio: an in-memory LRU in front of an IndexedDB
228// store, so renders survive page reloads (budgets and eviction live in
229// noteCache.ts).
230const noteCache = new NoteCache('recorder-instrument-notes');
232// --- the explorer path (full params, staged, with diagnostics) ---
234async function renderExplore(
235 req: TrackRenderRequest,
236 env: RenderEnv,
237): Promise<{ audio: Float32Array; diag: RecorderExploreDiag }> {
238 const params = req.params ?? {};
239 const mfiles = env.mfiles;
240 let quality = req.quality;
242 const num = (k: string, d: number) => Number(params[k] ?? d);
244 const codeKey = hashMFiles(mfiles);
245 const basisParamsKey = 'x|' + codeKey + '|' + keyFor(params, BASIS_KEYS);
246 const jetParamsKey = keyFor(params, JET_KEYS);
248 // The gesture, mirrored from recorder_notes.m.
249 const K = Math.min(3, Math.max(1, Math.round(num('note_count', 1))));
250 const noteDurS = num('note_dur_s', 1.6);
251 const tailS = num('tail_s', 0.4);
252 const notes = [num('note1', 5), num('note2', 6), num('note3', 7)]
253 .slice(0, K)
254 .map((n) => Math.min(14, Math.max(1, Math.round(n))));
255 const gap = Math.max(0, num('gap_ms', 60)) * 1e-3;
256 const tOn = notes.map((_, i) => i * (noteDurS + gap));
257 const spans = notes.map((_, i) => noteDurS + (i < K - 1 ? Math.min(tailS, 0.3) : tailS));
258 const durationS = tOn[K - 1] + noteDurS + tailS;
260 const fullOversample = num('oversample', 2);
261 const sampleRate = num('sample_rate', req.sampleRate);
263 const jetKeyAt = (i: number, os: number) =>
264 basisParamsKey + `|n=${notes[i]}` + '||' + jetParamsKey + `|span=${spans[i]}|ord=${i + 1}|os=${os}`;
266 // A preview right after a full render reuses the full-rate jet runs.
267 if (quality === 'preview' && notes.every((_, i) => jetCache.has(jetKeyAt(i, fullOversample)))) {
268 quality = 'full';
269 }
270 const oversample = quality === 'preview' ? Math.min(1, fullOversample) : fullOversample;
271 const fsHi = sampleRate * oversample;
273 const base = `P = recorder_defaults();\n${pLiteral(params)}`;
275 let basisMs = 0;
276 let jetMs = 0;
277 let basisCached = true;
278 let jetCached = true;
279 const basisEntries: BasisEntry[] = [];
280 const jetEntries: JetEntry[] = [];
282 for (let i = 0; i < K; i++) {
283 // ---- basis stage: transmission-line solve + modal fit ----
284 const bKey = basisParamsKey + `|n=${notes[i]}`;
285 let bEntry = lruGet(basisCache, bKey);
286 if (!bEntry) {
287 basisCached = false;
288 env.post(((i + 0.5) / K) * 0.2, 'basis');
289 await env.yieldToHost();
290 env.checkCancel();
291 const t0 = performance.now();
292 const r = runStage(mfiles, `
293${base}
294basis = recorder_basis(P, ${notes[i]});
295${BASIS_FIELDS.map((f) => `b_${f} = basis.${f};`).join('\n')}
296b_profP = basis.profP;
297b_profX = basis.profX;
298b_gridF = basis.gridF;
299b_gridY = basis.gridY;
300`);
301 basisMs += performance.now() - t0;
302 const vars: Record<string, RuntimeValue> = {};
303 for (const f of BASIS_FIELDS) vars[`b_${f}`] = r.variableValues[`b_${f}`];
304 bEntry = {
305 vars,
306 modeF: toF32(r.variableValues.b_f),
307 modeQ: toF32(r.variableValues.b_Q),
308 gridF: toF32(r.variableValues.b_gridF),
309 gridY: toF32(r.variableValues.b_gridY),
310 profX: toF32(r.variableValues.b_profX),
311 profP: toF32(r.variableValues.b_profP),
312 };
313 lruSet(basisCache, bKey, bEntry, BASIS_CACHE_MAX);
314 }
315 basisEntries.push(bEntry);
317 // ---- jet stage: the time-domain integration ----
318 const jKey = jetKeyAt(i, oversample);
319 let jEntry = lruGet(jetCache, jKey);
320 if (!jEntry) {
321 jetCached = false;
322 env.post(0.2 + ((i + 0.5) / K) * 0.6, 'jet');
323 await env.yieldToHost();
324 env.checkCancel();
325 const t0 = performance.now();
326 const ds = Math.max(1, Math.round(fsHi / 2000));
327 const r = runStage(mfiles, `
328${base}
329${BASIS_FIELDS.map((f) => `basis.${f} = b_${f};`).join('\n')}
330basis.n = length(basis.f);
331[Zr, Zi, Qin, jinfo] = recorder_jet(P, basis, ${spans[i]}, ${i + 1}, ${fsHi});
332fPlay = jinfo.fPlay;
333fTarget = jinfo.fTarget;
334cents = jinfo.cents;
335qPlot = Qin(1:${ds}:length(Qin));
336`, bEntry.vars);
337 jetMs += performance.now() - t0;
338 jEntry = {
339 Zr: r.variableValues.Zr,
340 Zi: r.variableValues.Zi,
341 Qin: r.variableValues.Qin,
342 fPlay: asScalar(r.variableValues.fPlay),
343 fTarget: asScalar(r.variableValues.fTarget),
344 cents: asScalar(r.variableValues.cents),
345 qPlot: toF32(r.variableValues.qPlot),
346 qPlotRate: fsHi / ds,
347 fsHi,
348 };
349 lruSet(jetCache, jKey, jEntry, JET_CACHE_MAX);
350 }
351 jetEntries.push(jEntry);
352 }
354 // ---- radiate + assemble + output ----
355 env.post(0.9, 'radiate');
356 await env.yieldToHost();
357 env.checkCancel();
358 const t0 = performance.now();
359 const inputs: Record<string, RuntimeValue> = {};
360 const noteBlocks: string[] = [];
361 for (let i = 0; i < K; i++) {
362 for (const f of BASIS_FIELDS) inputs[`b${i + 1}_${f}`] = basisEntries[i].vars[`b_${f}`];
363 inputs[`n${i + 1}_Zr`] = jetEntries[i].Zr;
364 inputs[`n${i + 1}_Zi`] = jetEntries[i].Zi;
365 inputs[`n${i + 1}_Qin`] = jetEntries[i].Qin;
366 noteBlocks.push(`
367${BASIS_FIELDS.map((f) => `basis.${f} = b${i + 1}_${f};`).join('\n')}
368basis.n = length(basis.f);
369[pn, radinfo] = recorder_radiate(P, basis, n${i + 1}_Zr, n${i + 1}_Zi, n${i + 1}_Qin, ${fsHi});
370k0 = min(T, round(${tOn[i]} * ${fsHi}) + 1);
371len = min(length(pn), T - k0 + 1);
372p(k0:k0 + len - 1) = p(k0:k0 + len - 1) + pn(1:len);
373inside = max(inside, radinfo.listenerInside);
374distM = radinfo.listenerDistM;
375`);
376 }
377 const rOut = runStage(mfiles, `
378${base}
379T = floor(${durationS} * ${fsHi});
380p = zeros(1, T);
381inside = 0;
382distM = 0;
383${noteBlocks.join('\n')}
384[y, peakPa] = recorder_output(P, p, ${oversample});
385`, inputs);
386 const radiateMs = performance.now() - t0;
388 const y32 = toF32(rOut.variableValues.y);
389 const peakPa = asScalar(rOut.variableValues.peakPa);
390 const listenerInside = asScalar(rOut.variableValues.inside) !== 0;
391 const listenerDistM = asScalar(rOut.variableValues.distM);
393 // assemble the jet-flow envelope over the whole timeline
394 const qRate = jetEntries[0].qPlotRate;
395 const qPlot = new Float32Array(Math.ceil(durationS * qRate));
396 for (let i = 0; i < K; i++) {
397 const off = Math.round(tOn[i] * qRate);
398 const q = jetEntries[i].qPlot;
399 for (let k = 0; k < q.length && off + k < qPlot.length; k++) qPlot[off + k] += q[k];
400 }
402 const warns: string[] = [];
403 if (listenerInside) {
404 warns.push('the listener is within 2 cm of the instrument; the monopole field is evaluated at a clamped minimum distance');
405 }
406 for (let i = 0; i < K; i++) {
407 const j = jetEntries[i];
408 if (j.fPlay <= 0) {
409 warns.push(`note ${i + 1} did not speak (no oscillation locked); more breath or jet receptivity may help`);
410 } else if (Math.abs(j.cents) > 60) {
411 warns.push(`note ${i + 1} locked to ${j.fPlay.toFixed(0)} Hz, not its fingering's register (${j.fTarget.toFixed(0)} Hz)`);
412 }
413 }
414 if (!params.normalize) {
415 let peak = 0;
416 for (let i = 0; i < y32.length; i++) peak = Math.max(peak, Math.abs(y32[i]));
417 if (peak > 1.0) {
418 warns.push(`clipping: peak is ${peakPa.toFixed(2)} Pa but full_scale_pa is ${num('full_scale_pa', 0.5).toFixed(2)}`);
419 }
420 }
422 const diag: RecorderExploreDiag = {
423 quality,
424 noteIdx: new Float32Array(notes),
425 noteOn: new Float32Array(tOn),
426 fPlay: new Float32Array(jetEntries.map((j) => j.fPlay)),
427 fTarget: new Float32Array(jetEntries.map((j) => j.fTarget)),
428 cents: new Float32Array(jetEntries.map((j) => j.cents)),
429 qPlot,
430 qPlotRate: qRate,
431 modeF: basisEntries[0].modeF.slice(),
432 modeQ: basisEntries[0].modeQ.slice(),
433 gridF: basisEntries[0].gridF.slice(),
434 gridY: basisEntries[0].gridY.slice(),
435 profX: basisEntries[0].profX.slice(),
436 profP: basisEntries[0].profP.slice(),
437 diag: {
438 noteCount: K,
439 modesKept: basisEntries[0].modeF.length,
440 durationS,
441 peakPa,
442 peakSplDb: 20 * Math.log10(peakPa / 20e-6 + 1e-30),
443 listenerDistM,
444 warnings: warns,
445 basisMs,
446 jetMs,
447 radiateMs,
448 basisCached,
449 jetCached,
450 oversample,
451 },
452 };
453 return { audio: y32, diag };
454}
456// --- the track path (midiboot): per-note rendering with caching ---
458function notePLines(note: { chart: number; velocity: number; durationSec: number }, req: TrackRenderRequest): string {
459 // The note-to-parameter mapping: the folded pitch's nearest playable
460 // fingering, velocity onto breath pressure (the model's C5 reference; it
461 // tracks pressure up with pitch itself), and the note's own length onto
462 // the blown duration (clamped to what the model accepts). A single
463 // tongued note with a fixed turbulence seed so renders are cacheable,
464 // normalize off so velocity survives in pascals.
465 const settings = req.settings ?? {};
466 const lines = [
467 'P = recorder_defaults();',
468 `P.sample_rate = ${req.sampleRate};`,
469 'P.note_count = 1;',
470 'P.seed = 1;',
471 'P.pre_delay_ms = 0;',
472 'P.normalize = 0;',
473 ];
474 if (req.quality === 'preview') lines.push('P.oversample = 1;');
475 lines.push(
476 `P.note1 = ${note.chart};`,
477 `P.breath_pa = ${220 + (520 - 220) * note.velocity};`,
478 `P.note_dur_s = ${Math.max(0.15, Math.min(4, note.durationSec))};`,
479 `P.attack_ms = ${knobValue(settings, 'attack_ms')};`,
480 `P.release_ms = ${knobValue(settings, 'release_ms')};`,
481 );
482 return lines.join('\n');
483}
485async function renderTrackNotes(
486 req: TrackRenderRequest,
487 env: RenderEnv,
488): Promise<{ audio: Float32Array }> {
489 if (req.notes.length === 0) return { audio: new Float32Array(req.lengthSamples) };
490 const codeKey = hashMFiles(env.mfiles);
491 const settings = req.settings ?? {};
492 const knobsKey = RECORDER_KNOBS.map((k) => `${k.key}=${knobValue(settings, k.key)}`).join(',');
494 // Group notes by everything that affects their sound: fingering,
495 // velocity level, and blown duration (quantized to 0.25 s).
496 const groups = new Map<string, { chart: number; velocity: number; durationSec: number; onsets: number[] }>();
497 for (const n of req.notes) {
498 const chart = chartIndexForPitch(n.pitch);
499 const vel = bucketVelocity(n.velocity);
500 const dur = bucketDuration(n.durationSec);
501 const key = `${codeKey}|${req.quality}|${req.sampleRate}|${knobsKey}|${chart}|${vel}|${dur}`;
502 let g = groups.get(key);
503 if (!g) groups.set(key, (g = { chart, velocity: vel, durationSec: dur, onsets: [] }));
504 g.onsets.push(n.timeSec);
505 }
507 const placed: { buf: Float32Array; onsets: number[] }[] = [];
508 // Resolve cache hits first (memory, then IndexedDB), so the progress
509 // denominator counts only the sounds that actually need rendering.
510 const resolved = new Map<string, Float32Array>();
511 for (const key of groups.keys()) {
512 const hit = await noteCache.get(key);
513 if (hit) resolved.set(key, hit);
514 }
515 const misses = groups.size - resolved.size;
516 let done = 0;
517 for (const [key, g] of groups) {
518 let buf = resolved.get(key);
519 if (!buf) {
520 env.post(done / Math.max(1, misses), `note ${done + 1}/${misses}: jet`);
521 await env.yieldToHost();
522 env.checkCancel();
523 const r = runStage(env.mfiles, `
524${notePLines(g, req)}
525[y, basis, dinfo] = recorder_render(P);
526`);
527 buf = toF32(r.variableValues.y);
528 noteCache.put(key, buf);
529 done++;
530 }
531 placed.push({ buf, onsets: g.onsets });
532 }
534 // lengthSamples is a minimum: size the output to the longest tail so a
535 // ring that outlives the last note is returned in full, and place every
536 // copy.
537 let length = req.lengthSamples;
538 for (const p of placed) {
539 for (const t of p.onsets) length = Math.max(length, Math.round(t * req.sampleRate) + p.buf.length);
540 }
541 const out = new Float32Array(length);
542 for (const p of placed) {
543 for (const t of p.onsets) {
544 const off = Math.round(t * req.sampleRate);
545 const end = Math.min(out.length, off + p.buf.length);
546 for (let j = Math.max(0, off); j < end; j++) out[j] += p.buf[j - off];
547 }
548 }
549 return { audio: out };
550}
552/** The single entry point: explorer path when diagnostics are requested,
553 * the per-note track path otherwise. */
554export async function renderRequest(
555 variant: RecorderVariant,
556 req: TrackRenderRequest,
557 env: RenderEnv,
558): Promise<{ audio: Float32Array; diag?: RecorderExploreDiag }> {
559 void variant;
560 if (req.wantDiagnostics) return renderExplore(req, env);
561 return renderTrackNotes(req, env);
562}