4c0259bAdd instrument/: the triangle behind the track-render interfaceJeremy Magland 1// The triangle as a track-render instrument: pure rendering logic with no
2// DOM or Worker dependency, shared by the browser worker (worker.ts) and
3// the node tests (test.ts). Two request shapes arrive here:
4//
5// - the track path (midiboot): every note of a MIDI track. Notes are
6// grouped by cache key (pitch and bucketed velocity; a strike ignores
7// note duration, ring-out is the tail_s knob), each distinct sound is
8// rendered once through the staged pipeline, and copies are summed at
9// the onsets. The FEM eigensolve depends only on the pitch-scaled
10// geometry, so it is cached per pitch and shared across velocities,
11// knob values, and repeats.
12//
13// - the explorer path (the triangle web app, wantDiagnostics set): the
14// full parameter surface and live-edited .m sources drive one staged
15// render with the diagnostics payload the app's plots consume, exactly
16// the arrangement of the app's former render worker.
17//
18// Stage intermediates cross executeCode calls as numbl RuntimeValues
19// (column-major Float64).
21import { executeCode, type RuntimeValue, type RuntimeTensor } from 'numbl';
22import type { MFile, TrackRenderRequest } from './types.ts';
23import { TRIANGLE_KNOBS, type TriangleVariant } from './knobs.ts';
24import { NoteCache } from './noteCache.ts';
26export { TRIANGLE_KNOBS, type TriangleVariant };
28/** The environment the host provides: sources, progress, cancellation, and
29 * a macrotask yield so a browser worker can receive a cancel message. */
30export interface RenderEnv {
31 mfiles: MFile[];
32 post: (frac: number, stage: string) => void;
33 /** Throws to abandon the render; called between stages and notes. */
34 checkCancel: () => void;
35 yieldToHost: () => Promise<void>;
36}
38// --- diagnostics payload for the explorer path (the web app's plots) ---
40export interface TriangleExploreDiag {
41 quality: 'preview' | 'full';
42 strikeT: Float32Array;
43 strikeV: Float32Array;
44 strikeS: Float32Array;
45 modeF: Float32Array;
46 modeZeta: Float32Array;
47 modeZetaRad: Float32Array;
48 modeFamily: Float32Array;
49 nodeX: Float32Array;
50 nodeY: Float32Array;
51 nodeS: Float32Array;
52 shapeUx: Float32Array;
53 shapeUy: Float32Array;
54 shapeUz: Float32Array;
55 diag: {
56 modesKept: number;
57 modesIn: number;
58 modesOut: number;
59 strikeCount: number;
60 meanV: number;
61 maxV: number;
62 meanTauUs: number;
63 peakForceN: number;
64 durationS: number;
65 peakPa: number;
66 peakSplDb: number;
67 directPeakPa: number;
68 listenerDistM: number;
69 warnings: string[];
70 solveMs: number;
71 radiateMs: number;
72 solveCached: boolean;
73 radiateCached: boolean;
74 oversample: number;
75 };
76}
78// --- shared helpers ---
80export function hashMFiles(files: MFile[]): string {
81 let h = 0x811c9dc5;
82 for (const f of files) {
83 const s = f.name + ' ' + f.source;
84 for (let i = 0; i < s.length; i++) {
85 h ^= s.charCodeAt(i);
86 h = Math.imul(h, 0x01000193);
87 }
88 }
89 return (h >>> 0).toString(36);
90}
92function lruGet<T>(cache: Map<string, T>, key: string): T | undefined {
93 const v = cache.get(key);
94 if (v !== undefined) {
95 cache.delete(key);
96 cache.set(key, v);
97 }
98 return v;
99}
101function lruSet<T>(cache: Map<string, T>, key: string, value: T, max: number): void {
102 cache.delete(key);
103 cache.set(key, value);
104 while (cache.size > max) {
105 const oldest = cache.keys().next().value as string;
106 cache.delete(oldest);
107 }
108}
110type Params = Record<string, number | boolean>;
112function pLiteral(params: Params): string {
113 const lines: string[] = [];
114 for (const [k, v] of Object.entries(params)) {
115 if (typeof v === 'number') {
116 if (!Number.isFinite(v)) throw new Error(`parameter ${k} is not finite`);
117 lines.push(`P.${k} = ${v};`);
118 } else if (typeof v === 'boolean') lines.push(`P.${k} = ${v ? 1 : 0};`);
119 }
120 return lines.join('\n');
121}
123function keyFor(params: Params, keys: readonly string[]): string {
124 return keys.map((k) => String(params[k] ?? 'd')).join('|');
125}
127function runStage(mfiles: MFile[], source: string, inputs?: Record<string, RuntimeValue>) {
128 return executeCode(source, { initialVariableValues: inputs, displayResults: false }, mfiles, 'stage.m');
129}
131function asTensor(v: RuntimeValue): RuntimeTensor {
132 const t = v as RuntimeTensor;
133 if (!t || t.kind !== 'tensor') throw new Error('expected tensor result from numbl stage');
134 return t;
135}
137function asScalar(v: RuntimeValue): number {
138 if (typeof v === 'number') return v;
139 const t = v as RuntimeTensor;
140 if (t && t.kind === 'tensor' && t.data.length === 1) return t.data[0];
141 if (typeof v === 'boolean') return v ? 1 : 0;
142 throw new Error('expected scalar result from numbl stage');
143}
145function toF32(v: RuntimeValue): Float32Array {
146 const t = asTensor(v);
147 const out = new Float32Array(t.data.length);
148 for (let i = 0; i < t.data.length; i++) out[i] = t.data[i];
149 return out;
150}
152function knobValue(settings: Record<string, number>, key: string): number {
153 const spec = TRIANGLE_KNOBS.find((k) => k.key === key)!;
154 const raw = settings[key];
155 return raw === undefined ? spec.default : Math.max(spec.min, Math.min(spec.max, raw));
156}
158/** Velocity quantized to a few levels so identical strikes share a cached
159 * render; 8 levels is inaudible next to the model's own dynamics. */
160export function bucketVelocity(velocity01: number, levels = 8): number {
161 return Math.ceil(Math.max(0.001, Math.min(1, velocity01)) * levels) / levels;
162}
164// The basis fields handed from the solve stage into the radiate stage, in
165// the order the reassembly script expects.
166const BASIS_FIELDS = [
167 'f', 'omega', 'zeta', 'zetaRad', 'family',
168 'nodeX', 'nodeY', 'sfrac', 'totalLen',
169 'NUx', 'NUy', 'NUz',
170 'elemX', 'elemY', 'elemTanX', 'elemTanY', 'elemLen',
171 'EUpx', 'EUpy', 'EUpz',
172];
174const SOLVE_KEYS = [
175 'side_length_m', 'gap_m', 'rod_diameter_m',
176 'density', 'youngs_modulus', 'poisson_ratio', 'stiffness_trim',
177 'n_elements', 'min_mode_hz', 'max_mode_hz',
178 'loss_factor', 'loss_slope', 'radiation_loss',
179 'speed_of_sound', 'air_density',
180];
182const RADIATE_KEYS = [
183 'beater_radius_m', 'beater_mass_kg', 'beater_youngs', 'restitution',
184 'strike_velocity', 'strike_pos', 'strike_angle_deg',
185 'strike_count', 'strike_rate_hz', 'roll_pos2',
186 'timing_jitter_ms', 'velocity_spread', 'seed',
187 'direct_gain', 'listener_x_m', 'listener_y_m', 'listener_z_m',
188 'sample_rate', 'tail_s',
189];
191interface SolveEntry {
192 vars: Record<string, RuntimeValue>;
193 modeF: Float32Array;
194 modeZeta: Float32Array;
195 modeZetaRad: Float32Array;
196 modeFamily: Float32Array;
197 nodeX: Float32Array;
198 nodeY: Float32Array;
199 nodeS: Float32Array;
200 shapeUx: Float32Array;
201 shapeUy: Float32Array;
202 shapeUz: Float32Array;
203 modesIn: number;
204 modesOut: number;
205}
207interface RadiateEntry {
208 p: RuntimeValue;
209 strikeT: Float32Array;
210 strikeV: Float32Array;
211 strikeS: Float32Array;
212 strikeCount: number;
213 meanV: number;
214 maxV: number;
215 meanTauUs: number;
216 peakForceN: number;
217 durationS: number;
218 directPeakPa: number;
219 listenerDistM: number;
220 listenerInside: boolean;
221 fsHi: number;
222 oversample: number;
223}
225// Caches, all keyed under the code hash so editing the .m sources
226// invalidates everything computed from the previous version. The explorer
227// caches are small; the note-solve cache holds one FEM eigensolve per
228// pitch-scaled frame, which is what lets a track reuse the expensive stage
229// across velocities and repeats.
230const solveCache = new Map<string, SolveEntry>();
231const radiateCache = new Map<string, RadiateEntry>();
232const SOLVE_CACHE_MAX = 2;
233const RADIATE_CACHE_MAX = 3;
234interface NoteSolveEntry { vars: Record<string, RuntimeValue> }
235const noteSolveCache = new Map<string, NoteSolveEntry>();
236const NOTE_SOLVE_CACHE_MAX = 16;
238// Finished per-note audio: an in-memory LRU in front of an IndexedDB
239// store, so renders survive page reloads (budgets and eviction live in
240// noteCache.ts).
241const noteCache = new NoteCache('triangle-instrument-notes');
243// --- the explorer path (full params, staged, with diagnostics) ---
245async function renderExplore(
246 req: TrackRenderRequest,
247 env: RenderEnv,
248): Promise<{ audio: Float32Array; diag: TriangleExploreDiag }> {
249 const params = req.params ?? {};
250 const mfiles = env.mfiles;
251 let quality = req.quality;
253 const codeKey = hashMFiles(mfiles);
254 const solveKey = 'x|' + codeKey + '|' + keyFor(params, SOLVE_KEYS);
255 const fullOversample = Number(params.oversample ?? 2);
256 const radiateParamsKey = solveKey + '||' + keyFor(params, RADIATE_KEYS);
257 if (quality === 'preview' && radiateCache.has(radiateParamsKey + `|os=${fullOversample}`)) {
258 quality = 'full'; // full-rate radiation already cached
259 }
260 const oversample = quality === 'preview' ? Math.min(1, fullOversample) : fullOversample;
261 const sampleRate = Number(params.sample_rate ?? req.sampleRate);
262 const fsHi = sampleRate * oversample;
264 const base = `P = triangle_defaults();\n${pLiteral(params)}`;
266 // ---- solve stage: FEM assembly and eigensolve ----
267 let solveMs = 0;
268 let solveCached = true;
269 let solveEntry = lruGet(solveCache, solveKey);
270 if (!solveEntry) {
271 solveCached = false;
272 env.post(0.15, 'solve');
273 await env.yieldToHost();
274 env.checkCancel();
275 const t0 = performance.now();
276 const r = runStage(mfiles, `
277${base}
278basis = triangle_basis(P);
279${BASIS_FIELDS.map((f) => `b_${f} = basis.${f};`).join('\n')}
280modesIn = sum(basis.family == 0);
281modesOut = sum(basis.family == 1);
282`);
283 solveMs = performance.now() - t0;
285 const vars: Record<string, RuntimeValue> = {};
286 for (const f of BASIS_FIELDS) vars[`b_${f}`] = r.variableValues[`b_${f}`];
287 solveEntry = {
288 vars,
289 modeF: toF32(r.variableValues.b_f),
290 modeZeta: toF32(r.variableValues.b_zeta),
291 modeZetaRad: toF32(r.variableValues.b_zetaRad),
292 modeFamily: toF32(r.variableValues.b_family),
293 nodeX: toF32(r.variableValues.b_nodeX),
294 nodeY: toF32(r.variableValues.b_nodeY),
295 nodeS: toF32(r.variableValues.b_sfrac),
296 shapeUx: toF32(r.variableValues.b_NUx),
297 shapeUy: toF32(r.variableValues.b_NUy),
298 shapeUz: toF32(r.variableValues.b_NUz),
299 modesIn: asScalar(r.variableValues.modesIn),
300 modesOut: asScalar(r.variableValues.modesOut),
301 };
302 lruSet(solveCache, solveKey, solveEntry, SOLVE_CACHE_MAX);
303 }
305 // ---- radiate stage: strike process + modal synthesis ----
306 const radiateKey = radiateParamsKey + `|os=${oversample}`;
307 let radiateMs = 0;
308 let radiateCached = true;
309 let radEntry = lruGet(radiateCache, radiateKey);
310 if (!radEntry) {
311 radiateCached = false;
312 env.post(0.55, 'radiate');
313 await env.yieldToHost();
314 env.checkCancel();
315 const t0 = performance.now();
316 const r = runStage(mfiles, `
317${base}
318${BASIS_FIELDS.map((f) => `basis.${f} = b_${f};`).join('\n')}
319basis.n = length(basis.f);
320[tstr, vstr, jstr, taustr, fstr, sstr, sinfo] = triangle_strikes(P);
321[p, radinfo] = triangle_radiate(P, basis, tstr, vstr, jstr, taustr, fstr, sstr, ${fsHi});
322strikeCount = sinfo.count;
323meanV = sinfo.meanV;
324maxV = sinfo.maxV;
325meanTauUs = sinfo.meanTauUs;
326peakForceN = sinfo.peakForceN;
327durationS = sinfo.durationS;
328directPeakPa = radinfo.directPeakPa;
329listenerDistM = radinfo.listenerDistM;
330listenerInside = radinfo.listenerInside;
331`, solveEntry.vars);
332 radiateMs = performance.now() - t0;
333 radEntry = {
334 p: r.variableValues.p,
335 strikeT: toF32(r.variableValues.tstr),
336 strikeV: toF32(r.variableValues.vstr),
337 strikeS: toF32(r.variableValues.sstr),
338 strikeCount: asScalar(r.variableValues.strikeCount),
339 meanV: asScalar(r.variableValues.meanV),
340 maxV: asScalar(r.variableValues.maxV),
341 meanTauUs: asScalar(r.variableValues.meanTauUs),
342 peakForceN: asScalar(r.variableValues.peakForceN),
343 durationS: asScalar(r.variableValues.durationS),
344 directPeakPa: asScalar(r.variableValues.directPeakPa),
345 listenerDistM: asScalar(r.variableValues.listenerDistM),
346 listenerInside: asScalar(r.variableValues.listenerInside) !== 0,
347 fsHi,
348 oversample,
349 };
350 lruSet(radiateCache, radiateKey, radEntry, RADIATE_CACHE_MAX);
351 }
353 // ---- output stage ----
354 env.post(0.9, 'output');
355 await env.yieldToHost();
356 env.checkCancel();
357 const rOut = runStage(mfiles, `
358${base}
359[y, peakPa] = triangle_output(P, p, ${radEntry.oversample});
360`, { p: radEntry.p });
362 const y32 = toF32(rOut.variableValues.y);
363 const peakPa = asScalar(rOut.variableValues.peakPa);
365 const warnings: string[] = [];
366 if (radEntry.listenerInside) {
367 warnings.push('the listener is within 2 cm of the rod; the dipole field is evaluated at a clamped minimum distance');
368 }
369 if (!params.normalize) {
370 let peak = 0;
371 for (let i = 0; i < y32.length; i++) peak = Math.max(peak, Math.abs(y32[i]));
372 if (peak > 1.0) {
373 warnings.push(`clipping: peak is ${peakPa.toFixed(2)} Pa but full_scale_pa is ${Number(params.full_scale_pa ?? 0.2).toFixed(2)}`);
374 }
375 }
376 const topMode = solveEntry.modeF[solveEntry.modeF.length - 1];
377 const maxModeHz = Number(params.max_mode_hz ?? 0);
378 if (maxModeHz > 0 && topMode < 0.4 * maxModeHz) {
379 warnings.push(`the highest retained mode is only ${topMode.toFixed(0)} Hz; the shimmer above it falls entirely on the direct click`);
380 }
382 const diag: TriangleExploreDiag = {
383 quality,
384 strikeT: radEntry.strikeT.slice(),
385 strikeV: radEntry.strikeV.slice(),
386 strikeS: radEntry.strikeS.slice(),
387 modeF: solveEntry.modeF.slice(),
388 modeZeta: solveEntry.modeZeta.slice(),
389 modeZetaRad: solveEntry.modeZetaRad.slice(),
390 modeFamily: solveEntry.modeFamily.slice(),
391 nodeX: solveEntry.nodeX.slice(),
392 nodeY: solveEntry.nodeY.slice(),
393 nodeS: solveEntry.nodeS.slice(),
394 shapeUx: solveEntry.shapeUx.slice(),
395 shapeUy: solveEntry.shapeUy.slice(),
396 shapeUz: solveEntry.shapeUz.slice(),
397 diag: {
398 modesKept: solveEntry.modeF.length,
399 modesIn: solveEntry.modesIn,
400 modesOut: solveEntry.modesOut,
401 strikeCount: radEntry.strikeCount,
402 meanV: radEntry.meanV,
403 maxV: radEntry.maxV,
404 meanTauUs: radEntry.meanTauUs,
405 peakForceN: radEntry.peakForceN,
406 durationS: radEntry.durationS,
407 peakPa,
408 peakSplDb: 20 * Math.log10(peakPa / 20e-6 + 1e-30),
409 directPeakPa: radEntry.directPeakPa,
410 listenerDistM: radEntry.listenerDistM,
411 warnings,
412 solveMs,
413 radiateMs,
414 solveCached,
415 radiateCached,
416 oversample: radEntry.oversample,
417 },
418 };
419 return { audio: y32, diag };
420}
422// --- the track path (midiboot): per-strike rendering with caching ---
424function notePLines(note: { pitch: number; velocity: number }, req: TrackRenderRequest): string {
425 // The note-to-parameter mapping: MIDI pitch scales the rod frame, side
426 // length, gap, and rod diameter by s = 2^-((pitch - 84)/12), which
427 // shifts the bending-mode spectrum by the semitone ratio exactly (rod
428 // modes go as diameter / length^2). The beater stays fixed. Pitch 84 is
429 // the default 6-inch triangle, playable 72..96; the spectrum is
430 // inharmonic, so the note number is a handle on register, not a tuned
431 // fundamental. Velocity drives the beater speed. Fixed: a single
432 // strike, a fixed seed, normalize off so velocity survives in pascals.
433 const settings = req.settings ?? {};
434 const s = Math.pow(2, -(Math.max(72, Math.min(96, Math.round(note.pitch))) - 84) / 12);
435 const lines = [
436 'P = triangle_defaults();',
437 `P.sample_rate = ${req.sampleRate};`,
438 'P.strike_count = 1;',
439 'P.seed = 1;',
440 'P.pre_delay_ms = 0;',
441 'P.normalize = 0;',
442 ];
443 if (req.quality === 'preview') lines.push('P.oversample = 1;');
444 lines.push(
445 `P.side_length_m = P.side_length_m * ${s};`,
446 `P.gap_m = P.gap_m * ${s};`,
447 `P.rod_diameter_m = P.rod_diameter_m * ${s};`,
448 `P.strike_velocity = ${0.1 + (6 - 0.1) * note.velocity};`,
449 `P.strike_pos = ${knobValue(settings, 'strike_pos')};`,
450 `P.strike_angle_deg = ${knobValue(settings, 'strike_angle_deg')};`,
451 `P.tail_s = ${knobValue(settings, 'tail_s')};`,
452 );
453 return lines.join('\n');
454}
456async function renderNoteAudio(
457 note: { pitch: number; velocity: number },
458 req: TrackRenderRequest,
459 env: RenderEnv,
460 codeKey: string,
461 progress: (frac: number, stage: string) => void,
462): Promise<Float32Array> {
463 const pLines = notePLines(note, req);
465 // solve: one FEM eigensolve per pitch-scaled frame, shared across
466 // velocities and knob values (the knobs are all radiate-tier
467 // parameters).
468 const pitch = Math.max(72, Math.min(96, Math.round(note.pitch)));
469 const solveKey = `n|${codeKey}|${pitch}`;
470 let entry = lruGet(noteSolveCache, solveKey);
471 if (!entry) {
472 progress(0.1, 'solve');
473 await env.yieldToHost();
474 env.checkCancel();
475 const r = runStage(env.mfiles, `
476${pLines}
477basis = triangle_basis(P);
478${BASIS_FIELDS.map((f) => `b_${f} = basis.${f};`).join('\n')}
479`);
480 const vars: Record<string, RuntimeValue> = {};
481 for (const f of BASIS_FIELDS) vars[`b_${f}`] = r.variableValues[`b_${f}`];
482 entry = { vars };
483 lruSet(noteSolveCache, solveKey, entry, NOTE_SOLVE_CACHE_MAX);
484 }
486 progress(0.5, 'radiate');
487 await env.yieldToHost();
488 env.checkCancel();
489 const r = runStage(env.mfiles, `
490${pLines}
491${BASIS_FIELDS.map((f) => `basis.${f} = b_${f};`).join('\n')}
492basis.n = length(basis.f);
493[tstr, vstr, jstr, taustr, fstr, sstr, sinfo] = triangle_strikes(P);
494fsHi = P.sample_rate * P.oversample;
495[p, radinfo] = triangle_radiate(P, basis, tstr, vstr, jstr, taustr, fstr, sstr, fsHi);
496[y, peakPa] = triangle_output(P, p, P.oversample);
497`, entry.vars);
498 return toF32(r.variableValues.y);
499}
501async function renderTrackNotes(
502 req: TrackRenderRequest,
503 env: RenderEnv,
504): Promise<{ audio: Float32Array }> {
505 if (req.notes.length === 0) return { audio: new Float32Array(req.lengthSamples) };
506 const codeKey = hashMFiles(env.mfiles);
507 const settings = req.settings ?? {};
508 const knobsKey = TRIANGLE_KNOBS.map((k) => `${k.key}=${knobValue(settings, k.key)}`).join(',');
510 // Group notes by everything that affects their sound. A strike ignores
511 // note duration (ring-out is the tail_s knob), so a repeated pattern
512 // costs one render per distinct pitch and velocity level.
513 const groups = new Map<string, { pitch: number; velocity: number; onsets: number[] }>();
514 for (const n of req.notes) {
515 const pitch = Math.round(n.pitch);
516 const vel = bucketVelocity(n.velocity);
517 const key = `${codeKey}|${req.quality}|${req.sampleRate}|${knobsKey}|${pitch}|${vel}`;
518 let g = groups.get(key);
519 if (!g) groups.set(key, (g = { pitch, velocity: vel, onsets: [] }));
520 g.onsets.push(n.timeSec);
521 }
523 const placed: { buf: Float32Array; onsets: number[] }[] = [];
524 // Resolve cache hits first (memory, then IndexedDB), so the progress
525 // denominator counts only the sounds that actually need rendering.
526 const resolved = new Map<string, Float32Array>();
527 for (const key of groups.keys()) {
528 const hit = await noteCache.get(key);
529 if (hit) resolved.set(key, hit);
530 }
531 const misses = groups.size - resolved.size;
532 let done = 0;
533 for (const [key, g] of groups) {
534 let buf = resolved.get(key);
535 if (!buf) {
536 const i = done;
537 buf = await renderNoteAudio(g, req, env, codeKey, (frac, stage) =>
538 env.post((i + frac) / Math.max(1, misses), `note ${i + 1}/${misses}: ${stage}`));
539 noteCache.put(key, buf);
540 done++;
541 }
542 placed.push({ buf, onsets: g.onsets });
543 }
545 // lengthSamples is a minimum: size the output to the longest tail so a
546 // ring that outlives the last note is returned in full, and place every
547 // copy.
548 let length = req.lengthSamples;
549 for (const p of placed) {
550 for (const t of p.onsets) length = Math.max(length, Math.round(t * req.sampleRate) + p.buf.length);
551 }
552 const out = new Float32Array(length);
553 for (const p of placed) {
554 for (const t of p.onsets) {
555 const off = Math.round(t * req.sampleRate);
556 const end = Math.min(out.length, off + p.buf.length);
557 for (let j = Math.max(0, off); j < end; j++) out[j] += p.buf[j - off];
558 }
559 }
560 return { audio: out };
561}
563/** The single entry point: explorer path when diagnostics are requested,
564 * the per-strike track path otherwise. */
565export async function renderRequest(
566 variant: TriangleVariant,
567 req: TrackRenderRequest,
568 env: RenderEnv,
569): Promise<{ audio: Float32Array; diag?: TriangleExploreDiag }> {
570 void variant;
571 if (req.wantDiagnostics) return renderExplore(req, env);
572 return renderTrackNotes(req, env);
573}