1import { describe, expect, it } from 'vitest';
2import { buildDrum, buildDrumExcitation, buildDrumPickup, deriveDrum, hexLattice, type DrumParams } from '../src/instruments/bead-drum/model';
3import { DRUM_PRESETS } from '../src/instruments/bead-drum/presets';
4import { simulate } from '../src/sim/solver';
5import { spectrum } from '../src/ui/fft';
7const base = (over: Partial<DrumParams> = {}): DrumParams => ({ ...DRUM_PRESETS[0].params, ...over });
9describe('hex lattice', () => {
10 it('gives every bead six springs and is symmetric', () => {
11 const L = hexLattice(0.15, 8);
12 const deg = new Array(L.pos.length).fill(0);
13 for (const [i, j] of L.edges) {
14 deg[i]++;
15 if (j < L.pos.length) deg[j]++;
16 }
17 expect(deg.every((d) => d === 6)).toBe(true);
18 let cx = 0, cz = 0;
19 for (const [x, z] of L.pos) { cx += x; cz += z; }
20 expect(Math.abs(cx) + Math.abs(cz)).toBeLessThan(1e-9);
21 });
22});
24function run(p: DrumParams) {
25 const d = deriveDrum(p);
26 const r = simulate(buildDrum(d), buildDrumExcitation(p, d), { duration: p.duration, fs: p.fs, cfl: p.cfl, frameRate: p.frameRate, pickup: buildDrumPickup(p, d) });
27 return { d, r };
28}
30describe('drum', () => {
31 it('small centre press rings near the ideal membrane fundamental', () => {
32 const { d, r } = run(base({ excitation: 'press', pressAmp: 1e-5, excR: 0, excWidth: 0.03, t60Low: 0, t60High: 0, duration: 0.25, frameRate: 100 }));
33 const s = spectrum(r.audio, r.fs);
34 let best = 0;
35 for (let k = 1; k < s.freq.length && s.freq[k] < 400; k++) if (s.db[k] > s.db[best] || best === 0) best = k;
36 console.log('f01 measured', s.freq[best].toFixed(1), 'ideal', d.idealF01.toFixed(1));
37 expect(Math.abs(s.freq[best] / d.idealF01 - 1)).toBeLessThan(0.05);
38 });
39 it('mallet bounces off and energy balances', () => {
40 const { r } = run(base({ t60Low: 0, t60High: 0, duration: 0.05, frameRate: 1000 }));
41 const cf = r.contactForce!;
42 const nz = cf.reduce((c, v) => c + (v > 0 ? 1 : 0), 0);
43 console.log('contact ms', ((nz / r.fs) * 1000).toFixed(2), 'peak N', Math.max(...cf).toFixed(1), 'balance', Math.max(...r.balance.map(Math.abs)) / Math.max(...r.energy));
44 expect(cf[cf.length - 1]).toBe(0);
45 expect(r.malletPos![r.malletPos!.length - 2]).toBeGreaterThan(0); // y of the last frame: back above the head
46 expect(Math.max(...r.balance.map(Math.abs)) / Math.max(...r.energy)).toBeLessThan(1e-2);
47 });
48});