// Web Worker that renders the cymbal2 stroke by executing the MATLAB // implementation in ../matlab through numbl. Unlike the sibling cymbal app // there is no cheap stage to cache around: the cost IS the time integration, // one long executeCode call, so requests carry a quality tier // preview -> oversample 1, tail clamped to 1.5 s (the azimuthal grid is // kept: 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 rather than a meaningful progress fraction. // 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, type RuntimeValue, type RuntimeTensor } from 'numbl'; import { hashMFiles, type MFile } from '../mfiles'; import { MODES_KEYS, type Cymbal2Params } from '../params'; 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; warnings: string[]; }; } export interface ProgressMessage { type: 'progress'; id: number; stage: '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'); } function runStage(mfiles: MFile[], source: string, inputs?: Record) { return executeCode( source, { initialVariableValues: inputs, displayResults: false }, mfiles, 'stage.m', ); } 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; } const SKIP_WARNING = /skipping (\S+\.m) \(syntax error at line (\d+)\)/; 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(' ')); }; try { render(msg); } catch (err) { const e = err as { message?: string; file?: string; line?: number }; let 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, }; for (const w of warnings) { const hit = SKIP_WARNING.exec(w); if (hit) { m = { type: 'error', id: msg.id, message: 'syntax error', file: hit[1], line: Number(hit[2]) }; break; } } (self as unknown as Worker).postMessage(m); } finally { console.warn = origWarn; } }; function render(msg: RenderRequest): void { const { id, mfiles, quality } = msg; const params = { ...msg.params }; if (quality === 'preview') { params.oversample = 1; params.tail_s = Math.min(params.tail_s, 1.5); } const post = (m: ProgressMessage) => (self as unknown as Worker).postMessage(m); 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, one long call ---- post({ type: 'progress', id, stage: 'integrate', frac: 0.2 }); const t0 = performance.now(); 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 renderMs = performance.now() - t0; post({ type: 'progress', id, stage: 'output', frac: 0.95 }); const y32 = toF32(r.variableValues.y); const peakAccel = asScalar(r.variableValues.peakAccel); const nDropped = asScalar(r.variableValues.d_dropped); const warnings: string[] = []; 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: toF32(r.variableValues.tpool), forceN: toF32(r.variableValues.fpool), modeF: modesEntry.modeF.slice(), modeM: modesEntry.modeM.slice(), diag: { strokesPlayed: asScalar(r.variableValues.d_strokes), meanV: asScalar(r.variableValues.d_meanV), contactVel: asScalar(r.variableValues.d_cvel), peakForceN: asScalar(r.variableValues.d_peakF), contactMs: asScalar(r.variableValues.d_contactMs), contactEvents: asScalar(r.variableValues.d_events), peakXiMm: asScalar(r.variableValues.d_xi), peakDispMm: asScalar(r.variableValues.d_disp) * 1000, peakDispThickness: asScalar(r.variableValues.d_dispH), peakAccel, strikeRadiusM: asScalar(r.variableValues.d_strikeR), pickupRadiusM: asScalar(r.variableValues.d_pickupR), RsM: asScalar(r.variableValues.d_Rs), nsteps: asScalar(r.variableValues.d_nsteps), simRate: asScalar(r.variableValues.d_simRate), nDropped, durationS: y32.length / params.sample_rate, renderMs, oversample: params.oversample, nTheta: params.n_theta, warnings, }, }; (self as unknown as Worker).postMessage(resp, [ resp.y.buffer, resp.forceT.buffer, resp.forceN.buffer, resp.modeF.buffer, resp.modeM.buffer, ]); }