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];
249const STORAGE_KEY = 'cymbal2-webapp-params-v1';
251export function loadParams(): Cymbal2Params {
252 try {
253 const raw = localStorage.getItem(STORAGE_KEY);
254 if (!raw) return { ...DEFAULTS };
255 const parsed = JSON.parse(raw);
256 const out = { ...DEFAULTS };
257 for (const k of Object.keys(DEFAULTS) as (keyof Cymbal2Params)[]) {
258 if (typeof parsed[k] === typeof DEFAULTS[k]) {
259 (out as Record<string, unknown>)[k] = parsed[k];
260 }
261 }
262 return out;
263 } catch {
264 return { ...DEFAULTS };
265 }
266}
268export function saveParams(p: Cymbal2Params): void {
269 try {
270 localStorage.setItem(STORAGE_KEY, JSON.stringify(p));
271 } catch {
272 // storage unavailable; persistence is a convenience only
273 }
274}