// Web Worker that renders the triangle 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 -> triangle_basis (FEM assembly + eigensolve; the expensive // stage, cached on geometry/material/damping params) // radiate tier -> triangle_strikes + triangle_radiate (cached on the // strike process, the listener, and the synthesis rate) // output tier -> triangle_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 TriangleParams } from '../params'; export interface RenderRequest { type: 'render'; id: number; params: TriangleParams; quality: 'preview' | 'full'; mfiles: MFile[]; } export interface RenderResponse { type: 'result'; id: number; quality: 'preview' | 'full'; y: Float32Array; // final signal, [-1, 1] sampleRate: number; strikeT: Float32Array; // strike times, s strikeV: Float32Array; // strike velocities, m/s strikeS: Float32Array; // strike arclength fractions modeF: Float32Array; // mode frequencies, Hz modeZeta: Float32Array; // damping ratio of each mode modeZetaRad: Float32Array; // radiation part of the damping modeFamily: Float32Array; // 0 = in-plane, 1 = out-of-plane nodeX: Float32Array; // rod centerline nodes, m nodeY: Float32Array; nodeS: Float32Array; // arclength fraction of each node shapeUx: Float32Array; // node displacements per mode, nn x n col-major shapeUy: Float32Array; shapeUz: Float32Array; diag: { modesKept: number; modesIn: number; modesOut: number; strikeCount: number; meanV: number; maxV: number; meanTauUs: number; peakForceN: number; durationS: number; peakPa: number; peakSplDb: number; directPeakPa: number; listenerDistM: number; warnings: string[]; solveMs: number; // 0 when the cached eigensolve was reused radiateMs: number; // 0 when the cached radiation was reused solveCached: boolean; radiateCached: boolean; oversample: 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; } // Basis fields that cross from the solve stage into the radiate stage as // numbl RuntimeValues, plus their Float32 copies for the plots. interface SolveEntry { vars: Record; modeF: Float32Array; modeZeta: Float32Array; modeZetaRad: Float32Array; modeFamily: Float32Array; nodeX: Float32Array; nodeY: Float32Array; nodeS: Float32Array; shapeUx: Float32Array; shapeUy: Float32Array; shapeUz: Float32Array; modesIn: number; modesOut: number; } interface RadiateEntry { p: RuntimeValue; strikeT: Float32Array; strikeV: Float32Array; strikeS: Float32Array; strikeCount: number; meanV: number; maxV: number; meanTauUs: number; peakForceN: number; durationS: number; directPeakPa: number; listenerDistM: number; listenerInside: boolean; fsHi: number; oversample: number; } // Small LRU caches. The solve cache does not depend on the oversample rate, // so a preview render right after a full one reuses the same eigensolve. 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: TriangleParams, keys: readonly (keyof TriangleParams)[]): string { return keys.map((k) => String(params[k])).join('|'); } // MATLAB source lines assigning every parameter onto a struct P. function pLiteral(params: TriangleParams): 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'); } function toF32(v: RuntimeValue): Float32Array { const t = asTensor(v); const out = new Float32Array(t.data.length); for (let i = 0; i < t.data.length; i++) out[i] = t.data[i]; return out; } // The basis fields handed from the solve stage into the radiate stage, in the // order the reassembly script expects. const BASIS_FIELDS = [ 'f', 'omega', 'zeta', 'zetaRad', 'family', 'nodeX', 'nodeY', 'sfrac', 'totalLen', 'NUx', 'NUy', 'NUz', 'elemX', 'elemY', 'elemTanX', 'elemTanY', 'elemLen', 'EUpx', 'EUpy', 'EUpz', ]; 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 solveKey = codeKey + '|' + keyFor(params, SOLVE_KEYS); const fullOversample = params.oversample; const radiateParamsKey = solveKey + '||' + keyFor(params, RADIATE_KEYS); if (quality === 'preview' && radiateCache.has(radiateParamsKey + `|os=${fullOversample}`)) { quality = 'full'; // full-rate radiation already cached } const oversample = quality === 'preview' ? Math.min(1, params.oversample) : params.oversample; const fsHi = params.sample_rate * oversample; const post = (m: ProgressMessage) => (self as unknown as Worker).postMessage(m); // ---- solve stage: FEM assembly and eigensolve ---- 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 = triangle_basis(P); ${BASIS_FIELDS.map((f) => `b_${f} = basis.${f};`).join('\n')} modesIn = sum(basis.family == 0); modesOut = sum(basis.family == 1); `); solveMs = performance.now() - t0; const vars: Record = {}; for (const f of BASIS_FIELDS) vars[`b_${f}`] = r.variableValues[`b_${f}`]; solveEntry = { vars, modeF: toF32(r.variableValues.b_f), modeZeta: toF32(r.variableValues.b_zeta), modeZetaRad: toF32(r.variableValues.b_zetaRad), modeFamily: toF32(r.variableValues.b_family), nodeX: toF32(r.variableValues.b_nodeX), nodeY: toF32(r.variableValues.b_nodeY), nodeS: toF32(r.variableValues.b_sfrac), shapeUx: toF32(r.variableValues.b_NUx), shapeUy: toF32(r.variableValues.b_NUy), shapeUz: toF32(r.variableValues.b_NUz), modesIn: asScalar(r.variableValues.modesIn), modesOut: asScalar(r.variableValues.modesOut), }; lruSet(solveCache, solveKey, solveEntry, SOLVE_CACHE_MAX); } // ---- radiate stage: strike process + modal synthesis ---- const radiateKey = radiateParamsKey + `|os=${oversample}`; 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_FIELDS.map((f) => `basis.${f} = b_${f};`).join('\n')} basis.n = length(basis.f); [tstr, vstr, jstr, taustr, fstr, sstr, sinfo] = triangle_strikes(P); [p, radinfo] = triangle_radiate(P, basis, tstr, vstr, jstr, taustr, fstr, sstr, ${fsHi}); strikeCount = sinfo.count; meanV = sinfo.meanV; maxV = sinfo.maxV; meanTauUs = sinfo.meanTauUs; peakForceN = sinfo.peakForceN; durationS = sinfo.durationS; directPeakPa = radinfo.directPeakPa; listenerDistM = radinfo.listenerDistM; listenerInside = radinfo.listenerInside; `, solveEntry.vars); radiateMs = performance.now() - t0; radEntry = { p: r.variableValues.p, strikeT: toF32(r.variableValues.tstr), strikeV: toF32(r.variableValues.vstr), strikeS: toF32(r.variableValues.sstr), strikeCount: asScalar(r.variableValues.strikeCount), meanV: asScalar(r.variableValues.meanV), maxV: asScalar(r.variableValues.maxV), meanTauUs: asScalar(r.variableValues.meanTauUs), peakForceN: asScalar(r.variableValues.peakForceN), durationS: asScalar(r.variableValues.durationS), directPeakPa: asScalar(r.variableValues.directPeakPa), listenerDistM: asScalar(r.variableValues.listenerDistM), listenerInside: asScalar(r.variableValues.listenerInside) !== 0, fsHi, oversample, }; 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] = triangle_output(P, p, ${radEntry.oversample}); `, { p: radEntry.p }); const y32 = toF32(rOut.variableValues.y); const peakPa = asScalar(rOut.variableValues.peakPa); const warnings: string[] = []; if (radEntry.listenerInside) { warnings.push( 'the listener is within 2 cm of the rod; the dipole field is evaluated at a clamped minimum distance', ); } 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 topMode = solveEntry.modeF[solveEntry.modeF.length - 1]; if (topMode < 0.4 * params.max_mode_hz) { warnings.push( `the highest retained mode is only ${topMode.toFixed(0)} Hz; the shimmer above it falls entirely on the direct click`, ); } const resp: RenderResponse = { type: 'result', id, quality, y: y32, sampleRate: params.sample_rate, strikeT: radEntry.strikeT.slice(), strikeV: radEntry.strikeV.slice(), strikeS: radEntry.strikeS.slice(), modeF: solveEntry.modeF.slice(), modeZeta: solveEntry.modeZeta.slice(), modeZetaRad: solveEntry.modeZetaRad.slice(), modeFamily: solveEntry.modeFamily.slice(), nodeX: solveEntry.nodeX.slice(), nodeY: solveEntry.nodeY.slice(), nodeS: solveEntry.nodeS.slice(), shapeUx: solveEntry.shapeUx.slice(), shapeUy: solveEntry.shapeUy.slice(), shapeUz: solveEntry.shapeUz.slice(), diag: { modesKept: solveEntry.modeF.length, modesIn: solveEntry.modesIn, modesOut: solveEntry.modesOut, strikeCount: radEntry.strikeCount, meanV: radEntry.meanV, maxV: radEntry.maxV, meanTauUs: radEntry.meanTauUs, peakForceN: radEntry.peakForceN, durationS: radEntry.durationS, peakPa, peakSplDb: 20 * Math.log10(peakPa / 20e-6 + 1e-30), directPeakPa: radEntry.directPeakPa, listenerDistM: radEntry.listenerDistM, warnings, solveMs, radiateMs, solveCached, radiateCached, oversample: radEntry.oversample, }, }; (self as unknown as Worker).postMessage(resp, [ resp.y.buffer, resp.strikeT.buffer, resp.strikeV.buffer, resp.strikeS.buffer, resp.modeF.buffer, resp.modeZeta.buffer, resp.modeZetaRad.buffer, resp.modeFamily.buffer, resp.nodeX.buffer, resp.nodeY.buffer, resp.nodeS.buffer, resp.shapeUx.buffer, resp.shapeUy.buffer, resp.shapeUz.buffer, ]); }