1import type { Section } from '../../ui/controls';
2import { fmtNum } from '../../ui/plot';
3import { MembraneView } from '../../ui/membraneview';
4import type { Built, InstrumentConfig } from '../../ui/instrumentPage';
5import {
6 bendingLimitHz,
7 buildCymbal,
8 buildCymbalExcitation,
9 buildMic,
10 deriveCymbal,
11 hitPoint,
12 micDirection,
13 profileHeight,
14 type CymbalParams,
15} from './model';
16import { CYMBAL_PRESETS } from './presets';
18const isExc = (...t: CymbalParams['excitation'][]) => (p: CymbalParams) => t.includes(p.excitation);
20const SECTIONS: Section<CymbalParams>[] = [
21 {
22 title: 'Geometry',
23 params: [
24 { key: 'diameter', label: 'Diameter', kind: 'number', unit: 'cm', scale: 100 },
25 { key: 'rings', label: 'Rings of beads', kind: 'int', min: 2, help: 'Beads from the centre to the rim; bead count grows as about 3 × rings². More rings resolve higher frequencies but cost much more.' },
26 { key: 'holeRadius', label: 'Centre hole radius', kind: 'number', unit: 'mm', scale: 1000 },
27 { key: 'bowHeight', label: 'Bow height', kind: 'number', unit: 'mm', scale: 1000, help: 'Rise of the dome from the rim to the centre. 0 = flat.' },
28 { key: 'bellDiameter', label: 'Bell diameter', kind: 'number', unit: 'cm', scale: 100 },
29 { key: 'bellHeight', label: 'Bell height', kind: 'number', unit: 'mm', scale: 1000, help: '0 = no bell.' },
30 ],
31 },
32 {
33 title: 'Material',
34 params: [
35 { key: 'thickness', label: 'Thickness', kind: 'number', unit: 'mm', scale: 1000 },
36 { key: 'youngs', label: "Young's modulus", kind: 'number', unit: 'GPa', scale: 1e-9, help: 'B20 bronze: about 100 GPa.' },
37 { key: 'density', label: 'Density', kind: 'number', unit: 'kg/m³', help: 'B20 bronze: about 8600 kg/m³.' },
38 { key: 'soften', label: 'In-plane softening', kind: 'number', unit: '×', help: 'Divide the in-plane stiffness by this for speed (bending stays real). 1 = real bronze. Flat plates are unaffected at small amplitude; curved shapes and big hits are.' },
39 ],
40 },
41 {
42 title: 'Damping (0 = off)',
43 params: [
44 { key: 't60Low', label: 'T60 low', kind: 'number', unit: 's', help: 'Decay time of bending vibration at the low frequency.' },
45 { key: 'fLow', label: 'at', kind: 'number', unit: 'Hz' },
46 { key: 't60High', label: 'T60 high', kind: 'number', unit: 's' },
47 { key: 'fHigh', label: 'at', kind: 'number', unit: 'Hz' },
48 ],
49 },
50 {
51 title: 'Excitation',
52 params: [
53 {
54 key: 'excitation',
55 label: 'Type',
56 kind: 'select',
57 options: [
58 ['stick', 'Drumstick (hard contact)'],
59 ['mallet', 'Felt mallet'],
60 ['strike', 'Strike (initial velocity)'],
61 ['force', 'Force pulse'],
62 ],
63 },
64 { key: 'excR', label: 'Position', kind: 'number', unit: '× radius', help: '0 = centre, 1 = rim.' },
65 { key: 'excAngle', label: 'Azimuth', kind: 'number', unit: '°' },
66 { key: 'hitAngle', label: 'Hit angle', kind: 'number', unit: '° from normal', help: '0 = straight into the surface; larger angles tilt the stroke toward the rim, like a crash stroke.' },
67 { key: 'excWidth', label: 'Patch width', kind: 'number', unit: 'mm', scale: 1000, help: 'Gaussian width of the contact patch; 0 = a single bead. A real stick tip is smaller than a bead, but a single light bead makes the stick chatter on this coarse lattice, so the presets use about 1.5 bead spacings.' },
68 { key: 'stickMass', label: 'Stick mass', kind: 'number', unit: 'g', scale: 1000, show: isExc('stick'), help: 'Effective mass of the stick at the tip.' },
69 { key: 'stickSpeed', label: 'Stick speed', kind: 'number', unit: 'm/s', show: isExc('stick') },
70 { key: 'stickK', label: 'Tip stiffness K', kind: 'number', unit: 'N/m^1.5', show: isExc('stick'), help: 'Hertz contact F = K δ^1.5. Wood on bronze with a 5 mm tip: about 5e8 to 1e9.' },
71 { key: 'malletMass', label: 'Mallet mass', kind: 'number', unit: 'g', scale: 1000, show: isExc('mallet') },
72 { key: 'malletSpeed', label: 'Mallet speed', kind: 'number', unit: 'm/s', show: isExc('mallet') },
73 { key: 'malletK', label: 'Felt K', kind: 'number', unit: 'N/mᵖ', show: isExc('mallet') },
74 { key: 'malletP', label: 'Felt exponent p', kind: 'number', show: isExc('mallet') },
75 { key: 'friction', label: 'Friction', kind: 'number', show: isExc('stick', 'mallet'), help: 'Coulomb friction coefficient between tip and cymbal; matters for angled hits.' },
76 { key: 'strikeVel', label: 'Velocity', kind: 'number', unit: 'm/s', show: isExc('strike') },
77 { key: 'forcePeak', label: 'Peak force', kind: 'number', unit: 'N', show: isExc('force') },
78 { key: 'forceMs', label: 'Duration', kind: 'number', unit: 'ms', show: isExc('force') },
79 ],
80 },
81 {
82 title: 'Microphone',
83 params: [
84 { key: 'micDistance', label: 'Distance', kind: 'number', unit: 'm' },
85 { key: 'micElevation', label: 'Elevation', kind: 'number', unit: '°', help: 'Angle above the cymbal plane (90 = straight above, negative = below). Straight above, every mode with nodal diameters cancels.' },
86 { key: 'micAzimuth', label: 'Azimuth', kind: 'number', unit: '°' },
87 ],
88 },
89 {
90 title: 'Simulation',
91 params: [
92 { key: 'duration', label: 'Duration', kind: 'number', unit: 's' },
93 { key: 'fs', label: 'Sample rate', kind: 'select', options: [['44100', '44.1 kHz'], ['48000', '48 kHz'], ['96000', '96 kHz']] },
94 { key: 'cfl', label: 'Step safety', kind: 'number', help: 'Fraction of the leapfrog stability limit dt = 2/ω_max.' },
95 { key: 'frameRate', label: 'Replay frames', kind: 'number', unit: 'per s' },
96 ],
97 },
98];
100const build = (p: CymbalParams): Built | null => {
101 const d = deriveCymbal(p);
102 if (d.n < 3 || !(p.thickness > 0)) return null;
103 const sys = buildCymbal(d);
104 const exc = buildCymbalExcitation(p, d);
105 const mic = buildMic(p, d);
106 return {
107 sys,
108 exc,
109 pickup: mic.weights,
110 pickupDelay: mic.delay,
111 scene: {
112 sys,
113 exc,
114 excKind: p.excitation,
115 pickup: mic.weights,
116 pickupLabel: 'microphone',
117 note: 'free plate: nothing holds it, rigid motion removed',
118 mic: {
119 dir: micDirection(p),
120 from: [0, profileHeight(p, 0), 0],
121 label: `microphone direction (${fmtNum(p.micDistance)} m away, ${fmtNum(p.micElevation)}° up)`,
122 },
123 },
124 };
125};
127export const CYMBAL_CONFIG: InstrumentConfig<CymbalParams> = {
128 title: 'Cymbal',
129 wavName: 'bead-cymbal.wav',
130 audioQuantity: { label: 'pressure at the microphone', unit: 'Pa' },
131 presets: CYMBAL_PRESETS,
132 sections: SECTIONS,
133 setup(p) {
134 const d = deriveCymbal(p);
135 const R = p.diameter / 2;
136 const rows: [string, string][] = [
137 ['Beads', `${d.n}`],
138 ['Support', 'none: a free plate with its rigid-body motion removed'],
139 ['Lattice spacing', `${fmtNum(d.mesh.a * 1000)} mm`],
140 ['Mass', `${fmtNum(d.mass.reduce((s, m) => s + m, 0) * 1000)} g`],
141 ['Bending stiffness D', `${fmtNum(d.D)} N·m`],
142 ['In-plane wave speed', `${fmtNum(d.waveSpeedP)} m/s${p.soften > 1 ? ` (softened ${fmtNum(p.soften)}×)` : ''}`],
143 ['Free flat plate (2,0) mode', `${fmtNum((5.253 / (2 * Math.PI * R * R)) * d.bendingCoef)} Hz (ideal)`],
144 ['Bending resolved to', `~${fmtNum(bendingLimitHz(d))} Hz`],
145 ];
146 const warnings = [...d.warnings];
147 const fmax = bendingLimitHz(d);
148 if (fmax < 3000)
149 warnings.push(
150 `Bending waves are only resolved up to about ${fmtNum(fmax)} Hz: above that the lattice is too coarse and the sound is not cymbal-like. More rings help, at a steep cost.`,
151 );
152 if (Math.abs(p.micElevation) > 80) warnings.push('The microphone is almost straight above: modes with nodal diameters cancel there.');
153 if (p.hitAngle >= 85) warnings.push('A hit angle near 90° barely touches the surface.');
154 return { rows, warnings, built: build(p) };
155 },
156 summaryRows: (_p, _b, r) => [['Largest strain in the plate', `${fmtNum(r.maxStretch * 100)}%`]],
157 extraCards: '<section class="card" data-r="out" hidden><h2>Cymbal (top view and cross-section)</h2><div data-r="membrane"></div></section>',
158 mountExtras(q, timeline) {
159 const view = new MembraneView(q('membrane'), timeline);
160 return {
161 show(p, _built, r) {
162 const d = deriveCymbal(p);
163 view.set(
164 {
165 pos: d.mesh.pos,
166 radius: p.diameter / 2,
167 spacing: d.mesh.a,
168 strike: hitPoint(p),
169 pickup: null,
170 pickupNote:
171 'Sound: far-field microphone (Rayleigh integral: each bead’s normal acceleration × area, delayed by its distance; assumes a baffled plate, so sound bending around the rim is ignored).',
172 sectionAngle: p.excR > 0.01 ? p.excAngle : 0,
173 },
174 r.frames,
175 );
176 },
177 };
178 },
179};