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}
73// The defaults are the paper-demo configuration: cymbal2_defaults.m as
74// published, whose stroke was fitted against the authors' demonstration
75// recording. The app therefore opens on the configuration behind
76// cymbal2-paper-demo.wav.
77export const DEFAULTS: Cymbal2Params = {
78 radius_m: 0.23,
79 rise_m: 0.04,
80 thickness_m: 0.001,
81 youngs_modulus: 1.1e11,
82 poisson_ratio: 0.33,
83 density: 8860.0,
84 sigma0: 1.34,
85 sigma1: 1.2e-3,
86 washer_radius_m: 0.029,
87 washer_stiffness: 1.65e5,
88 washer_mass: 0.0,
89 washer_damping: 0.0,
90 support_clamped: false,
91 head_radius_m: 0.025,
92 head_mass_kg: 0.028,
93 head_stiffness: 1.6e8,
94 head_alpha: 2.54,
95 stick_length_m: 0.39,
96 stick_area_m2: 1.43139e-4,
97 stick_youngs: 8.8e9,
98 stick_density: 400.0,
99 stick_inertia_m4: 1.63044e-9,
100 stick_damping_rate: 10.0,
101 n_beam: 20,
102 grip_clamped: false,
103 grip_spring: 140.0,
104 strike_velocity: 5.75,
105 strike_accel: 250.0,
106 retract_accel: 2500.0,
107 strike_hold_ms: 0.5,
108 strike_gap_m: -1,
109 strike_r: 0.6,
110 strike_theta_deg: 180.0,
111 strike_count: 1,
112 strike_rate_hz: 4.0,
113 velocity_spread: 0.0,
114 seed: 1,
115 pickup_r: 0.6,
116 pickup_theta_deg: 0.0,
117 n_radial: 24,
118 n_theta: 60,
119 g_param: 0.2498,
120 phi_param: 0.7,
121 sample_rate: 48000,
122 oversample: 2,
123 tail_s: 6.0,
124 pre_delay_ms: 3.0,
125 normalize: true,
126 peak_dbfs: -3.0,
127 full_scale_accel: 4.0e4,
128 bit_depth: 24,
129};
131// Keys that determine the linear mode table (cymbal2_modes); the cheap modes
132// stage is cached on these.
133export const MODES_KEYS: (keyof Cymbal2Params)[] = [
134 'radius_m', 'rise_m', 'thickness_m',
135 'youngs_modulus', 'poisson_ratio', 'density',
136 'washer_radius_m', 'washer_stiffness', 'support_clamped',
137 'n_radial', 'n_theta',
138];
140export interface ParamSpec {
141 key: keyof Cymbal2Params;
142 label: string;
143 unit?: string;
144 min: number;
145 max: number;
146 step?: number;
147 log?: boolean;
148 integer?: boolean;
149 help?: string;
150}
152export interface ParamGroup {
153 name: string;
154 specs: ParamSpec[];
155}
157export const PARAM_GROUPS: ParamGroup[] = [
158 {
159 name: 'Stroke',
160 specs: [
161 { 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.' },
162 { 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.' },
163 { 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.' },
164 { 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.' },
165 { 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.' },
166 ],
167 },
168 {
169 name: 'Repeats',
170 specs: [
171 { 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.' },
172 { 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.' },
173 { key: 'velocity_spread', label: 'Velocity spread', min: 0, max: 0.7, step: 0.01, help: 'Relative scatter of the per-stroke hand speeds.' },
174 { key: 'seed', label: 'Random seed', min: 1, max: 9999, step: 1, integer: true, help: 'Redraws the velocity scatter; the same seed always reproduces it.' },
175 ],
176 },
177 {
178 name: 'Support (washer)',
179 specs: [
180 { 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.' },
181 { 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.' },
182 { 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.' },
183 { 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.' },
184 ],
185 },
186 {
187 name: 'Mallet',
188 specs: [
189 { 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.' },
190 { 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.' },
191 { 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.' },
192 { key: 'head_radius_m', label: 'Head radius', unit: 'm', min: 0.005, max: 0.05, step: 0.001 },
193 ],
194 },
195 {
196 name: 'Stick and grip',
197 specs: [
198 { 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.' },
199 { 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.' },
200 { key: 'stick_length_m', label: 'Stick length', unit: 'm', min: 0.2, max: 0.6, step: 0.005 },
201 { key: 'stick_density', label: 'Stick density', unit: 'kg/m³', min: 200, max: 1200, step: 10 },
202 { 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.' },
203 ],
204 },
205 {
206 name: 'Shell geometry',
207 specs: [
208 { 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².' },
209 { 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.' },
210 { 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.' },
211 ],
212 },
213 {
214 name: 'Material and damping',
215 specs: [
216 { key: 'density', label: 'Density', unit: 'kg/m³', min: 2000, max: 12000, log: true, help: 'The paper\'s measured bronze is 8860.' },
217 { key: 'youngs_modulus', label: "Young's modulus", unit: 'Pa', min: 3e10, max: 3e11, log: true },
218 { key: 'poisson_ratio', label: 'Poisson ratio', min: 0.0, max: 0.45, step: 0.01 },
219 { 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.' },
220 { 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.' },
221 ],
222 },
223 {
224 name: 'Pickup',
225 specs: [
226 { 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.' },
227 { 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.' },
228 ],
229 },
230 {
231 name: 'Output',
232 specs: [
233 { 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.' },
234 { key: 'pre_delay_ms', label: 'Pre-delay', unit: 'ms', min: 0, max: 50, step: 0.5 },
235 { key: 'peak_dbfs', label: 'Peak level', unit: 'dBFS', min: -24, max: 0, step: 0.5, help: 'Used when normalize is on.' },
236 { 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.' },
237 ],
238 },
239 {
240 name: 'Advanced / quality',
241 specs: [
242 { 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.' },
243 { 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.' },
244 { 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.' },
245 { 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.' },
246 { key: 'phi_param', label: 'Scheme φ', min: 0.5, max: 1.0, step: 0.01, help: 'Implicit weight of the washer and beam schemes.' },
247 { key: 'n_beam', label: 'Beam elements', min: 8, max: 40, step: 1, integer: true },
248 { 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.' },
249 ],
250 },
251];
254// ---- Presets ----
255// A preset is a patch on DEFAULTS, so switching presets replaces the whole
256// parameter set rather than layering on top of manual edits. "Paper demo" is
257// the defaults themselves (an empty patch), and is the configuration behind
258// the repository's cymbal2-paper-demo.wav: the stroke fitted against the
259// demonstration recording published by the paper's authors (band balance
260// within about 2 dB from 100 Hz to 12 kHz, the high-frequency buildup, and
261// the decay envelope).
263export interface Preset {
264 id: string;
265 name: string;
266 note: string;
267 patch: Partial<Cymbal2Params>;
268}
270export const PRESETS: Preset[] = [
271 {
272 id: 'paper-demo',
273 name: 'Paper demo',
274 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, and the app opens on it.',
275 patch: {},
276 },
277 {
278 id: 'firm-crash',
279 name: 'Firm crash',
280 note: 'The same measured 18-inch shell at a firm 4 m/s stroke: it crashes, but with less of the high-frequency buildup than the paper\'s demonstration stroke.',
281 patch: { strike_velocity: 4 },
282 },
283 {
284 id: 'gentle-tap',
285 name: 'Gentle tap',
286 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.',
287 patch: { strike_velocity: 0.5, tail_s: 8 },
288 },
289 {
290 id: 'medium-wash',
291 name: 'Medium wash',
292 note: 'A 2 m/s stroke, where the crash begins to wash: energy migrates upward over the first half second without a full crash.',
293 patch: { strike_velocity: 2 },
294 },
295 {
296 id: 'hard-crash',
297 name: 'Hard crash',
298 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.',
299 patch: { strike_velocity: 7 },
300 },
301 {
302 id: 'roll',
303 name: 'Roll',
304 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.',
305 patch: { strike_velocity: 2.5, strike_count: 8, strike_rate_hz: 6, velocity_spread: 0.2, tail_s: 5 },
306 },
307 {
308 id: 'rigid-clamp',
309 name: 'Rigid clamp',
310 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.',
311 patch: { support_clamped: true },
312 },
313 {
314 id: 'hard-tip',
315 name: 'Hard tip, tight grip',
316 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.',
317 patch: { strike_velocity: 4, head_stiffness: 4e8, head_alpha: 1.8, head_mass_kg: 0.02, grip_clamped: true },
318 },
319 {
320 id: 'soft-mallet',
321 name: 'Soft mallet',
322 note: 'A heavier, softer felt head: the contact lengthens, the attack darkens, and the wash builds without the metallic ping.',
323 patch: { strike_velocity: 3, head_stiffness: 5e6, head_alpha: 2.2, head_mass_kg: 0.045 },
324 },
325 {
326 id: 'splash',
327 name: 'Small splash',
328 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.',
329 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 },
330 },
331 {
332 id: 'ride',
333 name: 'Thick ride',
334 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.',
335 patch: { radius_m: 0.255, rise_m: 0.05, thickness_m: 0.002, strike_velocity: 3, tail_s: 8 },
336 },
337];
339export function applyPreset(preset: Preset): Cymbal2Params {
340 return { ...DEFAULTS, ...preset.patch };
341}
343// The preset whose applied parameter set matches exactly, or null.
344export function matchPresetId(params: Cymbal2Params): string | null {
345 for (const p of PRESETS) {
346 const applied = applyPreset(p);
347 let same = true;
348 for (const k of Object.keys(DEFAULTS) as (keyof Cymbal2Params)[]) {
349 if (params[k] !== applied[k]) { same = false; break; }
350 }
351 if (same) return p.id;
352 }
353 return null;
354}
356// Bumped when the defaults change, so a stored copy of the previous defaults
357// does not keep overriding them on reload.
358const STORAGE_KEY = 'cymbal2-webapp-params-v2';
360export function loadParams(): Cymbal2Params {
361 try {
362 const raw = localStorage.getItem(STORAGE_KEY);
363 if (!raw) return { ...DEFAULTS };
364 const parsed = JSON.parse(raw);
365 const out = { ...DEFAULTS };
366 for (const k of Object.keys(DEFAULTS) as (keyof Cymbal2Params)[]) {
367 if (typeof parsed[k] === typeof DEFAULTS[k]) {
368 (out as Record<string, unknown>)[k] = parsed[k];
369 }
370 }
371 return out;
372 } catch {
373 return { ...DEFAULTS };
374 }
375}
377export function saveParams(p: Cymbal2Params): void {
378 try {
379 localStorage.setItem(STORAGE_KEY, JSON.stringify(p));
380 } catch {
381 // storage unavailable; persistence is a convenience only
382 }
383}