1// The woodblock 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), each distinct strike renders once through
8// woodblock_render, and copies are summed at the onsets. MIDI pitch
9// scales the block geometry around pitch 76, exactly as the retired
10// per-note export driver did.
11//
12// - the explorer path (the woodblock web app, wantDiagnostics set): the
13// full parameter surface and live-edited .m sources drive one staged
14// render (solve -> radiate -> output, intermediates cached per tier)
15// with the diagnostics payload the app's plots consume.
17import { executeCode, type RuntimeValue, type RuntimeTensor } from 'numbl';
18import type { MFile, TrackRenderRequest } from './types.ts';
20import { WOODBLOCK_KNOBS, type WoodblockVariant } from './knobs.ts';
21import { NoteCache } from './noteCache.ts';
23export { WOODBLOCK_KNOBS, type WoodblockVariant };
25/** The environment the host provides: sources, progress, cancellation, and
26 * a macrotask yield so a browser worker can receive a cancel message. */
27export interface RenderEnv {
28 mfiles: MFile[];
29 post: (frac: number, stage: string) => void;
30 /** Throws to abandon the render; called between stages and notes. */
31 checkCancel: () => void;
32 yieldToHost: () => Promise<void>;
33}
35// --- diagnostics payload for the explorer path (the web app's plots) ---
37export interface WoodblockExploreDiag {
38 quality: 'preview' | 'full';
39 force: Float32Array; // contact force, first forceMs at fsHi
40 forceFsHz: number;
41 modeShapes: Float32Array; // n x shapePts, concatenated
42 shapePts: number;
43 diag: {
44 modesKept: number;
45 partialsHz: number[];
46 ratios: number[];
47 helmholtzHz: number;
48 contactMs: number;
49 peakForceN: number;
50 peakPa: number;
51 peakSplDb: number;
52 listenerDistM: number;
53 warnings: string[];
54 solveMs: number;
55 radiateMs: number;
56 solveCached: boolean;
57 radiateCached: boolean;
58 oversample: number;
59 elements: number;
60 };
61}
63// --- shared helpers ---
65export function hashMFiles(files: MFile[]): string {
66 let h = 0x811c9dc5;
67 for (const f of files) {
68 const s = f.name + ' ' + f.source;
69 for (let i = 0; i < s.length; i++) {
70 h ^= s.charCodeAt(i);
71 h = Math.imul(h, 0x01000193);
72 }
73 }
74 return (h >>> 0).toString(36);
75}
77function lruGet<T>(cache: Map<string, T>, key: string): T | undefined {
78 const v = cache.get(key);
79 if (v !== undefined) {
80 cache.delete(key);
81 cache.set(key, v);
82 }
83 return v;
84}
86function lruSet<T>(cache: Map<string, T>, key: string, value: T, max: number): void {
87 cache.delete(key);
88 cache.set(key, value);
89 while (cache.size > max) {
90 const oldest = cache.keys().next().value as string;
91 cache.delete(oldest);
92 }
93}
95type Params = Record<string, number | boolean>;
97function pLiteral(params: Params): string {
98 const lines: string[] = [];
99 for (const [k, v] of Object.entries(params)) {
100 if (typeof v === 'number') {
101 if (!Number.isFinite(v)) throw new Error(`parameter ${k} is not finite`);
102 lines.push(`P.${k} = ${v};`);
103 } else if (typeof v === 'boolean') lines.push(`P.${k} = ${v ? 1 : 0};`);
104 }
105 return lines.join('\n');
106}
108function keyFor(params: Params, keys: readonly string[]): string {
109 return keys.map((k) => String(params[k] ?? 'd')).join('|');
110}
112function runStage(mfiles: MFile[], source: string, inputs?: Record<string, RuntimeValue>) {
113 return executeCode(source, { initialVariableValues: inputs, displayResults: false }, mfiles, 'stage.m');
114}
116function asTensor(v: RuntimeValue): RuntimeTensor {
117 const t = v as RuntimeTensor;
118 if (!t || t.kind !== 'tensor') throw new Error('expected tensor result from numbl stage');
119 return t;
120}
122function asScalar(v: RuntimeValue): number {
123 if (typeof v === 'number') return v;
124 const t = v as RuntimeTensor;
125 if (t && t.kind === 'tensor' && t.data.length === 1) return t.data[0];
126 if (typeof v === 'boolean') return v ? 1 : 0;
127 throw new Error('expected scalar result from numbl stage');
128}
130function toF32(v: RuntimeValue): Float32Array {
131 const t = asTensor(v);
132 const out = new Float32Array(t.data.length);
133 for (let i = 0; i < t.data.length; i++) out[i] = t.data[i];
134 return out;
135}
137function knobValue(settings: Record<string, number>, key: string): number {
138 const spec = WOODBLOCK_KNOBS.find((k) => k.key === key)!;
139 const raw = settings[key];
140 return raw === undefined ? spec.default : Math.max(spec.min, Math.min(spec.max, raw));
141}
143/** Velocity quantized to a few levels so identical strikes share a cached
144 * render; 8 levels is inaudible next to the model's own dynamics. */
145export function bucketVelocity(velocity01: number, levels = 8): number {
146 return Math.ceil(Math.max(0.001, Math.min(1, velocity01)) * levels) / levels;
147}
149const SOLVE_KEYS = [
150 'length_m', 'width_m', 'wall_thickness_m',
151 'density', 'youngs_modulus', 'hollow_softening', 'participating_mass_kg',
152 'inharmonicity', 'n_modes', 'max_mode_hz',
153 'loss_factor', 'loss_slope',
154 'strike_position', 'mallet_mass', 'mallet_velocity', 'mallet_stiffness',
155 'contact_exponent', 'contact_damping',
156 'cavity_volume_m3', 'slit_area_m2', 'slit_length_m', 'cavity_damping',
157 'cavity_coupling', 'cavity_span_lo', 'cavity_span_hi',
158 'speed_of_sound', 'air_density', 'sample_rate', 'duration_s',
159];
161const RADIATE_KEYS = [
162 'block_height_m',
163 'listener_x_m', 'listener_y_m', 'listener_z_m',
164 'slit_x_m', 'slit_y_m', 'slit_z_m',
165];
167const SHAPE_PTS = 128;
168const FORCE_MS = 8;
170interface SolveEntry {
171 Qdd: RuntimeValue;
172 Xdd: RuntimeValue;
173 fsHi: number;
174 f: number[];
175 modeShapes: Float32Array;
176 force: Float32Array;
177 helmholtzHz: number;
178 contactMs: number;
179 peakForceN: number;
180}
182interface RadiateEntry {
183 p: RuntimeValue;
184 warnings: string[];
185}
187// Explorer-tier caches, keyed under the code hash so editing the .m sources
188// invalidates everything computed from the previous version.
189const solveCache = new Map<string, SolveEntry>();
190const radiateCache = new Map<string, RadiateEntry>();
191const SOLVE_CACHE_MAX = 2;
192const RADIATE_CACHE_MAX = 3;
194// Finished per-note audio: an in-memory LRU in front of an IndexedDB
195// store, so renders survive page reloads (budgets and eviction live in
196// noteCache.ts).
197const noteCache = new NoteCache('woodblock-instrument-notes');
199// --- the explorer path (full params, staged, with diagnostics) ---
201async function renderExplore(
202 req: TrackRenderRequest,
203 env: RenderEnv,
204): Promise<{ audio: Float32Array; diag: WoodblockExploreDiag }> {
205 const params = req.params ?? {};
206 const mfiles = env.mfiles;
207 let quality = req.quality;
209 const codeKey = hashMFiles(mfiles);
210 const solveParamsKey = 'x|' + codeKey + '|' + keyFor(params, SOLVE_KEYS);
211 const fullOversample = Number(params.oversample ?? 16);
212 const fullSolveKey = solveParamsKey + `|os=${fullOversample}`;
213 if (quality === 'preview' && solveCache.has(fullSolveKey)) {
214 quality = 'full'; // full solve already cached; only cheap stages remain
215 }
217 const oversample = quality === 'preview' ? Math.min(8, fullOversample) : fullOversample;
218 const fullElements = Number(params.radiating_elements ?? 24);
219 const elements = quality === 'preview' ? Math.min(24, fullElements) : fullElements;
220 const sampleRate = Number(params.sample_rate ?? req.sampleRate);
221 const fsHi = sampleRate * oversample;
223 const base = `P = woodblock_defaults();\n${pLiteral(params)}`;
225 // ---- solve stage ----
226 const solveKey = solveParamsKey + `|os=${oversample}`;
227 let solveMs = 0;
228 let solveCached = true;
229 let solveEntry = lruGet(solveCache, solveKey);
230 if (!solveEntry) {
231 solveCached = false;
232 env.post(0.15, 'solve');
233 await env.yieldToHost();
234 env.checkCancel();
235 const t0 = performance.now();
236 const r = runStage(mfiles, `
237${base}
238basis = woodblock_basis(P);
239[Qdd, Xdd, force, helmholtzHz, contactMs, peakForceN, maxRealLam] = woodblock_solve(P, basis, ${oversample});
240f = basis.f;
241shapes = basis.shapes(1:32:end, :);
242`);
243 solveMs = performance.now() - t0;
245 const fT = asTensor(r.variableValues.f);
246 const f = Array.from(fT.data);
247 const n = f.length;
249 // decimated mode shapes, normalized per mode for the viewer
250 const shapesT = asTensor(r.variableValues.shapes);
251 const rows = shapesT.shape[0];
252 const modeShapes = new Float32Array(n * SHAPE_PTS);
253 for (let m = 0; m < n; m++) {
254 let peak = 0;
255 for (let i = 0; i < rows; i++) peak = Math.max(peak, Math.abs(shapesT.data[m * rows + i]));
256 for (let i = 0; i < SHAPE_PTS; i++) {
257 const j = Math.round((i / (SHAPE_PTS - 1)) * (rows - 1));
258 modeShapes[m * SHAPE_PTS + i] = peak > 0 ? shapesT.data[m * rows + j] / peak : 0;
259 }
260 }
262 const forceT = asTensor(r.variableValues.force);
263 const forceLen = Math.min(forceT.data.length, Math.round((FORCE_MS / 1000) * fsHi));
264 const force = new Float32Array(forceLen);
265 for (let i = 0; i < forceLen; i++) force[i] = forceT.data[i];
267 solveEntry = {
268 Qdd: r.variableValues.Qdd,
269 Xdd: r.variableValues.Xdd,
270 fsHi,
271 f,
272 modeShapes,
273 force,
274 helmholtzHz: asScalar(r.variableValues.helmholtzHz),
275 contactMs: asScalar(r.variableValues.contactMs),
276 peakForceN: asScalar(r.variableValues.peakForceN),
277 };
278 lruSet(solveCache, solveKey, solveEntry, SOLVE_CACHE_MAX);
279 }
281 // ---- radiate stage ----
282 const radiateKey = solveKey + '||' + keyFor(params, RADIATE_KEYS) + `|M=${elements}`;
283 let radiateMs = 0;
284 let radiateCached = true;
285 let radEntry = lruGet(radiateCache, radiateKey);
286 if (!radEntry) {
287 radiateCached = false;
288 env.post(0.55, 'radiate');
289 await env.yieldToHost();
290 env.checkCancel();
291 const t0 = performance.now();
292 const r = runStage(mfiles, `
293${base}
294basis = woodblock_basis(P);
295[p, spacingNeed, listenerInside] = woodblock_radiate(P, basis, Qdd, Xdd, ${solveEntry.fsHi}, ${elements});
296`, { Qdd: solveEntry.Qdd, Xdd: solveEntry.Xdd });
297 radiateMs = performance.now() - t0;
299 const warnings: string[] = [];
300 const spacingNeed = asScalar(r.variableValues.spacingNeed);
301 if (spacingNeed > 0) {
302 const fMax = solveEntry.f[solveEntry.f.length - 1];
303 warnings.push(`element spacing is coarse at ${fMax.toFixed(0)} Hz; consider ${spacingNeed} radiating elements`);
304 }
305 if (asScalar(r.variableValues.listenerInside) !== 0) {
306 const d = Math.hypot(Number(params.listener_x_m ?? 0), Number(params.listener_y_m ?? 0), Number(params.listener_z_m ?? 0));
307 warnings.push(`listener is ${(d * 100).toFixed(0)} cm from the block centre, inside the source region; point-source elements are crude here`);
308 }
309 radEntry = { p: r.variableValues.p, warnings };
310 lruSet(radiateCache, radiateKey, radEntry, RADIATE_CACHE_MAX);
311 }
313 // ---- output stage ----
314 env.post(0.9, 'output');
315 await env.yieldToHost();
316 env.checkCancel();
317 const rOut = runStage(mfiles, `
318${base}
319[y, peakPa] = woodblock_output(P, p, ${oversample});
320`, { p: radEntry.p });
322 const y32 = toF32(rOut.variableValues.y);
323 const peakPa = asScalar(rOut.variableValues.peakPa);
325 const warnings = [...radEntry.warnings];
326 if (!params.normalize) {
327 let peak = 0;
328 for (let i = 0; i < y32.length; i++) peak = Math.max(peak, Math.abs(y32[i]));
329 if (peak > 1.0) {
330 warnings.push(`clipping: peak is ${peakPa.toFixed(2)} Pa but full_scale_pa is ${Number(params.full_scale_pa ?? 0.2).toFixed(2)}`);
331 }
332 }
333 const lx = Number(params.listener_x_m ?? 0), ly = Number(params.listener_y_m ?? 0), lz = Number(params.listener_z_m ?? 0);
335 const diag: WoodblockExploreDiag = {
336 quality,
337 force: solveEntry.force.slice(),
338 forceFsHz: solveEntry.fsHi,
339 modeShapes: solveEntry.modeShapes.slice(),
340 shapePts: SHAPE_PTS,
341 diag: {
342 modesKept: solveEntry.f.length,
343 partialsHz: solveEntry.f,
344 ratios: solveEntry.f.map((f) => f / solveEntry.f[0]),
345 helmholtzHz: solveEntry.helmholtzHz,
346 contactMs: solveEntry.contactMs,
347 peakForceN: solveEntry.peakForceN,
348 peakPa,
349 peakSplDb: 20 * Math.log10(peakPa / 20e-6 + 1e-30),
350 listenerDistM: Math.sqrt(lx * lx + ly * ly + lz * lz),
351 warnings,
352 solveMs,
353 radiateMs,
354 solveCached,
355 radiateCached,
356 oversample,
357 elements,
358 },
359 };
360 return { audio: y32, diag };
361}
363// --- the track path (midiboot): per-strike rendering with caching ---
365function notePLines(note: { pitch: number; velocity: number }, req: TrackRenderRequest): string {
366 // The note-to-parameter mapping: MIDI pitch scales the block, every
367 // length by s = 2^-((pitch - 76)/12), the participating mass by s^3, and
368 // the resonant cavity consistently (volume s^3, slit area s^2, slit
369 // length s), so both the bar modes and the Helmholtz resonance shift by
370 // the semitone ratio exactly. The mallet stays fixed; velocity drives
371 // the mallet speed. Pitch 76 is the default block, playable 64..88.
372 const settings = req.settings ?? {};
373 const s = Math.pow(2, -(Math.max(64, Math.min(88, note.pitch)) - 76) / 12);
374 const lines = [
375 'P = woodblock_defaults();',
376 `P.sample_rate = ${req.sampleRate};`,
377 'P.pre_delay_ms = 0;',
378 'P.normalize = 0;',
379 ];
380 if (req.quality === 'preview') lines.push('P.oversample = 4;');
381 lines.push(
382 `P.length_m = P.length_m * ${s};`,
383 `P.width_m = P.width_m * ${s};`,
384 `P.wall_thickness_m = P.wall_thickness_m * ${s};`,
385 `P.block_height_m = P.block_height_m * ${s};`,
386 `P.participating_mass_kg = P.participating_mass_kg * ${s ** 3};`,
387 `P.cavity_volume_m3 = P.cavity_volume_m3 * ${s ** 3};`,
388 `P.slit_area_m2 = P.slit_area_m2 * ${s ** 2};`,
389 `P.slit_length_m = P.slit_length_m * ${s};`,
390 `P.mallet_velocity = ${0.2 + (8 - 0.2) * note.velocity};`,
391 `P.strike_position = ${knobValue(settings, 'strike_position')};`,
392 `P.mallet_stiffness = ${knobValue(settings, 'mallet_stiffness')};`,
393 );
394 return lines.join('\n');
395}
397async function renderTrackNotes(
398 req: TrackRenderRequest,
399 env: RenderEnv,
400): Promise<{ audio: Float32Array }> {
401 if (req.notes.length === 0) return { audio: new Float32Array(req.lengthSamples) };
402 const codeKey = hashMFiles(env.mfiles);
403 const settings = req.settings ?? {};
404 const knobsKey = WOODBLOCK_KNOBS.map((k) => `${k.key}=${knobValue(settings, k.key)}`).join(',');
406 // Group notes by everything that affects their sound. A strike ignores
407 // note duration, so a repeated pattern costs one render per distinct
408 // pitch and velocity level.
409 const groups = new Map<string, { pitch: number; velocity: number; onsets: number[] }>();
410 for (const n of req.notes) {
411 const pitch = Math.round(n.pitch);
412 const vel = bucketVelocity(n.velocity);
413 const key = `${codeKey}|${req.quality}|${req.sampleRate}|${knobsKey}|${pitch}|${vel}`;
414 let g = groups.get(key);
415 if (!g) groups.set(key, (g = { pitch, velocity: vel, onsets: [] }));
416 g.onsets.push(n.timeSec);
417 }
419 const placed: { buf: Float32Array; onsets: number[] }[] = [];
420 // Resolve cache hits first (memory, then IndexedDB), so the progress
421 // denominator counts only the sounds that actually need rendering.
422 const resolved = new Map<string, Float32Array>();
423 for (const key of groups.keys()) {
424 const hit = await noteCache.get(key);
425 if (hit) resolved.set(key, hit);
426 }
427 const misses = groups.size - resolved.size;
428 let done = 0;
429 for (const [key, g] of groups) {
430 let buf = resolved.get(key);
431 if (!buf) {
432 env.post(done / Math.max(1, misses), `note ${done + 1}/${misses}: solve`);
433 await env.yieldToHost();
434 env.checkCancel();
435 const r = runStage(env.mfiles, `
436${notePLines(g, req)}
437[y, basis, dinfo] = woodblock_render(P);
438`);
439 buf = toF32(r.variableValues.y);
440 noteCache.put(key, buf);
441 done++;
442 }
443 placed.push({ buf, onsets: g.onsets });
444 }
446 // lengthSamples is a minimum: size the output to the longest tail so a
447 // ring that outlives the last note is returned in full, and place every
448 // copy.
449 let length = req.lengthSamples;
450 for (const p of placed) {
451 for (const t of p.onsets) length = Math.max(length, Math.round(t * req.sampleRate) + p.buf.length);
452 }
453 const out = new Float32Array(length);
454 for (const p of placed) {
455 for (const t of p.onsets) {
456 const off = Math.round(t * req.sampleRate);
457 const end = Math.min(out.length, off + p.buf.length);
458 for (let j = Math.max(0, off); j < end; j++) out[j] += p.buf[j - off];
459 }
460 }
461 return { audio: out };
462}
464/** The single entry point: explorer path when diagnostics are requested,
465 * the per-strike track path otherwise. */
466export async function renderRequest(
467 variant: WoodblockVariant,
468 req: TrackRenderRequest,
469 env: RenderEnv,
470): Promise<{ audio: Float32Array; diag?: WoodblockExploreDiag }> {
471 void variant;
472 if (req.wantDiagnostics) return renderExplore(req, env);
473 return renderTrackNotes(req, env);
474}