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 { gainArray, hitMessages } from "../src/render.ts";
14import { computeModes } from "../src/precompute/pipeline.ts";
16const base = parseConfigYaml(PRESETS[0].yaml).config!;
17// a coarse mesh keeps the dense eigensolve to a second or so
18const config = structuredClone(base);
19config.mesh.element_size = 0.02;
20config.imperfections.n_dents = 40;
21config.imperfections.dent_sigma = 0.02;
23let modes: ModesData;
25test("precompute produces a plausible cymbal", () => {
26 modes = computeModes(config, { elementSize: 0.02, fmax: 20000 }, () => {});
27 assert.ok(modes.nModes > 50, `expected many modes, got ${modes.nModes}`);
28 assert.ok(modes.nNodes > 300 && modes.nNodes < 700, `unexpected node count ${modes.nNodes}`);
29 // an 18 inch bronze crash has its lowest modes near 60 Hz
30 assert.ok(modes.freq[0] > 30 && modes.freq[0] < 150, `lowest mode at ${modes.freq[0]} Hz`);
31 for (let m = 1; m < modes.nModes; m++) assert.ok(modes.freq[m] >= modes.freq[m - 1], "frequencies must ascend");
32 assert.ok(modes.freq.every(Number.isFinite) && modes.dampQ.every((q) => Number.isFinite(q) && q >= 0));
33 // the plate mass: rho * mean thickness * area, within a factor of two
34 const rough = config.material.rho * 1.5e-3 * Math.PI * config.profile.radius ** 2;
35 assert.ok(modes.meta.mass > 0.5 * rough && modes.meta.mass < 2 * rough, `mass ${modes.meta.mass} kg`);
36});
38test("the modes file round-trips", () => {
39 const again = parseModes(serializeModes(modes));
40 assert.equal(again.nModes, modes.nModes);
41 assert.equal(again.meta.E0, modes.meta.E0);
42 assert.deepEqual(Array.from(again.freq.slice(0, 20)), Array.from(modes.freq.slice(0, 20)));
43 assert.deepEqual(Array.from(again.phiQ.slice(0, 100)), Array.from(modes.phiQ.slice(0, 100)));
44});
46test("the live material and damping relations are exact", () => {
47 const changed = structuredClone(config);
48 changed.material.E *= 0.78;
49 changed.material.rho *= 1.35;
50 changed.material.sigma0 = 2.7;
51 changed.material.sigma1 = 3.1e-3;
52 const live = derive(modes, changed);
53 const fresh = computeModes(changed, { elementSize: 0.02, fmax: 20000 }, () => {});
54 const n = Math.min(live.freq.length, fresh.freq.length);
55 assert.ok(n > 50);
56 for (let m = 0; m < n; m++) {
57 assert.ok(Math.abs(live.freq[m] - fresh.freq[m]) / fresh.freq[m] < 1e-8, `mode ${m} frequency`);
58 const s = changed.material.sigma0 + changed.material.sigma1 * fresh.dampQ[m];
59 assert.ok(Math.abs(live.sigma[m] - s) / s < 1e-6, `mode ${m} decay rate`);
60 }
61 assert.ok(Math.abs(live.phiFactor - 1 / Math.sqrt(1.35)) < 1e-12);
62});
64test("every design field reads and writes its configuration slot", () => {
65 const cfg = structuredClone(config);
66 for (const f of ALL_FIELDS) {
67 const before = getValue(cfg, f);
68 assert.ok(Number.isFinite(before), `${f.section}.${f.key} is not a number`);
69 setValue(cfg, f, before + 1);
70 assert.equal(getValue(cfg, f), before + 1, `${f.section}.${f.key} did not take a write`);
71 setValue(cfg, f, before);
72 }
73});
75test("a hit through the worklet makes finite sound and rebounds", async () => {
76 const g = globalThis as unknown as Record<string, unknown>;
77 const sent: Record<string, unknown>[] = [];
78 g.sampleRate = 44100;
79 g.currentFrame = 0;
80 let Proc: new () => { port: { onmessage: ((ev: { data: unknown }) => void) | null }; process(i: Float32Array[][], o: Float32Array[][]): boolean };
81 g.AudioWorkletProcessor = class {
82 port = { onmessage: null, postMessage: (m: Record<string, unknown>) => sent.push(m) };
83 };
84 g.registerProcessor = (_n: string, ctor: typeof Proc) => { Proc = ctor; };
85 await import("../src/worklet.ts");
87 const der = derive(modes, config);
88 const gain = new Float32Array(2 * modes.nModes);
89 for (let i = 0; i < gain.length; i++) gain[i] = modes.gainFront[i] - modes.gainBack[i];
90 const proc = new Proc!();
91 proc.port.onmessage!({ data: { type: "init", freq: der.freq, sigma: der.sigma, gain } });
93 const ex = structuredClone(base.hits[0].exciter);
94 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);
95 assert.ok(patch, "the patch should land inside the mesh");
96 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 } });
98 const B = 128, blocks = Math.ceil(0.25 * 44100 / B);
99 let peak = 0;
100 for (let b = 0; b < blocks; b++) {
101 const out = [[new Float32Array(B)]];
102 g.currentFrame = b * B;
103 proc.process([], out);
104 for (const x of out[0][0]) {
105 assert.ok(Number.isFinite(x), "the output must stay finite");
106 peak = Math.max(peak, Math.abs(x));
107 }
108 }
109 assert.ok(peak > 0.05, `a 0.7 J stroke should be audible, peak was ${peak} Pa`);
110 const done = sent.find((m) => m.type === "hitDone") as { peakForce: number; contactTime: number; rebound: number } | undefined;
111 assert.ok(done, "the exciter should rebound and report");
112 assert.ok(done.peakForce > 100, `peak force ${done.peakForce} N`);
113 assert.ok(done.contactTime > 1e-4 && done.contactTime < 0.02, `contact ${done.contactTime} s`);
114 assert.ok(done.rebound > 0 && done.rebound < 7, `rebound ${done.rebound} m/s`);
115});
117test("the exported configuration is valid cymsim input", () => {
118 const yaml = exportYaml(config, [
119 { id: 1, time: 10.0, r: 0.17, phi: 0.3, velocity: 7, exciter: structuredClone(base.hits[0].exciter), source: "Q" },
120 { id: 2, time: 10.4, r: 0.12, phi: 2.0, velocity: 3, exciter: structuredClone(base.hits[0].exciter), source: "A" },
121 ], { leadIn: 0.05, tail: 3, prefix: "smoke" });
122 const { config: back, issues } = parseConfigYaml(yaml);
123 assert.deepEqual(issues, []);
124 assert.ok(back);
125 assert.equal(back.hits.length, 2);
126 assert.ok(Math.abs(back.hits[0].time - 0.05) < 1e-9, "the first hit sits at the lead-in");
127 assert.ok(Math.abs(back.hits[1].time - 0.45) < 1e-6, "spacing is preserved");
128 assert.ok(Math.abs(back.numerics.duration - 3.45) < 1e-6, "the duration covers the tail");
129 assert.equal(back.output.prefix, "smoke");
130});
132test("a recording schedules its hits at the right frames", async () => {
133 const g = globalThis as unknown as Record<string, unknown>;
134 const sent: Record<string, unknown>[] = [];
135 g.sampleRate = 44100;
136 g.currentFrame = 0;
137 let Proc: new (o?: unknown) => { port: { onmessage: ((ev: { data: unknown }) => void) | null }; process(i: Float32Array[][], o: Float32Array[][]): boolean };
138 g.AudioWorkletProcessor = class {
139 port = { onmessage: null, postMessage: (m: Record<string, unknown>) => sent.push(m) };
140 };
141 g.registerProcessor = (_n: string, ctor: typeof Proc) => { Proc = ctor; };
142 const mod = await import("../src/worklet.ts?scheduled");
143 void mod;
145 const der = derive(modes, config);
146 const ex = { kind: "stick" as const, mass: 0.03, k: 5e8, alpha: 1.5, patch_length: 0.03, patch_points: 7, patch_angle: 0.6, gap: 1e-4 };
147 const recorded = [
148 { id: 1, time: 4.00, r: 0.17, phi: 0.3, velocity: 7, exciter: ex, source: "Q" },
149 { id: 2, time: 4.25, r: 0.12, phi: 2.0, velocity: 5, exciter: ex, source: "A" },
150 ];
151 // the release frames are relative to the first hit, offset by the base
152 const base = 1000;
153 const { messages, span } = hitMessages(modes, der, recorded, 44100, base, () => 7);
154 assert.equal(messages.length, 2);
155 assert.equal(messages[0].atFrame, base);
156 assert.equal(messages[1].atFrame, base + Math.round(0.25 * 44100));
157 assert.ok(Math.abs(span - 0.25) < 1e-9);
159 // played through the processor, the exciters are released at those frames, not before
160 const proc = new Proc!();
161 proc.port.onmessage!({ data: { type: "init", freq: der.freq, sigma: der.sigma, gain: gainArray(modes, true) } });
162 for (const m of messages) proc.port.onmessage!({ data: { ...m, silent: false } });
163 const B = 128;
164 for (let b = 0; b < Math.ceil(0.4 * 44100 / B); b++) {
165 const out = [[new Float32Array(B)]];
166 g.currentFrame = b * B;
167 proc.process([], out);
168 for (const x of out[0][0]) assert.ok(Number.isFinite(x));
169 }
170 const started = sent.filter((m) => m.type === "hitStarted") as { id: number; frame: number }[];
171 assert.equal(started.length, 2, "both hits should be released");
172 for (const s of started) {
173 const want = messages.find((m) => m.id === s.id)!.atFrame as number;
174 assert.ok(Math.abs(s.frame - want) <= 1, `hit ${s.id} released at ${s.frame}, wanted ${want}`);
175 }
176});