// Web Worker that renders the cymbal2 stroke. The MATLAB implementation in // ../matlab remains the source of truth; what varies is who runs the time // loop. When the browser exposes WebGPU, the worker executes the unmodified // setup stage (grid, per-wavenumber inverses, mallet, stroke schedule) // through numbl, hands the operators to a WGSL port of cymbal2_sim.m // (./gpuSim.ts), and feeds the returned acceleration back through the // unmodified cymbal2_output.m; this is 20x or more faster than interpreting // the loop. Without WebGPU, or when cymbal2_sim.m has been edited in the app // (the WGSL mirrors the shipped step, so edits there must fall back), the // original interpreter path runs instead. Requests carry a quality tier // 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 // full -> the parameters as given // and the UI shows elapsed time, plus integration progress on the GPU path. // A cheap linear-modes stage (cymbal2_modes per wavenumber) is cached on the // geometry so the spectrum plot can overlay the mode table immediately. import { executeCode, RTV, type RuntimeValue, type RuntimeTensor } from 'numbl'; import { hashMFiles, DEFAULT_MFILES, EXECUTED_BY_APP, type MFile } from '../mfiles'; import { MODES_KEYS, type Cymbal2Params } from '../params'; import { requestGpuDevice, runGpuSim, type GpuSetup } from './gpuSim'; export interface RenderRequest { type: 'render'; id: number; params: Cymbal2Params; quality: 'preview' | 'full'; mfiles: MFile[]; } export interface RenderResponse { type: 'result'; id: number; quality: 'preview' | 'full'; y: Float32Array; // final signal, [-1, 1] sampleRate: number; 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[]; }; } export interface ProgressMessage { type: 'progress'; id: number; stage: 'parsing' | 'modes' | 'integrate' | 'output'; frac: number; } export interface ErrorMessage { type: 'error'; id: number; message: string; file?: string; line?: number; } interface ModesEntry { modeF: Float32Array; modeM: Float32Array; } const modesCache = new Map(); const MODES_CACHE_MAX = 4; 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); } } function keyFor(params: Cymbal2Params, keys: readonly (keyof Cymbal2Params)[]): string { return keys.map((k) => String(params[k])).join('|'); } // MATLAB source lines assigning every parameter onto a struct P. function pLiteral(params: Cymbal2Params): string { const lines: string[] = []; for (const [k, v] of Object.entries(params)) { if (k === 'bit_depth') continue; if (typeof v === 'number') lines.push(`P.${k} = ${v};`); else if (typeof v === 'boolean') lines.push(`P.${k} = ${v ? 1 : 0};`); } return lines.join('\n'); } // 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 with the rest, 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. Every numbl call runs // with console.warn captured, and a dropped file becomes a CodeError. Only // the files the app executes count: the CLI entry points (cymbal2_demo.m and // the rest) are in the editor but play no part in a render, so a typo there // must not stop one. 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) { // A file numbl dropped is the better explanation than whatever the run // failed on afterwards (typically a call to the missing function). 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 (or, if the modes table is // cached, after the whole integration). Executing a trivial script with the // files attached is enough: numbl pre-parses the whole workspace. 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 }; } self.onmessage = (ev: MessageEvent) => { const msg = ev.data; if (msg.type !== 'render') return; const warnings: string[] = []; const origWarn = console.warn; console.warn = (...args: unknown[]) => { warnings.push(args.map(String).join(' ')); }; render(msg) .catch((err) => { // runStage turns a dropped file into a CodeError itself; this catches // the same warning from anything that reached numbl another way. const e = (skipWarningToError(warnings) ?? err) as { message?: string; file?: string; line?: number; }; const m: ErrorMessage = { type: 'error', id: msg.id, message: e?.message ? String(e.message) : String(err), file: typeof e?.file === 'string' ? e.file : undefined, line: typeof e?.line === 'number' ? e.line : undefined, }; (self as unknown as Worker).postMessage(m); }) .finally(() => { console.warn = origWarn; }); }; // Everything the response assembly needs, produced by either backend. interface SimOutcome { y32: Float32Array; peakAccel: number; nDropped: number; tpool: Float32Array; fpool: Float32Array; backend: 'webgpu' | 'cpu'; 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; }; } // 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); `; async function renderOnGpu( mfiles: MFile[], params: Cymbal2Params, 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, ` ${pLiteral(params)} 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; `); 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: params.head_stiffness, alM: params.head_alpha, aH: params.strike_accel, aHr: params.retract_accel, 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 sim = await runGpuSim(device, setup, (done, n) => 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, ` ${pLiteral(params)} [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', 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 / params.thickness_m, nsteps: setup.nsteps, simRate, strikeRadiusM: s('c_strikeR'), pickupRadiusM: s('c_pickupR'), RsM: setup.Rs, }, }; } finally { device.destroy(); } } function renderOnCpu(mfiles: MFile[], params: Cymbal2Params): SimOutcome { const r = runStage(mfiles, ` ${pLiteral(params)} 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; `); 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', 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), }, }; } async function render(msg: RenderRequest): Promise { const { id, mfiles, quality } = msg; const params = { ...msg.params }; if (quality === 'preview') { params.oversample = 1; } const post = (m: ProgressMessage) => (self as unknown as Worker).postMessage(m); // Cheap, and it must come first: a file that does not parse stops the // render here rather than partway through a stage. post({ type: 'progress', id, stage: 'parsing', frac: 0.01 }); checkMFiles(mfiles); const codeKey = hashMFiles(mfiles); // ---- linear modes, cheap, cached on geometry ---- const modesKey = codeKey + '|' + keyFor(params, MODES_KEYS); let modesEntry = lruGet(modesCache, modesKey); if (!modesEntry) { post({ type: 'progress', id, stage: 'modes', frac: 0.05 }); const r = runStage(mfiles, ` ${pLiteral(params)} 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 ---- post({ type: 'progress', id, stage: 'integrate', frac: 0.1 }); const t0 = performance.now(); const warnings: string[] = []; // 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. const simSource = mfiles.find((f) => f.name === 'cymbal2_sim.m')?.source; const simShipped = DEFAULT_MFILES.find((f) => f.name === 'cymbal2_sim.m')?.source; const simEdited = simSource !== simShipped; let out: SimOutcome | null = null; if (!simEdited) { try { out = await renderOnGpu(mfiles, params, (frac) => post({ type: 'progress', id, stage: 'integrate', frac: 0.1 + 0.85 * frac }), ); } catch (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`); } } if (!out) { out = renderOnCpu(mfiles, params); if (simEdited) { // 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)'); } } const renderMs = performance.now() - t0; post({ type: 'progress', id, stage: 'output', frac: 0.97 }); 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 (!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 acceleration ${peakAccel.toExponential(2)} m/s² exceeds full_scale_accel`, ); } } const resp: RenderResponse = { type: 'result', id, quality, y: y32, sampleRate: params.sample_rate, 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 / params.sample_rate, renderMs, oversample: params.oversample, nTheta: params.n_theta, backend: out.backend, warnings, }, }; (self as unknown as Worker).postMessage(resp, [ resp.y.buffer, resp.forceT.buffer, resp.forceN.buffer, resp.modeF.buffer, resp.modeM.buffer, ]); }