import { describe, expect, it } from 'vitest'; import { buildExcitation, buildPickup, buildSystem, derive, type StringParams } from '../src/instruments/bead-string/model'; import { simulate } from '../src/sim/solver'; import { PRESETS } from '../src/instruments/bead-string/presets'; const base = (over: Partial = {}): StringParams => ({ ...PRESETS[0].params, ...over }); describe('linear modes', () => { it('match the discrete fixed-fixed formula', () => { const p = base({ n: 20 }); const d = derive(p); for (let m = 1; m <= 5; m++) { const w = 2 * Math.sqrt(p.tension / (p.mass * p.spacing)) * Math.sin((m * Math.PI) / (2 * (p.n + 1))); expect(d.transverseHz[m - 1]).toBeCloseTo(w / (2 * Math.PI), 6); } }); it('sliding ends give the free-free set (no zero mode kept)', () => { const p = base({ n: 10, leftEnd: 'sliding', rightEnd: 'sliding' }); const d = derive(p); const w1 = 2 * Math.sqrt(p.tension / (p.mass * p.spacing)) * Math.sin(Math.PI / (2 * p.n)); expect(d.transverseHz[0]).toBeCloseTo(w1 / (2 * Math.PI), 6); }); }); function runMode1(p: StringParams, amp: number, duration: number) { const d = derive(p); const sys = buildSystem(p, d); const exc = buildExcitation(p); exc.u0.fill(0); for (let i = 0; i < p.n; i++) exc.u0[3 * i + 1] = amp * Math.sin((Math.PI * (i + 1)) / (p.n + 1)); const r = simulate(sys, exc, { duration, fs: 48000, cfl: 0.5, frameRate: 48000, pickup: buildPickup(p) }); return { d, r }; } describe('solver', () => { it('small mode-1 vibration oscillates at the linear frequency', () => { const p = base({ n: 9, t60Low: 0, t60High: 0 }); const { d, r } = runMode1(p, 1e-6, 0.05); const mid = 3 * 4 + 1; const f = d.transverseHz[0]; let err = 0; for (let k = 0; k < r.frameTimes.length; k++) { const exact = 1e-6 * Math.cos(2 * Math.PI * f * r.frameTimes[k]); err = Math.max(err, Math.abs(r.frames[k * 3 * p.n + mid] - exact)); } expect(err / 1e-6).toBeLessThan(2e-3); }); it('conserves energy without damping and balances it with damping and a hammer', () => { for (const damp of [false, true]) { const p = base({ n: 15, excitation: 'hammer', hammerForce: 5, hammerMs: 1, t60Low: damp ? 0.5 : 0, t60High: damp ? 0.1 : 0 }); const d = derive(p); const r = simulate(buildSystem(p, d), buildExcitation(p), { duration: 0.1, fs: 48000, cfl: 0.5, frameRate: 1000, pickup: buildPickup(p) }); const emax = Math.max(...r.energy); const bmax = Math.max(...r.balance.map(Math.abs)); expect(bmax / emax).toBeLessThan(1e-2); } }); it('mode 1 decays at the requested T60', () => { const p = base({ n: 9, t60Low: 0.3, fLow: 0, t60High: 0 }); const { r } = runMode1(p, 1e-6, 0.3); const mid = 3 * 4 + 1; const env = (t0: number, t1: number) => { let m = 0; for (let k = 0; k < r.frameTimes.length; k++) if (r.frameTimes[k] >= t0 && r.frameTimes[k] < t1) m = Math.max(m, Math.abs(r.frames[k * 3 * p.n + mid])); return m; }; const ratio = env(0.25, 0.3) / env(0, 0.05); expect(20 * Math.log10(ratio)).toBeGreaterThan(-60 * (0.25 / 0.3) - 3); expect(20 * Math.log10(ratio)).toBeLessThan(-60 * (0.25 / 0.3) + 3); }); });