// The cymbal (Samejima 2021 nonlinear FD shell) 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 crash ignores // note duration, the ring is the physics), each distinct stroke is a // full time integration, and copies are summed at the onsets. MIDI // pitch scales the shell geometry around pitch 60, exactly as the // retired per-note export driver did. // // - the explorer path (the cymbal2 web app, wantDiagnostics set): the // full parameter surface and live-edited .m sources drive one render // with the diagnostics payload the app's plots consume. // // Either way the MATLAB implementation in ../matlab remains the source of // truth; what varies is who runs the time loop. When WebGPU is available // the unmodified setup stage runs through numbl, the operators go to the // WGSL port of cymbal2_sim.m (./gpuSim.ts), and the acceleration comes // back through the unmodified cymbal2_output.m; this is 20x or more // faster than interpreting the loop, and the GPU chunk boundaries give // integration progress and cooperative cancellation. Without WebGPU, or // when cymbal2_sim.m has been edited in the app (the WGSL mirrors the // shipped step), the original interpreter path runs instead. import { executeCode, RTV, type RuntimeValue, type RuntimeTensor } from 'numbl'; import type { MFile, TrackRenderRequest } from './types.ts'; import { CYMBAL2_KNOBS, type Cymbal2Variant } from './knobs.ts'; import { requestGpuDevice, runGpuSim, type GpuSetup } from './gpuSim.ts'; import { NoteCache } from './noteCache.ts'; export { CYMBAL2_KNOBS, type Cymbal2Variant }; /** Thrown by env.checkCancel to abandon a render; the worker maps it to a * cancelled response rather than an error. */ export class RenderCancelled extends Error { constructor() { super('render cancelled'); this.name = 'RenderCancelled'; } } /** 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[]; /** The shipped sources, for the cymbal2_sim.m edited-step check. */ shipped: MFile[]; post: (frac: number, stage: string) => void; /** Throws RenderCancelled to abandon the render; checked between stages, * between notes, and between GPU chunks. */ checkCancel: () => void; yieldToHost: () => Promise; } // --- diagnostics payload for the explorer path (the web app's plots) --- export interface Cymbal2ExploreDiag { quality: 'preview' | 'full'; forceT: Float32Array; // contact force history (max-pooled), s forceN: Float32Array; // and its values, N modeF: Float32Array; // linear mode frequencies, Hz modeM: Float32Array; // nodal diameters of each diag: { strokesPlayed: number; meanV: number; contactVel: number; peakForceN: number; contactMs: number; contactEvents: number; peakXiMm: number; peakDispMm: number; peakDispThickness: number; peakAccel: number; strikeRadiusM: number; pickupRadiusM: number; RsM: number; nsteps: number; simRate: number; nDropped: number; durationS: number; renderMs: number; oversample: number; nTheta: number; backend: 'webgpu' | 'cpu'; warnings: string[]; }; } // --- 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; // MATLAB source lines assigning parameters onto a struct P. bit_depth is a // client-side WAV setting, not a model parameter. function pLiteral(params: Params): string { const lines: string[] = []; for (const [k, v] of Object.entries(params)) { if (k === 'bit_depth') continue; 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 knobValue(settings: Record, key: string): number { const spec = CYMBAL2_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; } // Files a render actually executes: a parse failure in one of these stops // the render, while a typo in a CLI entry point must not. const EXECUTED_BY_APP = new Set([ 'cymbal2_grid.m', 'cymbal2_perm.m', 'cymbal2_system.m', 'cymbal2_mallet.m', 'cymbal2_sim.m', 'cymbal2_output.m', 'cymbal2_modes.m', ]); const MODES_KEYS = [ 'radius_m', 'rise_m', 'thickness_m', 'youngs_modulus', 'poisson_ratio', 'density', 'washer_radius_m', 'washer_stiffness', 'support_clamped', 'n_radial', 'n_theta', ]; // An error in the MATLAB source, carrying the file and line where numbl put // it. Reported to the UI, which stops the render and shows nothing playable. class CodeError extends Error { file?: string; line?: number; constructor(message: string, file?: string, line?: number) { super(message); this.name = 'CodeError'; this.file = file; this.line = line; } } const SKIP_WARNING = /skipping (\S+\.m) \((?:syntax error at line (\d+)|parse error)\)/; // numbl does not abort on a file it cannot parse: it drops that file with a // warning and carries on, so a render could otherwise finish (or fail much // later with a confusing 'undefined function') while the real fault is a // typo in a file the stage never reached. function skipWarningToError(warnings: string[]): CodeError | null { for (const w of warnings) { const hit = SKIP_WARNING.exec(w); if (!hit) continue; const file = hit[1]; if (!EXECUTED_BY_APP.has(file)) continue; const line = hit[2] ? Number(hit[2]) : undefined; return new CodeError( line !== undefined ? 'syntax error; the render was stopped' : 'the file cannot be parsed; the render was stopped', file, line, ); } return null; } function runStage(mfiles: MFile[], source: string, inputs?: Record) { const warnings: string[] = []; const prevWarn = console.warn; console.warn = (...args: unknown[]) => { warnings.push(args.map(String).join(' ')); prevWarn(...(args as [])); }; try { const r = executeCode( source, { initialVariableValues: inputs, displayResults: false }, mfiles, 'stage.m', ); const skipped = skipWarningToError(warnings); if (skipped) throw skipped; return r; } catch (err) { const skipped = skipWarningToError(warnings); if (skipped) throw skipped; throw err; } finally { console.warn = prevWarn; } } // Parse every .m file before any expensive stage runs, so a syntax error is // reported immediately instead of after a stage. function checkMFiles(mfiles: MFile[]): void { runStage(mfiles, '1;'); } 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 = v as RuntimeTensor; if (!t || t.kind !== 'tensor') throw new Error('expected tensor result from numbl stage'); const out = new Float32Array(t.data.length); for (let i = 0; i < t.data.length; i++) out[i] = t.data[i]; return out; } // Max-pool the contact force history to about 2000 points for the plot. function poolForce(fhist: Float32Array, simRate: number): { tpool: Float32Array; fpool: Float32Array } { const nsim = fhist.length; const np0 = Math.min(2000, nsim); const blk = Math.ceil(nsim / np0); const np = Math.ceil(nsim / blk); const fpool = new Float32Array(np); const tpool = new Float32Array(np); for (let b = 0; b < np; b++) { let m = 0; const end = Math.min(nsim, (b + 1) * blk); for (let i = b * blk; i < end; i++) m = Math.max(m, fhist[i]); fpool[b] = m; tpool[b] = (b * blk + blk / 2) / simRate; } return { tpool, fpool }; } // --- the simulation, on either backend --- // The stroke-schedule block from cymbal2_sim.m, verbatim, so the GPU render // integrates exactly the schedule the interpreter would. const SCHEDULE_SRC = ` K = max(1, round(P.strike_count)); tstr = P.pre_delay_ms * 1e-3 + (0:K - 1) / max(1e-3, P.strike_rate_hz); vstr = zeros(1, K); state = P.seed * 2654435761; for i = 1:K state = mod(state * 1664525 + 1013904223, 4294967296); x = state / 4294967296; vstr(i) = max(0.05, P.strike_velocity * (1 + P.velocity_spread * (2 * x - 1))); end gap = P.strike_gap_m; if gap <= 0 gap = 1.05 * max(vstr)^2 / (2 * P.strike_accel) + 0.005; end travel = gap ./ vstr + vstr / (2 * P.strike_accel); tlaunch = tstr - travel; off = -min(0, min(tlaunch)); tlaunch = tlaunch + off; duration = off + (K - 1) / max(1e-3, P.strike_rate_hz) + P.tail_s; nsteps = floor(duration * P.sample_rate * P.oversample); `; // Everything the response assembly needs, produced by either backend. The // scalar model facts (rates, grid, thickness) come out of the setup stage // rather than from a TS-side parameter struct, so the note-mapping path and // the full-surface path share one implementation. interface SimOutcome { y32: Float32Array; peakAccel: number; nDropped: number; tpool: Float32Array; fpool: Float32Array; backend: 'webgpu' | 'cpu'; sampleRate: number; oversample: number; nTheta: number; normalize: boolean; d: { strokesPlayed: number; meanV: number; contactVel: number; peakForceN: number; contactMs: number; contactEvents: number; peakXiMm: number; peakDispM: number; peakDispThickness: number; nsteps: number; simRate: number; strikeRadiusM: number; pickupRadiusM: number; RsM: number; }; } // Scalars every backend extracts from P after the base source has run. const P_FACTS = ` c_sr = P.sample_rate; c_os = P.oversample; c_nTheta = P.n_theta; c_thick = P.thickness_m; c_norm = P.normalize; `; async function renderOnGpu( pBase: string, mfiles: MFile[], env: RenderEnv, onProgress: (frac: number) => void, ): Promise { // A WebGPU device first: if there is none, fail before spending seconds on // the setup stage. const device = await requestGpuDevice(); try { const r = runStage(mfiles, ` ${pBase} S = cymbal2_system(P); MB = cymbal2_mallet(P); G = S.G; o_D1 = G.D1; o_D2 = G.D2; o_F = G.F; o_Gi = G.Gi; o_Rd = G.Rd; o_R2d = G.R2d; o_AiB = S.AiB; o_AiC = S.AiC; o_Ai = S.Ai; o_Fi = S.Fi; o_ASiB = MB.ASiB; o_ASiC = MB.ASiC; o_vM = MB.vM; o_vR = MB.vR; o_vK = MB.vK; o_tipcol = MB.tipcol; ${SCHEDULE_SRC} o_tlaunch = tlaunch; o_vstr = vstr; c_nrt = G.nrt; c_Nr = G.Nr; c_nth = G.nth; c_hr = G.hr; c_hth = G.hth; c_ht = S.ht; c_Eh = S.Eh; c_Rs = S.Rs; c_rhoh = S.rhoh; c_fsc = S.forceScale; c_nlr0 = S.eomRow0; c_nDropped = S.nDropped; c_ls = S.ls; c_js = S.js; c_lp = S.lp; c_jp = S.jp; c_strikeR = S.strikeRadiusM; c_pickupR = S.pickupRadiusM; c_tipi = MB.tipi; c_nfree = MB.nfree; c_ps = MB.ps; c_holdSteps = max(1, round(P.strike_hold_ms * 1e-3 / S.ht)); c_missDepth = gap + 0.06; c_gap = gap; c_K = K; c_nsteps = nsteps; c_simRate = P.sample_rate * P.oversample; c_KM = P.head_stiffness; c_alM = P.head_alpha; c_aH = P.strike_accel; c_aHr = P.retract_accel; ${P_FACTS} `); const v = r.variableValues; const s = (n: string) => asScalar(v[n]); const t = (n: string) => toF32(v[n]); const setup: GpuSetup = { ops: { D1: t('o_D1'), D2: t('o_D2'), F: t('o_F'), Gi: t('o_Gi'), Rd: t('o_Rd'), R2d: t('o_R2d'), AiB: t('o_AiB'), AiC: t('o_AiC'), Ai: t('o_Ai'), Fi: t('o_Fi'), ASiB: t('o_ASiB'), ASiC: t('o_ASiC'), vM: t('o_vM'), vR: t('o_vR'), vK: t('o_vK'), tipcol: t('o_tipcol'), }, tlaunch: Array.from((v.o_tlaunch as RuntimeTensor).data), vstr: Array.from((v.o_vstr as RuntimeTensor).data), nrt: s('c_nrt'), Nr: s('c_Nr'), nth: s('c_nth'), hr: s('c_hr'), hth: s('c_hth'), ht: s('c_ht'), Eh: s('c_Eh'), Rs: s('c_Rs'), rhoh: s('c_rhoh'), fsc: s('c_fsc'), nlr0: s('c_nlr0'), ls: s('c_ls'), js: s('c_js'), lp: s('c_lp'), jp: s('c_jp'), tipi: s('c_tipi'), nfree: s('c_nfree'), ps: s('c_ps'), KM: s('c_KM'), alM: s('c_alM'), aH: s('c_aH'), aHr: s('c_aHr'), holdSteps: s('c_holdSteps'), missDepth: s('c_missDepth'), gap: s('c_gap'), K: s('c_K'), nsteps: s('c_nsteps'), }; const simRate = s('c_simRate'); const thickness = s('c_thick'); const sim = await runGpuSim(device, setup, (done, n) => { env.checkCancel(); onProgress(done / n); }); // The output stage through the unmodified cymbal2_output.m. const accT = RTV.tensor(Float64Array.from(sim.acc), [1, sim.acc.length]); const ro = runStage(mfiles, ` ${pBase} [y, peakAccel] = cymbal2_output(P, acc); `, { acc: accT }); // Contact bookkeeping from the force history, as cymbal2_sim does it. let peakF = 0, contactSteps = 0, events = 0, prev = 0; for (let i = 0; i < sim.fhist.length; i++) { const f = sim.fhist[i]; if (f > peakF) peakF = f; if (f > 0) { contactSteps++; if (prev === 0) events++; } prev = f; } const { tpool, fpool } = poolForce(sim.fhist, simRate); const meanV = setup.vstr.reduce((a, b) => a + b, 0) / setup.vstr.length; return { y32: toF32(ro.variableValues.y), peakAccel: asScalar(ro.variableValues.peakAccel), nDropped: s('c_nDropped'), tpool, fpool, backend: 'webgpu', sampleRate: s('c_sr'), oversample: s('c_os'), nTheta: s('c_nTheta'), normalize: s('c_norm') !== 0, d: { strokesPlayed: sim.strokesPlayed, meanV, contactVel: sim.contactVel[0] ?? 0, peakForceN: peakF, contactMs: (1000 * contactSteps) / simRate, contactEvents: events, peakXiMm: sim.peakXi * 1000, peakDispM: sim.peakDisp, peakDispThickness: sim.peakDisp / thickness, nsteps: setup.nsteps, simRate, strikeRadiusM: s('c_strikeR'), pickupRadiusM: s('c_pickupR'), RsM: setup.Rs, }, }; } finally { device.destroy(); } } function renderOnCpu(pBase: string, mfiles: MFile[]): SimOutcome { const r = runStage(mfiles, ` ${pBase} S = cymbal2_system(P); MB = cymbal2_mallet(P); [acc, dinfo, fhist] = cymbal2_sim(P, S, MB); [y, peakAccel] = cymbal2_output(P, acc); % max-pool the contact force history to about 2000 points for the plot nsim = length(fhist); np = min(2000, nsim); blk = ceil(nsim / np); np = ceil(nsim / blk); fpool = max(reshape([fhist, zeros(1, np * blk - nsim)], blk, np), [], 1); tpool = ((0:np - 1) * blk + blk / 2) / dinfo.simRate; d_strokes = dinfo.strokesPlayed; d_meanV = dinfo.meanV; d_cvel = dinfo.contactVel(1); d_peakF = dinfo.peakForceN; d_contactMs = dinfo.contactMs; d_events = dinfo.contactEvents; d_xi = dinfo.peakXiMm; d_disp = dinfo.peakDispM; d_dispH = dinfo.peakDispThickness; d_nsteps = dinfo.nsteps; d_simRate = dinfo.simRate; d_dropped = S.nDropped; d_strikeR = S.strikeRadiusM; d_pickupR = S.pickupRadiusM; d_Rs = S.Rs; ${P_FACTS} `); const v = r.variableValues; return { y32: toF32(v.y), peakAccel: asScalar(v.peakAccel), nDropped: asScalar(v.d_dropped), tpool: toF32(v.tpool), fpool: toF32(v.fpool), backend: 'cpu', sampleRate: asScalar(v.c_sr), oversample: asScalar(v.c_os), nTheta: asScalar(v.c_nTheta), normalize: asScalar(v.c_norm) !== 0, d: { strokesPlayed: asScalar(v.d_strokes), meanV: asScalar(v.d_meanV), contactVel: asScalar(v.d_cvel), peakForceN: asScalar(v.d_peakF), contactMs: asScalar(v.d_contactMs), contactEvents: asScalar(v.d_events), peakXiMm: asScalar(v.d_xi), peakDispM: asScalar(v.d_disp), peakDispThickness: asScalar(v.d_dispH), nsteps: asScalar(v.d_nsteps), simRate: asScalar(v.d_simRate), strikeRadiusM: asScalar(v.d_strikeR), pickupRadiusM: asScalar(v.d_pickupR), RsM: asScalar(v.d_Rs), }, }; } // The WGSL mirrors the shipped cymbal2_sim.m, so an edited step falls back // to the interpreter, where the edit is live. Edits to the setup and output // stages flow through numbl on both paths. function simEdited(env: RenderEnv): boolean { const current = env.mfiles.find((f) => f.name === 'cymbal2_sim.m')?.source; const shipped = env.shipped.find((f) => f.name === 'cymbal2_sim.m')?.source; return current !== shipped; } async function renderSim( pBase: string, env: RenderEnv, edited: boolean, onIntegrateProgress: (frac: number) => void, warnings: string[], ): Promise { if (!edited) { try { return await renderOnGpu(pBase, env.mfiles, env, onIntegrateProgress); } catch (err) { if (err instanceof RenderCancelled) throw err; const detail = err instanceof Error ? err.message : String(err); // An error in the MATLAB source (it carries a file, or names the // divergence guard) is a fault to report, not a reason to retry the // same code on the CPU. Only a genuine GPU problem falls back. const fromCode = err instanceof CodeError || typeof (err as { file?: unknown }).file === 'string' || (err as { name?: string }).name === 'RuntimeError'; if (fromCode || /diverged/.test(detail)) throw err; // console.warn is intercepted during a render; use info so the reason // is visible in the devtools console. console.info(`cymbal2: WebGPU unavailable (${detail}); rendering on the CPU`); } } const out = renderOnCpu(pBase, env.mfiles); if (edited) { // Not a fault: just say why the render took the slow path. warnings.push('cymbal2_sim.m differs from the shipped source, so the render used the interpreter (the GPU path mirrors the shipped step)'); } return out; } // --- the explorer path (full params, with diagnostics) --- interface ModesEntry { modeF: Float32Array; modeM: Float32Array; } const modesCache = new Map(); const MODES_CACHE_MAX = 4; // 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('cymbal2-instrument-notes'); async function renderExplore( req: TrackRenderRequest, env: RenderEnv, ): Promise<{ audio: Float32Array; diag: Cymbal2ExploreDiag }> { const quality = req.quality; const params: Params = { ...(req.params ?? {}) }; if (quality === 'preview') { // oversample 1, everything else as given: the render covers the full // duration, so the decay can be judged, and the azimuthal grid is kept // because hard strokes need the full wavenumber band to carry the // cascade, and coarsening it destabilizes the integration. params.oversample = 1; } const pBase = `P = cymbal2_defaults();\n${pLiteral(params)}`; // Cheap, and it must come first: a file that does not parse stops the // render here rather than partway through a stage. env.post(0.01, 'parsing'); await env.yieldToHost(); env.checkCancel(); checkMFiles(env.mfiles); const codeKey = hashMFiles(env.mfiles); // ---- linear modes, cheap, cached on geometry ---- const modesKey = codeKey + '|' + keyFor(params, MODES_KEYS); let modesEntry = lruGet(modesCache, modesKey); if (!modesEntry) { env.post(0.05, 'modes'); await env.yieldToHost(); env.checkCancel(); const r = runStage(env.mfiles, ` ${pBase} mF = []; mM = []; for mw = 0:min(10, floor(P.n_theta / 2)) f = cymbal2_modes(P, mw); f = f(f > 0.5 & f < 4000); nk = min(4, length(f)); mF = [mF; f(1:nk)]; mM = [mM; mw * ones(nk, 1)]; end `); modesEntry = { modeF: toF32(r.variableValues.mF), modeM: toF32(r.variableValues.mM), }; lruSet(modesCache, modesKey, modesEntry, MODES_CACHE_MAX); } // ---- the time integration ---- env.post(0.1, 'integrate'); await env.yieldToHost(); env.checkCancel(); const t0 = performance.now(); const warnings: string[] = []; const out = await renderSim(pBase, env, simEdited(env), (frac) => env.post(0.1 + 0.85 * frac, 'integrate'), warnings); const renderMs = performance.now() - t0; env.post(0.97, 'output'); const { y32, peakAccel, nDropped } = out; if (nDropped > 0) { warnings.push( `${nDropped} azimuthal columns were truncated: the radial grid cannot resolve their inner boundary layer; raise n_radial`, ); } if (!out.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 acceleration ${peakAccel.toExponential(2)} m/s² exceeds full_scale_accel`, ); } } const diag: Cymbal2ExploreDiag = { quality, forceT: out.tpool, forceN: out.fpool, modeF: modesEntry.modeF.slice(), modeM: modesEntry.modeM.slice(), diag: { strokesPlayed: out.d.strokesPlayed, meanV: out.d.meanV, contactVel: out.d.contactVel, peakForceN: out.d.peakForceN, contactMs: out.d.contactMs, contactEvents: out.d.contactEvents, peakXiMm: out.d.peakXiMm, peakDispMm: out.d.peakDispM * 1000, peakDispThickness: out.d.peakDispThickness, peakAccel, strikeRadiusM: out.d.strikeRadiusM, pickupRadiusM: out.d.pickupRadiusM, RsM: out.d.RsM, nsteps: out.d.nsteps, simRate: out.d.simRate, nDropped, durationS: y32.length / out.sampleRate, renderMs, oversample: out.oversample, nTheta: out.nTheta, backend: out.backend, warnings, }, }; return { audio: y32, diag }; } // --- the track path (midiboot): per-stroke rendering with caching --- function fadeTail(buf: Float32Array, sampleRate: number): void { const n = Math.min(buf.length, Math.round(0.25 * sampleRate)); if (n < 2) return; for (let i = 0; i < n; i++) { buf[buf.length - n + i] *= 0.5 * (1 + Math.cos((Math.PI * (i + 1)) / n)); } } function notePLines(note: { pitch: number; velocity: number }, req: TrackRenderRequest, previewTailS: number): string { // The note-to-parameter mapping: MIDI pitch scales the shell geometry, // every length by s = 2^-((pitch - 60)/12), which shifts the whole // spectrum by the semitone ratio exactly (shell modes go as // thickness / radius^2, and the curvature and washer frequencies scale // the same way when the washer circle scales too). The mallet stays // fixed, so this is one player striking a family of cymbal sizes; pitch // 60 is the default 18-inch shell, playable 48..72. The sound is // inharmonic, so the note number is a handle on register, not a tuned // fundamental. Velocity drives the hand speed of the stroke, and the // crash character follows from the physics: soft notes stay nearly // linear, hard notes wash. Fixed: a single stroke, a fixed seed, // normalize off so velocity survives. The preview tier drops the // oversampling and shortens the tail but keeps the azimuthal grid; how // short the tail is depends on the backend (the caller probes for // WebGPU), since a longer ring costs seconds on the GPU but minutes on // the interpreter. Optional request params land after the mapping, // overriding it, which is what the node tests use to shrink the render. const settings = req.settings ?? {}; const s = Math.pow(2, -(Math.max(48, Math.min(72, Math.round(note.pitch))) - 60) / 12); const lines = [ 'P = cymbal2_defaults();', `P.sample_rate = ${req.sampleRate};`, 'P.strike_count = 1;', 'P.seed = 1;', 'P.pre_delay_ms = 0;', 'P.normalize = 0;', ]; if (req.quality === 'preview') lines.push('P.oversample = 1;', `P.tail_s = ${previewTailS};`); lines.push( `P.radius_m = P.radius_m * ${s};`, `P.rise_m = P.rise_m * ${s};`, `P.thickness_m = P.thickness_m * ${s};`, `P.washer_radius_m = P.washer_radius_m * ${s};`, `P.strike_velocity = ${0.3 + (6.5 - 0.3) * note.velocity};`, `P.strike_r = ${knobValue(settings, 'strike_r')};`, `P.grip_spring = ${knobValue(settings, 'grip_spring')};`, ); if (req.params) lines.push(pLiteral(req.params)); 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) }; env.post(0.005, 'parsing'); await env.yieldToHost(); env.checkCancel(); checkMFiles(env.mfiles); const codeKey = hashMFiles(env.mfiles); const settings = req.settings ?? {}; const knobsKey = CYMBAL2_KNOBS.map((k) => `${k.key}=${knobValue(settings, k.key)}`).join(','); const overridesKey = req.params ? JSON.stringify(req.params) : ''; const edited = simEdited(env); // The preview tail is a compromise with the backend: the cymbal's low // modes carry a T60 of about 5 s, and on the GPU a 4 s ring costs a few // seconds, but on the interpreter it costs minutes. Probe once per // request and choose accordingly; the tail is part of the cache key, so // a browser gaining or losing WebGPU re-renders rather than reusing the // other tier's tail. let previewTailS = 1.5; if (req.quality === 'preview' && !edited) { try { const probe = await requestGpuDevice(); probe.destroy(); previewTailS = 4; } catch { /* no WebGPU; keep the short tail */ } } // Group notes by everything that affects their sound. A crash ignores // note duration (the ring is the physics), so a repeated stroke costs // one integration 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}|${previewTailS}|${req.sampleRate}|${knobsKey}|${overridesKey}|${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) { const i = done; const label = `note ${i + 1}/${misses}: integrate`; env.post(i / Math.max(1, misses), label); await env.yieldToHost(); env.checkCancel(); const warnings: string[] = []; const sim = await renderSim(notePLines(g, req, previewTailS), env, edited, (frac) => env.post((i + 0.05 + 0.9 * frac) / Math.max(1, misses), label), warnings); buf = sim.y32; // The integration stops at tail_s, which for a cymbal is well before // silence; a short raised-cosine fade removes the cliff. fadeTail(buf, req.sampleRate); 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: Cymbal2Variant, req: TrackRenderRequest, env: RenderEnv, ): Promise<{ audio: Float32Array; diag?: Cymbal2ExploreDiag }> { void variant; if (req.wantDiagnostics) return renderExplore(req, env); return renderTrackNotes(req, env); }