1// The cymbal (Samejima 2021 nonlinear FD shell) as a track-render
2// instrument: pure rendering logic with no DOM or Worker dependency,
3// shared by the browser worker (worker.ts) and the node tests (test.ts).
4// Two request shapes arrive here:
5//
6// - the track path (midiboot): every note of a MIDI track. Notes are
7// grouped by cache key (pitch and bucketed velocity; a crash ignores
8// note duration, the ring is the physics), each distinct stroke is a
9// full time integration, and copies are summed at the onsets. MIDI
10// pitch scales the shell geometry around pitch 60, exactly as the
11// retired per-note export driver did.
12//
13// - the explorer path (the cymbal2 web app, wantDiagnostics set): the
14// full parameter surface and live-edited .m sources drive one render
15// with the diagnostics payload the app's plots consume.
16//
17// Either way the MATLAB implementation in ../matlab remains the source of
18// truth; what varies is who runs the time loop. When WebGPU is available
19// the unmodified setup stage runs through numbl, the operators go to the
20// WGSL port of cymbal2_sim.m (./gpuSim.ts), and the acceleration comes
21// back through the unmodified cymbal2_output.m; this is 20x or more
22// faster than interpreting the loop, and the GPU chunk boundaries give
23// integration progress and cooperative cancellation. Without WebGPU, or
24// when cymbal2_sim.m has been edited in the app (the WGSL mirrors the
25// shipped step), the original interpreter path runs instead.
27import { executeCode, RTV, type RuntimeValue, type RuntimeTensor } from 'numbl';
28import type { MFile, TrackRenderRequest } from './types.ts';
29import { CYMBAL2_KNOBS, type Cymbal2Variant } from './knobs.ts';
30import { requestGpuDevice, runGpuSim, type GpuSetup } from './gpuSim.ts';
31import { NoteCache } from './noteCache.ts';
33export { CYMBAL2_KNOBS, type Cymbal2Variant };
35/** Thrown by env.checkCancel to abandon a render; the worker maps it to a
36 * cancelled response rather than an error. */
37export class RenderCancelled extends Error {
38 constructor() {
39 super('render cancelled');
40 this.name = 'RenderCancelled';
41 }
42}
44/** The environment the host provides: sources, progress, cancellation, and
45 * a macrotask yield so a browser worker can receive a cancel message. */
46export interface RenderEnv {
47 mfiles: MFile[];
48 /** The shipped sources, for the cymbal2_sim.m edited-step check. */
49 shipped: MFile[];
50 post: (frac: number, stage: string) => void;
51 /** Throws RenderCancelled to abandon the render; checked between stages,
52 * between notes, and between GPU chunks. */
53 checkCancel: () => void;
54 yieldToHost: () => Promise<void>;
55}
57// --- diagnostics payload for the explorer path (the web app's plots) ---
59export interface Cymbal2ExploreDiag {
60 quality: 'preview' | 'full';
61 forceT: Float32Array; // contact force history (max-pooled), s
62 forceN: Float32Array; // and its values, N
63 modeF: Float32Array; // linear mode frequencies, Hz
64 modeM: Float32Array; // nodal diameters of each
65 diag: {
66 strokesPlayed: number;
67 meanV: number;
68 contactVel: number;
69 peakForceN: number;
70 contactMs: number;
71 contactEvents: number;
72 peakXiMm: number;
73 peakDispMm: number;
74 peakDispThickness: number;
75 peakAccel: number;
76 strikeRadiusM: number;
77 pickupRadiusM: number;
78 RsM: number;
79 nsteps: number;
80 simRate: number;
81 nDropped: number;
82 durationS: number;
83 renderMs: number;
84 oversample: number;
85 nTheta: number;
86 backend: 'webgpu' | 'cpu';
87 warnings: string[];
88 };
89}
91// --- shared helpers ---
93export function hashMFiles(files: MFile[]): string {
94 let h = 0x811c9dc5;
95 for (const f of files) {
96 const s = f.name + ' ' + f.source;
97 for (let i = 0; i < s.length; i++) {
98 h ^= s.charCodeAt(i);
99 h = Math.imul(h, 0x01000193);
100 }
101 }
102 return (h >>> 0).toString(36);
103}
105function lruGet<T>(cache: Map<string, T>, key: string): T | undefined {
106 const v = cache.get(key);
107 if (v !== undefined) {
108 cache.delete(key);
109 cache.set(key, v);
110 }
111 return v;
112}
114function lruSet<T>(cache: Map<string, T>, key: string, value: T, max: number): void {
115 cache.delete(key);
116 cache.set(key, value);
117 while (cache.size > max) {
118 const oldest = cache.keys().next().value as string;
119 cache.delete(oldest);
120 }
121}
123type Params = Record<string, number | boolean>;
125// MATLAB source lines assigning parameters onto a struct P. bit_depth is a
126// client-side WAV setting, not a model parameter.
127function pLiteral(params: Params): string {
128 const lines: string[] = [];
129 for (const [k, v] of Object.entries(params)) {
130 if (k === 'bit_depth') continue;
131 if (typeof v === 'number') {
132 if (!Number.isFinite(v)) throw new Error(`parameter ${k} is not finite`);
133 lines.push(`P.${k} = ${v};`);
134 } else if (typeof v === 'boolean') lines.push(`P.${k} = ${v ? 1 : 0};`);
135 }
136 return lines.join('\n');
137}
139function keyFor(params: Params, keys: readonly string[]): string {
140 return keys.map((k) => String(params[k] ?? 'd')).join('|');
141}
143function knobValue(settings: Record<string, number>, key: string): number {
144 const spec = CYMBAL2_KNOBS.find((k) => k.key === key)!;
145 const raw = settings[key];
146 return raw === undefined ? spec.default : Math.max(spec.min, Math.min(spec.max, raw));
147}
149/** Velocity quantized to a few levels so identical strokes share a cached
150 * render; 8 levels is inaudible next to the model's own dynamics. */
151export function bucketVelocity(velocity01: number, levels = 8): number {
152 return Math.ceil(Math.max(0.001, Math.min(1, velocity01)) * levels) / levels;
153}
155// Files a render actually executes: a parse failure in one of these stops
156// the render, while a typo in a CLI entry point must not.
157const EXECUTED_BY_APP = new Set([
158 'cymbal2_grid.m',
159 'cymbal2_perm.m',
160 'cymbal2_system.m',
161 'cymbal2_mallet.m',
162 'cymbal2_sim.m',
163 'cymbal2_output.m',
164 'cymbal2_modes.m',
165]);
167const MODES_KEYS = [
168 'radius_m', 'rise_m', 'thickness_m',
169 'youngs_modulus', 'poisson_ratio', 'density',
170 'washer_radius_m', 'washer_stiffness', 'support_clamped',
171 'n_radial', 'n_theta',
172];
174// An error in the MATLAB source, carrying the file and line where numbl put
175// it. Reported to the UI, which stops the render and shows nothing playable.
176class CodeError extends Error {
177 file?: string;
178 line?: number;
179 constructor(message: string, file?: string, line?: number) {
180 super(message);
181 this.name = 'CodeError';
182 this.file = file;
183 this.line = line;
184 }
185}
187const SKIP_WARNING = /skipping (\S+\.m) \((?:syntax error at line (\d+)|parse error)\)/;
189// numbl does not abort on a file it cannot parse: it drops that file with a
190// warning and carries on, so a render could otherwise finish (or fail much
191// later with a confusing 'undefined function') while the real fault is a
192// typo in a file the stage never reached.
193function skipWarningToError(warnings: string[]): CodeError | null {
194 for (const w of warnings) {
195 const hit = SKIP_WARNING.exec(w);
196 if (!hit) continue;
197 const file = hit[1];
198 if (!EXECUTED_BY_APP.has(file)) continue;
199 const line = hit[2] ? Number(hit[2]) : undefined;
200 return new CodeError(
201 line !== undefined
202 ? 'syntax error; the render was stopped'
203 : 'the file cannot be parsed; the render was stopped',
204 file,
205 line,
206 );
207 }
208 return null;
209}
211function runStage(mfiles: MFile[], source: string, inputs?: Record<string, RuntimeValue>) {
212 const warnings: string[] = [];
213 const prevWarn = console.warn;
214 console.warn = (...args: unknown[]) => {
215 warnings.push(args.map(String).join(' '));
216 prevWarn(...(args as []));
217 };
218 try {
219 const r = executeCode(
220 source,
221 { initialVariableValues: inputs, displayResults: false },
222 mfiles,
223 'stage.m',
224 );
225 const skipped = skipWarningToError(warnings);
226 if (skipped) throw skipped;
227 return r;
228 } catch (err) {
229 const skipped = skipWarningToError(warnings);
230 if (skipped) throw skipped;
231 throw err;
232 } finally {
233 console.warn = prevWarn;
234 }
235}
237// Parse every .m file before any expensive stage runs, so a syntax error is
238// reported immediately instead of after a stage.
239function checkMFiles(mfiles: MFile[]): void {
240 runStage(mfiles, '1;');
241}
243function asScalar(v: RuntimeValue): number {
244 if (typeof v === 'number') return v;
245 const t = v as RuntimeTensor;
246 if (t && t.kind === 'tensor' && t.data.length === 1) return t.data[0];
247 if (typeof v === 'boolean') return v ? 1 : 0;
248 throw new Error('expected scalar result from numbl stage');
249}
251function toF32(v: RuntimeValue): Float32Array {
252 const t = v as RuntimeTensor;
253 if (!t || t.kind !== 'tensor') throw new Error('expected tensor result from numbl stage');
254 const out = new Float32Array(t.data.length);
255 for (let i = 0; i < t.data.length; i++) out[i] = t.data[i];
256 return out;
257}
259// Max-pool the contact force history to about 2000 points for the plot.
260function poolForce(fhist: Float32Array, simRate: number): { tpool: Float32Array; fpool: Float32Array } {
261 const nsim = fhist.length;
262 const np0 = Math.min(2000, nsim);
263 const blk = Math.ceil(nsim / np0);
264 const np = Math.ceil(nsim / blk);
265 const fpool = new Float32Array(np);
266 const tpool = new Float32Array(np);
267 for (let b = 0; b < np; b++) {
268 let m = 0;
269 const end = Math.min(nsim, (b + 1) * blk);
270 for (let i = b * blk; i < end; i++) m = Math.max(m, fhist[i]);
271 fpool[b] = m;
272 tpool[b] = (b * blk + blk / 2) / simRate;
273 }
274 return { tpool, fpool };
275}
277// --- the simulation, on either backend ---
279// The stroke-schedule block from cymbal2_sim.m, verbatim, so the GPU render
280// integrates exactly the schedule the interpreter would.
281const SCHEDULE_SRC = `
282K = max(1, round(P.strike_count));
283tstr = P.pre_delay_ms * 1e-3 + (0:K - 1) / max(1e-3, P.strike_rate_hz);
284vstr = zeros(1, K);
285state = P.seed * 2654435761;
286for i = 1:K
287 state = mod(state * 1664525 + 1013904223, 4294967296);
288 x = state / 4294967296;
289 vstr(i) = max(0.05, P.strike_velocity * (1 + P.velocity_spread * (2 * x - 1)));
290end
291gap = P.strike_gap_m;
292if gap <= 0
293 gap = 1.05 * max(vstr)^2 / (2 * P.strike_accel) + 0.005;
294end
295travel = gap ./ vstr + vstr / (2 * P.strike_accel);
296tlaunch = tstr - travel;
297off = -min(0, min(tlaunch));
298tlaunch = tlaunch + off;
299duration = off + (K - 1) / max(1e-3, P.strike_rate_hz) + P.tail_s;
300nsteps = floor(duration * P.sample_rate * P.oversample);
301`;
303// Everything the response assembly needs, produced by either backend. The
304// scalar model facts (rates, grid, thickness) come out of the setup stage
305// rather than from a TS-side parameter struct, so the note-mapping path and
306// the full-surface path share one implementation.
307interface SimOutcome {
308 y32: Float32Array;
309 peakAccel: number;
310 nDropped: number;
311 tpool: Float32Array;
312 fpool: Float32Array;
313 backend: 'webgpu' | 'cpu';
314 sampleRate: number;
315 oversample: number;
316 nTheta: number;
317 normalize: boolean;
318 d: {
319 strokesPlayed: number;
320 meanV: number;
321 contactVel: number;
322 peakForceN: number;
323 contactMs: number;
324 contactEvents: number;
325 peakXiMm: number;
326 peakDispM: number;
327 peakDispThickness: number;
328 nsteps: number;
329 simRate: number;
330 strikeRadiusM: number;
331 pickupRadiusM: number;
332 RsM: number;
333 };
334}
336// Scalars every backend extracts from P after the base source has run.
337const P_FACTS = `
338c_sr = P.sample_rate; c_os = P.oversample; c_nTheta = P.n_theta;
339c_thick = P.thickness_m; c_norm = P.normalize;
340`;
342async function renderOnGpu(
343 pBase: string,
344 mfiles: MFile[],
345 env: RenderEnv,
346 onProgress: (frac: number) => void,
347): Promise<SimOutcome> {
348 // A WebGPU device first: if there is none, fail before spending seconds on
349 // the setup stage.
350 const device = await requestGpuDevice();
351 try {
352 const r = runStage(mfiles, `
353${pBase}
354S = cymbal2_system(P);
355MB = cymbal2_mallet(P);
356G = S.G;
357o_D1 = G.D1; o_D2 = G.D2;
358o_F = G.F; o_Gi = G.Gi;
359o_Rd = G.Rd; o_R2d = G.R2d;
360o_AiB = S.AiB; o_AiC = S.AiC; o_Ai = S.Ai; o_Fi = S.Fi;
361o_ASiB = MB.ASiB; o_ASiC = MB.ASiC;
362o_vM = MB.vM; o_vR = MB.vR; o_vK = MB.vK; o_tipcol = MB.tipcol;
363${SCHEDULE_SRC}
364o_tlaunch = tlaunch; o_vstr = vstr;
365c_nrt = G.nrt; c_Nr = G.Nr; c_nth = G.nth; c_hr = G.hr; c_hth = G.hth;
366c_ht = S.ht; c_Eh = S.Eh; c_Rs = S.Rs; c_rhoh = S.rhoh;
367c_fsc = S.forceScale; c_nlr0 = S.eomRow0; c_nDropped = S.nDropped;
368c_ls = S.ls; c_js = S.js; c_lp = S.lp; c_jp = S.jp;
369c_strikeR = S.strikeRadiusM; c_pickupR = S.pickupRadiusM;
370c_tipi = MB.tipi; c_nfree = MB.nfree; c_ps = MB.ps;
371c_holdSteps = max(1, round(P.strike_hold_ms * 1e-3 / S.ht));
372c_missDepth = gap + 0.06;
373c_gap = gap; c_K = K; c_nsteps = nsteps;
374c_simRate = P.sample_rate * P.oversample;
375c_KM = P.head_stiffness; c_alM = P.head_alpha;
376c_aH = P.strike_accel; c_aHr = P.retract_accel;
377${P_FACTS}
378`);
379 const v = r.variableValues;
380 const s = (n: string) => asScalar(v[n]);
381 const t = (n: string) => toF32(v[n]);
382 const setup: GpuSetup = {
383 ops: {
384 D1: t('o_D1'), D2: t('o_D2'),
385 F: t('o_F'), Gi: t('o_Gi'),
386 Rd: t('o_Rd'), R2d: t('o_R2d'),
387 AiB: t('o_AiB'), AiC: t('o_AiC'), Ai: t('o_Ai'), Fi: t('o_Fi'),
388 ASiB: t('o_ASiB'), ASiC: t('o_ASiC'),
389 vM: t('o_vM'), vR: t('o_vR'), vK: t('o_vK'), tipcol: t('o_tipcol'),
390 },
391 tlaunch: Array.from((v.o_tlaunch as RuntimeTensor).data),
392 vstr: Array.from((v.o_vstr as RuntimeTensor).data),
393 nrt: s('c_nrt'), Nr: s('c_Nr'), nth: s('c_nth'),
394 hr: s('c_hr'), hth: s('c_hth'),
395 ht: s('c_ht'), Eh: s('c_Eh'), Rs: s('c_Rs'), rhoh: s('c_rhoh'),
396 fsc: s('c_fsc'), nlr0: s('c_nlr0'),
397 ls: s('c_ls'), js: s('c_js'), lp: s('c_lp'), jp: s('c_jp'),
398 tipi: s('c_tipi'), nfree: s('c_nfree'), ps: s('c_ps'),
399 KM: s('c_KM'), alM: s('c_alM'),
400 aH: s('c_aH'), aHr: s('c_aHr'),
401 holdSteps: s('c_holdSteps'), missDepth: s('c_missDepth'),
402 gap: s('c_gap'), K: s('c_K'), nsteps: s('c_nsteps'),
403 };
404 const simRate = s('c_simRate');
405 const thickness = s('c_thick');
407 const sim = await runGpuSim(device, setup, (done, n) => {
408 env.checkCancel();
409 onProgress(done / n);
410 });
412 // The output stage through the unmodified cymbal2_output.m.
413 const accT = RTV.tensor(Float64Array.from(sim.acc), [1, sim.acc.length]);
414 const ro = runStage(mfiles, `
415${pBase}
416[y, peakAccel] = cymbal2_output(P, acc);
417`, { acc: accT });
419 // Contact bookkeeping from the force history, as cymbal2_sim does it.
420 let peakF = 0, contactSteps = 0, events = 0, prev = 0;
421 for (let i = 0; i < sim.fhist.length; i++) {
422 const f = sim.fhist[i];
423 if (f > peakF) peakF = f;
424 if (f > 0) { contactSteps++; if (prev === 0) events++; }
425 prev = f;
426 }
427 const { tpool, fpool } = poolForce(sim.fhist, simRate);
428 const meanV = setup.vstr.reduce((a, b) => a + b, 0) / setup.vstr.length;
430 return {
431 y32: toF32(ro.variableValues.y),
432 peakAccel: asScalar(ro.variableValues.peakAccel),
433 nDropped: s('c_nDropped'),
434 tpool,
435 fpool,
436 backend: 'webgpu',
437 sampleRate: s('c_sr'),
438 oversample: s('c_os'),
439 nTheta: s('c_nTheta'),
440 normalize: s('c_norm') !== 0,
441 d: {
442 strokesPlayed: sim.strokesPlayed,
443 meanV,
444 contactVel: sim.contactVel[0] ?? 0,
445 peakForceN: peakF,
446 contactMs: (1000 * contactSteps) / simRate,
447 contactEvents: events,
448 peakXiMm: sim.peakXi * 1000,
449 peakDispM: sim.peakDisp,
450 peakDispThickness: sim.peakDisp / thickness,
451 nsteps: setup.nsteps,
452 simRate,
453 strikeRadiusM: s('c_strikeR'),
454 pickupRadiusM: s('c_pickupR'),
455 RsM: setup.Rs,
456 },
457 };
458 } finally {
459 device.destroy();
460 }
461}
463function renderOnCpu(pBase: string, mfiles: MFile[]): SimOutcome {
464 const r = runStage(mfiles, `
465${pBase}
466S = cymbal2_system(P);
467MB = cymbal2_mallet(P);
468[acc, dinfo, fhist] = cymbal2_sim(P, S, MB);
469[y, peakAccel] = cymbal2_output(P, acc);
470% max-pool the contact force history to about 2000 points for the plot
471nsim = length(fhist);
472np = min(2000, nsim);
473blk = ceil(nsim / np);
474np = ceil(nsim / blk);
475fpool = max(reshape([fhist, zeros(1, np * blk - nsim)], blk, np), [], 1);
476tpool = ((0:np - 1) * blk + blk / 2) / dinfo.simRate;
477d_strokes = dinfo.strokesPlayed;
478d_meanV = dinfo.meanV;
479d_cvel = dinfo.contactVel(1);
480d_peakF = dinfo.peakForceN;
481d_contactMs = dinfo.contactMs;
482d_events = dinfo.contactEvents;
483d_xi = dinfo.peakXiMm;
484d_disp = dinfo.peakDispM;
485d_dispH = dinfo.peakDispThickness;
486d_nsteps = dinfo.nsteps;
487d_simRate = dinfo.simRate;
488d_dropped = S.nDropped;
489d_strikeR = S.strikeRadiusM;
490d_pickupR = S.pickupRadiusM;
491d_Rs = S.Rs;
492${P_FACTS}
493`);
494 const v = r.variableValues;
495 return {
496 y32: toF32(v.y),
497 peakAccel: asScalar(v.peakAccel),
498 nDropped: asScalar(v.d_dropped),
499 tpool: toF32(v.tpool),
500 fpool: toF32(v.fpool),
501 backend: 'cpu',
502 sampleRate: asScalar(v.c_sr),
503 oversample: asScalar(v.c_os),
504 nTheta: asScalar(v.c_nTheta),
505 normalize: asScalar(v.c_norm) !== 0,
506 d: {
507 strokesPlayed: asScalar(v.d_strokes),
508 meanV: asScalar(v.d_meanV),
509 contactVel: asScalar(v.d_cvel),
510 peakForceN: asScalar(v.d_peakF),
511 contactMs: asScalar(v.d_contactMs),
512 contactEvents: asScalar(v.d_events),
513 peakXiMm: asScalar(v.d_xi),
514 peakDispM: asScalar(v.d_disp),
515 peakDispThickness: asScalar(v.d_dispH),
516 nsteps: asScalar(v.d_nsteps),
517 simRate: asScalar(v.d_simRate),
518 strikeRadiusM: asScalar(v.d_strikeR),
519 pickupRadiusM: asScalar(v.d_pickupR),
520 RsM: asScalar(v.d_Rs),
521 },
522 };
523}
525// The WGSL mirrors the shipped cymbal2_sim.m, so an edited step falls back
526// to the interpreter, where the edit is live. Edits to the setup and output
527// stages flow through numbl on both paths.
528function simEdited(env: RenderEnv): boolean {
529 const current = env.mfiles.find((f) => f.name === 'cymbal2_sim.m')?.source;
530 const shipped = env.shipped.find((f) => f.name === 'cymbal2_sim.m')?.source;
531 return current !== shipped;
532}
534async function renderSim(
535 pBase: string,
536 env: RenderEnv,
537 edited: boolean,
538 onIntegrateProgress: (frac: number) => void,
539 warnings: string[],
540): Promise<SimOutcome> {
541 if (!edited) {
542 try {
543 return await renderOnGpu(pBase, env.mfiles, env, onIntegrateProgress);
544 } catch (err) {
545 if (err instanceof RenderCancelled) throw err;
546 const detail = err instanceof Error ? err.message : String(err);
547 // An error in the MATLAB source (it carries a file, or names the
548 // divergence guard) is a fault to report, not a reason to retry the
549 // same code on the CPU. Only a genuine GPU problem falls back.
550 const fromCode =
551 err instanceof CodeError ||
552 typeof (err as { file?: unknown }).file === 'string' ||
553 (err as { name?: string }).name === 'RuntimeError';
554 if (fromCode || /diverged/.test(detail)) throw err;
555 // console.warn is intercepted during a render; use info so the reason
556 // is visible in the devtools console.
557 console.info(`cymbal2: WebGPU unavailable (${detail}); rendering on the CPU`);
558 }
559 }
560 const out = renderOnCpu(pBase, env.mfiles);
561 if (edited) {
562 // Not a fault: just say why the render took the slow path.
563 warnings.push('cymbal2_sim.m differs from the shipped source, so the render used the interpreter (the GPU path mirrors the shipped step)');
564 }
565 return out;
566}
568// --- the explorer path (full params, with diagnostics) ---
570interface ModesEntry {
571 modeF: Float32Array;
572 modeM: Float32Array;
573}
575const modesCache = new Map<string, ModesEntry>();
576const MODES_CACHE_MAX = 4;
578// Finished per-note audio: an in-memory LRU in front of an IndexedDB
579// store, so renders survive page reloads (budgets and eviction live in
580// noteCache.ts).
581const noteCache = new NoteCache('cymbal2-instrument-notes');
583async function renderExplore(
584 req: TrackRenderRequest,
585 env: RenderEnv,
586): Promise<{ audio: Float32Array; diag: Cymbal2ExploreDiag }> {
587 const quality = req.quality;
588 const params: Params = { ...(req.params ?? {}) };
589 if (quality === 'preview') {
590 // oversample 1, everything else as given: the render covers the full
591 // duration, so the decay can be judged, and the azimuthal grid is kept
592 // because hard strokes need the full wavenumber band to carry the
593 // cascade, and coarsening it destabilizes the integration.
594 params.oversample = 1;
595 }
596 const pBase = `P = cymbal2_defaults();\n${pLiteral(params)}`;
598 // Cheap, and it must come first: a file that does not parse stops the
599 // render here rather than partway through a stage.
600 env.post(0.01, 'parsing');
601 await env.yieldToHost();
602 env.checkCancel();
603 checkMFiles(env.mfiles);
605 const codeKey = hashMFiles(env.mfiles);
607 // ---- linear modes, cheap, cached on geometry ----
608 const modesKey = codeKey + '|' + keyFor(params, MODES_KEYS);
609 let modesEntry = lruGet(modesCache, modesKey);
610 if (!modesEntry) {
611 env.post(0.05, 'modes');
612 await env.yieldToHost();
613 env.checkCancel();
614 const r = runStage(env.mfiles, `
615${pBase}
616mF = [];
617mM = [];
618for mw = 0:min(10, floor(P.n_theta / 2))
619 f = cymbal2_modes(P, mw);
620 f = f(f > 0.5 & f < 4000);
621 nk = min(4, length(f));
622 mF = [mF; f(1:nk)];
623 mM = [mM; mw * ones(nk, 1)];
624end
625`);
626 modesEntry = {
627 modeF: toF32(r.variableValues.mF),
628 modeM: toF32(r.variableValues.mM),
629 };
630 lruSet(modesCache, modesKey, modesEntry, MODES_CACHE_MAX);
631 }
633 // ---- the time integration ----
634 env.post(0.1, 'integrate');
635 await env.yieldToHost();
636 env.checkCancel();
637 const t0 = performance.now();
638 const warnings: string[] = [];
639 const out = await renderSim(pBase, env, simEdited(env), (frac) =>
640 env.post(0.1 + 0.85 * frac, 'integrate'), warnings);
641 const renderMs = performance.now() - t0;
643 env.post(0.97, 'output');
644 const { y32, peakAccel, nDropped } = out;
646 if (nDropped > 0) {
647 warnings.push(
648 `${nDropped} azimuthal columns were truncated: the radial grid cannot resolve their inner boundary layer; raise n_radial`,
649 );
650 }
651 if (!out.normalize) {
652 let peak = 0;
653 for (let i = 0; i < y32.length; i++) peak = Math.max(peak, Math.abs(y32[i]));
654 if (peak > 1.0) {
655 warnings.push(
656 `clipping: peak acceleration ${peakAccel.toExponential(2)} m/s² exceeds full_scale_accel`,
657 );
658 }
659 }
661 const diag: Cymbal2ExploreDiag = {
662 quality,
663 forceT: out.tpool,
664 forceN: out.fpool,
665 modeF: modesEntry.modeF.slice(),
666 modeM: modesEntry.modeM.slice(),
667 diag: {
668 strokesPlayed: out.d.strokesPlayed,
669 meanV: out.d.meanV,
670 contactVel: out.d.contactVel,
671 peakForceN: out.d.peakForceN,
672 contactMs: out.d.contactMs,
673 contactEvents: out.d.contactEvents,
674 peakXiMm: out.d.peakXiMm,
675 peakDispMm: out.d.peakDispM * 1000,
676 peakDispThickness: out.d.peakDispThickness,
677 peakAccel,
678 strikeRadiusM: out.d.strikeRadiusM,
679 pickupRadiusM: out.d.pickupRadiusM,
680 RsM: out.d.RsM,
681 nsteps: out.d.nsteps,
682 simRate: out.d.simRate,
683 nDropped,
684 durationS: y32.length / out.sampleRate,
685 renderMs,
686 oversample: out.oversample,
687 nTheta: out.nTheta,
688 backend: out.backend,
689 warnings,
690 },
691 };
692 return { audio: y32, diag };
693}
695// --- the track path (midiboot): per-stroke rendering with caching ---
697function fadeTail(buf: Float32Array, sampleRate: number): void {
698 const n = Math.min(buf.length, Math.round(0.25 * sampleRate));
699 if (n < 2) return;
700 for (let i = 0; i < n; i++) {
701 buf[buf.length - n + i] *= 0.5 * (1 + Math.cos((Math.PI * (i + 1)) / n));
702 }
703}
705function notePLines(note: { pitch: number; velocity: number }, req: TrackRenderRequest, previewTailS: number): string {
706 // The note-to-parameter mapping: MIDI pitch scales the shell geometry,
707 // every length by s = 2^-((pitch - 60)/12), which shifts the whole
708 // spectrum by the semitone ratio exactly (shell modes go as
709 // thickness / radius^2, and the curvature and washer frequencies scale
710 // the same way when the washer circle scales too). The mallet stays
711 // fixed, so this is one player striking a family of cymbal sizes; pitch
712 // 60 is the default 18-inch shell, playable 48..72. The sound is
713 // inharmonic, so the note number is a handle on register, not a tuned
714 // fundamental. Velocity drives the hand speed of the stroke, and the
715 // crash character follows from the physics: soft notes stay nearly
716 // linear, hard notes wash. Fixed: a single stroke, a fixed seed,
717 // normalize off so velocity survives. The preview tier drops the
718 // oversampling and shortens the tail but keeps the azimuthal grid; how
719 // short the tail is depends on the backend (the caller probes for
720 // WebGPU), since a longer ring costs seconds on the GPU but minutes on
721 // the interpreter. Optional request params land after the mapping,
722 // overriding it, which is what the node tests use to shrink the render.
723 const settings = req.settings ?? {};
724 const s = Math.pow(2, -(Math.max(48, Math.min(72, Math.round(note.pitch))) - 60) / 12);
725 const lines = [
726 'P = cymbal2_defaults();',
727 `P.sample_rate = ${req.sampleRate};`,
728 'P.strike_count = 1;',
729 'P.seed = 1;',
730 'P.pre_delay_ms = 0;',
731 'P.normalize = 0;',
732 ];
733 if (req.quality === 'preview') lines.push('P.oversample = 1;', `P.tail_s = ${previewTailS};`);
734 lines.push(
735 `P.radius_m = P.radius_m * ${s};`,
736 `P.rise_m = P.rise_m * ${s};`,
737 `P.thickness_m = P.thickness_m * ${s};`,
738 `P.washer_radius_m = P.washer_radius_m * ${s};`,
739 `P.strike_velocity = ${0.3 + (6.5 - 0.3) * note.velocity};`,
740 `P.strike_r = ${knobValue(settings, 'strike_r')};`,
741 `P.grip_spring = ${knobValue(settings, 'grip_spring')};`,
742 );
743 if (req.params) lines.push(pLiteral(req.params));
744 return lines.join('\n');
745}
747async function renderTrackNotes(
748 req: TrackRenderRequest,
749 env: RenderEnv,
750): Promise<{ audio: Float32Array }> {
751 if (req.notes.length === 0) return { audio: new Float32Array(req.lengthSamples) };
753 env.post(0.005, 'parsing');
754 await env.yieldToHost();
755 env.checkCancel();
756 checkMFiles(env.mfiles);
758 const codeKey = hashMFiles(env.mfiles);
759 const settings = req.settings ?? {};
760 const knobsKey = CYMBAL2_KNOBS.map((k) => `${k.key}=${knobValue(settings, k.key)}`).join(',');
761 const overridesKey = req.params ? JSON.stringify(req.params) : '';
762 const edited = simEdited(env);
764 // The preview tail is a compromise with the backend: the cymbal's low
765 // modes carry a T60 of about 5 s, and on the GPU a 4 s ring costs a few
766 // seconds, but on the interpreter it costs minutes. Probe once per
767 // request and choose accordingly; the tail is part of the cache key, so
768 // a browser gaining or losing WebGPU re-renders rather than reusing the
769 // other tier's tail.
770 let previewTailS = 1.5;
771 if (req.quality === 'preview' && !edited) {
772 try {
773 const probe = await requestGpuDevice();
774 probe.destroy();
775 previewTailS = 4;
776 } catch { /* no WebGPU; keep the short tail */ }
777 }
779 // Group notes by everything that affects their sound. A crash ignores
780 // note duration (the ring is the physics), so a repeated stroke costs
781 // one integration per distinct pitch and velocity level.
782 const groups = new Map<string, { pitch: number; velocity: number; onsets: number[] }>();
783 for (const n of req.notes) {
784 const pitch = Math.round(n.pitch);
785 const vel = bucketVelocity(n.velocity);
786 const key = `${codeKey}|${req.quality}|${previewTailS}|${req.sampleRate}|${knobsKey}|${overridesKey}|${pitch}|${vel}`;
787 let g = groups.get(key);
788 if (!g) groups.set(key, (g = { pitch, velocity: vel, onsets: [] }));
789 g.onsets.push(n.timeSec);
790 }
792 const placed: { buf: Float32Array; onsets: number[] }[] = [];
793 // Resolve cache hits first (memory, then IndexedDB), so the progress
794 // denominator counts only the sounds that actually need rendering.
795 const resolved = new Map<string, Float32Array>();
796 for (const key of groups.keys()) {
797 const hit = await noteCache.get(key);
798 if (hit) resolved.set(key, hit);
799 }
800 const misses = groups.size - resolved.size;
801 let done = 0;
802 for (const [key, g] of groups) {
803 let buf = resolved.get(key);
804 if (!buf) {
805 const i = done;
806 const label = `note ${i + 1}/${misses}: integrate`;
807 env.post(i / Math.max(1, misses), label);
808 await env.yieldToHost();
809 env.checkCancel();
810 const warnings: string[] = [];
811 const sim = await renderSim(notePLines(g, req, previewTailS), env, edited, (frac) =>
812 env.post((i + 0.05 + 0.9 * frac) / Math.max(1, misses), label), warnings);
813 buf = sim.y32;
814 // The integration stops at tail_s, which for a cymbal is well before
815 // silence; a short raised-cosine fade removes the cliff.
816 fadeTail(buf, req.sampleRate);
817 noteCache.put(key, buf);
818 done++;
819 }
820 placed.push({ buf, onsets: g.onsets });
821 }
823 // lengthSamples is a minimum: size the output to the longest tail so a
824 // ring that outlives the last note is returned in full, and place every
825 // copy.
826 let length = req.lengthSamples;
827 for (const p of placed) {
828 for (const t of p.onsets) length = Math.max(length, Math.round(t * req.sampleRate) + p.buf.length);
829 }
830 const out = new Float32Array(length);
831 for (const p of placed) {
832 for (const t of p.onsets) {
833 const off = Math.round(t * req.sampleRate);
834 const end = Math.min(out.length, off + p.buf.length);
835 for (let j = Math.max(0, off); j < end; j++) out[j] += p.buf[j - off];
836 }
837 }
838 return { audio: out };
839}
841/** The single entry point: explorer path when diagnostics are requested,
842 * the per-stroke track path otherwise. */
843export async function renderRequest(
844 variant: Cymbal2Variant,
845 req: TrackRenderRequest,
846 env: RenderEnv,
847): Promise<{ audio: Float32Array; diag?: Cymbal2ExploreDiag }> {
848 void variant;
849 if (req.wantDiagnostics) return renderExplore(req, env);
850 return renderTrackNotes(req, env);
851}