// Rendering orchestration: solve -> radiate -> output stage. Mirrors render() // in woodblock.py, split into stages so the worker can cache intermediates. import type { WoodblockParams } from '../params'; import { buildBasis, type ModalBasisData } from './basis'; import { simulate, type SolveResult } from './solver'; import { radiate } from './radiate'; import { decimate } from './resample'; export interface Diagnostics { modesKept: number; partialsHz: number[]; ratios: number[]; helmholtzHz: number; contactMs: number; peakForceN: number; peakPa: number; peakSplDb: number; listenerDistM: number; warnings: string[]; } export interface OutputStageResult { y: Float64Array; peakPa: number; peakSplDb: number; clipWarning: string | null; } // Steps 3-5 of ยง8: decimate, pre-roll, fade, normalize or absolute scale. export function outputStage(cfg: WoodblockParams, p: Float64Array, oversample: number): OutputStageResult { let peakPa = 0; for (let i = 0; i < p.length; i++) { const a = Math.abs(p[i]); if (a > peakPa) peakPa = a; } const peakSplDb = 20.0 * Math.log10(peakPa / 20e-6 + 1e-30); let y = decimate(p, oversample); const pad = Math.floor((cfg.pre_delay_ms * 1e-3) * cfg.sample_rate); const total = Math.floor(cfg.duration_s * cfg.sample_rate); if (pad > 0) { const padded = new Float64Array(Math.min(pad + y.length, total)); padded.set(y.subarray(0, padded.length - pad), pad); y = padded; } else if (y.length > total) { y = y.subarray(0, total).slice(); } const fade = Math.min(256, Math.floor(y.length / 8)); if (fade > 1) { for (let i = 0; i < fade; i++) { const g = 1.0 - i / (fade - 1); y[y.length - fade + i] *= g * g; } } let clipWarning: string | null = null; if (cfg.normalize) { let peak = 0; for (let i = 0; i < y.length; i++) peak = Math.max(peak, Math.abs(y[i])); if (peak > 0) { const g = (10 ** (cfg.peak_dbfs / 20.0)) / peak; for (let i = 0; i < y.length; i++) y[i] *= g; } } else { let peak = 0; for (let i = 0; i < y.length; i++) { y[i] /= cfg.full_scale_pa; peak = Math.max(peak, Math.abs(y[i])); } if (peak > 1.0) { clipWarning = `clipping: peak is ${peakPa.toFixed(2)} Pa but full_scale_pa is ${cfg.full_scale_pa.toFixed(2)}`; } } return { y, peakPa, peakSplDb, clipWarning }; } export interface FullRenderResult { y: Float64Array; basis: ModalBasisData; solve: SolveResult; diag: Diagnostics; } // One-shot render, used by the self-test; the worker calls the stages // individually so it can cache them. export function renderAll(cfg: WoodblockParams, oversample?: number, elements?: number): FullRenderResult { const os = oversample ?? cfg.oversample; const M = elements ?? cfg.radiating_elements; const basis = buildBasis(cfg); const solve = simulate(cfg, basis, os); const rad = radiate(cfg, solve.Qdd, solve.Xdd, basis, solve.fsHi, M); const out = outputStage(cfg, rad.p, os); const lx = cfg.listener_x_m, ly = cfg.listener_y_m, lz = cfg.listener_z_m; const warnings = [...rad.warnings]; if (out.clipWarning) warnings.push(out.clipWarning); const diag: Diagnostics = { 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, }; return { y: out.y, basis, solve, diag }; }