Cymbal: replace felt washers with a minimal rigid hub (pinned beads around the hole), no support damping
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012zsyegMfQnzJEzgMiFLzew
6 changed files+47−69
README.mdmodified+1−1View file
@@ -39,7 +39,7 @@ String tests (`test/string.test.ts`): modes vs the discrete-string formula; smal
3939 - Thin bronze plate (E = 100 GPa, 8600 kg/m^3, 0.8 mm by default), round, free rim, centre hole; flat by default, optional bow (dome, y = h (1 - (r/R)^2)) and bell (raised cosine). Beads on a triangular lattice lifted to the profile; the mesh is scaled so its area equals the disk's.
4040 - In-plane: springs with k = sqrt(3)/2 E t / softening (an edge with one triangle gets half). Softening (default 30x in the fast preset) speeds the run; bending is unaffected.
4141 - Bending: dihedral angles across interior edges (`src/sim/hinge.ts`, Bridson et al. 2003), sign-normalized so a positive change folds toward the upward normal. Each triangle builds a shape operator S = sum_e phi_e l_e / (2A) m_e m_e^T and stores the plate energy A D/2 [(tr S)^2 - 2 (1 - nu) det S] (nu = 1/3) as a 3x3 matrix in its hinge angles, condensed over free edges so they carry no moment. Plain hinge springs get the twisting term wrong on a regular mesh (effective nu = -1/3), which matters for a free rim. Damping uses the same matrices (Rayleigh, sigma1 omega^2).
42-- Centre support: beads within the felt radius get six short springs with dashpots to fixed points above, below and to the sides.
42+- Centre support: a small rigid hub. Beads within the hub radius (default: the first ring around the hole) are held fixed; no springs, no added damping, so the rest rings like a free plate. At least three beads are needed, or the plate can tilt freely about the support.
4343 - Excitation: drumstick (Hertz, K delta^1.5) or felt mallet as a 3D point mass with contact along the surface normal, regularized Coulomb friction and a contact reach; hit angle tilts the stroke toward the rim. Also strike and force pulse.
4444 - Sound: far-field microphone via the Rayleigh integral, p = rho / (2 pi) sum A_i a_n,i(t - R_i / c) / R_i (baffled-plate assumption: no diffraction around the rim).
4545 - Tests (`test/cymbal.test.ts`, `test/hinge.test.ts`): hinge gradient vs finite differences; bending energy matches the plate for cylinder, sphere, saddle and twist to 0.1%; a free flat plate's (2,0) mode within 3.4% of Leissa's value on a 9-ring lattice; energy balance with a glancing stick, friction, felts and damping.
src/instruments/bead-cymbal/model.tsmodified+18−33View file
@@ -1,9 +1,10 @@
1-import type { Anchor, BeadSystem, Bending, Excitation } from '../../sim/types';
1+import type { BeadSystem, Bending, Excitation } from '../../sim/types';
22 import { hingeAngleGrad } from '../../sim/hinge';
33 import { dampingFromT60 } from '../bead-string/model';
44
55 // A cymbal: a thin bronze plate, round, free at the rim, with a hole in the
6-// centre where it sits between felt washers. Beads on a triangular lattice in
6+// centre; the beads around the hole are held fixed (a small rigid hub), so the
7+// rest of the plate rings like a free plate, with no added damping. Beads on a triangular lattice in
78 // the x-z plane, lifted to the profile height y(r) (flat by default; optional
89 // bow and bell). In-plane springs on every edge; plate bending from the
910 // dihedral angles across interior edges, per triangle (see Bending in sim/types). Vibration is mostly along y.
@@ -23,11 +24,8 @@ export interface CymbalParams {
2324 youngs: number; // Pa
2425 density: number; // kg/m^3
2526 soften: number; // in-plane stiffness divided by this (1 = real bronze)
26- // centre support
27- feltRadius: number; // m, beads within this radius sit in the felts
28- feltK: number; // N/m, total vertical stiffness
29- feltC: number; // N s/m, total damping
30- feltGap: number; // m, felt thickness (spring length)
27+ // centre support: beads within this radius are held fixed (a rigid hub)
28+ hubRadius: number; // m
3129 // damping
3230 t60Low: number;
3331 fLow: number;
@@ -130,7 +128,7 @@ export interface CymbalDerived {
130128 mass: Float64Array;
131129 k: number; // interior spring stiffness
132130 D: number; // bending stiffness
133- support: number[]; // beads in the felts
131+ support: number[]; // beads held fixed at the hub
134132 sigma0: number;
135133 sigma1: number;
136134 waveSpeedP: number; // in-plane P waves (after softening)
@@ -180,11 +178,15 @@ export function deriveCymbal(p: CymbalParams): CymbalDerived {
180178 const D = (p.youngs * p.thickness ** 3) / (12 * (1 - nu * nu));
181179 const support: number[] = [];
182180 mesh.pos.forEach(([x, z], i) => {
183- if (Math.hypot(x, z) <= p.feltRadius + 1e-9) support.push(i);
181+ if (Math.hypot(x, z) <= p.hubRadius + 1e-9) support.push(i);
184182 });
185183 const warnings: string[] = [];
186- if (!support.length) warnings.push('No beads sit inside the felt radius: the cymbal is unsupported and will drift. Increase the felt radius.');
187- if (p.holeRadius >= p.feltRadius) warnings.push('The centre hole is as large as the felt radius.');
184+ if (support.length < 3)
185+ warnings.push(
186+ support.length
187+ ? 'Fewer than three beads are held at the hub: the plate can tilt freely about them and will swing or spin after an off-centre hit. Increase the hub radius.'
188+ : 'No beads are held at the hub: the plate is free and will drift away after a hit. Increase the hub radius.',
189+ );
188190 if (p.soften > 1)
189191 warnings.push(
190192 `In-plane stiffness is softened ${p.soften}×. A flat plate bends the same at small amplitude; curved shapes lose curvature stiffening and big hits exaggerate the nonlinear coupling.`,
@@ -217,7 +219,7 @@ export function bendingLimitHz(d: CymbalDerived): number {
217219 return (d.bendingCoef * kb * kb) / (2 * Math.PI);
218220 }
219221
220-export function buildCymbal(p: CymbalParams, d: CymbalDerived): BeadSystem {
222+export function buildCymbal(d: CymbalDerived): BeadSystem {
221223 const { n, rest, mesh } = d;
222224 const si: number[] = [];
223225 const sj: number[] = [];
@@ -233,32 +235,15 @@ export function buildCymbal(p: CymbalParams, d: CymbalDerived): BeadSystem {
233235 sL0.push(len(e.i, e.j));
234236 sc.push(0);
235237 }
236- // felt washers: each supported bead gets six short springs to fixed points
237- // above, below and to the sides; dashpots on all of them
238- const anchors: Anchor[] = [];
239- const ns = d.support.length;
240- const kf = ns ? p.feltK / (2 * ns) : 0;
241- const cf = ns ? p.feltC / (6 * ns) : 0;
242- for (const i of d.support) {
243- for (let c = 0; c < 3; c++)
244- for (const s of [1, -1]) {
245- const pos: [number, number, number] = [rest[3 * i], rest[3 * i + 1], rest[3 * i + 2]];
246- pos[c] += s * p.feltGap;
247- anchors.push({ pos, slideBead: -1 });
248- si.push(i);
249- sj.push(n + anchors.length - 1);
250- sk.push(kf);
251- sL0.push(p.feltGap);
252- sc.push(cf);
253- }
254- }
238+ const pinned = new Uint8Array(n);
239+ for (const i of d.support) pinned[i] = 1;
255240 const bending = buildBending(d, 1 / 3);
256241 return {
257242 n,
258243 rest,
259244 mass: d.mass,
260- pinned: new Uint8Array(n),
261- anchors,
245+ pinned,
246+ anchors: [],
262247 si: Int32Array.from(si),
263248 sj: Int32Array.from(sj),
264249 sk: Float64Array.from(sk),
src/instruments/bead-cymbal/page.tsmodified+13−9View file
@@ -39,12 +39,16 @@ const SECTIONS: Section<CymbalParams>[] = [
3939 ],
4040 },
4141 {
42- title: 'Centre support (felts)',
42+ title: 'Centre support',
4343 params: [
44- { key: 'feltRadius', label: 'Felt radius', kind: 'number', unit: 'mm', scale: 1000, help: 'Beads within this radius sit between the felt washers.' },
45- { key: 'feltK', label: 'Felt stiffness', kind: 'number', unit: 'N/m', help: 'Total vertical stiffness of the felts (sideways the same).' },
46- { key: 'feltC', label: 'Felt damping', kind: 'number', unit: 'N·s/m', help: 'Total damping of the felts.' },
47- { key: 'feltGap', label: 'Felt thickness', kind: 'number', unit: 'mm', scale: 1000 },
44+ {
45+ key: 'hubRadius',
46+ label: 'Hub radius',
47+ kind: 'number',
48+ unit: 'mm',
49+ scale: 1000,
50+ help: 'Beads within this radius are held fixed: a small rigid hub, no springs or damping. Keep it small (just past the first ring of beads) for a nearly free plate.',
51+ },
4852 ],
4953 },
5054 {
@@ -110,7 +114,7 @@ export function mountBeadCymbal(root: HTMLElement) {
110114 const build = (p: CymbalParams): Built | null => {
111115 const d = deriveCymbal(p);
112116 if (d.n < 3 || !(p.thickness > 0)) return null;
113- const sys = buildCymbal(p, d);
117+ const sys = buildCymbal(d);
114118 const exc = buildCymbalExcitation(p, d);
115119 const mic = buildMic(p, d);
116120 return {
@@ -124,7 +128,7 @@ export function mountBeadCymbal(root: HTMLElement) {
124128 excKind: p.excitation,
125129 pickup: mic.weights,
126130 pickupLabel: 'microphone',
127- felt: { radius: Math.max(p.feltRadius, d.mesh.a * 0.6), gap: p.feltGap, y: profileHeight(p, 0) },
131+ post: { y: profileHeight(p, 0) },
128132 mic: {
129133 dir: micDirection(p),
130134 from: [0, profileHeight(p, 0), 0],
@@ -144,12 +148,12 @@ export function mountBeadCymbal(root: HTMLElement) {
144148 const d = deriveCymbal(p);
145149 const R = p.diameter / 2;
146150 const rows: [string, string][] = [
147- ['Beads', `${d.n} (${d.support.length} in the felts)`],
151+ ['Beads', `${d.n} (${d.support.length} held at the hub)`],
148152 ['Lattice spacing', `${fmtNum(d.mesh.a * 1000)} mm`],
149153 ['Mass', `${fmtNum(d.mass.reduce((s, m) => s + m, 0) * 1000)} g`],
150154 ['Bending stiffness D', `${fmtNum(d.D)} N·m`],
151155 ['In-plane wave speed', `${fmtNum(d.waveSpeedP)} m/s${p.soften > 1 ? ` (softened ${fmtNum(p.soften)}×)` : ''}`],
152- ['Free flat plate (2,0) mode', `${fmtNum((5.253 / (2 * Math.PI * R * R)) * d.bendingCoef)} Hz (ideal, no felts)`],
156+ ['Free flat plate (2,0) mode', `${fmtNum((5.253 / (2 * Math.PI * R * R)) * d.bendingCoef)} Hz (ideal, no support)`],
153157 ['Bending resolved to', `~${fmtNum(bendingLimitHz(d))} Hz`],
154158 ];
155159 const warnings = [...d.warnings];
src/instruments/bead-cymbal/presets.tsmodified+2−5View file
@@ -12,10 +12,7 @@ const toy: CymbalParams = {
1212 youngs: 1.0e11,
1313 density: 8600,
1414 soften: 30,
15- feltRadius: 0.02,
16- feltK: 2e5,
17- feltC: 5,
18- feltGap: 0.006,
15+ hubRadius: 0.011, // the first ring of beads around the hole
1916 t60Low: 4,
2017 fLow: 200,
2118 t60High: 1,
@@ -55,7 +52,7 @@ export const CYMBAL_PRESETS: CymbalPreset[] = [
5552 {
5653 name: 'Flat 8" toy, stick (fast)',
5754 description:
58- 'Flat 0.8 mm bronze plate, 20 cm, ~330 beads, on felts. In-plane stiffness softened 30× for speed (bending is real). Stick hit near the edge, spread over a 15 mm patch (a single bead would make the stick chatter). Bending is resolved to about 1.3 kHz.',
55+ 'Flat 0.8 mm bronze plate, 20 cm, ~370 beads, held only at a small hub (the 6 beads around the hole). In-plane stiffness softened 30× for speed (bending is real). Stick hit near the edge, spread over a 15 mm patch (a single bead would make the stick chatter). Bending is resolved to about 1.3 kHz.',
5956 params: toy,
6057 },
6158 {
src/ui/scene3d.tsmodified+8−16View file
@@ -15,7 +15,7 @@ export interface SceneSpec {
1515 pickup: Float64Array; // 3n weights
1616 pickupLabel: string;
1717 hoop?: { radius: number }; // round rim in the x-z plane, centred on the origin (instead of walls)
18- felt?: { radius: number; gap: number; y: number }; // centre support: felt washers above and below (0, y, 0)
18+ post?: { y: number }; // centre support: a stand rod from (0, y, 0) down to the floor
1919 mic?: { dir: [number, number, number]; label: string; from: [number, number, number] }; // direction to a far-field microphone
2020 }
2121
@@ -291,26 +291,18 @@ export class BeadScene {
291291 hoop.position.set(-this.center.x * this.scale, -this.center.y * this.scale, -this.center.z * this.scale);
292292 this.model.add(hoop);
293293 }
294- if (spec.felt) {
295- const o = new THREE.Vector3(-this.center.x, spec.felt.y - this.center.y, -this.center.z).multiplyScalar(this.scale);
296- const fr = spec.felt.radius * this.scale;
297- const gap = Math.max(spec.felt.gap * this.scale, 0.006);
298- const feltMat = new THREE.MeshStandardMaterial({ color: cInk, roughness: 0.95, transparent: true, opacity: 0.55 });
299- for (const side of [1, -1]) {
300- const washer = new THREE.Mesh(new THREE.CylinderGeometry(fr, fr, 0.012, 32), feltMat);
301- washer.position.copy(o).add(new THREE.Vector3(0, side * (gap + 0.006), 0));
302- this.model.add(washer);
303- }
294+ if (spec.post) {
295+ const o = new THREE.Vector3(-this.center.x, spec.post.y - this.center.y, -this.center.z).multiplyScalar(this.scale);
304296 const rodLen = o.y + 0.22;
305- const rod = new THREE.Mesh(new THREE.CylinderGeometry(0.006, 0.006, rodLen + 0.06, 10), new THREE.MeshStandardMaterial({ color: cMuted }));
306- rod.position.copy(o).add(new THREE.Vector3(0, 0.03 - rodLen / 2, 0));
297+ const rod = new THREE.Mesh(new THREE.CylinderGeometry(0.006, 0.006, rodLen + 0.04, 10), new THREE.MeshStandardMaterial({ color: cMuted }));
298+ rod.position.copy(o).add(new THREE.Vector3(0, 0.02 - rodLen / 2, 0));
307299 this.model.add(rod);
308300 }
309301 if (spec.mic) {
310302 const o = new THREE.Vector3(...spec.mic.from).sub(this.center).multiplyScalar(this.scale);
311303 this.model.add(new THREE.ArrowHelper(new THREE.Vector3(...spec.mic.dir).normalize(), o, 0.42, new THREE.Color(css('--series-3')), 0.05, 0.025));
312304 }
313- if (!spec.hoop && !spec.felt) sys.anchors.forEach((a) => {
305+ if (!spec.hoop) sys.anchors.forEach((a) => {
314306 const at = this.nodePos(sys, sys.n + sys.anchors.indexOf(a), new THREE.Vector3());
315307 const side = at.x < 0 ? -1 : 1;
316308 if (a.slideBead < 0) {
@@ -344,9 +336,9 @@ export class BeadScene {
344336 const legend: string[] = [];
345337 const sw = (c: THREE.Color, shape = 'dot') => `<i class="glyph ${shape}" style="--c:#${c.getHexString()}"></i>`;
346338 legend.push(`<span>${sw(cBead)}bead</span>`);
347- if (pinned.length) legend.push(`<span>${sw(cInk, 'square')}pinned bead</span>`);
339+ if (pinned.length) legend.push(`<span>${sw(cInk, 'square')}${spec.post ? 'bead held at the hub' : 'pinned bead'}</span>`);
348340 if (spec.hoop) legend.push(`<span>${sw(cMuted, 'ring')}clamped rim</span>`);
349- else if (spec.felt) legend.push(`<span>${sw(cInk, 'square')}felt washers (centre support)</span>`);
341+
350342 else if (sys.anchors.some((a) => a.slideBead < 0)) legend.push(`<span>${sw(cMuted, 'square')}clamped end (wall)</span>`);
351343 if (sys.anchors.some((a) => a.slideBead >= 0)) legend.push(`<span>${sw(cInk, 'ring')}sliding end (ring on a frictionless plane)</span>`);
352344 const size0 = modelSize(sys);
test/cymbal.test.tsmodified+5−5View file
@@ -10,7 +10,7 @@ describe('cymbal lattice', () => {
1010 it('bending energy matches the plate (nu = 1/3) for bending, doming and twisting', () => {
1111 const p = base({ holeRadius: 0, rings: 12 });
1212 const d = deriveCymbal(p);
13- const sys = buildCymbal(p, d);
13+ const sys = buildCymbal(d);
1414 const k = 0.5; // 1/m, small: k * a << 1
1515 const R = p.diameter / 2;
1616 const nu = 1 / 3;
@@ -39,9 +39,9 @@ describe('cymbal lattice', () => {
3939
4040 it('a free flat plate rings near the (2,0) mode of a free circular plate', () => {
4141 // no felts, no hole, no damping; initial shape r^2 cos(2 phi), small
42- const p = base({ holeRadius: 0, rings: 9, feltRadius: -1, t60Low: 0, t60High: 0, duration: 0.3, frameRate: 50 });
42+ const p = base({ holeRadius: 0, rings: 9, hubRadius: -1, t60Low: 0, t60High: 0, duration: 0.3, frameRate: 50 });
4343 const d = deriveCymbal(p);
44- const sys = buildCymbal(p, d);
44+ const sys = buildCymbal(d);
4545 const exc = buildCymbalExcitation({ ...p, excitation: 'strike', strikeVel: 0 }, d);
4646 for (let i = 0; i < sys.n; i++) {
4747 const x = sys.rest[3 * i], z = sys.rest[3 * i + 2];
@@ -61,11 +61,11 @@ describe('cymbal lattice', () => {
6161 expect(Math.abs(s.freq[best] / ideal - 1)).toBeLessThan(0.05);
6262 }, 60000);
6363
64- it('energy balances with a glancing stick hit, friction, felts and damping', () => {
64+ it('energy balances with a glancing stick hit, friction, hub and damping', () => {
6565 const p = base({ rings: 7, hitAngle: 60, stickSpeed: 6, bowHeight: 0.01, bellHeight: 0.01, duration: 0.03, frameRate: 2000 });
6666 const d = deriveCymbal(p);
6767 const mic = buildMic(p, d);
68- const r = simulate(buildCymbal(p, d), buildCymbalExcitation(p, d), { duration: p.duration, fs: p.fs, cfl: p.cfl, frameRate: p.frameRate, pickup: mic.weights, pickupDelay: mic.delay });
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 });
6969 const emax = Math.max(...r.energy);
7070 const bal = Math.max(...r.balance.map(Math.abs));
7171 const cf = r.contactForce!;