1import { describe, expect, it } from 'vitest';
2import { buildCymbal, buildCymbalExcitation, buildMic, deriveCymbal, type CymbalParams } from '../src/instruments/bead-cymbal/model';
3import { CYMBAL_PRESETS } from '../src/instruments/bead-cymbal/presets';
4import { bendingEnergy, simulate } from '../src/sim/solver';
5import { spectrum } from '../src/ui/fft';
7const base = (over: Partial<CymbalParams> = {}): CymbalParams => ({ ...CYMBAL_PRESETS[0].params, ...over });
9describe('cymbal lattice', () => {
10 it('bending energy matches the plate (nu = 1/3) for bending, doming and twisting', () => {
11 const p = base({ holeRadius: 0, rings: 12 });
12 const d = deriveCymbal(p);
13 const sys = buildCymbal(d);
14 const k = 0.5; // 1/m, small: k * a << 1
15 const R = p.diameter / 2;
16 const nu = 1 / 3;
17 const inner = (t: number) => d.mesh.tris[t].every((i) => Math.hypot(sys.rest[3 * i], sys.rest[3 * i + 2]) < 0.6 * R);
18 let area = 0;
19 d.mesh.tris.forEach((tr, t) => {
20 if (!inner(t)) return;
21 const [u, v, w] = tr.map((i) => [sys.rest[3 * i], sys.rest[3 * i + 2]]);
22 area += 0.5 * Math.abs((v[0] - u[0]) * (w[1] - u[1]) - (w[0] - u[0]) * (v[1] - u[1]));
23 });
24 const shapes: [string, (x: number, z: number) => number, number][] = [
25 ['cylinder', (x) => 0.5 * k * x * x, 0.5 * k * k],
26 ['sphere', (x, z) => 0.5 * k * (x * x + z * z), 0.5 * (4 - 2 * (1 - nu)) * k * k],
27 ['saddle', (x, z) => 0.5 * k * (x * x - z * z), (1 - nu) * k * k],
28 ['twist', (x, z) => k * x * z, (1 - nu) * k * k],
29 ];
30 for (const [name, f, density] of shapes) {
31 const X = new Float64Array(3 * (sys.n + sys.anchors.length));
32 X.set(sys.rest);
33 for (let i = 0; i < sys.n; i++) X[3 * i + 1] = f(sys.rest[3 * i], sys.rest[3 * i + 2]);
34 const ratio = bendingEnergy(sys.bending!, X, inner) / (d.D * density * area);
35 console.log(name, 'bending energy / plate', ratio.toFixed(4));
36 expect(ratio).toBeCloseTo(1, 2);
37 }
38 });
40 it('a free flat plate rings near the (2,0) mode of a free circular plate', () => {
41 // no felts, no hole, no damping; initial shape r^2 cos(2 phi), small
42 const p = base({ holeRadius: 0, rings: 9, hubRadius: -1, t60Low: 0, t60High: 0, duration: 0.3, frameRate: 50 });
43 const d = deriveCymbal(p);
44 const sys = buildCymbal(d);
45 const exc = buildCymbalExcitation({ ...p, excitation: 'strike', strikeVel: 0 }, d);
46 for (let i = 0; i < sys.n; i++) {
47 const x = sys.rest[3 * i], z = sys.rest[3 * i + 2];
48 exc.u0[3 * i + 1] = 1e-7 * (x * x - z * z) / 0.01;
49 }
50 const pick = new Float64Array(3 * sys.n);
51 let edge = 0;
52 for (let i = 0; i < sys.n; i++) if (sys.rest[3 * i] > sys.rest[3 * edge]) edge = i;
53 pick[3 * edge + 1] = 1;
54 const r = simulate(sys, exc, { duration: p.duration, fs: p.fs, cfl: p.cfl, frameRate: p.frameRate, pickup: pick });
55 const s = spectrum(r.audio, r.fs);
56 let best = 1;
57 for (let k = 1; k < s.freq.length && s.freq[k] < 300; k++) if (s.db[k] > s.db[best]) best = k;
58 const R = p.diameter / 2;
59 const ideal = (5.253 / (2 * Math.PI * R * R)) * d.bendingCoef; // Leissa, nu ~ 1/3
60 console.log('free (2,0) measured', s.freq[best].toFixed(1), 'Hz, ideal', ideal.toFixed(1), 'Hz');
61 expect(Math.abs(s.freq[best] / ideal - 1)).toBeLessThan(0.05);
62 }, 60000);
64 it('energy balances with a glancing stick hit, friction, hub and damping', () => {
65 const p = base({ rings: 7, hitAngle: 60, stickSpeed: 6, bowHeight: 0.01, bellHeight: 0.01, duration: 0.03, frameRate: 2000 });
66 const d = deriveCymbal(p);
67 const mic = buildMic(p, d);
68 const r = simulate(buildCymbal(d), buildCymbalExcitation(p, d), { duration: p.duration, fs: p.fs, cfl: p.cfl, frameRate: p.frameRate, pickup: mic.weights, pickupDelay: mic.delay });
69 const emax = Math.max(...r.energy);
70 const bal = Math.max(...r.balance.map(Math.abs));
71 const cf = r.contactForce!;
72 console.log('contact samples', cf.reduce((c, v) => c + (v > 0 ? 1 : 0), 0), 'peak N', Math.max(...cf).toFixed(0), 'balance', (bal / emax).toExponential(2));
73 expect(bal / emax).toBeLessThan(1e-2);
74 expect(cf[cf.length - 1]).toBe(0);
75 expect(r.audio.some((v) => v !== 0)).toBe(true);
76 });
77});