// Web Worker that renders the woodblock strike by executing the MATLAB // implementation in ../matlab through numbl. The stages run as separate // executeCode calls so intermediates can be cached and cheap parameter // changes skip the expensive ones: // solve tier -> woodblock_solve (cached on solve-tier params + oversample) // radiate tier -> woodblock_radiate (cached on radiate-tier params + M) // output tier -> woodblock_output (always cheap, always re-run) // Tensors cross the boundary as numbl RuntimeValues (column-major Float64). import { executeCode, type RuntimeValue, type RuntimeTensor } from 'numbl'; import { hashMFiles, type MFile } from '../mfiles'; import { SOLVE_KEYS, RADIATE_KEYS, type WoodblockParams } from '../params'; export interface RenderRequest { type: 'render'; id: number; params: WoodblockParams; quality: 'preview' | 'full'; mfiles: MFile[]; } 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 shapePts, 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; } // Raised by a fault in the .m code (the user can edit it), so it is reported // rather than left to stall the request. export interface ErrorMessage { type: 'error'; id: number; message: string; file?: string; line?: number; } const SHAPE_PTS = 128; const FORCE_MS = 8; interface SolveEntry { Qdd: RuntimeValue; Xdd: RuntimeValue; fsHi: number; f: number[]; modeShapes: Float32Array; force: Float32Array; helmholtzHz: number; contactMs: number; peakForceN: number; } interface RadiateEntry { p: RuntimeValue; warnings: string[]; } // 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); } 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('|'); } // MATLAB source lines assigning every parameter onto a struct P. function pLiteral(params: WoodblockParams): string { const lines: string[] = []; for (const [k, v] of Object.entries(params)) { 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 asTensor(v: RuntimeValue): RuntimeTensor { const t = v as RuntimeTensor; if (!t || t.kind !== 'tensor') throw new Error('expected tensor result from numbl stage'); return t; } 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'); } const SKIP_WARNING = /skipping (\S+\.m) \(syntax error at line (\d+)\)/; self.onmessage = (ev: MessageEvent) => { const msg = ev.data; if (msg.type !== 'render') return; // numbl reports a file it could not parse through console.warn and then // skips it, so the failure surfaces at the call site as "Undefined // function". Capture the warning to report the actual syntax error instead. const warnings: string[] = []; const origWarn = console.warn; console.warn = (...args: unknown[]) => { warnings.push(args.map(String).join(' ')); }; try { render(msg); } catch (err) { // Most likely a fault in the (user-editable) .m code. Report it so the // request completes and the message reaches the code panel. 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, params, mfiles } = msg; let quality = msg.quality; // The .m sources are part of the cache identity: editing the code must // invalidate everything computed from the previous version. const codeKey = hashMFiles(mfiles); const solveParamsKey = codeKey + '|' + 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 fsHi = params.sample_rate * oversample; const post = (m: ProgressMessage) => (self as unknown as Worker).postMessage(m); // ---- solve stage ---- const solveKey = solveParamsKey + `|os=${oversample}`; let solveMs = 0; let solveCached = true; let solveEntry = lruGet(solveCache, solveKey); if (!solveEntry) { solveCached = false; post({ type: 'progress', id, stage: 'solve', frac: 0.15 }); const t0 = performance.now(); const r = runStage(mfiles, ` ${pLiteral(params)} basis = woodblock_basis(P); [Qdd, Xdd, force, helmholtzHz, contactMs, peakForceN, maxRealLam] = woodblock_solve(P, basis, ${oversample}); f = basis.f; shapes = basis.shapes(1:32:end, :); `); solveMs = performance.now() - t0; const fT = asTensor(r.variableValues.f); const f = Array.from(fT.data); const n = f.length; // decimated mode shapes, normalized per mode for the viewer const shapesT = asTensor(r.variableValues.shapes); const rows = shapesT.shape[0]; const modeShapes = new Float32Array(n * SHAPE_PTS); for (let m = 0; m < n; m++) { let peak = 0; for (let i = 0; i < rows; i++) peak = Math.max(peak, Math.abs(shapesT.data[m * rows + i])); for (let i = 0; i < SHAPE_PTS; i++) { const j = Math.round((i / (SHAPE_PTS - 1)) * (rows - 1)); modeShapes[m * SHAPE_PTS + i] = peak > 0 ? shapesT.data[m * rows + j] / peak : 0; } } const forceT = asTensor(r.variableValues.force); const forceLen = Math.min(forceT.data.length, Math.round((FORCE_MS / 1000) * fsHi)); const force = new Float32Array(forceLen); for (let i = 0; i < forceLen; i++) force[i] = forceT.data[i]; solveEntry = { Qdd: r.variableValues.Qdd, Xdd: r.variableValues.Xdd, fsHi, f, modeShapes, force, helmholtzHz: asScalar(r.variableValues.helmholtzHz), contactMs: asScalar(r.variableValues.contactMs), peakForceN: asScalar(r.variableValues.peakForceN), }; lruSet(solveCache, solveKey, solveEntry, SOLVE_CACHE_MAX); } // ---- radiate stage ---- const radiateKey = solveKey + '||' + keyFor(params, RADIATE_KEYS) + `|M=${elements}`; let radiateMs = 0; let radiateCached = true; let radEntry = lruGet(radiateCache, radiateKey); if (!radEntry) { radiateCached = false; post({ type: 'progress', id, stage: 'radiate', frac: 0.55 }); const t0 = performance.now(); const r = runStage(mfiles, ` ${pLiteral(params)} basis = woodblock_basis(P); [p, spacingNeed, listenerInside] = woodblock_radiate(P, basis, Qdd, Xdd, ${solveEntry.fsHi}, ${elements}); `, { Qdd: solveEntry.Qdd, Xdd: solveEntry.Xdd }); radiateMs = performance.now() - t0; const warnings: string[] = []; const spacingNeed = asScalar(r.variableValues.spacingNeed); if (spacingNeed > 0) { const fMax = solveEntry.f[solveEntry.f.length - 1]; warnings.push( `element spacing is coarse at ${fMax.toFixed(0)} Hz; consider ${spacingNeed} radiating elements`, ); } if (asScalar(r.variableValues.listenerInside) !== 0) { const d = Math.hypot(params.listener_x_m, params.listener_y_m, params.listener_z_m); warnings.push( `listener is ${(d * 100).toFixed(0)} cm from the block centre, inside the source region; point-source elements are crude here`, ); } radEntry = { p: r.variableValues.p, warnings }; lruSet(radiateCache, radiateKey, radEntry, RADIATE_CACHE_MAX); } // ---- output stage ---- post({ type: 'progress', id, stage: 'output', frac: 0.9 }); const rOut = runStage(mfiles, ` ${pLiteral(params)} [y, peakPa] = woodblock_output(P, p, ${oversample}); `, { p: radEntry.p }); const yT = asTensor(rOut.variableValues.y); const y32 = new Float32Array(yT.data.length); for (let i = 0; i < yT.data.length; i++) y32[i] = yT.data[i]; const peakPa = asScalar(rOut.variableValues.peakPa); const warnings = [...radEntry.warnings]; 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 is ${peakPa.toFixed(2)} Pa but full_scale_pa is ${params.full_scale_pa.toFixed(2)}`, ); } } const lx = params.listener_x_m, ly = params.listener_y_m, lz = params.listener_z_m; const force = solveEntry.force.slice(); const modeShapes = solveEntry.modeShapes.slice(); const resp: RenderResponse = { type: 'result', id, quality, y: y32, sampleRate: params.sample_rate, force, forceFsHz: solveEntry.fsHi, modeShapes, shapePts: SHAPE_PTS, diag: { modesKept: solveEntry.f.length, partialsHz: solveEntry.f, ratios: solveEntry.f.map((f) => f / solveEntry.f[0]), helmholtzHz: solveEntry.helmholtzHz, contactMs: solveEntry.contactMs, peakForceN: solveEntry.peakForceN, peakPa, peakSplDb: 20 * Math.log10(peakPa / 20e-6 + 1e-30), 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]); }