// The maraca 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 shake ignores // note duration), each distinct stroke renders once through // maraca_render, and copies are summed at the onsets. MIDI pitch // scales the gourd around pitch 60, exactly as the retired per-note // export driver did. Velocity and both knobs feed the stochastic // impact process itself, so there is no pitch-only stage to share and // each distinct sound is one render. // // - the explorer path (the maraca 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 { MARACA_KNOBS, type MaracaVariant } from './knobs.ts'; import { NoteCache } from './noteCache.ts'; export { MARACA_KNOBS, type MaracaVariant }; /** 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 MaracaExploreDiag { quality: 'preview' | 'full'; impactT: Float32Array; impactV: Float32Array; impactPos: Float32Array; rateT: Float32Array; rateHz: Float32Array; modeF: Float32Array; modeL: Float32Array; modeZeta: Float32Array; modeSigma: Float32Array; diag: { modesKept: number; breathingHz: number; breathingDryHz: number; impactCount: number; meanV: number; maxV: number; meanTauUs: 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 = MARACA_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 strokes 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 = [ 'shell_radius_m', 'shell_thickness_m', 'density', 'youngs_modulus', 'poisson_ratio', 'stiffness_trim', 'max_mode_l', 'max_mode_hz', 'loss_factor', 'loss_slope', 'radiation_loss', 'grain_radius_m', 'grain_density', 'grain_youngs', 'grain_size_spread', 'restitution', 'stroke_rate_hz', 'stroke_count', 'accent_period', 'accent_gain', 'grain_count', 'bounces_per_grain', 'shake_velocity', 'slosh_lag_ms', 'burst_width_ms', 'impact_spread_deg', 'seed', 'cavity_coupling', 'speed_of_sound', 'air_density', 'sample_rate', 'tail_s', ]; const RADIATE_KEYS = [ 'direct_gain', 'listener_x_m', 'listener_y_m', 'listener_z_m', ]; // Impacts drawn for the raster plot. The full process runs to thousands, // and plotting every one of them is neither visible nor worth the // transfer. const MAX_PLOT_IMPACTS = 4000; interface SolveEntry { timp: RuntimeValue; jimp: RuntimeValue; tauimp: RuntimeValue; fimp: RuntimeValue; ximp: RuntimeValue; fsHi: number; impactT: Float32Array; impactV: Float32Array; impactPos: Float32Array; rateT: Float32Array; rateHz: Float32Array; modeF: Float32Array; modeL: Float32Array; modeZeta: Float32Array; modeSigma: Float32Array; breathingHz: number; breathingDryHz: number; impactCount: number; meanV: number; maxV: number; meanTauUs: number; durationS: number; } interface RadiateEntry { p: RuntimeValue; directPeakPa: number; listenerDistM: number; listenerInside: boolean; } // 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('maraca-instrument-notes'); // --- the explorer path (full params, staged, with diagnostics) --- async function renderExplore( req: TrackRenderRequest, env: RenderEnv, ): Promise<{ audio: Float32Array; diag: MaracaExploreDiag }> { 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 ?? 2); const fullSolveKey = solveParamsKey + `|os=${fullOversample}`; if (quality === 'preview' && solveCache.has(fullSolveKey)) { quality = 'full'; // full-rate impact process 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 = maraca_defaults();\n${pLiteral(params)}`; // ---- solve stage: mode basis and the stochastic impact process ---- 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 = maraca_basis(P); [timp, jimp, tauimp, fimp, ximp, ginfo] = maraca_impacts(P); modeF = basis.f; modeL = basis.l; modeZeta = basis.zeta; modeSigma = basis.sigma; breathingHz = basis.breathingHz; breathingDryHz = basis.breathingDryHz; impactCount = ginfo.count; meanV = ginfo.meanV; maxV = ginfo.maxV; meanTauUs = ginfo.meanTauUs; durationS = ginfo.durationS; rateT = ginfo.rateT; rateHz = ginfo.rateHz; vimp = ginfo.vimp; `); solveMs = performance.now() - t0; const impactCount = asScalar(r.variableValues.impactCount); const allT = toF32(r.variableValues.timp); const allV = toF32(r.variableValues.vimp); const allX = toF32(r.variableValues.ximp); // thin uniformly for the raster plot, keeping the time ordering const stride = Math.max(1, Math.ceil(impactCount / MAX_PLOT_IMPACTS)); const nPlot = Math.ceil(impactCount / stride); const impactT = new Float32Array(nPlot); const impactV = new Float32Array(nPlot); const impactPos = new Float32Array(nPlot * 3); for (let i = 0, j = 0; j < nPlot; i += stride, j++) { impactT[j] = allT[i]; impactV[j] = allV[i]; impactPos[j * 3] = allX[i * 3]; impactPos[j * 3 + 1] = allX[i * 3 + 1]; impactPos[j * 3 + 2] = allX[i * 3 + 2]; } solveEntry = { timp: r.variableValues.timp, jimp: r.variableValues.jimp, tauimp: r.variableValues.tauimp, fimp: r.variableValues.fimp, ximp: r.variableValues.ximp, fsHi, impactT, impactV, impactPos, rateT: toF32(r.variableValues.rateT), rateHz: toF32(r.variableValues.rateHz), modeF: toF32(r.variableValues.modeF), modeL: toF32(r.variableValues.modeL), modeZeta: toF32(r.variableValues.modeZeta), modeSigma: toF32(r.variableValues.modeSigma), breathingHz: asScalar(r.variableValues.breathingHz), breathingDryHz: asScalar(r.variableValues.breathingDryHz), impactCount, meanV: asScalar(r.variableValues.meanV), maxV: asScalar(r.variableValues.maxV), meanTauUs: asScalar(r.variableValues.meanTauUs), durationS: asScalar(r.variableValues.durationS), }; lruSet(solveCache, solveKey, solveEntry, SOLVE_CACHE_MAX); } // ---- radiate stage ---- const radiateKey = solveKey + '||' + keyFor(params, RADIATE_KEYS); 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 = maraca_basis(P); [p, radinfo] = maraca_radiate(P, basis, timp, jimp, tauimp, fimp, ximp, ${solveEntry.fsHi}); directPeakPa = radinfo.directPeakPa; listenerDistM = radinfo.listenerDistM; listenerInside = radinfo.listenerInside; `, { timp: solveEntry.timp, jimp: solveEntry.jimp, tauimp: solveEntry.tauimp, fimp: solveEntry.fimp, ximp: solveEntry.ximp, }); radiateMs = performance.now() - t0; radEntry = { p: r.variableValues.p, directPeakPa: asScalar(r.variableValues.directPeakPa), listenerDistM: asScalar(r.variableValues.listenerDistM), listenerInside: asScalar(r.variableValues.listenerInside) !== 0, }; 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] = maraca_output(P, p, ${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 at or inside the shell surface; the multipole field is evaluated at the nearest valid radius instead'); } 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; raise max degree l to keep the shell from sounding truncated`); } const diag: MaracaExploreDiag = { quality, impactT: solveEntry.impactT.slice(), impactV: solveEntry.impactV.slice(), impactPos: solveEntry.impactPos.slice(), rateT: solveEntry.rateT.slice(), rateHz: solveEntry.rateHz.slice(), modeF: solveEntry.modeF.slice(), modeL: solveEntry.modeL.slice(), modeZeta: solveEntry.modeZeta.slice(), modeSigma: solveEntry.modeSigma.slice(), diag: { modesKept: solveEntry.modeF.length, breathingHz: solveEntry.breathingHz, breathingDryHz: solveEntry.breathingDryHz, impactCount: solveEntry.impactCount, meanV: solveEntry.meanV, maxV: solveEntry.maxV, meanTauUs: solveEntry.meanTauUs, durationS: solveEntry.durationS, peakPa, peakSplDb: 20 * Math.log10(peakPa / 20e-6 + 1e-30), directPeakPa: radEntry.directPeakPa, listenerDistM: radEntry.listenerDistM, warnings, solveMs, radiateMs, solveCached, radiateCached, oversample, }, }; return { audio: y32, diag }; } // --- the track path (midiboot): per-stroke rendering with caching --- function notePLines(note: { pitch: number; velocity: number }, req: TrackRenderRequest): string { // The note-to-parameter mapping: MIDI pitch scales the gourd, shell // radius and thickness by s = 2^-((pitch - 60)/12), shifting the shell // modes and the cavity resonance by the semitone ratio while the seed // beads stay the same size, so higher notes are smaller, brighter // shakers. Pitch 60 is the default gourd, playable 48..72; the sound is // noise-based, so the note number sets register rather than a tuned // fundamental. Velocity drives the stroke speed. Fixed: a single // stroke, a fixed seed, normalize off so velocity survives in pascals. const settings = req.settings ?? {}; const s = Math.pow(2, -(Math.max(48, Math.min(72, Math.round(note.pitch))) - 60) / 12); const lines = [ 'P = maraca_defaults();', `P.sample_rate = ${req.sampleRate};`, 'P.stroke_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.shell_radius_m = P.shell_radius_m * ${s};`, `P.shell_thickness_m = P.shell_thickness_m * ${s};`, `P.shake_velocity = ${0.2 + (6 - 0.2) * note.velocity};`, `P.grain_count = ${Math.round(knobValue(settings, 'grain_count'))};`, `P.burst_width_ms = ${knobValue(settings, 'burst_width_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 = MARACA_KNOBS.map((k) => `${k.key}=${knobValue(settings, k.key)}`).join(','); // Group notes by everything that affects their sound. A shake 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); } // 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; const placed: { buf: Float32Array; onsets: number[] }[] = []; 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] = maraca_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-stroke track path otherwise. */ export async function renderRequest( variant: MaracaVariant, req: TrackRenderRequest, env: RenderEnv, ): Promise<{ audio: Float32Array; diag?: MaracaExploreDiag }> { void variant; if (req.wantDiagnostics) return renderExplore(req, env); return renderTrackNotes(req, env); }