// Self-contained check that the whole chain works: precompute a coarse cymbal, round-trip the // modes file, apply the live material and damping relations, and render a hit through the real // AudioWorklet processor. No fixtures and no network; this is what CI runs. import assert from "node:assert/strict"; import { test } from "node:test"; import { PRESETS } from "../src/config/presets.ts"; import { parseConfigYaml } from "../src/config/yaml.ts"; import { derive } from "../src/derived.ts"; import { ALL_FIELDS, getValue, setValue } from "../src/design.ts"; import { exportYaml } from "../src/hits.ts"; import { parseModes, serializeModes, type ModesData } from "../src/modes.ts"; import { buildHitPatch } from "../src/patch.ts"; import { computeModes } from "../src/precompute/pipeline.ts"; const base = parseConfigYaml(PRESETS[0].yaml).config!; // a coarse mesh keeps the dense eigensolve to a second or so const config = structuredClone(base); config.mesh.element_size = 0.02; config.imperfections.n_dents = 40; config.imperfections.dent_sigma = 0.02; let modes: ModesData; test("precompute produces a plausible cymbal", () => { modes = computeModes(config, { elementSize: 0.02, fmax: 20000 }, () => {}); assert.ok(modes.nModes > 50, `expected many modes, got ${modes.nModes}`); assert.ok(modes.nNodes > 300 && modes.nNodes < 700, `unexpected node count ${modes.nNodes}`); // an 18 inch bronze crash has its lowest modes near 60 Hz assert.ok(modes.freq[0] > 30 && modes.freq[0] < 150, `lowest mode at ${modes.freq[0]} Hz`); for (let m = 1; m < modes.nModes; m++) assert.ok(modes.freq[m] >= modes.freq[m - 1], "frequencies must ascend"); assert.ok(modes.freq.every(Number.isFinite) && modes.dampQ.every((q) => Number.isFinite(q) && q >= 0)); // the plate mass: rho * mean thickness * area, within a factor of two const rough = config.material.rho * 1.5e-3 * Math.PI * config.profile.radius ** 2; assert.ok(modes.meta.mass > 0.5 * rough && modes.meta.mass < 2 * rough, `mass ${modes.meta.mass} kg`); }); test("the modes file round-trips", () => { const again = parseModes(serializeModes(modes)); assert.equal(again.nModes, modes.nModes); assert.equal(again.meta.E0, modes.meta.E0); assert.deepEqual(Array.from(again.freq.slice(0, 20)), Array.from(modes.freq.slice(0, 20))); assert.deepEqual(Array.from(again.phiQ.slice(0, 100)), Array.from(modes.phiQ.slice(0, 100))); }); test("the live material and damping relations are exact", () => { const changed = structuredClone(config); changed.material.E *= 0.78; changed.material.rho *= 1.35; changed.material.sigma0 = 2.7; changed.material.sigma1 = 3.1e-3; const live = derive(modes, changed); const fresh = computeModes(changed, { elementSize: 0.02, fmax: 20000 }, () => {}); const n = Math.min(live.freq.length, fresh.freq.length); assert.ok(n > 50); for (let m = 0; m < n; m++) { assert.ok(Math.abs(live.freq[m] - fresh.freq[m]) / fresh.freq[m] < 1e-8, `mode ${m} frequency`); const s = changed.material.sigma0 + changed.material.sigma1 * fresh.dampQ[m]; assert.ok(Math.abs(live.sigma[m] - s) / s < 1e-6, `mode ${m} decay rate`); } assert.ok(Math.abs(live.phiFactor - 1 / Math.sqrt(1.35)) < 1e-12); }); test("every design field reads and writes its configuration slot", () => { const cfg = structuredClone(config); for (const f of ALL_FIELDS) { const before = getValue(cfg, f); assert.ok(Number.isFinite(before), `${f.section}.${f.key} is not a number`); setValue(cfg, f, before + 1); assert.equal(getValue(cfg, f), before + 1, `${f.section}.${f.key} did not take a write`); setValue(cfg, f, before); } }); test("a hit through the worklet makes finite sound and rebounds", async () => { const g = globalThis as unknown as Record; const sent: Record[] = []; g.sampleRate = 44100; g.currentFrame = 0; let Proc: new () => { port: { onmessage: ((ev: { data: unknown }) => void) | null }; process(i: Float32Array[][], o: Float32Array[][]): boolean }; g.AudioWorkletProcessor = class { port = { onmessage: null, postMessage: (m: Record) => sent.push(m) }; }; g.registerProcessor = (_n: string, ctor: typeof Proc) => { Proc = ctor; }; await import("../src/worklet.ts"); const der = derive(modes, config); const gain = new Float32Array(2 * modes.nModes); for (let i = 0; i < gain.length; i++) gain[i] = modes.gainFront[i] - modes.gainBack[i]; const proc = new Proc!(); proc.port.onmessage!({ data: { type: "init", freq: der.freq, sigma: der.sigma, gain } }); const ex = structuredClone(base.hits[0].exciter); 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); assert.ok(patch, "the patch should land inside the mesh"); 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 } }); const B = 128, blocks = Math.ceil(0.25 * 44100 / B); let peak = 0; for (let b = 0; b < blocks; b++) { const out = [[new Float32Array(B)]]; g.currentFrame = b * B; proc.process([], out); for (const x of out[0][0]) { assert.ok(Number.isFinite(x), "the output must stay finite"); peak = Math.max(peak, Math.abs(x)); } } assert.ok(peak > 0.05, `a 0.7 J stroke should be audible, peak was ${peak} Pa`); const done = sent.find((m) => m.type === "hitDone") as { peakForce: number; contactTime: number; rebound: number } | undefined; assert.ok(done, "the exciter should rebound and report"); assert.ok(done.peakForce > 100, `peak force ${done.peakForce} N`); assert.ok(done.contactTime > 1e-4 && done.contactTime < 0.02, `contact ${done.contactTime} s`); assert.ok(done.rebound > 0 && done.rebound < 7, `rebound ${done.rebound} m/s`); }); test("the exported configuration is valid cymsim input", () => { const yaml = exportYaml(config, [ { id: 1, time: 10.0, r: 0.17, phi: 0.3, velocity: 7, exciter: structuredClone(base.hits[0].exciter), source: "Q" }, { id: 2, time: 10.4, r: 0.12, phi: 2.0, velocity: 3, exciter: structuredClone(base.hits[0].exciter), source: "A" }, ], { leadIn: 0.05, tail: 3, prefix: "smoke" }); const { config: back, issues } = parseConfigYaml(yaml); assert.deepEqual(issues, []); assert.ok(back); assert.equal(back.hits.length, 2); assert.ok(Math.abs(back.hits[0].time - 0.05) < 1e-9, "the first hit sits at the lead-in"); assert.ok(Math.abs(back.hits[1].time - 0.45) < 1e-6, "spacing is preserved"); assert.ok(Math.abs(back.numerics.duration - 3.45) < 1e-6, "the duration covers the tail"); assert.equal(back.output.prefix, "smoke"); });