// Web Worker that runs the simulation off the main thread and caches // intermediates so cheap parameter changes skip the expensive stages: // solve tier -> ODE solve (cached on solve-tier params + oversample) // radiate tier -> retarded-source sum (cached on radiate-tier params + M) // output tier -> decimation + normalization (always cheap, always re-run) import { SOLVE_KEYS, RADIATE_KEYS, type WoodblockParams } from '../params'; import { buildBasis, type ModalBasisData } from '../physics/basis'; import { simulate, type SolveResult } from '../physics/solver'; import { radiate } from '../physics/radiate'; import { outputStage } from '../physics/render'; export interface RenderRequest { type: 'render'; id: number; params: WoodblockParams; quality: 'preview' | 'full'; } export interface RenderResponse { type: 'result'; id: number; quality: 'preview' | 'full'; y: Float32Array; // final signal, [-1, 1] sampleRate: number; force: Float32Array; // contact force, first forceMs at fsHi forceFsHz: number; modeShapes: Float32Array; // n x SHAPE_PTS, concatenated shapePts: number; diag: { modesKept: number; partialsHz: number[]; ratios: number[]; helmholtzHz: number; contactMs: number; peakForceN: number; peakPa: number; peakSplDb: number; listenerDistM: number; warnings: string[]; solveMs: number; // 0 when the cached solve was reused radiateMs: number; // 0 when the cached radiation was reused solveCached: boolean; radiateCached: boolean; oversample: number; elements: number; }; } export interface ProgressMessage { type: 'progress'; id: number; stage: 'solve' | 'radiate' | 'output'; frac: number; } const SHAPE_PTS = 129; const FORCE_MS = 8; // Small LRU caches so preview- and full-quality intermediates coexist. A // radiate-tier change right after a full render then reuses the full solve, // and the "preview" is silently upgraded to full quality. const solveCache = new Map(); const radiateCache = new Map(); const SOLVE_CACHE_MAX = 2; const RADIATE_CACHE_MAX = 3; function lruGet(cache: Map, key: string): T | undefined { const v = cache.get(key); if (v !== undefined) { cache.delete(key); cache.set(key, v); // refresh recency } 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: WoodblockParams, keys: readonly (keyof WoodblockParams)[]): string { return keys.map((k) => String(params[k])).join('|'); } self.onmessage = (ev: MessageEvent) => { const msg = ev.data; if (msg.type !== 'render') return; const { id, params } = msg; let quality = msg.quality; const solveParamsKey = keyFor(params, SOLVE_KEYS); const fullSolveKey = solveParamsKey + `|os=${params.oversample}`; if (quality === 'preview' && solveCache.has(fullSolveKey)) { quality = 'full'; // full solve already cached; only cheap stages remain } const oversample = quality === 'preview' ? Math.min(8, params.oversample) : params.oversample; const elements = quality === 'preview' ? Math.min(24, params.radiating_elements) : params.radiating_elements; const post = (m: ProgressMessage) => (self as unknown as Worker).postMessage(m); const solveKey = solveParamsKey + `|os=${oversample}`; let solveMs = 0; let solveCached = true; let solveEntry = lruGet(solveCache, solveKey); if (!solveEntry) { solveCached = false; const t0 = performance.now(); const basis = buildBasis(params); const solve = simulate(params, basis, oversample, (frac) => post({ type: 'progress', id, stage: 'solve', frac }), ); solveMs = performance.now() - t0; solveEntry = { basis, solve }; lruSet(solveCache, solveKey, solveEntry, SOLVE_CACHE_MAX); } const { basis, solve } = solveEntry; const radiateKey = solveKey + '||' + keyFor(params, RADIATE_KEYS) + `|M=${elements}`; let radiateMs = 0; let radiateCached = true; let radEntry = lruGet(radiateCache, radiateKey); if (!radEntry) { radiateCached = false; const t0 = performance.now(); const rad = radiate(params, solve.Qdd, solve.Xdd, basis, solve.fsHi, elements, (frac) => post({ type: 'progress', id, stage: 'radiate', frac }), ); radiateMs = performance.now() - t0; radEntry = { p: rad.p, warnings: rad.warnings }; lruSet(radiateCache, radiateKey, radEntry, RADIATE_CACHE_MAX); } const out = outputStage(params, radEntry.p, oversample); // Contact force excerpt for plotting const forceLen = Math.min(solve.force.length, Math.round((FORCE_MS / 1000) * solve.fsHi)); const force = new Float32Array(forceLen); for (let i = 0; i < forceLen; i++) force[i] = solve.force[i]; // Mode shapes decimated for the mode viewer (unnormalized visual scale) const modeShapes = new Float32Array(basis.n * SHAPE_PTS); for (let n = 0; n < basis.n; n++) { const src = basis.shapes[n]; let peak = 0; for (let i = 0; i < src.length; i++) peak = Math.max(peak, Math.abs(src[i])); for (let i = 0; i < SHAPE_PTS; i++) { const j = Math.round((i / (SHAPE_PTS - 1)) * (src.length - 1)); modeShapes[n * SHAPE_PTS + i] = peak > 0 ? src[j] / peak : 0; } } const y32 = new Float32Array(out.y.length); for (let i = 0; i < out.y.length; i++) y32[i] = out.y[i]; const warnings = [...radEntry.warnings]; if (out.clipWarning) warnings.push(out.clipWarning); const lx = params.listener_x_m, ly = params.listener_y_m, lz = params.listener_z_m; const resp: RenderResponse = { type: 'result', id, quality, y: y32, sampleRate: params.sample_rate, force, forceFsHz: solve.fsHi, modeShapes, shapePts: SHAPE_PTS, diag: { modesKept: basis.n, partialsHz: Array.from(basis.f), ratios: Array.from(basis.f).map((f) => f / basis.f[0]), helmholtzHz: solve.helmholtzHz, contactMs: solve.contactMs, peakForceN: solve.peakForceN, peakPa: out.peakPa, peakSplDb: out.peakSplDb, listenerDistM: Math.sqrt(lx * lx + ly * ly + lz * lz), warnings, solveMs, radiateMs, solveCached, radiateCached, oversample, elements, }, }; (self as unknown as Worker).postMessage(resp, [ y32.buffer, force.buffer, modeShapes.buffer, ]); };