/ simulated_instruments / woodblock
Sign in
simulated_instruments / woodblock
woodblock / webapp / src / physics / render.ts
114 lines · 3.5 KBBlameHistoryRaw
1// Rendering orchestration: solve -> radiate -> output stage. Mirrors render()
2// in woodblock.py, split into stages so the worker can cache intermediates.
4import type { WoodblockParams } from '../params';
5import { buildBasis, type ModalBasisData } from './basis';
6import { simulate, type SolveResult } from './solver';
7import { radiate } from './radiate';
8import { decimate } from './resample';
10export interface Diagnostics {
11 modesKept: number;
12 partialsHz: number[];
13 ratios: number[];
14 helmholtzHz: number;
15 contactMs: number;
16 peakForceN: number;
17 peakPa: number;
18 peakSplDb: number;
19 listenerDistM: number;
20 warnings: string[];
23export interface OutputStageResult {
24 y: Float64Array;
25 peakPa: number;
26 peakSplDb: number;
27 clipWarning: string | null;
30// Steps 3-5 of §8: decimate, pre-roll, fade, normalize or absolute scale.
31export function outputStage(cfg: WoodblockParams, p: Float64Array, oversample: number): OutputStageResult {
32 let peakPa = 0;
33 for (let i = 0; i < p.length; i++) {
34 const a = Math.abs(p[i]);
35 if (a > peakPa) peakPa = a;
36 }
37 const peakSplDb = 20.0 * Math.log10(peakPa / 20e-6 + 1e-30);
39 let y = decimate(p, oversample);
41 const pad = Math.floor((cfg.pre_delay_ms * 1e-3) * cfg.sample_rate);
42 const total = Math.floor(cfg.duration_s * cfg.sample_rate);
43 if (pad > 0) {
44 const padded = new Float64Array(Math.min(pad + y.length, total));
45 padded.set(y.subarray(0, padded.length - pad), pad);
46 y = padded;
47 } else if (y.length > total) {
48 y = y.subarray(0, total).slice();
49 }
51 const fade = Math.min(256, Math.floor(y.length / 8));
52 if (fade > 1) {
53 for (let i = 0; i < fade; i++) {
54 const g = 1.0 - i / (fade - 1);
55 y[y.length - fade + i] *= g * g;
56 }
57 }
59 let clipWarning: string | null = null;
60 if (cfg.normalize) {
61 let peak = 0;
62 for (let i = 0; i < y.length; i++) peak = Math.max(peak, Math.abs(y[i]));
63 if (peak > 0) {
64 const g = (10 ** (cfg.peak_dbfs / 20.0)) / peak;
65 for (let i = 0; i < y.length; i++) y[i] *= g;
66 }
67 } else {
68 let peak = 0;
69 for (let i = 0; i < y.length; i++) {
70 y[i] /= cfg.full_scale_pa;
71 peak = Math.max(peak, Math.abs(y[i]));
72 }
73 if (peak > 1.0) {
74 clipWarning = `clipping: peak is ${peakPa.toFixed(2)} Pa but full_scale_pa is ${cfg.full_scale_pa.toFixed(2)}`;
75 }
76 }
78 return { y, peakPa, peakSplDb, clipWarning };
81export interface FullRenderResult {
82 y: Float64Array;
83 basis: ModalBasisData;
84 solve: SolveResult;
85 diag: Diagnostics;
88// One-shot render, used by the self-test; the worker calls the stages
89// individually so it can cache them.
90export function renderAll(cfg: WoodblockParams, oversample?: number, elements?: number): FullRenderResult {
91 const os = oversample ?? cfg.oversample;
92 const M = elements ?? cfg.radiating_elements;
93 const basis = buildBasis(cfg);
94 const solve = simulate(cfg, basis, os);
95 const rad = radiate(cfg, solve.Qdd, solve.Xdd, basis, solve.fsHi, M);
96 const out = outputStage(cfg, rad.p, os);
98 const lx = cfg.listener_x_m, ly = cfg.listener_y_m, lz = cfg.listener_z_m;
99 const warnings = [...rad.warnings];
100 if (out.clipWarning) warnings.push(out.clipWarning);
101 const diag: Diagnostics = {
102 modesKept: basis.n,
103 partialsHz: Array.from(basis.f),
104 ratios: Array.from(basis.f).map((f) => f / basis.f[0]),
105 helmholtzHz: solve.helmholtzHz,
106 contactMs: solve.contactMs,
107 peakForceN: solve.peakForceN,
108 peakPa: out.peakPa,
109 peakSplDb: out.peakSplDb,
110 listenerDistM: Math.sqrt(lx * lx + ly * ly + lz * lz),
111 warnings,
112 };
113 return { y: out.y, basis, solve, diag };
↑↓ move↵openescclose