1// Parameter definitions for the cymbal2 model, mirroring cymbal2_defaults.m.
2// Unlike the sibling cymbal app there are no recomputation tiers: the
3// expensive stage is the time integration itself, and everything feeds it.
4// The render duration is not a parameter: it is
5// (strike_count - 1) / strike_rate_hz + tail_s, plus the first stroke's
6// travel time.
8export interface Cymbal2Params {
9 // Shell geometry
10 radius_m: number;
11 rise_m: number;
12 thickness_m: number;
13 // Material
14 youngs_modulus: number;
15 poisson_ratio: number;
16 density: number;
17 // Damping
18 sigma0: number;
19 sigma1: number;
20 // Support
21 washer_radius_m: number;
22 washer_stiffness: number;
23 washer_mass: number;
24 washer_damping: number;
25 support_clamped: boolean;
26 // Mallet head
27 head_radius_m: number;
28 head_mass_kg: number;
29 head_stiffness: number;
30 head_alpha: number;
31 // Mallet stick
32 stick_length_m: number;
33 stick_area_m2: number;
34 stick_youngs: number;
35 stick_density: number;
36 stick_inertia_m4: number;
37 stick_damping_rate: number;
38 n_beam: number;
39 // Grip
40 grip_clamped: boolean;
41 grip_spring: number;
42 // Strike gesture
43 strike_velocity: number;
44 strike_accel: number;
45 retract_accel: number;
46 strike_hold_ms: number;
47 strike_gap_m: number;
48 strike_r: number;
49 strike_theta_deg: number;
50 strike_count: number;
51 strike_rate_hz: number;
52 velocity_spread: number;
53 seed: number;
54 // Pickup
55 pickup_r: number;
56 pickup_theta_deg: number;
57 // Discretization
58 n_radial: number;
59 n_theta: number;
60 g_param: number;
61 phi_param: number;
62 // Rendering
63 sample_rate: number;
64 oversample: number;
65 tail_s: number;
66 pre_delay_ms: number;
67 normalize: boolean;
68 peak_dbfs: number;
69 full_scale_accel: number;
70 bit_depth: number;
71}
73export const DEFAULTS: Cymbal2Params = {
74 radius_m: 0.23,
75 rise_m: 0.04,
76 thickness_m: 0.001,
77 youngs_modulus: 1.1e11,
78 poisson_ratio: 0.33,
79 density: 8860.0,
80 sigma0: 1.34,
81 sigma1: 1.2e-3,
82 washer_radius_m: 0.029,
83 washer_stiffness: 1.65e5,
84 washer_mass: 0.0,
85 washer_damping: 0.0,
86 support_clamped: false,
87 head_radius_m: 0.025,
88 head_mass_kg: 0.028,
89 head_stiffness: 1.6e8,
90 head_alpha: 2.54,
91 stick_length_m: 0.39,
92 stick_area_m2: 1.43139e-4,
93 stick_youngs: 8.8e9,
94 stick_density: 400.0,
95 stick_inertia_m4: 1.63044e-9,
96 stick_damping_rate: 10.0,
97 n_beam: 20,
98 grip_clamped: false,
99 grip_spring: 140.0,
100 strike_velocity: 4.0,
101 strike_accel: 250.0,
102 retract_accel: 2500.0,
103 strike_hold_ms: 0.5,
104 strike_gap_m: -1,
105 strike_r: 0.6,
106 strike_theta_deg: 180.0,
107 strike_count: 1,
108 strike_rate_hz: 4.0,
109 velocity_spread: 0.0,
110 seed: 1,
111 pickup_r: 0.6,
112 pickup_theta_deg: 0.0,
113 n_radial: 24,
114 n_theta: 60,
115 g_param: 0.2498,
116 phi_param: 0.7,
117 sample_rate: 48000,
118 oversample: 2,
119 tail_s: 6.0,
120 pre_delay_ms: 3.0,
121 normalize: true,
122 peak_dbfs: -3.0,
123 full_scale_accel: 4.0e4,
124 bit_depth: 24,
125};
127// Keys that determine the linear mode table (cymbal2_modes); the cheap modes
128// stage is cached on these.
129export const MODES_KEYS: (keyof Cymbal2Params)[] = [
130 'radius_m', 'rise_m', 'thickness_m',
131 'youngs_modulus', 'poisson_ratio', 'density',
132 'washer_radius_m', 'washer_stiffness', 'support_clamped',
133 'n_radial', 'n_theta',
134];
136export interface ParamSpec {
137 key: keyof Cymbal2Params;
138 label: string;
139 unit?: string;
140 min: number;
141 max: number;
142 step?: number;
143 log?: boolean;
144 integer?: boolean;
145 help?: string;
146}
148export interface ParamGroup {
149 name: string;
150 specs: ParamSpec[];
151}
153export const PARAM_GROUPS: ParamGroup[] = [
154 {
155 name: 'Stroke',
156 specs: [
157 { key: 'strike_velocity', label: 'Hand speed', unit: 'm/s', min: 0.25, max: 7.0, step: 0.05, help: 'Peak speed of the hand. The compliant stick whips, so the tip arrives about 1.3x faster. 0.5 taps, 2 begins to wash, 4 and up crash. Above about 7 the explicit nonlinear integration diverges, which is also where the shallow-shell physics stops being valid; the renderer reports it rather than emitting garbage.' },
158 { key: 'strike_r', label: 'Contact position', min: 0.05, max: 0.99, step: 0.005, help: 'Radial fraction from the washer (0) to the rim (1); draggable on the cross-section above. Near the rim drives the high nodal-diameter modes; near the washer rings sparser.' },
159 { key: 'strike_accel', label: 'Downstroke accel', unit: 'm/s²', min: 50, max: 4000, log: true, help: 'How hard the arm accelerates. Faster strokes give the compliant stick less time to follow, so the tip whips more.' },
160 { key: 'retract_accel', label: 'Retract accel', unit: 'm/s²', min: 200, max: 20000, log: true, help: 'The wrist snap pulling the stick back after contact. Too slow and the stick shoves the wobbling shell and re-hits it.' },
161 { key: 'strike_hold_ms', label: 'Hold', unit: 'ms', min: 0, max: 20, step: 0.5, help: 'Dwell between first contact and the retract. Long holds choke the shell against the head.' },
162 ],
163 },
164 {
165 name: 'Repeats',
166 specs: [
167 { key: 'strike_count', label: 'Strokes', min: 1, max: 16, step: 1, integer: true, help: 'The hand returns to rest between strokes, which the paper\'s constant-acceleration hand cannot do.' },
168 { key: 'strike_rate_hz', label: 'Rate', unit: 'Hz', min: 0.5, max: 12, step: 0.25, help: 'Strokes per second. A stroke whose predecessor has not finished retracting is skipped.' },
169 { key: 'velocity_spread', label: 'Velocity spread', min: 0, max: 0.7, step: 0.01, help: 'Relative scatter of the per-stroke hand speeds.' },
170 { key: 'seed', label: 'Random seed', min: 1, max: 9999, step: 1, integer: true, help: 'Redraws the velocity scatter; the same seed always reproduces it.' },
171 ],
172 },
173 {
174 name: 'Support (washer)',
175 specs: [
176 { key: 'washer_stiffness', label: 'Washer stiffness', unit: 'N/m/m', min: 1e4, max: 1e7, log: true, help: 'Spring per unit length of the mounting circle; 1.65e5 is the paper\'s measured felt washer. It sets the bounce (about 13 Hz) and tilt (about 4 Hz) of the whole shell on its mount, and through the boundary condition the low audible modes too.' },
177 { key: 'washer_damping', label: 'Washer damping', unit: 'N s/m/m', min: 0, max: 500, step: 1, help: 'Dashpot per unit length alongside the spring. The paper sets 0; a real felt sleeve has some.' },
178 { key: 'washer_mass', label: 'Washer mass', unit: 'kg/m', min: 0, max: 1, step: 0.005, help: 'Mass per unit length moving with the mounting circle. The paper sets 0.' },
179 { key: 'washer_radius_m', label: 'Washer radius', unit: 'm', min: 0.015, max: 0.06, step: 0.001, help: 'Radius of the mounting circle. The grid needs it above roughly 3 radial spacings; the builder refuses configurations it cannot resolve.' },
180 ],
181 },
182 {
183 name: 'Mallet',
184 specs: [
185 { key: 'head_mass_kg', label: 'Head mass', unit: 'kg', min: 0.002, max: 0.2, log: true, help: 'The 28 g head of the paper\'s measured mallet. Heavier darkens and lengthens the contact.' },
186 { key: 'head_stiffness', label: 'Head stiffness', unit: 'N m⁻ᵅ', min: 1e6, max: 1e11, log: true, help: 'Coefficient of the Hertz-type contact f = K delta^alpha. Softer rolls the attack off; a wood tip is orders of magnitude stiffer than felt.' },
187 { key: 'head_alpha', label: 'Contact exponent', min: 1.2, max: 3.5, step: 0.02, help: 'Hertz theory gives 1.5 for a sphere on a plate; the paper\'s measured mallet is 2.54, felt being strongly hardening.' },
188 { key: 'head_radius_m', label: 'Head radius', unit: 'm', min: 0.005, max: 0.05, step: 0.001 },
189 ],
190 },
191 {
192 name: 'Stick and grip',
193 specs: [
194 { key: 'grip_spring', label: 'Grip stiffness', unit: 'N m/rad', min: 10, max: 5000, log: true, help: 'Rotational spring of the loose grip. Softer lets the stick pivot freely and whip; the audible effect is on the attack, as the paper\'s grip-tightness experiment found.' },
195 { key: 'stick_youngs', label: 'Stick modulus', unit: 'Pa', min: 1e9, max: 1e11, log: true, help: 'Wood is about 9e9. A stiffer stick follows the hand faster and doubles the contact peaks differently.' },
196 { key: 'stick_length_m', label: 'Stick length', unit: 'm', min: 0.2, max: 0.6, step: 0.005 },
197 { key: 'stick_density', label: 'Stick density', unit: 'kg/m³', min: 200, max: 1200, step: 10 },
198 { key: 'stick_damping_rate', label: 'Stick damping', unit: 's⁻¹', min: 0, max: 100, step: 1, help: 'Mass-proportional damping of the stick; the paper uses 10.' },
199 ],
200 },
201 {
202 name: 'Shell geometry',
203 specs: [
204 { key: 'radius_m', label: 'Radius', unit: 'm', min: 0.12, max: 0.35, step: 0.001, help: '0.23 is the paper\'s 18-inch crash. Mode frequencies scale roughly as thickness / radius².' },
205 { key: 'rise_m', label: 'Rise', unit: 'm', min: 0.005, max: 0.09, step: 0.001, help: 'Height of the cap; sets the radius of curvature Rs = a²/(2 rise). More curvature stiffens the low modes through the membrane coupling and raises the ring frequency.' },
206 { key: 'thickness_m', label: 'Thickness', unit: 'm', min: 0.0004, max: 0.003, step: 0.00005, help: 'Uniform; the shallow-shell formulation cannot carry a taper. Thin crashes, thick rides. The nonlinearity strengthens as displacement over thickness.' },
207 ],
208 },
209 {
210 name: 'Material and damping',
211 specs: [
212 { key: 'density', label: 'Density', unit: 'kg/m³', min: 2000, max: 12000, log: true, help: 'The paper\'s measured bronze is 8860.' },
213 { key: 'youngs_modulus', label: "Young's modulus", unit: 'Pa', min: 3e10, max: 3e11, log: true },
214 { key: 'poisson_ratio', label: 'Poisson ratio', min: 0.0, max: 0.45, step: 0.01 },
215 { key: 'sigma0', label: 'Damping σ₀', unit: 's⁻¹', min: 0.05, max: 10, log: true, help: 'Frequency-independent decay rate: T60 = 6.91 / (2 σ₀) ... more precisely 3 ln10 / σ₀ at the bottom of the band. The paper fits 1.34.' },
216 { key: 'sigma1', label: 'Damping σ₁', min: 0, max: 0.02, step: 0.0001, help: 'High-frequency damping, growing as the squared wavenumber. The paper fits 1.2e-3.' },
217 ],
218 },
219 {
220 name: 'Pickup',
221 specs: [
222 { key: 'pickup_r', label: 'Pickup position', min: 0.05, max: 0.99, step: 0.005, help: 'Radial fraction of the pickup point, draggable on the cross-section. The output is the shell\'s normal acceleration there: a contact pickup, not a microphone.' },
223 { key: 'pickup_theta_deg', label: 'Pickup azimuth', unit: '°', min: 0, max: 359, step: 1, help: 'The strike is at 180°. On top of it you hear the contact hardest.' },
224 ],
225 },
226 {
227 name: 'Output',
228 specs: [
229 { key: 'tail_s', label: 'Tail', unit: 's', min: 0.5, max: 12.0, step: 0.1, help: 'Ring time rendered after the last stroke. The low modes carry T60 near 5 s at the default damping.' },
230 { key: 'pre_delay_ms', label: 'Pre-delay', unit: 'ms', min: 0, max: 50, step: 0.5 },
231 { key: 'peak_dbfs', label: 'Peak level', unit: 'dBFS', min: -24, max: 0, step: 0.5, help: 'Used when normalize is on.' },
232 { key: 'full_scale_accel', label: 'Full scale', unit: 'm/s²', min: 1e2, max: 1e6, log: true, help: 'Used when normalize is off: this acceleration maps to 0 dBFS.' },
233 ],
234 },
235 {
236 name: 'Advanced / quality',
237 specs: [
238 { key: 'n_radial', label: 'Radial intervals', min: 20, max: 48, step: 1, integer: true, help: 'The paper\'s grid uses 24. The washer circle needs about 3 radial spacings inside it; too-coarse grids are refused. Cost is roughly linear.' },
239 { key: 'n_theta', label: 'Azimuthal points', min: 24, max: 120, step: 2, integer: true, help: 'The paper uses 60. Sets the highest nodal-diameter order carried; the crash\'s top octaves live there, and hard strokes need the full ladder or the cascade piles up at the cutoff and diverges. Cost is roughly linear.' },
240 { key: 'oversample', label: 'Oversample', min: 1, max: 4, step: 1, integer: true, help: 'Simulation rate multiple of the output rate. 2 matches the paper\'s 88.2 kHz; hard strokes need it for stability of the explicit nonlinearity.' },
241 { key: 'g_param', label: 'Scheme g', min: 0.24, max: 0.3, step: 0.0002, help: 'Free parameter of the implicit shell scheme; the paper uses 0.2498. At 0.25 and above the linear part is unconditionally stable.' },
242 { key: 'phi_param', label: 'Scheme φ', min: 0.5, max: 1.0, step: 0.01, help: 'Implicit weight of the washer and beam schemes.' },
243 { key: 'n_beam', label: 'Beam elements', min: 8, max: 40, step: 1, integer: true },
244 { key: 'strike_gap_m', label: 'Rest clearance', unit: 'm', min: -1, max: 0.5, step: 0.005, help: 'Head-to-shell clearance at rest. Nonpositive sizes it to the stroke so contact arrives just after full speed, which keeps the tip whip consistent.' },
245 ],
246 },
247];
250// ---- Presets ----
251// A preset is a patch on DEFAULTS, so switching presets replaces the whole
252// parameter set rather than layering on top of manual edits. "Paper demo" is
253// the configuration behind the repository's cymbal2-paper-demo.wav: the
254// stroke fitted against the demonstration recording published by the paper's
255// authors (band balance within about 2 dB from 100 Hz to 12 kHz, the
256// high-frequency buildup, and the decay envelope).
258export interface Preset {
259 id: string;
260 name: string;
261 note: string;
262 patch: Partial<Cymbal2Params>;
263}
265export const PRESETS: Preset[] = [
266 {
267 id: 'defaults',
268 name: 'App defaults',
269 note: 'The paper\'s measured 18-inch crash at a firm 4 m/s stroke.',
270 patch: {},
271 },
272 {
273 id: 'paper-demo',
274 name: 'Paper demo',
275 note: 'The stroke fitted against the authors\' published demonstration recording (5.75 m/s hand, tip arriving near 8.2 m/s, about 311 N peak). This is the configuration behind cymbal2-paper-demo.wav.',
276 patch: { strike_velocity: 5.75 },
277 },
278 {
279 id: 'gentle-tap',
280 name: 'Gentle tap',
281 note: 'A 0.5 m/s fingertip-weight tap: the shell stays nearly linear and rings like the bell of a gong. Longer tail to hear the full decay.',
282 patch: { strike_velocity: 0.5, tail_s: 8 },
283 },
284 {
285 id: 'medium-wash',
286 name: 'Medium wash',
287 note: 'A 2 m/s stroke, where the crash begins to wash: energy migrates upward over the first half second without a full crash.',
288 patch: { strike_velocity: 2 },
289 },
290 {
291 id: 'hard-crash',
292 name: 'Hard crash',
293 note: 'A 7 m/s stroke, the practical ceiling: above this the explicit nonlinear integration diverges, which is also where the shallow-shell physics runs out.',
294 patch: { strike_velocity: 7 },
295 },
296 {
297 id: 'roll',
298 name: 'Roll',
299 note: 'Eight strokes at 6 per second with scattered velocities; the hand state machine returns to rest between strokes, which the paper\'s constant-acceleration hand cannot do. Harder rolls pump energy in faster than it decays and the integration diverges.',
300 patch: { strike_velocity: 2.5, strike_count: 8, strike_rate_hz: 6, velocity_spread: 0.2, tail_s: 5 },
301 },
302 {
303 id: 'rigid-clamp',
304 name: 'Rigid clamp',
305 note: 'The rigid center support of the earlier literature (Bilbao 2010) in place of the measured felt washer: the low end stiffens and the mounted-cymbal wobble disappears.',
306 patch: { strike_velocity: 5.75, support_clamped: true },
307 },
308 {
309 id: 'hard-tip',
310 name: 'Hard tip, tight grip',
311 note: 'A stiffer, lighter head with a more nearly Hertzian exponent, on a clamped grip: the contact shortens to about 1.5 ms and the attack turns into a bright ping. Much stiffer contacts outrun what the explicit contact scheme resolves.',
312 patch: { strike_velocity: 4, head_stiffness: 4e8, head_alpha: 1.8, head_mass_kg: 0.02, grip_clamped: true },
313 },
314 {
315 id: 'soft-mallet',
316 name: 'Soft mallet',
317 note: 'A heavier, softer felt head: the contact lengthens, the attack darkens, and the wash builds without the metallic ping.',
318 patch: { strike_velocity: 3, head_stiffness: 5e6, head_alpha: 2.2, head_mass_kg: 0.045 },
319 },
320 {
321 id: 'splash',
322 name: 'Small splash',
323 note: 'A 30 cm shell, thinner and flatter, with heavier damping as small cymbals have: everything shifts up and decays fast. The washer circle stays wide enough for the radial grid to resolve.',
324 patch: { radius_m: 0.15, rise_m: 0.025, thickness_m: 0.0009, washer_radius_m: 0.022, sigma0: 2.5, sigma1: 0.003, strike_velocity: 3, tail_s: 4 },
325 },
326 {
327 id: 'ride',
328 name: 'Thick ride',
329 note: 'A 51 cm shell at 2 mm: displacement per thickness drops, so the nonlinearity weakens and the stroke pings and sustains instead of crashing.',
330 patch: { radius_m: 0.255, rise_m: 0.05, thickness_m: 0.002, strike_velocity: 3, tail_s: 8 },
331 },
332];
334export function applyPreset(preset: Preset): Cymbal2Params {
335 return { ...DEFAULTS, ...preset.patch };
336}
338// The preset whose applied parameter set matches exactly, or null.
339export function matchPresetId(params: Cymbal2Params): string | null {
340 for (const p of PRESETS) {
341 const applied = applyPreset(p);
342 let same = true;
343 for (const k of Object.keys(DEFAULTS) as (keyof Cymbal2Params)[]) {
344 if (params[k] !== applied[k]) { same = false; break; }
345 }
346 if (same) return p.id;
347 }
348 return null;
349}
351const STORAGE_KEY = 'cymbal2-webapp-params-v1';
353export function loadParams(): Cymbal2Params {
354 try {
355 const raw = localStorage.getItem(STORAGE_KEY);
356 if (!raw) return { ...DEFAULTS };
357 const parsed = JSON.parse(raw);
358 const out = { ...DEFAULTS };
359 for (const k of Object.keys(DEFAULTS) as (keyof Cymbal2Params)[]) {
360 if (typeof parsed[k] === typeof DEFAULTS[k]) {
361 (out as Record<string, unknown>)[k] = parsed[k];
362 }
363 }
364 return out;
365 } catch {
366 return { ...DEFAULTS };
367 }
368}
370export function saveParams(p: Cymbal2Params): void {
371 try {
372 localStorage.setItem(STORAGE_KEY, JSON.stringify(p));
373 } catch {
374 // storage unavailable; persistence is a convenience only
375 }
376}