1// Self-contained check that the whole chain works: precompute a coarse cymbal, round-trip the
2// modes file, apply the live material and damping relations, and render a hit through the real
3// AudioWorklet processor. No fixtures and no network; this is what CI runs.
4import assert from "node:assert/strict";
5import { test } from "node:test";
6import { PRESETS } from "../src/config/presets.ts";
7import { parseConfigYaml } from "../src/config/yaml.ts";
8import { derive } from "../src/derived.ts";
9import { ALL_FIELDS, getValue, setValue } from "../src/design.ts";
10import { exportYaml } from "../src/hits.ts";
11import { parseModes, serializeModes, type ModesData } from "../src/modes.ts";
12import { buildHitPatch } from "../src/patch.ts";
13import { computeModes } from "../src/precompute/pipeline.ts";
15const base = parseConfigYaml(PRESETS[0].yaml).config!;
16// a coarse mesh keeps the dense eigensolve to a second or so
17const config = structuredClone(base);
18config.mesh.element_size = 0.02;
19config.imperfections.n_dents = 40;
20config.imperfections.dent_sigma = 0.02;
22let modes: ModesData;
24test("precompute produces a plausible cymbal", () => {
25 modes = computeModes(config, { elementSize: 0.02, fmax: 20000 }, () => {});
26 assert.ok(modes.nModes > 50, `expected many modes, got ${modes.nModes}`);
27 assert.ok(modes.nNodes > 300 && modes.nNodes < 700, `unexpected node count ${modes.nNodes}`);
28 // an 18 inch bronze crash has its lowest modes near 60 Hz
29 assert.ok(modes.freq[0] > 30 && modes.freq[0] < 150, `lowest mode at ${modes.freq[0]} Hz`);
30 for (let m = 1; m < modes.nModes; m++) assert.ok(modes.freq[m] >= modes.freq[m - 1], "frequencies must ascend");
31 assert.ok(modes.freq.every(Number.isFinite) && modes.dampQ.every((q) => Number.isFinite(q) && q >= 0));
32 // the plate mass: rho * mean thickness * area, within a factor of two
33 const rough = config.material.rho * 1.5e-3 * Math.PI * config.profile.radius ** 2;
34 assert.ok(modes.meta.mass > 0.5 * rough && modes.meta.mass < 2 * rough, `mass ${modes.meta.mass} kg`);
35});
37test("the modes file round-trips", () => {
38 const again = parseModes(serializeModes(modes));
39 assert.equal(again.nModes, modes.nModes);
40 assert.equal(again.meta.E0, modes.meta.E0);
41 assert.deepEqual(Array.from(again.freq.slice(0, 20)), Array.from(modes.freq.slice(0, 20)));
42 assert.deepEqual(Array.from(again.phiQ.slice(0, 100)), Array.from(modes.phiQ.slice(0, 100)));
43});
45test("the live material and damping relations are exact", () => {
46 const changed = structuredClone(config);
47 changed.material.E *= 0.78;
48 changed.material.rho *= 1.35;
49 changed.material.sigma0 = 2.7;
50 changed.material.sigma1 = 3.1e-3;
51 const live = derive(modes, changed);
52 const fresh = computeModes(changed, { elementSize: 0.02, fmax: 20000 }, () => {});
53 const n = Math.min(live.freq.length, fresh.freq.length);
54 assert.ok(n > 50);
55 for (let m = 0; m < n; m++) {
56 assert.ok(Math.abs(live.freq[m] - fresh.freq[m]) / fresh.freq[m] < 1e-8, `mode ${m} frequency`);
57 const s = changed.material.sigma0 + changed.material.sigma1 * fresh.dampQ[m];
58 assert.ok(Math.abs(live.sigma[m] - s) / s < 1e-6, `mode ${m} decay rate`);
59 }
60 assert.ok(Math.abs(live.phiFactor - 1 / Math.sqrt(1.35)) < 1e-12);
61});
63test("every design field reads and writes its configuration slot", () => {
64 const cfg = structuredClone(config);
65 for (const f of ALL_FIELDS) {
66 const before = getValue(cfg, f);
67 assert.ok(Number.isFinite(before), `${f.section}.${f.key} is not a number`);
68 setValue(cfg, f, before + 1);
69 assert.equal(getValue(cfg, f), before + 1, `${f.section}.${f.key} did not take a write`);
70 setValue(cfg, f, before);
71 }
72});
74test("a hit through the worklet makes finite sound and rebounds", async () => {
75 const g = globalThis as unknown as Record<string, unknown>;
76 const sent: Record<string, unknown>[] = [];
77 g.sampleRate = 44100;
78 g.currentFrame = 0;
79 let Proc: new () => { port: { onmessage: ((ev: { data: unknown }) => void) | null }; process(i: Float32Array[][], o: Float32Array[][]): boolean };
80 g.AudioWorkletProcessor = class {
81 port = { onmessage: null, postMessage: (m: Record<string, unknown>) => sent.push(m) };
82 };
83 g.registerProcessor = (_n: string, ctor: typeof Proc) => { Proc = ctor; };
84 await import("../src/worklet.ts");
86 const der = derive(modes, config);
87 const gain = new Float32Array(2 * modes.nModes);
88 for (let i = 0; i < gain.length; i++) gain[i] = modes.gainFront[i] - modes.gainBack[i];
89 const proc = new Proc!();
90 proc.port.onmessage!({ data: { type: "init", freq: der.freq, sigma: der.sigma, gain } });
92 const ex = structuredClone(base.hits[0].exciter);
93 const patch = buildHitPatch(modes, { ...ex, kind: "stick", mass: 0.03, k: 5e8, alpha: 1.5, patch_length: 0.03, patch_points: 7, patch_angle: 0.6, gap: 1e-4 }, 0.17, 0.3, 7, 7, der.phiFactor);
94 assert.ok(patch, "the patch should land inside the mesh");
95 proc.port.onmessage!({ data: { type: "hit", id: 1, phi: patch.phi, offsets: patch.offsets, kPoint: patch.kPoint, alpha: patch.alpha, mass: patch.mass, gap: patch.gap, velocity: 7 } });
97 const B = 128, blocks = Math.ceil(0.25 * 44100 / B);
98 let peak = 0;
99 for (let b = 0; b < blocks; b++) {
100 const out = [[new Float32Array(B)]];
101 g.currentFrame = b * B;
102 proc.process([], out);
103 for (const x of out[0][0]) {
104 assert.ok(Number.isFinite(x), "the output must stay finite");
105 peak = Math.max(peak, Math.abs(x));
106 }
107 }
108 assert.ok(peak > 0.05, `a 0.7 J stroke should be audible, peak was ${peak} Pa`);
109 const done = sent.find((m) => m.type === "hitDone") as { peakForce: number; contactTime: number; rebound: number } | undefined;
110 assert.ok(done, "the exciter should rebound and report");
111 assert.ok(done.peakForce > 100, `peak force ${done.peakForce} N`);
112 assert.ok(done.contactTime > 1e-4 && done.contactTime < 0.02, `contact ${done.contactTime} s`);
113 assert.ok(done.rebound > 0 && done.rebound < 7, `rebound ${done.rebound} m/s`);
114});
116test("the exported configuration is valid cymsim input", () => {
117 const yaml = exportYaml(config, [
118 { id: 1, time: 10.0, r: 0.17, phi: 0.3, velocity: 7, exciter: structuredClone(base.hits[0].exciter), source: "Q" },
119 { id: 2, time: 10.4, r: 0.12, phi: 2.0, velocity: 3, exciter: structuredClone(base.hits[0].exciter), source: "A" },
120 ], { leadIn: 0.05, tail: 3, prefix: "smoke" });
121 const { config: back, issues } = parseConfigYaml(yaml);
122 assert.deepEqual(issues, []);
123 assert.ok(back);
124 assert.equal(back.hits.length, 2);
125 assert.ok(Math.abs(back.hits[0].time - 0.05) < 1e-9, "the first hit sits at the lead-in");
126 assert.ok(Math.abs(back.hits[1].time - 0.45) < 1e-6, "spacing is preserved");
127 assert.ok(Math.abs(back.numerics.duration - 3.45) < 1e-6, "the duration covers the tail");
128 assert.equal(back.output.prefix, "smoke");
129});