// The soprano recorder as a track-render instrument: pure rendering logic // with no DOM or Worker dependency, shared by the browser worker // (worker.ts) and the node tests (test.ts). Two request shapes arrive: // // - the track path (midiboot): every note of a MIDI track. Notes are // grouped by cache key (fingering, bucketed velocity, and bucketed // duration; the recorder genuinely reads note length as blown // duration), each distinct sound renders once through recorder_render, // and copies are summed at the onsets. Pitch folds by octaves into the // baroque fingering chart's span (C5..A6), exactly as the retired // per-note export driver did. // // - the explorer path (the recorder web app, wantDiagnostics set): the // full parameter surface and live-edited .m sources drive one staged // render (basis + jet per note -> radiate/output, intermediates cached // per tier) with the diagnostics payload the app's plots consume. import { executeCode, type RuntimeValue, type RuntimeTensor } from 'numbl'; import type { MFile, TrackRenderRequest } from './types.ts'; import { RECORDER_KNOBS, type RecorderVariant } from './knobs.ts'; import { NoteCache } from './noteCache.ts'; export { RECORDER_KNOBS, type RecorderVariant }; /** The environment the host provides: sources, progress, cancellation, and * a macrotask yield so a browser worker can receive a cancel message. */ export interface RenderEnv { mfiles: MFile[]; post: (frac: number, stage: string) => void; /** Throws to abandon the render; called between stages and notes. */ checkCancel: () => void; yieldToHost: () => Promise; } // --- diagnostics payload for the explorer path (the web app's plots) --- export interface RecorderExploreDiag { quality: 'preview' | 'full'; noteIdx: Float32Array; noteOn: Float32Array; fPlay: Float32Array; fTarget: Float32Array; cents: Float32Array; qPlot: Float32Array; qPlotRate: number; modeF: Float32Array; modeQ: Float32Array; gridF: Float32Array; gridY: Float32Array; profX: Float32Array; profP: Float32Array; diag: { noteCount: number; modesKept: number; durationS: number; peakPa: number; peakSplDb: number; listenerDistM: number; warnings: string[]; basisMs: number; jetMs: number; radiateMs: number; basisCached: boolean; jetCached: boolean; oversample: number; }; } // --- shared helpers --- export function hashMFiles(files: MFile[]): string { let h = 0x811c9dc5; for (const f of files) { const s = f.name + ' ' + f.source; for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 0x01000193); } } return (h >>> 0).toString(36); } function lruGet(cache: Map, key: string): T | undefined { const v = cache.get(key); if (v !== undefined) { cache.delete(key); cache.set(key, v); } return v; } function lruSet(cache: Map, key: string, value: T, max: number): void { cache.delete(key); cache.set(key, value); while (cache.size > max) { const oldest = cache.keys().next().value as string; cache.delete(oldest); } } type Params = Record; function pLiteral(params: Params): string { const lines: string[] = []; for (const [k, v] of Object.entries(params)) { if (typeof v === 'number') { if (!Number.isFinite(v)) throw new Error(`parameter ${k} is not finite`); lines.push(`P.${k} = ${v};`); } else if (typeof v === 'boolean') lines.push(`P.${k} = ${v ? 1 : 0};`); } return lines.join('\n'); } function keyFor(params: Params, keys: readonly string[]): string { return keys.map((k) => String(params[k] ?? 'd')).join('|'); } function runStage(mfiles: MFile[], source: string, inputs?: Record) { return executeCode(source, { initialVariableValues: inputs, displayResults: false }, mfiles, 'stage.m'); } function asTensor(v: RuntimeValue): RuntimeTensor { const t = v as RuntimeTensor; if (!t || t.kind !== 'tensor') throw new Error('expected tensor result from numbl stage'); return t; } function asScalar(v: RuntimeValue): number { if (typeof v === 'number') return v; const t = v as RuntimeTensor; if (t && t.kind === 'tensor' && t.data.length === 1) return t.data[0]; if (typeof v === 'boolean') return v ? 1 : 0; throw new Error('expected scalar result from numbl stage'); } function toF32(v: RuntimeValue): Float32Array { const t = asTensor(v); const out = new Float32Array(t.data.length); for (let i = 0; i < t.data.length; i++) out[i] = t.data[i]; return out; } function knobValue(settings: Record, key: string): number { const spec = RECORDER_KNOBS.find((k) => k.key === key)!; const raw = settings[key]; return raw === undefined ? spec.default : Math.max(spec.min, Math.min(spec.max, raw)); } /** Velocity quantized to a few levels so identical notes share a cached * render; 8 levels is inaudible next to the model's 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; blown duration is real input here. */ export function bucketDuration(durationSec: number): number { return Math.max(0.25, Math.round(durationSec / 0.25) * 0.25); } // The playable pitches of the baroque fingering chart, C5..A6, and the // fold of an arbitrary MIDI pitch into that span by octaves. const CHART_MIDI = [72, 74, 76, 77, 79, 81, 83, 84, 85, 86, 88, 89, 91, 93]; export function chartIndexForPitch(pitch: number): number { let p = Math.round(pitch); if (p < 72) p += 12 * Math.ceil((72 - p) / 12); if (p > 93) p -= 12 * Math.ceil((p - 93) / 12); let best = 0; for (let i = 1; i < CHART_MIDI.length; i++) { if (Math.abs(CHART_MIDI[i] - p) < Math.abs(CHART_MIDI[best] - p)) best = i; } return best + 1; // 1-based chart row } const BASIS_KEYS = [ 'bore_length_m', 'bore_r_head_m', 'bore_r_foot_m', 'wall_m', 'hole_scale', 'thumb_pinch', 'pinch_loss', 'cutup_m', 'window_width_m', 'max_mode_hz', 'max_modes', 'grid_hz', 'speed_of_sound', 'air_density', ]; const JET_KEYS = [ 'flue_h_m', 'flue_w_m', 'labium_offset_m', 'jet_gain', 'jet_growth', 'jet_conv', 'jet_stcut', 'vortex_loss', 'noise_level', 'breath_pa', 'breath_track', 'attack_ms', 'release_ms', 'vibrato_hz', 'vibrato_depth', 'note_dur_s', 'tail_s', 'seed', 'sample_rate', ]; // Basis fields that cross from the basis stage into the jet and radiate // stages as numbl RuntimeValues. const BASIS_FIELDS = ['f', 'omega', 'Q', 'g', 'B', 'apX', 'apW', 'Sm', 'fTarget']; interface BasisEntry { vars: Record; modeF: Float32Array; modeQ: Float32Array; gridF: Float32Array; gridY: Float32Array; profX: Float32Array; profP: Float32Array; } interface JetEntry { Zr: RuntimeValue; Zi: RuntimeValue; Qin: RuntimeValue; fPlay: number; fTarget: number; cents: number; qPlot: Float32Array; qPlotRate: number; fsHi: number; } // Explorer-tier caches, keyed under the code hash so editing the .m sources // invalidates everything computed from the previous version. const basisCache = new Map(); const jetCache = new Map(); const BASIS_CACHE_MAX = 6; const JET_CACHE_MAX = 4; // Finished per-note audio: an in-memory LRU in front of an IndexedDB // store, so renders survive page reloads (budgets and eviction live in // noteCache.ts). const noteCache = new NoteCache('recorder-instrument-notes'); // --- the explorer path (full params, staged, with diagnostics) --- async function renderExplore( req: TrackRenderRequest, env: RenderEnv, ): Promise<{ audio: Float32Array; diag: RecorderExploreDiag }> { const params = req.params ?? {}; const mfiles = env.mfiles; let quality = req.quality; const num = (k: string, d: number) => Number(params[k] ?? d); const codeKey = hashMFiles(mfiles); const basisParamsKey = 'x|' + codeKey + '|' + keyFor(params, BASIS_KEYS); const jetParamsKey = keyFor(params, JET_KEYS); // The gesture, mirrored from recorder_notes.m. const K = Math.min(3, Math.max(1, Math.round(num('note_count', 1)))); const noteDurS = num('note_dur_s', 1.6); const tailS = num('tail_s', 0.4); const notes = [num('note1', 5), num('note2', 6), num('note3', 7)] .slice(0, K) .map((n) => Math.min(14, Math.max(1, Math.round(n)))); const gap = Math.max(0, num('gap_ms', 60)) * 1e-3; const tOn = notes.map((_, i) => i * (noteDurS + gap)); const spans = notes.map((_, i) => noteDurS + (i < K - 1 ? Math.min(tailS, 0.3) : tailS)); const durationS = tOn[K - 1] + noteDurS + tailS; const fullOversample = num('oversample', 2); const sampleRate = num('sample_rate', req.sampleRate); const jetKeyAt = (i: number, os: number) => basisParamsKey + `|n=${notes[i]}` + '||' + jetParamsKey + `|span=${spans[i]}|ord=${i + 1}|os=${os}`; // A preview right after a full render reuses the full-rate jet runs. if (quality === 'preview' && notes.every((_, i) => jetCache.has(jetKeyAt(i, fullOversample)))) { quality = 'full'; } const oversample = quality === 'preview' ? Math.min(1, fullOversample) : fullOversample; const fsHi = sampleRate * oversample; const base = `P = recorder_defaults();\n${pLiteral(params)}`; let basisMs = 0; let jetMs = 0; let basisCached = true; let jetCached = true; const basisEntries: BasisEntry[] = []; const jetEntries: JetEntry[] = []; for (let i = 0; i < K; i++) { // ---- basis stage: transmission-line solve + modal fit ---- const bKey = basisParamsKey + `|n=${notes[i]}`; let bEntry = lruGet(basisCache, bKey); if (!bEntry) { basisCached = false; env.post(((i + 0.5) / K) * 0.2, 'basis'); await env.yieldToHost(); env.checkCancel(); const t0 = performance.now(); const r = runStage(mfiles, ` ${base} basis = recorder_basis(P, ${notes[i]}); ${BASIS_FIELDS.map((f) => `b_${f} = basis.${f};`).join('\n')} b_profP = basis.profP; b_profX = basis.profX; b_gridF = basis.gridF; b_gridY = basis.gridY; `); basisMs += performance.now() - t0; const vars: Record = {}; for (const f of BASIS_FIELDS) vars[`b_${f}`] = r.variableValues[`b_${f}`]; bEntry = { vars, modeF: toF32(r.variableValues.b_f), modeQ: toF32(r.variableValues.b_Q), gridF: toF32(r.variableValues.b_gridF), gridY: toF32(r.variableValues.b_gridY), profX: toF32(r.variableValues.b_profX), profP: toF32(r.variableValues.b_profP), }; lruSet(basisCache, bKey, bEntry, BASIS_CACHE_MAX); } basisEntries.push(bEntry); // ---- jet stage: the time-domain integration ---- const jKey = jetKeyAt(i, oversample); let jEntry = lruGet(jetCache, jKey); if (!jEntry) { jetCached = false; env.post(0.2 + ((i + 0.5) / K) * 0.6, 'jet'); await env.yieldToHost(); env.checkCancel(); const t0 = performance.now(); const ds = Math.max(1, Math.round(fsHi / 2000)); const r = runStage(mfiles, ` ${base} ${BASIS_FIELDS.map((f) => `basis.${f} = b_${f};`).join('\n')} basis.n = length(basis.f); [Zr, Zi, Qin, jinfo] = recorder_jet(P, basis, ${spans[i]}, ${i + 1}, ${fsHi}); fPlay = jinfo.fPlay; fTarget = jinfo.fTarget; cents = jinfo.cents; qPlot = Qin(1:${ds}:length(Qin)); `, bEntry.vars); jetMs += performance.now() - t0; jEntry = { Zr: r.variableValues.Zr, Zi: r.variableValues.Zi, Qin: r.variableValues.Qin, fPlay: asScalar(r.variableValues.fPlay), fTarget: asScalar(r.variableValues.fTarget), cents: asScalar(r.variableValues.cents), qPlot: toF32(r.variableValues.qPlot), qPlotRate: fsHi / ds, fsHi, }; lruSet(jetCache, jKey, jEntry, JET_CACHE_MAX); } jetEntries.push(jEntry); } // ---- radiate + assemble + output ---- env.post(0.9, 'radiate'); await env.yieldToHost(); env.checkCancel(); const t0 = performance.now(); const inputs: Record = {}; const noteBlocks: string[] = []; for (let i = 0; i < K; i++) { for (const f of BASIS_FIELDS) inputs[`b${i + 1}_${f}`] = basisEntries[i].vars[`b_${f}`]; inputs[`n${i + 1}_Zr`] = jetEntries[i].Zr; inputs[`n${i + 1}_Zi`] = jetEntries[i].Zi; inputs[`n${i + 1}_Qin`] = jetEntries[i].Qin; noteBlocks.push(` ${BASIS_FIELDS.map((f) => `basis.${f} = b${i + 1}_${f};`).join('\n')} basis.n = length(basis.f); [pn, radinfo] = recorder_radiate(P, basis, n${i + 1}_Zr, n${i + 1}_Zi, n${i + 1}_Qin, ${fsHi}); k0 = min(T, round(${tOn[i]} * ${fsHi}) + 1); len = min(length(pn), T - k0 + 1); p(k0:k0 + len - 1) = p(k0:k0 + len - 1) + pn(1:len); inside = max(inside, radinfo.listenerInside); distM = radinfo.listenerDistM; `); } const rOut = runStage(mfiles, ` ${base} T = floor(${durationS} * ${fsHi}); p = zeros(1, T); inside = 0; distM = 0; ${noteBlocks.join('\n')} [y, peakPa] = recorder_output(P, p, ${oversample}); `, inputs); const radiateMs = performance.now() - t0; const y32 = toF32(rOut.variableValues.y); const peakPa = asScalar(rOut.variableValues.peakPa); const listenerInside = asScalar(rOut.variableValues.inside) !== 0; const listenerDistM = asScalar(rOut.variableValues.distM); // assemble the jet-flow envelope over the whole timeline const qRate = jetEntries[0].qPlotRate; const qPlot = new Float32Array(Math.ceil(durationS * qRate)); for (let i = 0; i < K; i++) { const off = Math.round(tOn[i] * qRate); const q = jetEntries[i].qPlot; for (let k = 0; k < q.length && off + k < qPlot.length; k++) qPlot[off + k] += q[k]; } const warns: string[] = []; if (listenerInside) { warns.push('the listener is within 2 cm of the instrument; the monopole field is evaluated at a clamped minimum distance'); } for (let i = 0; i < K; i++) { const j = jetEntries[i]; if (j.fPlay <= 0) { warns.push(`note ${i + 1} did not speak (no oscillation locked); more breath or jet receptivity may help`); } else if (Math.abs(j.cents) > 60) { warns.push(`note ${i + 1} locked to ${j.fPlay.toFixed(0)} Hz, not its fingering's register (${j.fTarget.toFixed(0)} Hz)`); } } if (!params.normalize) { let peak = 0; for (let i = 0; i < y32.length; i++) peak = Math.max(peak, Math.abs(y32[i])); if (peak > 1.0) { warns.push(`clipping: peak is ${peakPa.toFixed(2)} Pa but full_scale_pa is ${num('full_scale_pa', 0.5).toFixed(2)}`); } } const diag: RecorderExploreDiag = { quality, noteIdx: new Float32Array(notes), noteOn: new Float32Array(tOn), fPlay: new Float32Array(jetEntries.map((j) => j.fPlay)), fTarget: new Float32Array(jetEntries.map((j) => j.fTarget)), cents: new Float32Array(jetEntries.map((j) => j.cents)), qPlot, qPlotRate: qRate, modeF: basisEntries[0].modeF.slice(), modeQ: basisEntries[0].modeQ.slice(), gridF: basisEntries[0].gridF.slice(), gridY: basisEntries[0].gridY.slice(), profX: basisEntries[0].profX.slice(), profP: basisEntries[0].profP.slice(), diag: { noteCount: K, modesKept: basisEntries[0].modeF.length, durationS, peakPa, peakSplDb: 20 * Math.log10(peakPa / 20e-6 + 1e-30), listenerDistM, warnings: warns, basisMs, jetMs, radiateMs, basisCached, jetCached, oversample, }, }; return { audio: y32, diag }; } // --- the track path (midiboot): per-note rendering with caching --- function notePLines(note: { chart: number; velocity: number; durationSec: number }, req: TrackRenderRequest): string { // The note-to-parameter mapping: the folded pitch's nearest playable // fingering, velocity onto breath pressure (the model's C5 reference; it // tracks pressure up with pitch itself), and the note's own length onto // the blown duration (clamped to what the model accepts). A single // tongued note with a fixed turbulence seed so renders are cacheable, // normalize off so velocity survives in pascals. const settings = req.settings ?? {}; const lines = [ 'P = recorder_defaults();', `P.sample_rate = ${req.sampleRate};`, 'P.note_count = 1;', 'P.seed = 1;', 'P.pre_delay_ms = 0;', 'P.normalize = 0;', ]; if (req.quality === 'preview') lines.push('P.oversample = 1;'); lines.push( `P.note1 = ${note.chart};`, `P.breath_pa = ${220 + (520 - 220) * note.velocity};`, `P.note_dur_s = ${Math.max(0.15, Math.min(4, note.durationSec))};`, `P.attack_ms = ${knobValue(settings, 'attack_ms')};`, `P.release_ms = ${knobValue(settings, 'release_ms')};`, ); return lines.join('\n'); } async function renderTrackNotes( req: TrackRenderRequest, env: RenderEnv, ): Promise<{ audio: Float32Array }> { if (req.notes.length === 0) return { audio: new Float32Array(req.lengthSamples) }; const codeKey = hashMFiles(env.mfiles); const settings = req.settings ?? {}; const knobsKey = RECORDER_KNOBS.map((k) => `${k.key}=${knobValue(settings, k.key)}`).join(','); // Group notes by everything that affects their sound: fingering, // velocity level, and blown duration (quantized to 0.25 s). const groups = new Map(); for (const n of req.notes) { const chart = chartIndexForPitch(n.pitch); const vel = bucketVelocity(n.velocity); const dur = bucketDuration(n.durationSec); const key = `${codeKey}|${req.quality}|${req.sampleRate}|${knobsKey}|${chart}|${vel}|${dur}`; let g = groups.get(key); if (!g) groups.set(key, (g = { chart, velocity: vel, durationSec: dur, onsets: [] })); g.onsets.push(n.timeSec); } const placed: { buf: Float32Array; onsets: number[] }[] = []; // Resolve cache hits first (memory, then IndexedDB), so the progress // denominator counts only the sounds that actually need rendering. const resolved = new Map(); for (const key of groups.keys()) { const hit = await noteCache.get(key); if (hit) resolved.set(key, hit); } const misses = groups.size - resolved.size; let done = 0; for (const [key, g] of groups) { let buf = resolved.get(key); if (!buf) { env.post(done / Math.max(1, misses), `note ${done + 1}/${misses}: jet`); await env.yieldToHost(); env.checkCancel(); const r = runStage(env.mfiles, ` ${notePLines(g, req)} [y, basis, dinfo] = recorder_render(P); `); buf = toF32(r.variableValues.y); noteCache.put(key, buf); done++; } placed.push({ buf, onsets: g.onsets }); } // lengthSamples is a minimum: size the output to the longest tail so a // ring that outlives the last note is returned in full, and place every // copy. let length = req.lengthSamples; for (const p of placed) { for (const t of p.onsets) length = Math.max(length, Math.round(t * req.sampleRate) + p.buf.length); } const out = new Float32Array(length); for (const p of placed) { for (const t of p.onsets) { const off = Math.round(t * req.sampleRate); const end = Math.min(out.length, off + p.buf.length); for (let j = Math.max(0, off); j < end; j++) out[j] += p.buf[j - off]; } } return { audio: out }; } /** The single entry point: explorer path when diagnostics are requested, * the per-note track path otherwise. */ export async function renderRequest( variant: RecorderVariant, req: TrackRenderRequest, env: RenderEnv, ): Promise<{ audio: Float32Array; diag?: RecorderExploreDiag }> { void variant; if (req.wantDiagnostics) return renderExplore(req, env); return renderTrackNotes(req, env); }