import { describe, expect, it } from 'vitest'; import { buildDrum, buildDrumExcitation, buildDrumPickup, deriveDrum, hexLattice, type DrumParams } from '../src/instruments/bead-drum/model'; import { DRUM_PRESETS } from '../src/instruments/bead-drum/presets'; import { simulate } from '../src/sim/solver'; import { spectrum } from '../src/ui/fft'; const base = (over: Partial = {}): DrumParams => ({ ...DRUM_PRESETS[0].params, ...over }); describe('hex lattice', () => { it('gives every bead six springs and is symmetric', () => { const L = hexLattice(0.15, 8); const deg = new Array(L.pos.length).fill(0); for (const [i, j] of L.edges) { deg[i]++; if (j < L.pos.length) deg[j]++; } expect(deg.every((d) => d === 6)).toBe(true); let cx = 0, cz = 0; for (const [x, z] of L.pos) { cx += x; cz += z; } expect(Math.abs(cx) + Math.abs(cz)).toBeLessThan(1e-9); }); }); function run(p: DrumParams) { const d = deriveDrum(p); const r = simulate(buildDrum(d), buildDrumExcitation(p, d), { duration: p.duration, fs: p.fs, cfl: p.cfl, frameRate: p.frameRate, pickup: buildDrumPickup(p, d) }); return { d, r }; } describe('drum', () => { it('small centre press rings near the ideal membrane fundamental', () => { const { d, r } = run(base({ excitation: 'press', pressAmp: 1e-5, excR: 0, excWidth: 0.03, t60Low: 0, t60High: 0, duration: 0.25, frameRate: 100 })); const s = spectrum(r.audio, r.fs); let best = 0; for (let k = 1; k < s.freq.length && s.freq[k] < 400; k++) if (s.db[k] > s.db[best] || best === 0) best = k; console.log('f01 measured', s.freq[best].toFixed(1), 'ideal', d.idealF01.toFixed(1)); expect(Math.abs(s.freq[best] / d.idealF01 - 1)).toBeLessThan(0.05); }); it('mallet bounces off and energy balances', () => { const { r } = run(base({ t60Low: 0, t60High: 0, duration: 0.05, frameRate: 1000 })); const cf = r.contactForce!; const nz = cf.reduce((c, v) => c + (v > 0 ? 1 : 0), 0); 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)); expect(cf[cf.length - 1]).toBe(0); expect(r.malletPos![r.malletPos!.length - 2]).toBeGreaterThan(0); // y of the last frame: back above the head expect(Math.max(...r.balance.map(Math.abs)) / Math.max(...r.energy)).toBeLessThan(1e-2); }); });