1import { describe, expect, it } from 'vitest';
2import { buildExcitation, buildPickup, buildSystem, derive, type StringParams } from '../src/instruments/bead-string/model';
3import { simulate } from '../src/sim/solver';
4import { PRESETS } from '../src/instruments/bead-string/presets';
6const base = (over: Partial<StringParams> = {}): StringParams => ({ ...PRESETS[0].params, ...over });
8describe('linear modes', () => {
9 it('match the discrete fixed-fixed formula', () => {
10 const p = base({ n: 20 });
11 const d = derive(p);
12 for (let m = 1; m <= 5; m++) {
13 const w = 2 * Math.sqrt(p.tension / (p.mass * p.spacing)) * Math.sin((m * Math.PI) / (2 * (p.n + 1)));
14 expect(d.transverseHz[m - 1]).toBeCloseTo(w / (2 * Math.PI), 6);
15 }
16 });
17 it('sliding ends give the free-free set (no zero mode kept)', () => {
18 const p = base({ n: 10, leftEnd: 'sliding', rightEnd: 'sliding' });
19 const d = derive(p);
20 const w1 = 2 * Math.sqrt(p.tension / (p.mass * p.spacing)) * Math.sin(Math.PI / (2 * p.n));
21 expect(d.transverseHz[0]).toBeCloseTo(w1 / (2 * Math.PI), 6);
22 });
23});
25function runMode1(p: StringParams, amp: number, duration: number) {
26 const d = derive(p);
27 const sys = buildSystem(p, d);
28 const exc = buildExcitation(p);
29 exc.u0.fill(0);
30 for (let i = 0; i < p.n; i++) exc.u0[3 * i + 1] = amp * Math.sin((Math.PI * (i + 1)) / (p.n + 1));
31 const r = simulate(sys, exc, { duration, fs: 48000, cfl: 0.5, frameRate: 48000, pickup: buildPickup(p) });
32 return { d, r };
33}
35describe('solver', () => {
36 it('small mode-1 vibration oscillates at the linear frequency', () => {
37 const p = base({ n: 9, t60Low: 0, t60High: 0 });
38 const { d, r } = runMode1(p, 1e-6, 0.05);
39 const mid = 3 * 4 + 1;
40 const f = d.transverseHz[0];
41 let err = 0;
42 for (let k = 0; k < r.frameTimes.length; k++) {
43 const exact = 1e-6 * Math.cos(2 * Math.PI * f * r.frameTimes[k]);
44 err = Math.max(err, Math.abs(r.frames[k * 3 * p.n + mid] - exact));
45 }
46 expect(err / 1e-6).toBeLessThan(2e-3);
47 });
48 it('conserves energy without damping and balances it with damping and a hammer', () => {
49 for (const damp of [false, true]) {
50 const p = base({ n: 15, excitation: 'hammer', hammerForce: 5, hammerMs: 1, t60Low: damp ? 0.5 : 0, t60High: damp ? 0.1 : 0 });
51 const d = derive(p);
52 const r = simulate(buildSystem(p, d), buildExcitation(p), { duration: 0.1, fs: 48000, cfl: 0.5, frameRate: 1000, pickup: buildPickup(p) });
53 const emax = Math.max(...r.energy);
54 const bmax = Math.max(...r.balance.map(Math.abs));
55 expect(bmax / emax).toBeLessThan(1e-2);
56 }
57 });
58 it('mode 1 decays at the requested T60', () => {
59 const p = base({ n: 9, t60Low: 0.3, fLow: 0, t60High: 0 });
60 const { r } = runMode1(p, 1e-6, 0.3);
61 const mid = 3 * 4 + 1;
62 const env = (t0: number, t1: number) => {
63 let m = 0;
64 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]));
65 return m;
66 };
67 const ratio = env(0.25, 0.3) / env(0, 0.05);
68 expect(20 * Math.log10(ratio)).toBeGreaterThan(-60 * (0.25 / 0.3) - 3);
69 expect(20 * Math.log10(ratio)).toBeLessThan(-60 * (0.25 / 0.3) + 3);
70 });
71});