// The woodblock 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), each distinct strike renders once through // woodblock_render, and copies are summed at the onsets. MIDI pitch // scales the block geometry around pitch 76, exactly as the retired // per-note export driver did. // // - the explorer path (the woodblock web app, wantDiagnostics set): the // full parameter surface and live-edited .m sources drive one staged // render (solve -> 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 { WOODBLOCK_KNOBS, type WoodblockVariant } from './knobs.ts'; import { NoteCache } from './noteCache.ts'; export { WOODBLOCK_KNOBS, type WoodblockVariant }; /** 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 WoodblockExploreDiag { quality: 'preview' | 'full'; force: Float32Array; // contact force, first forceMs at fsHi forceFsHz: number; modeShapes: Float32Array; // n x shapePts, concatenated shapePts: number; diag: { modesKept: number; partialsHz: number[]; ratios: number[]; helmholtzHz: number; contactMs: number; peakForceN: number; peakPa: number; peakSplDb: number; listenerDistM: number; warnings: string[]; solveMs: number; radiateMs: number; solveCached: boolean; radiateCached: boolean; oversample: number; elements: 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 = WOODBLOCK_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; } const SOLVE_KEYS = [ 'length_m', 'width_m', 'wall_thickness_m', 'density', 'youngs_modulus', 'hollow_softening', 'participating_mass_kg', 'inharmonicity', 'n_modes', 'max_mode_hz', 'loss_factor', 'loss_slope', 'strike_position', 'mallet_mass', 'mallet_velocity', 'mallet_stiffness', 'contact_exponent', 'contact_damping', 'cavity_volume_m3', 'slit_area_m2', 'slit_length_m', 'cavity_damping', 'cavity_coupling', 'cavity_span_lo', 'cavity_span_hi', 'speed_of_sound', 'air_density', 'sample_rate', 'duration_s', ]; const RADIATE_KEYS = [ 'block_height_m', 'listener_x_m', 'listener_y_m', 'listener_z_m', 'slit_x_m', 'slit_y_m', 'slit_z_m', ]; const SHAPE_PTS = 128; const FORCE_MS = 8; interface SolveEntry { Qdd: RuntimeValue; Xdd: RuntimeValue; fsHi: number; f: number[]; modeShapes: Float32Array; force: Float32Array; helmholtzHz: number; contactMs: number; peakForceN: number; } interface RadiateEntry { p: RuntimeValue; warnings: string[]; } // Explorer-tier caches, keyed under the code hash so editing the .m sources // invalidates everything computed from the previous version. const solveCache = new Map(); const radiateCache = new Map(); const SOLVE_CACHE_MAX = 2; const RADIATE_CACHE_MAX = 3; // 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('woodblock-instrument-notes'); // --- the explorer path (full params, staged, with diagnostics) --- async function renderExplore( req: TrackRenderRequest, env: RenderEnv, ): Promise<{ audio: Float32Array; diag: WoodblockExploreDiag }> { const params = req.params ?? {}; const mfiles = env.mfiles; let quality = req.quality; const codeKey = hashMFiles(mfiles); const solveParamsKey = 'x|' + codeKey + '|' + keyFor(params, SOLVE_KEYS); const fullOversample = Number(params.oversample ?? 16); const fullSolveKey = solveParamsKey + `|os=${fullOversample}`; if (quality === 'preview' && solveCache.has(fullSolveKey)) { quality = 'full'; // full solve already cached; only cheap stages remain } const oversample = quality === 'preview' ? Math.min(8, fullOversample) : fullOversample; const fullElements = Number(params.radiating_elements ?? 24); const elements = quality === 'preview' ? Math.min(24, fullElements) : fullElements; const sampleRate = Number(params.sample_rate ?? req.sampleRate); const fsHi = sampleRate * oversample; const base = `P = woodblock_defaults();\n${pLiteral(params)}`; // ---- solve stage ---- const solveKey = solveParamsKey + `|os=${oversample}`; 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 = woodblock_basis(P); [Qdd, Xdd, force, helmholtzHz, contactMs, peakForceN, maxRealLam] = woodblock_solve(P, basis, ${oversample}); f = basis.f; shapes = basis.shapes(1:32:end, :); `); solveMs = performance.now() - t0; const fT = asTensor(r.variableValues.f); const f = Array.from(fT.data); const n = f.length; // decimated mode shapes, normalized per mode for the viewer const shapesT = asTensor(r.variableValues.shapes); const rows = shapesT.shape[0]; const modeShapes = new Float32Array(n * SHAPE_PTS); for (let m = 0; m < n; m++) { let peak = 0; for (let i = 0; i < rows; i++) peak = Math.max(peak, Math.abs(shapesT.data[m * rows + i])); for (let i = 0; i < SHAPE_PTS; i++) { const j = Math.round((i / (SHAPE_PTS - 1)) * (rows - 1)); modeShapes[m * SHAPE_PTS + i] = peak > 0 ? shapesT.data[m * rows + j] / peak : 0; } } const forceT = asTensor(r.variableValues.force); const forceLen = Math.min(forceT.data.length, Math.round((FORCE_MS / 1000) * fsHi)); const force = new Float32Array(forceLen); for (let i = 0; i < forceLen; i++) force[i] = forceT.data[i]; solveEntry = { Qdd: r.variableValues.Qdd, Xdd: r.variableValues.Xdd, fsHi, f, modeShapes, force, helmholtzHz: asScalar(r.variableValues.helmholtzHz), contactMs: asScalar(r.variableValues.contactMs), peakForceN: asScalar(r.variableValues.peakForceN), }; lruSet(solveCache, solveKey, solveEntry, SOLVE_CACHE_MAX); } // ---- radiate stage ---- const radiateKey = solveKey + '||' + keyFor(params, RADIATE_KEYS) + `|M=${elements}`; 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 = woodblock_basis(P); [p, spacingNeed, listenerInside] = woodblock_radiate(P, basis, Qdd, Xdd, ${solveEntry.fsHi}, ${elements}); `, { Qdd: solveEntry.Qdd, Xdd: solveEntry.Xdd }); radiateMs = performance.now() - t0; const warnings: string[] = []; const spacingNeed = asScalar(r.variableValues.spacingNeed); if (spacingNeed > 0) { const fMax = solveEntry.f[solveEntry.f.length - 1]; warnings.push(`element spacing is coarse at ${fMax.toFixed(0)} Hz; consider ${spacingNeed} radiating elements`); } if (asScalar(r.variableValues.listenerInside) !== 0) { const d = Math.hypot(Number(params.listener_x_m ?? 0), Number(params.listener_y_m ?? 0), Number(params.listener_z_m ?? 0)); warnings.push(`listener is ${(d * 100).toFixed(0)} cm from the block centre, inside the source region; point-source elements are crude here`); } radEntry = { p: r.variableValues.p, warnings }; 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] = woodblock_output(P, p, ${oversample}); `, { p: radEntry.p }); const y32 = toF32(rOut.variableValues.y); const peakPa = asScalar(rOut.variableValues.peakPa); const warnings = [...radEntry.warnings]; 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 lx = Number(params.listener_x_m ?? 0), ly = Number(params.listener_y_m ?? 0), lz = Number(params.listener_z_m ?? 0); const diag: WoodblockExploreDiag = { quality, force: solveEntry.force.slice(), forceFsHz: solveEntry.fsHi, modeShapes: solveEntry.modeShapes.slice(), shapePts: SHAPE_PTS, diag: { modesKept: solveEntry.f.length, partialsHz: solveEntry.f, ratios: solveEntry.f.map((f) => f / solveEntry.f[0]), helmholtzHz: solveEntry.helmholtzHz, contactMs: solveEntry.contactMs, peakForceN: solveEntry.peakForceN, peakPa, peakSplDb: 20 * Math.log10(peakPa / 20e-6 + 1e-30), listenerDistM: Math.sqrt(lx * lx + ly * ly + lz * lz), warnings, solveMs, radiateMs, solveCached, radiateCached, oversample, elements, }, }; 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 block, every // length by s = 2^-((pitch - 76)/12), the participating mass by s^3, and // the resonant cavity consistently (volume s^3, slit area s^2, slit // length s), so both the bar modes and the Helmholtz resonance shift by // the semitone ratio exactly. The mallet stays fixed; velocity drives // the mallet speed. Pitch 76 is the default block, playable 64..88. const settings = req.settings ?? {}; const s = Math.pow(2, -(Math.max(64, Math.min(88, note.pitch)) - 76) / 12); const lines = [ 'P = woodblock_defaults();', `P.sample_rate = ${req.sampleRate};`, 'P.pre_delay_ms = 0;', 'P.normalize = 0;', ]; if (req.quality === 'preview') lines.push('P.oversample = 4;'); lines.push( `P.length_m = P.length_m * ${s};`, `P.width_m = P.width_m * ${s};`, `P.wall_thickness_m = P.wall_thickness_m * ${s};`, `P.block_height_m = P.block_height_m * ${s};`, `P.participating_mass_kg = P.participating_mass_kg * ${s ** 3};`, `P.cavity_volume_m3 = P.cavity_volume_m3 * ${s ** 3};`, `P.slit_area_m2 = P.slit_area_m2 * ${s ** 2};`, `P.slit_length_m = P.slit_length_m * ${s};`, `P.mallet_velocity = ${0.2 + (8 - 0.2) * note.velocity};`, `P.strike_position = ${knobValue(settings, 'strike_position')};`, `P.mallet_stiffness = ${knobValue(settings, 'mallet_stiffness')};`, ); 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 = WOODBLOCK_KNOBS.map((k) => `${k.key}=${knobValue(settings, k.key)}`).join(','); // Group notes by everything that affects their sound. A strike ignores // note duration, 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) { env.post(done / Math.max(1, misses), `note ${done + 1}/${misses}: solve`); await env.yieldToHost(); env.checkCancel(); const r = runStage(env.mfiles, ` ${notePLines(g, req)} [y, basis, dinfo] = woodblock_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-strike track path otherwise. */ export async function renderRequest( variant: WoodblockVariant, req: TrackRenderRequest, env: RenderEnv, ): Promise<{ audio: Float32Array; diag?: WoodblockExploreDiag }> { void variant; if (req.wantDiagnostics) return renderExplore(req, env); return renderTrackNotes(req, env); }