/ simulated_instruments / maraca
Sign in
simulated_instruments / maraca
maraca / instrument / core.ts
534 lines · 18.2 KBBlameHistoryRaw
1// The maraca 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 shake ignores
7// note duration), each distinct stroke renders once through
8// maraca_render, and copies are summed at the onsets. MIDI pitch
9// scales the gourd around pitch 60, exactly as the retired per-note
10// export driver did. Velocity and both knobs feed the stochastic
11// impact process itself, so there is no pitch-only stage to share and
12// each distinct sound is one render.
13//
14// - the explorer path (the maraca web app, wantDiagnostics set): the
15// full parameter surface and live-edited .m sources drive one staged
16// render (solve -> radiate -> output, intermediates cached per tier)
17// with the diagnostics payload the app's plots consume.
19import { executeCode, type RuntimeValue, type RuntimeTensor } from 'numbl';
20import type { MFile, TrackRenderRequest } from './types.ts';
21import { MARACA_KNOBS, type MaracaVariant } from './knobs.ts';
22import { NoteCache } from './noteCache.ts';
24export { MARACA_KNOBS, type MaracaVariant };
26/** The environment the host provides: sources, progress, cancellation, and
27 * a macrotask yield so a browser worker can receive a cancel message. */
28export interface RenderEnv {
29 mfiles: MFile[];
30 post: (frac: number, stage: string) => void;
31 /** Throws to abandon the render; called between stages and notes. */
32 checkCancel: () => void;
33 yieldToHost: () => Promise<void>;
36// --- diagnostics payload for the explorer path (the web app's plots) ---
38export interface MaracaExploreDiag {
39 quality: 'preview' | 'full';
40 impactT: Float32Array;
41 impactV: Float32Array;
42 impactPos: Float32Array;
43 rateT: Float32Array;
44 rateHz: Float32Array;
45 modeF: Float32Array;
46 modeL: Float32Array;
47 modeZeta: Float32Array;
48 modeSigma: Float32Array;
49 diag: {
50 modesKept: number;
51 breathingHz: number;
52 breathingDryHz: number;
53 impactCount: number;
54 meanV: number;
55 maxV: number;
56 meanTauUs: number;
57 durationS: number;
58 peakPa: number;
59 peakSplDb: number;
60 directPeakPa: number;
61 listenerDistM: number;
62 warnings: string[];
63 solveMs: number;
64 radiateMs: number;
65 solveCached: boolean;
66 radiateCached: boolean;
67 oversample: number;
68 };
71// --- shared helpers ---
73export function hashMFiles(files: MFile[]): string {
74 let h = 0x811c9dc5;
75 for (const f of files) {
76 const s = f.name + ' ' + f.source;
77 for (let i = 0; i < s.length; i++) {
78 h ^= s.charCodeAt(i);
79 h = Math.imul(h, 0x01000193);
80 }
81 }
82 return (h >>> 0).toString(36);
85function lruGet<T>(cache: Map<string, T>, key: string): T | undefined {
86 const v = cache.get(key);
87 if (v !== undefined) {
88 cache.delete(key);
89 cache.set(key, v);
90 }
91 return v;
94function lruSet<T>(cache: Map<string, T>, key: string, value: T, max: number): void {
95 cache.delete(key);
96 cache.set(key, value);
97 while (cache.size > max) {
98 const oldest = cache.keys().next().value as string;
99 cache.delete(oldest);
100 }
103type Params = Record<string, number | boolean>;
105function pLiteral(params: Params): string {
106 const lines: string[] = [];
107 for (const [k, v] of Object.entries(params)) {
108 if (typeof v === 'number') {
109 if (!Number.isFinite(v)) throw new Error(`parameter ${k} is not finite`);
110 lines.push(`P.${k} = ${v};`);
111 } else if (typeof v === 'boolean') lines.push(`P.${k} = ${v ? 1 : 0};`);
112 }
113 return lines.join('\n');
116function keyFor(params: Params, keys: readonly string[]): string {
117 return keys.map((k) => String(params[k] ?? 'd')).join('|');
120function runStage(mfiles: MFile[], source: string, inputs?: Record<string, RuntimeValue>) {
121 return executeCode(source, { initialVariableValues: inputs, displayResults: false }, mfiles, 'stage.m');
124function asTensor(v: RuntimeValue): RuntimeTensor {
125 const t = v as RuntimeTensor;
126 if (!t || t.kind !== 'tensor') throw new Error('expected tensor result from numbl stage');
127 return t;
130function asScalar(v: RuntimeValue): number {
131 if (typeof v === 'number') return v;
132 const t = v as RuntimeTensor;
133 if (t && t.kind === 'tensor' && t.data.length === 1) return t.data[0];
134 if (typeof v === 'boolean') return v ? 1 : 0;
135 throw new Error('expected scalar result from numbl stage');
138function toF32(v: RuntimeValue): Float32Array {
139 const t = asTensor(v);
140 const out = new Float32Array(t.data.length);
141 for (let i = 0; i < t.data.length; i++) out[i] = t.data[i];
142 return out;
145function knobValue(settings: Record<string, number>, key: string): number {
146 const spec = MARACA_KNOBS.find((k) => k.key === key)!;
147 const raw = settings[key];
148 return raw === undefined ? spec.default : Math.max(spec.min, Math.min(spec.max, raw));
151/** Velocity quantized to a few levels so identical strokes share a cached
152 * render; 8 levels is inaudible next to the model's own dynamics. */
153export function bucketVelocity(velocity01: number, levels = 8): number {
154 return Math.ceil(Math.max(0.001, Math.min(1, velocity01)) * levels) / levels;
157const SOLVE_KEYS = [
158 'shell_radius_m', 'shell_thickness_m',
159 'density', 'youngs_modulus', 'poisson_ratio', 'stiffness_trim',
160 'max_mode_l', 'max_mode_hz',
161 'loss_factor', 'loss_slope', 'radiation_loss',
162 'grain_radius_m', 'grain_density', 'grain_youngs', 'grain_size_spread',
163 'restitution',
164 'stroke_rate_hz', 'stroke_count', 'accent_period', 'accent_gain',
165 'grain_count', 'bounces_per_grain', 'shake_velocity', 'slosh_lag_ms', 'burst_width_ms',
166 'impact_spread_deg', 'seed',
167 'cavity_coupling',
168 'speed_of_sound', 'air_density', 'sample_rate', 'tail_s',
169];
171const RADIATE_KEYS = [
172 'direct_gain', 'listener_x_m', 'listener_y_m', 'listener_z_m',
173];
175// Impacts drawn for the raster plot. The full process runs to thousands,
176// and plotting every one of them is neither visible nor worth the
177// transfer.
178const MAX_PLOT_IMPACTS = 4000;
180interface SolveEntry {
181 timp: RuntimeValue;
182 jimp: RuntimeValue;
183 tauimp: RuntimeValue;
184 fimp: RuntimeValue;
185 ximp: RuntimeValue;
186 fsHi: number;
187 impactT: Float32Array;
188 impactV: Float32Array;
189 impactPos: Float32Array;
190 rateT: Float32Array;
191 rateHz: Float32Array;
192 modeF: Float32Array;
193 modeL: Float32Array;
194 modeZeta: Float32Array;
195 modeSigma: Float32Array;
196 breathingHz: number;
197 breathingDryHz: number;
198 impactCount: number;
199 meanV: number;
200 maxV: number;
201 meanTauUs: number;
202 durationS: number;
205interface RadiateEntry {
206 p: RuntimeValue;
207 directPeakPa: number;
208 listenerDistM: number;
209 listenerInside: boolean;
212// Explorer-tier caches, keyed under the code hash so editing the .m
213// sources invalidates everything computed from the previous version.
214const solveCache = new Map<string, SolveEntry>();
215const radiateCache = new Map<string, RadiateEntry>();
216const SOLVE_CACHE_MAX = 2;
217const RADIATE_CACHE_MAX = 3;
219// Finished per-note audio: an in-memory LRU in front of an IndexedDB
220// store, so renders survive page reloads (budgets and eviction live in
221// noteCache.ts).
222const noteCache = new NoteCache('maraca-instrument-notes');
224// --- the explorer path (full params, staged, with diagnostics) ---
226async function renderExplore(
227 req: TrackRenderRequest,
228 env: RenderEnv,
229): Promise<{ audio: Float32Array; diag: MaracaExploreDiag }> {
230 const params = req.params ?? {};
231 const mfiles = env.mfiles;
232 let quality = req.quality;
234 const codeKey = hashMFiles(mfiles);
235 const solveParamsKey = 'x|' + codeKey + '|' + keyFor(params, SOLVE_KEYS);
236 const fullOversample = Number(params.oversample ?? 2);
237 const fullSolveKey = solveParamsKey + `|os=${fullOversample}`;
238 if (quality === 'preview' && solveCache.has(fullSolveKey)) {
239 quality = 'full'; // full-rate impact process already cached
240 }
242 const oversample = quality === 'preview' ? Math.min(1, fullOversample) : fullOversample;
243 const sampleRate = Number(params.sample_rate ?? req.sampleRate);
244 const fsHi = sampleRate * oversample;
246 const base = `P = maraca_defaults();\n${pLiteral(params)}`;
248 // ---- solve stage: mode basis and the stochastic impact process ----
249 const solveKey = solveParamsKey + `|os=${oversample}`;
250 let solveMs = 0;
251 let solveCached = true;
252 let solveEntry = lruGet(solveCache, solveKey);
253 if (!solveEntry) {
254 solveCached = false;
255 env.post(0.15, 'solve');
256 await env.yieldToHost();
257 env.checkCancel();
258 const t0 = performance.now();
259 const r = runStage(mfiles, `
260${base}
261basis = maraca_basis(P);
262[timp, jimp, tauimp, fimp, ximp, ginfo] = maraca_impacts(P);
263modeF = basis.f;
264modeL = basis.l;
265modeZeta = basis.zeta;
266modeSigma = basis.sigma;
267breathingHz = basis.breathingHz;
268breathingDryHz = basis.breathingDryHz;
269impactCount = ginfo.count;
270meanV = ginfo.meanV;
271maxV = ginfo.maxV;
272meanTauUs = ginfo.meanTauUs;
273durationS = ginfo.durationS;
274rateT = ginfo.rateT;
275rateHz = ginfo.rateHz;
276vimp = ginfo.vimp;
277`);
278 solveMs = performance.now() - t0;
280 const impactCount = asScalar(r.variableValues.impactCount);
281 const allT = toF32(r.variableValues.timp);
282 const allV = toF32(r.variableValues.vimp);
283 const allX = toF32(r.variableValues.ximp);
284 // thin uniformly for the raster plot, keeping the time ordering
285 const stride = Math.max(1, Math.ceil(impactCount / MAX_PLOT_IMPACTS));
286 const nPlot = Math.ceil(impactCount / stride);
287 const impactT = new Float32Array(nPlot);
288 const impactV = new Float32Array(nPlot);
289 const impactPos = new Float32Array(nPlot * 3);
290 for (let i = 0, j = 0; j < nPlot; i += stride, j++) {
291 impactT[j] = allT[i];
292 impactV[j] = allV[i];
293 impactPos[j * 3] = allX[i * 3];
294 impactPos[j * 3 + 1] = allX[i * 3 + 1];
295 impactPos[j * 3 + 2] = allX[i * 3 + 2];
296 }
298 solveEntry = {
299 timp: r.variableValues.timp,
300 jimp: r.variableValues.jimp,
301 tauimp: r.variableValues.tauimp,
302 fimp: r.variableValues.fimp,
303 ximp: r.variableValues.ximp,
304 fsHi,
305 impactT,
306 impactV,
307 impactPos,
308 rateT: toF32(r.variableValues.rateT),
309 rateHz: toF32(r.variableValues.rateHz),
310 modeF: toF32(r.variableValues.modeF),
311 modeL: toF32(r.variableValues.modeL),
312 modeZeta: toF32(r.variableValues.modeZeta),
313 modeSigma: toF32(r.variableValues.modeSigma),
314 breathingHz: asScalar(r.variableValues.breathingHz),
315 breathingDryHz: asScalar(r.variableValues.breathingDryHz),
316 impactCount,
317 meanV: asScalar(r.variableValues.meanV),
318 maxV: asScalar(r.variableValues.maxV),
319 meanTauUs: asScalar(r.variableValues.meanTauUs),
320 durationS: asScalar(r.variableValues.durationS),
321 };
322 lruSet(solveCache, solveKey, solveEntry, SOLVE_CACHE_MAX);
323 }
325 // ---- radiate stage ----
326 const radiateKey = solveKey + '||' + keyFor(params, RADIATE_KEYS);
327 let radiateMs = 0;
328 let radiateCached = true;
329 let radEntry = lruGet(radiateCache, radiateKey);
330 if (!radEntry) {
331 radiateCached = false;
332 env.post(0.55, 'radiate');
333 await env.yieldToHost();
334 env.checkCancel();
335 const t0 = performance.now();
336 const r = runStage(mfiles, `
337${base}
338basis = maraca_basis(P);
339[p, radinfo] = maraca_radiate(P, basis, timp, jimp, tauimp, fimp, ximp, ${solveEntry.fsHi});
340directPeakPa = radinfo.directPeakPa;
341listenerDistM = radinfo.listenerDistM;
342listenerInside = radinfo.listenerInside;
343`, {
344 timp: solveEntry.timp,
345 jimp: solveEntry.jimp,
346 tauimp: solveEntry.tauimp,
347 fimp: solveEntry.fimp,
348 ximp: solveEntry.ximp,
349 });
350 radiateMs = performance.now() - t0;
351 radEntry = {
352 p: r.variableValues.p,
353 directPeakPa: asScalar(r.variableValues.directPeakPa),
354 listenerDistM: asScalar(r.variableValues.listenerDistM),
355 listenerInside: asScalar(r.variableValues.listenerInside) !== 0,
356 };
357 lruSet(radiateCache, radiateKey, radEntry, RADIATE_CACHE_MAX);
358 }
360 // ---- output stage ----
361 env.post(0.9, 'output');
362 await env.yieldToHost();
363 env.checkCancel();
364 const rOut = runStage(mfiles, `
365${base}
366[y, peakPa] = maraca_output(P, p, ${oversample});
367`, { p: radEntry.p });
369 const y32 = toF32(rOut.variableValues.y);
370 const peakPa = asScalar(rOut.variableValues.peakPa);
372 const warnings: string[] = [];
373 if (radEntry.listenerInside) {
374 warnings.push('the listener is at or inside the shell surface; the multipole field is evaluated at the nearest valid radius instead');
375 }
376 if (!params.normalize) {
377 let peak = 0;
378 for (let i = 0; i < y32.length; i++) peak = Math.max(peak, Math.abs(y32[i]));
379 if (peak > 1.0) {
380 warnings.push(`clipping: peak is ${peakPa.toFixed(2)} Pa but full_scale_pa is ${Number(params.full_scale_pa ?? 0.2).toFixed(2)}`);
381 }
382 }
383 const topMode = solveEntry.modeF[solveEntry.modeF.length - 1];
384 const maxModeHz = Number(params.max_mode_hz ?? 0);
385 if (maxModeHz > 0 && topMode < 0.4 * maxModeHz) {
386 warnings.push(`the highest retained mode is only ${topMode.toFixed(0)} Hz; raise max degree l to keep the shell from sounding truncated`);
387 }
389 const diag: MaracaExploreDiag = {
390 quality,
391 impactT: solveEntry.impactT.slice(),
392 impactV: solveEntry.impactV.slice(),
393 impactPos: solveEntry.impactPos.slice(),
394 rateT: solveEntry.rateT.slice(),
395 rateHz: solveEntry.rateHz.slice(),
396 modeF: solveEntry.modeF.slice(),
397 modeL: solveEntry.modeL.slice(),
398 modeZeta: solveEntry.modeZeta.slice(),
399 modeSigma: solveEntry.modeSigma.slice(),
400 diag: {
401 modesKept: solveEntry.modeF.length,
402 breathingHz: solveEntry.breathingHz,
403 breathingDryHz: solveEntry.breathingDryHz,
404 impactCount: solveEntry.impactCount,
405 meanV: solveEntry.meanV,
406 maxV: solveEntry.maxV,
407 meanTauUs: solveEntry.meanTauUs,
408 durationS: solveEntry.durationS,
409 peakPa,
410 peakSplDb: 20 * Math.log10(peakPa / 20e-6 + 1e-30),
411 directPeakPa: radEntry.directPeakPa,
412 listenerDistM: radEntry.listenerDistM,
413 warnings,
414 solveMs,
415 radiateMs,
416 solveCached,
417 radiateCached,
418 oversample,
419 },
420 };
421 return { audio: y32, diag };
424// --- the track path (midiboot): per-stroke rendering with caching ---
426function notePLines(note: { pitch: number; velocity: number }, req: TrackRenderRequest): string {
427 // The note-to-parameter mapping: MIDI pitch scales the gourd, shell
428 // radius and thickness by s = 2^-((pitch - 60)/12), shifting the shell
429 // modes and the cavity resonance by the semitone ratio while the seed
430 // beads stay the same size, so higher notes are smaller, brighter
431 // shakers. Pitch 60 is the default gourd, playable 48..72; the sound is
432 // noise-based, so the note number sets register rather than a tuned
433 // fundamental. Velocity drives the stroke speed. Fixed: a single
434 // stroke, a fixed seed, normalize off so velocity survives in pascals.
435 const settings = req.settings ?? {};
436 const s = Math.pow(2, -(Math.max(48, Math.min(72, Math.round(note.pitch))) - 60) / 12);
437 const lines = [
438 'P = maraca_defaults();',
439 `P.sample_rate = ${req.sampleRate};`,
440 'P.stroke_count = 1;',
441 'P.seed = 1;',
442 'P.pre_delay_ms = 0;',
443 'P.normalize = 0;',
444 ];
445 if (req.quality === 'preview') lines.push('P.oversample = 1;');
446 lines.push(
447 `P.shell_radius_m = P.shell_radius_m * ${s};`,
448 `P.shell_thickness_m = P.shell_thickness_m * ${s};`,
449 `P.shake_velocity = ${0.2 + (6 - 0.2) * note.velocity};`,
450 `P.grain_count = ${Math.round(knobValue(settings, 'grain_count'))};`,
451 `P.burst_width_ms = ${knobValue(settings, 'burst_width_ms')};`,
452 );
453 return lines.join('\n');
456async function renderTrackNotes(
457 req: TrackRenderRequest,
458 env: RenderEnv,
459): Promise<{ audio: Float32Array }> {
460 if (req.notes.length === 0) return { audio: new Float32Array(req.lengthSamples) };
461 const codeKey = hashMFiles(env.mfiles);
462 const settings = req.settings ?? {};
463 const knobsKey = MARACA_KNOBS.map((k) => `${k.key}=${knobValue(settings, k.key)}`).join(',');
465 // Group notes by everything that affects their sound. A shake ignores
466 // note duration, so a repeated pattern costs one render per distinct
467 // pitch and velocity level.
468 const groups = new Map<string, { pitch: number; velocity: number; onsets: number[] }>();
469 for (const n of req.notes) {
470 const pitch = Math.round(n.pitch);
471 const vel = bucketVelocity(n.velocity);
472 const key = `${codeKey}|${req.quality}|${req.sampleRate}|${knobsKey}|${pitch}|${vel}`;
473 let g = groups.get(key);
474 if (!g) groups.set(key, (g = { pitch, velocity: vel, onsets: [] }));
475 g.onsets.push(n.timeSec);
476 }
478 // Resolve cache hits first (memory, then IndexedDB), so the progress
479 // denominator counts only the sounds that actually need rendering.
480 const resolved = new Map<string, Float32Array>();
481 for (const key of groups.keys()) {
482 const hit = await noteCache.get(key);
483 if (hit) resolved.set(key, hit);
484 }
485 const misses = groups.size - resolved.size;
487 const placed: { buf: Float32Array; onsets: number[] }[] = [];
488 let done = 0;
489 for (const [key, g] of groups) {
490 let buf = resolved.get(key);
491 if (!buf) {
492 env.post(done / Math.max(1, misses), `note ${done + 1}/${misses}: solve`);
493 await env.yieldToHost();
494 env.checkCancel();
495 const r = runStage(env.mfiles, `
496${notePLines(g, req)}
497[y, basis, dinfo] = maraca_render(P);
498`);
499 buf = toF32(r.variableValues.y);
500 noteCache.put(key, buf);
501 done++;
502 }
503 placed.push({ buf, onsets: g.onsets });
504 }
506 // lengthSamples is a minimum: size the output to the longest tail so a
507 // ring that outlives the last note is returned in full, and place every
508 // copy.
509 let length = req.lengthSamples;
510 for (const p of placed) {
511 for (const t of p.onsets) length = Math.max(length, Math.round(t * req.sampleRate) + p.buf.length);
512 }
513 const out = new Float32Array(length);
514 for (const p of placed) {
515 for (const t of p.onsets) {
516 const off = Math.round(t * req.sampleRate);
517 const end = Math.min(out.length, off + p.buf.length);
518 for (let j = Math.max(0, off); j < end; j++) out[j] += p.buf[j - off];
519 }
520 }
521 return { audio: out };
524/** The single entry point: explorer path when diagnostics are requested,
525 * the per-stroke track path otherwise. */
526export async function renderRequest(
527 variant: MaracaVariant,
528 req: TrackRenderRequest,
529 env: RenderEnv,
530): Promise<{ audio: Float32Array; diag?: MaracaExploreDiag }> {
531 void variant;
532 if (req.wantDiagnostics) return renderExplore(req, env);
533 return renderTrackNotes(req, env);
↑↓ move↵openescclose