// The triangle 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 here: // // - the track path (midiboot): every note of a MIDI track. Notes are // grouped by cache key (pitch and bucketed velocity; a strike ignores // note duration, ring-out is the tail_s knob), each distinct sound is // rendered once through the staged pipeline, and copies are summed at // the onsets. The FEM eigensolve depends only on the pitch-scaled // geometry, so it is cached per pitch and shared across velocities, // knob values, and repeats. // // - the explorer path (the triangle web app, wantDiagnostics set): the // full parameter surface and live-edited .m sources drive one staged // render with the diagnostics payload the app's plots consume, exactly // the arrangement of the app's former render worker. // // Stage intermediates cross executeCode calls as numbl RuntimeValues // (column-major Float64). import { executeCode, type RuntimeValue, type RuntimeTensor } from 'numbl'; import type { MFile, TrackRenderRequest } from './types.ts'; import { TRIANGLE_KNOBS, type TriangleVariant } from './knobs.ts'; import { NoteCache } from './noteCache.ts'; export { TRIANGLE_KNOBS, type TriangleVariant }; /** 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 TriangleExploreDiag { quality: 'preview' | 'full'; strikeT: Float32Array; strikeV: Float32Array; strikeS: Float32Array; modeF: Float32Array; modeZeta: Float32Array; modeZetaRad: Float32Array; modeFamily: Float32Array; nodeX: Float32Array; nodeY: Float32Array; nodeS: Float32Array; shapeUx: Float32Array; shapeUy: Float32Array; shapeUz: Float32Array; diag: { modesKept: number; modesIn: number; modesOut: number; strikeCount: number; meanV: number; maxV: number; meanTauUs: number; peakForceN: number; durationS: number; peakPa: number; peakSplDb: number; directPeakPa: number; listenerDistM: number; warnings: string[]; solveMs: number; radiateMs: number; solveCached: boolean; radiateCached: 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 = TRIANGLE_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 strikes 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; } // The basis fields handed from the solve stage into the radiate stage, in // the order the reassembly script expects. const BASIS_FIELDS = [ 'f', 'omega', 'zeta', 'zetaRad', 'family', 'nodeX', 'nodeY', 'sfrac', 'totalLen', 'NUx', 'NUy', 'NUz', 'elemX', 'elemY', 'elemTanX', 'elemTanY', 'elemLen', 'EUpx', 'EUpy', 'EUpz', ]; const SOLVE_KEYS = [ 'side_length_m', 'gap_m', 'rod_diameter_m', 'density', 'youngs_modulus', 'poisson_ratio', 'stiffness_trim', 'n_elements', 'min_mode_hz', 'max_mode_hz', 'loss_factor', 'loss_slope', 'radiation_loss', 'speed_of_sound', 'air_density', ]; const RADIATE_KEYS = [ 'beater_radius_m', 'beater_mass_kg', 'beater_youngs', 'restitution', 'strike_velocity', 'strike_pos', 'strike_angle_deg', 'strike_count', 'strike_rate_hz', 'roll_pos2', 'timing_jitter_ms', 'velocity_spread', 'seed', 'direct_gain', 'listener_x_m', 'listener_y_m', 'listener_z_m', 'sample_rate', 'tail_s', ]; interface SolveEntry { vars: Record; modeF: Float32Array; modeZeta: Float32Array; modeZetaRad: Float32Array; modeFamily: Float32Array; nodeX: Float32Array; nodeY: Float32Array; nodeS: Float32Array; shapeUx: Float32Array; shapeUy: Float32Array; shapeUz: Float32Array; modesIn: number; modesOut: number; } interface RadiateEntry { p: RuntimeValue; strikeT: Float32Array; strikeV: Float32Array; strikeS: Float32Array; strikeCount: number; meanV: number; maxV: number; meanTauUs: number; peakForceN: number; durationS: number; directPeakPa: number; listenerDistM: number; listenerInside: boolean; fsHi: number; oversample: number; } // Caches, all keyed under the code hash so editing the .m sources // invalidates everything computed from the previous version. The explorer // caches are small; the note-solve cache holds one FEM eigensolve per // pitch-scaled frame, which is what lets a track reuse the expensive stage // across velocities and repeats. const solveCache = new Map(); const radiateCache = new Map(); const SOLVE_CACHE_MAX = 2; const RADIATE_CACHE_MAX = 3; interface NoteSolveEntry { vars: Record } const noteSolveCache = new Map(); const NOTE_SOLVE_CACHE_MAX = 16; // 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('triangle-instrument-notes'); // --- the explorer path (full params, staged, with diagnostics) --- async function renderExplore( req: TrackRenderRequest, env: RenderEnv, ): Promise<{ audio: Float32Array; diag: TriangleExploreDiag }> { const params = req.params ?? {}; const mfiles = env.mfiles; let quality = req.quality; const codeKey = hashMFiles(mfiles); const solveKey = 'x|' + codeKey + '|' + keyFor(params, SOLVE_KEYS); const fullOversample = Number(params.oversample ?? 2); const radiateParamsKey = solveKey + '||' + keyFor(params, RADIATE_KEYS); if (quality === 'preview' && radiateCache.has(radiateParamsKey + `|os=${fullOversample}`)) { quality = 'full'; // full-rate radiation already cached } const oversample = quality === 'preview' ? Math.min(1, fullOversample) : fullOversample; const sampleRate = Number(params.sample_rate ?? req.sampleRate); const fsHi = sampleRate * oversample; const base = `P = triangle_defaults();\n${pLiteral(params)}`; // ---- solve stage: FEM assembly and eigensolve ---- let solveMs = 0; let solveCached = true; let solveEntry = lruGet(solveCache, solveKey); if (!solveEntry) { solveCached = false; env.post(0.15, 'solve'); await env.yieldToHost(); env.checkCancel(); const t0 = performance.now(); const r = runStage(mfiles, ` ${base} basis = triangle_basis(P); ${BASIS_FIELDS.map((f) => `b_${f} = basis.${f};`).join('\n')} modesIn = sum(basis.family == 0); modesOut = sum(basis.family == 1); `); solveMs = performance.now() - t0; const vars: Record = {}; for (const f of BASIS_FIELDS) vars[`b_${f}`] = r.variableValues[`b_${f}`]; solveEntry = { vars, modeF: toF32(r.variableValues.b_f), modeZeta: toF32(r.variableValues.b_zeta), modeZetaRad: toF32(r.variableValues.b_zetaRad), modeFamily: toF32(r.variableValues.b_family), nodeX: toF32(r.variableValues.b_nodeX), nodeY: toF32(r.variableValues.b_nodeY), nodeS: toF32(r.variableValues.b_sfrac), shapeUx: toF32(r.variableValues.b_NUx), shapeUy: toF32(r.variableValues.b_NUy), shapeUz: toF32(r.variableValues.b_NUz), modesIn: asScalar(r.variableValues.modesIn), modesOut: asScalar(r.variableValues.modesOut), }; lruSet(solveCache, solveKey, solveEntry, SOLVE_CACHE_MAX); } // ---- radiate stage: strike process + modal synthesis ---- const radiateKey = radiateParamsKey + `|os=${oversample}`; let radiateMs = 0; let radiateCached = true; let radEntry = lruGet(radiateCache, radiateKey); if (!radEntry) { radiateCached = false; env.post(0.55, 'radiate'); await env.yieldToHost(); env.checkCancel(); const t0 = performance.now(); const r = runStage(mfiles, ` ${base} ${BASIS_FIELDS.map((f) => `basis.${f} = b_${f};`).join('\n')} basis.n = length(basis.f); [tstr, vstr, jstr, taustr, fstr, sstr, sinfo] = triangle_strikes(P); [p, radinfo] = triangle_radiate(P, basis, tstr, vstr, jstr, taustr, fstr, sstr, ${fsHi}); strikeCount = sinfo.count; meanV = sinfo.meanV; maxV = sinfo.maxV; meanTauUs = sinfo.meanTauUs; peakForceN = sinfo.peakForceN; durationS = sinfo.durationS; directPeakPa = radinfo.directPeakPa; listenerDistM = radinfo.listenerDistM; listenerInside = radinfo.listenerInside; `, solveEntry.vars); radiateMs = performance.now() - t0; radEntry = { p: r.variableValues.p, strikeT: toF32(r.variableValues.tstr), strikeV: toF32(r.variableValues.vstr), strikeS: toF32(r.variableValues.sstr), strikeCount: asScalar(r.variableValues.strikeCount), meanV: asScalar(r.variableValues.meanV), maxV: asScalar(r.variableValues.maxV), meanTauUs: asScalar(r.variableValues.meanTauUs), peakForceN: asScalar(r.variableValues.peakForceN), durationS: asScalar(r.variableValues.durationS), directPeakPa: asScalar(r.variableValues.directPeakPa), listenerDistM: asScalar(r.variableValues.listenerDistM), listenerInside: asScalar(r.variableValues.listenerInside) !== 0, fsHi, oversample, }; lruSet(radiateCache, radiateKey, radEntry, RADIATE_CACHE_MAX); } // ---- output stage ---- env.post(0.9, 'output'); await env.yieldToHost(); env.checkCancel(); const rOut = runStage(mfiles, ` ${base} [y, peakPa] = triangle_output(P, p, ${radEntry.oversample}); `, { p: radEntry.p }); const y32 = toF32(rOut.variableValues.y); const peakPa = asScalar(rOut.variableValues.peakPa); const warnings: string[] = []; if (radEntry.listenerInside) { warnings.push('the listener is within 2 cm of the rod; the dipole field is evaluated at a clamped minimum distance'); } 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) { warnings.push(`clipping: peak is ${peakPa.toFixed(2)} Pa but full_scale_pa is ${Number(params.full_scale_pa ?? 0.2).toFixed(2)}`); } } const topMode = solveEntry.modeF[solveEntry.modeF.length - 1]; const maxModeHz = Number(params.max_mode_hz ?? 0); if (maxModeHz > 0 && topMode < 0.4 * maxModeHz) { warnings.push(`the highest retained mode is only ${topMode.toFixed(0)} Hz; the shimmer above it falls entirely on the direct click`); } const diag: TriangleExploreDiag = { quality, strikeT: radEntry.strikeT.slice(), strikeV: radEntry.strikeV.slice(), strikeS: radEntry.strikeS.slice(), modeF: solveEntry.modeF.slice(), modeZeta: solveEntry.modeZeta.slice(), modeZetaRad: solveEntry.modeZetaRad.slice(), modeFamily: solveEntry.modeFamily.slice(), nodeX: solveEntry.nodeX.slice(), nodeY: solveEntry.nodeY.slice(), nodeS: solveEntry.nodeS.slice(), shapeUx: solveEntry.shapeUx.slice(), shapeUy: solveEntry.shapeUy.slice(), shapeUz: solveEntry.shapeUz.slice(), diag: { modesKept: solveEntry.modeF.length, modesIn: solveEntry.modesIn, modesOut: solveEntry.modesOut, strikeCount: radEntry.strikeCount, meanV: radEntry.meanV, maxV: radEntry.maxV, meanTauUs: radEntry.meanTauUs, peakForceN: radEntry.peakForceN, durationS: radEntry.durationS, peakPa, peakSplDb: 20 * Math.log10(peakPa / 20e-6 + 1e-30), directPeakPa: radEntry.directPeakPa, listenerDistM: radEntry.listenerDistM, warnings, solveMs, radiateMs, solveCached, radiateCached, oversample: radEntry.oversample, }, }; return { audio: y32, diag }; } // --- the track path (midiboot): per-strike rendering with caching --- function notePLines(note: { pitch: number; velocity: number }, req: TrackRenderRequest): string { // The note-to-parameter mapping: MIDI pitch scales the rod frame, side // length, gap, and rod diameter by s = 2^-((pitch - 84)/12), which // shifts the bending-mode spectrum by the semitone ratio exactly (rod // modes go as diameter / length^2). The beater stays fixed. Pitch 84 is // the default 6-inch triangle, playable 72..96; the spectrum is // inharmonic, so the note number is a handle on register, not a tuned // fundamental. Velocity drives the beater speed. Fixed: a single // strike, a fixed seed, normalize off so velocity survives in pascals. const settings = req.settings ?? {}; const s = Math.pow(2, -(Math.max(72, Math.min(96, Math.round(note.pitch))) - 84) / 12); const lines = [ 'P = triangle_defaults();', `P.sample_rate = ${req.sampleRate};`, 'P.strike_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.side_length_m = P.side_length_m * ${s};`, `P.gap_m = P.gap_m * ${s};`, `P.rod_diameter_m = P.rod_diameter_m * ${s};`, `P.strike_velocity = ${0.1 + (6 - 0.1) * note.velocity};`, `P.strike_pos = ${knobValue(settings, 'strike_pos')};`, `P.strike_angle_deg = ${knobValue(settings, 'strike_angle_deg')};`, `P.tail_s = ${knobValue(settings, 'tail_s')};`, ); return lines.join('\n'); } async function renderNoteAudio( note: { pitch: number; velocity: number }, req: TrackRenderRequest, env: RenderEnv, codeKey: string, progress: (frac: number, stage: string) => void, ): Promise { const pLines = notePLines(note, req); // solve: one FEM eigensolve per pitch-scaled frame, shared across // velocities and knob values (the knobs are all radiate-tier // parameters). const pitch = Math.max(72, Math.min(96, Math.round(note.pitch))); const solveKey = `n|${codeKey}|${pitch}`; let entry = lruGet(noteSolveCache, solveKey); if (!entry) { progress(0.1, 'solve'); await env.yieldToHost(); env.checkCancel(); const r = runStage(env.mfiles, ` ${pLines} basis = triangle_basis(P); ${BASIS_FIELDS.map((f) => `b_${f} = basis.${f};`).join('\n')} `); const vars: Record = {}; for (const f of BASIS_FIELDS) vars[`b_${f}`] = r.variableValues[`b_${f}`]; entry = { vars }; lruSet(noteSolveCache, solveKey, entry, NOTE_SOLVE_CACHE_MAX); } progress(0.5, 'radiate'); await env.yieldToHost(); env.checkCancel(); const r = runStage(env.mfiles, ` ${pLines} ${BASIS_FIELDS.map((f) => `basis.${f} = b_${f};`).join('\n')} basis.n = length(basis.f); [tstr, vstr, jstr, taustr, fstr, sstr, sinfo] = triangle_strikes(P); fsHi = P.sample_rate * P.oversample; [p, radinfo] = triangle_radiate(P, basis, tstr, vstr, jstr, taustr, fstr, sstr, fsHi); [y, peakPa] = triangle_output(P, p, P.oversample); `, entry.vars); return toF32(r.variableValues.y); } 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 = TRIANGLE_KNOBS.map((k) => `${k.key}=${knobValue(settings, k.key)}`).join(','); // Group notes by everything that affects their sound. A strike ignores // note duration (ring-out is the tail_s knob), so a repeated pattern // costs one render per distinct pitch and velocity level. const groups = new Map(); for (const n of req.notes) { const pitch = Math.round(n.pitch); const vel = bucketVelocity(n.velocity); const key = `${codeKey}|${req.quality}|${req.sampleRate}|${knobsKey}|${pitch}|${vel}`; let g = groups.get(key); if (!g) groups.set(key, (g = { pitch, velocity: vel, 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) { const i = done; buf = await renderNoteAudio(g, req, env, codeKey, (frac, stage) => env.post((i + frac) / Math.max(1, misses), `note ${i + 1}/${misses}: ${stage}`)); 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-strike track path otherwise. */ export async function renderRequest( variant: TriangleVariant, req: TrackRenderRequest, env: RenderEnv, ): Promise<{ audio: Float32Array; diag?: TriangleExploreDiag }> { void variant; if (req.wantDiagnostics) return renderExplore(req, env); return renderTrackNotes(req, env); }