/ simulated_instruments / woodblock
Sign in
simulated_instruments / woodblock
woodblock / webapp / src / worker / renderWorker.ts
201 lines · 6.5 KBCodeBlameHistory
982850fWoodblock strike simulator: Python reference model and interactive web appJeremy Magland 1// Web Worker that runs the simulation off the main thread and caches
2// intermediates so cheap parameter changes skip the expensive stages:
3// solve tier -> ODE solve (cached on solve-tier params + oversample)
4// radiate tier -> retarded-source sum (cached on radiate-tier params + M)
5// output tier -> decimation + normalization (always cheap, always re-run)
7import { SOLVE_KEYS, RADIATE_KEYS, type WoodblockParams } from '../params';
8import { buildBasis, type ModalBasisData } from '../physics/basis';
9import { simulate, type SolveResult } from '../physics/solver';
10import { radiate } from '../physics/radiate';
11import { outputStage } from '../physics/render';
13export interface RenderRequest {
14 type: 'render';
15 id: number;
16 params: WoodblockParams;
17 quality: 'preview' | 'full';
20export interface RenderResponse {
21 type: 'result';
22 id: number;
23 quality: 'preview' | 'full';
24 y: Float32Array; // final signal, [-1, 1]
25 sampleRate: number;
26 force: Float32Array; // contact force, first forceMs at fsHi
27 forceFsHz: number;
28 modeShapes: Float32Array; // n x SHAPE_PTS, concatenated
29 shapePts: number;
30 diag: {
31 modesKept: number;
32 partialsHz: number[];
33 ratios: number[];
34 helmholtzHz: number;
35 contactMs: number;
36 peakForceN: number;
37 peakPa: number;
38 peakSplDb: number;
39 listenerDistM: number;
40 warnings: string[];
41 solveMs: number; // 0 when the cached solve was reused
42 radiateMs: number; // 0 when the cached radiation was reused
43 solveCached: boolean;
44 radiateCached: boolean;
45 oversample: number;
46 elements: number;
47 };
50export interface ProgressMessage {
51 type: 'progress';
52 id: number;
53 stage: 'solve' | 'radiate' | 'output';
54 frac: number;
57const SHAPE_PTS = 129;
58const FORCE_MS = 8;
60// Small LRU caches so preview- and full-quality intermediates coexist. A
61// radiate-tier change right after a full render then reuses the full solve,
62// and the "preview" is silently upgraded to full quality.
63const solveCache = new Map<string, { basis: ModalBasisData; solve: SolveResult }>();
64const radiateCache = new Map<string, { p: Float64Array; warnings: string[] }>();
65const SOLVE_CACHE_MAX = 2;
66const RADIATE_CACHE_MAX = 3;
68function lruGet<T>(cache: Map<string, T>, key: string): T | undefined {
69 const v = cache.get(key);
70 if (v !== undefined) {
71 cache.delete(key);
72 cache.set(key, v); // refresh recency
73 }
74 return v;
77function lruSet<T>(cache: Map<string, T>, key: string, value: T, max: number): void {
78 cache.delete(key);
79 cache.set(key, value);
80 while (cache.size > max) {
81 const oldest = cache.keys().next().value as string;
82 cache.delete(oldest);
83 }
86function keyFor(params: WoodblockParams, keys: readonly (keyof WoodblockParams)[]): string {
87 return keys.map((k) => String(params[k])).join('|');
90self.onmessage = (ev: MessageEvent<RenderRequest>) => {
91 const msg = ev.data;
92 if (msg.type !== 'render') return;
93 const { id, params } = msg;
94 let quality = msg.quality;
96 const solveParamsKey = keyFor(params, SOLVE_KEYS);
97 const fullSolveKey = solveParamsKey + `|os=${params.oversample}`;
98 if (quality === 'preview' && solveCache.has(fullSolveKey)) {
99 quality = 'full'; // full solve already cached; only cheap stages remain
100 }
102 const oversample = quality === 'preview' ? Math.min(8, params.oversample) : params.oversample;
103 const elements = quality === 'preview'
104 ? Math.min(24, params.radiating_elements)
105 : params.radiating_elements;
107 const post = (m: ProgressMessage) => (self as unknown as Worker).postMessage(m);
109 const solveKey = solveParamsKey + `|os=${oversample}`;
110 let solveMs = 0;
111 let solveCached = true;
112 let solveEntry = lruGet(solveCache, solveKey);
113 if (!solveEntry) {
114 solveCached = false;
115 const t0 = performance.now();
116 const basis = buildBasis(params);
117 const solve = simulate(params, basis, oversample, (frac) =>
118 post({ type: 'progress', id, stage: 'solve', frac }),
119 );
120 solveMs = performance.now() - t0;
121 solveEntry = { basis, solve };
122 lruSet(solveCache, solveKey, solveEntry, SOLVE_CACHE_MAX);
123 }
124 const { basis, solve } = solveEntry;
126 const radiateKey = solveKey + '||' + keyFor(params, RADIATE_KEYS) + `|M=${elements}`;
127 let radiateMs = 0;
128 let radiateCached = true;
129 let radEntry = lruGet(radiateCache, radiateKey);
130 if (!radEntry) {
131 radiateCached = false;
132 const t0 = performance.now();
133 const rad = radiate(params, solve.Qdd, solve.Xdd, basis, solve.fsHi, elements, (frac) =>
134 post({ type: 'progress', id, stage: 'radiate', frac }),
135 );
136 radiateMs = performance.now() - t0;
137 radEntry = { p: rad.p, warnings: rad.warnings };
138 lruSet(radiateCache, radiateKey, radEntry, RADIATE_CACHE_MAX);
139 }
141 const out = outputStage(params, radEntry.p, oversample);
143 // Contact force excerpt for plotting
144 const forceLen = Math.min(solve.force.length, Math.round((FORCE_MS / 1000) * solve.fsHi));
145 const force = new Float32Array(forceLen);
146 for (let i = 0; i < forceLen; i++) force[i] = solve.force[i];
148 // Mode shapes decimated for the mode viewer (unnormalized visual scale)
149 const modeShapes = new Float32Array(basis.n * SHAPE_PTS);
150 for (let n = 0; n < basis.n; n++) {
151 const src = basis.shapes[n];
152 let peak = 0;
153 for (let i = 0; i < src.length; i++) peak = Math.max(peak, Math.abs(src[i]));
154 for (let i = 0; i < SHAPE_PTS; i++) {
155 const j = Math.round((i / (SHAPE_PTS - 1)) * (src.length - 1));
156 modeShapes[n * SHAPE_PTS + i] = peak > 0 ? src[j] / peak : 0;
157 }
158 }
160 const y32 = new Float32Array(out.y.length);
161 for (let i = 0; i < out.y.length; i++) y32[i] = out.y[i];
163 const warnings = [...radEntry.warnings];
164 if (out.clipWarning) warnings.push(out.clipWarning);
165 const lx = params.listener_x_m, ly = params.listener_y_m, lz = params.listener_z_m;
167 const resp: RenderResponse = {
168 type: 'result',
169 id,
170 quality,
171 y: y32,
172 sampleRate: params.sample_rate,
173 force,
174 forceFsHz: solve.fsHi,
175 modeShapes,
176 shapePts: SHAPE_PTS,
177 diag: {
178 modesKept: basis.n,
179 partialsHz: Array.from(basis.f),
180 ratios: Array.from(basis.f).map((f) => f / basis.f[0]),
181 helmholtzHz: solve.helmholtzHz,
182 contactMs: solve.contactMs,
183 peakForceN: solve.peakForceN,
184 peakPa: out.peakPa,
185 peakSplDb: out.peakSplDb,
186 listenerDistM: Math.sqrt(lx * lx + ly * ly + lz * lz),
187 warnings,
188 solveMs,
189 radiateMs,
190 solveCached,
191 radiateCached,
192 oversample,
193 elements,
194 },
195 };
196 (self as unknown as Worker).postMessage(resp, [
197 y32.buffer,
198 force.buffer,
199 modeShapes.buffer,
200 ]);
201};
↑↓ move↵openescclose