4ddcc1bCymbal simulator: shell-of-revolution modal model with a nonlinear energy cascade, numbl MATLAB reference, and interactive web appJeremy Magland 1// Parameter definitions for the cymbal model, mirroring cymbal_defaults.m.
2// Each parameter is assigned a tier that determines how much recomputation a
3// change requires:
4// solve -> shell eigensolves + strikes + cascade + radiation + output
5// radiate -> strikes + cascade + radiation + output only (the cached mode
6// basis is reused; the eigensolves are the expensive part here)
7// output -> output stage only
8//
9// Note that the render duration is not a parameter: it is
10// (strike_count - 1) / strike_rate_hz + tail_s.
12export interface CymbalParams {
13 // Plate geometry
14 radius_m: number;
15 hole_radius_m: number;
16 bell_radius_m: number;
17 bell_height_m: number;
18 bow_height_m: number;
19 thickness_bell_m: number;
20 thickness_rim_m: number;
21 // Material
22 density: number;
23 youngs_modulus: number;
24 poisson_ratio: number;
25 stiffness_trim: number;
26 // Discretization / mode retention
27 n_elements: number;
28 n_diameters: number;
29 min_mode_hz: number;
30 max_mode_hz: number;
31 // Damping
32 damp_rate: number;
33 loss_factor: number;
34 loss_slope: number;
35 radiation_loss: number;
36 mount_loss: number;
37 // Stick
38 tip_radius_m: number;
39 stick_mass_kg: number;
40 stick_youngs: number;
41 restitution: number;
42 // Strike gesture
43 strike_velocity: number;
44 strike_r: number;
45 strike_angle_deg: number;
46 strike_count: number;
47 strike_rate_hz: number;
48 roll_r2: number;
49 timing_jitter_ms: number;
50 velocity_spread: number;
51 seed: number;
52 // Nonlinear cascade
53 cascade_gain: number;
54 cascade_back: number;
55 cascade_ratio: number;
56 cascade_leak: number;
57 cascade_step_ms: number;
58 // Radiation / listener
59 direct_gain: number;
60 dipole_scale: number;
61 n_azimuth: number;
62 listener_x_m: number;
63 listener_y_m: number;
64 listener_z_m: number;
65 speed_of_sound: number;
66 air_density: number;
67 // Rendering
68 sample_rate: number;
69 oversample: number;
70 tail_s: number;
71 pre_delay_ms: number;
72 normalize: boolean;
73 peak_dbfs: number;
74 full_scale_pa: number;
75 bit_depth: number;
76}
78export const DEFAULTS: CymbalParams = {
79 radius_m: 0.2032,
80 hole_radius_m: 0.0064,
81 bell_radius_m: 0.045,
82 bell_height_m: 0.017,
83 bow_height_m: 0.02,
84 thickness_bell_m: 0.00115,
85 thickness_rim_m: 0.00075,
86 density: 8700.0,
87 youngs_modulus: 1.05e11,
88 poisson_ratio: 0.33,
89 stiffness_trim: 1.0,
90 n_elements: 28,
91 n_diameters: 48,
92 min_mode_hz: 40.0,
93 max_mode_hz: 8000.0,
94 damp_rate: 0.35,
95 loss_factor: 1.5e-4,
96 loss_slope: 2.0e-9,
97 radiation_loss: 1.0,
98 mount_loss: 3.0,
99 tip_radius_m: 0.005,
100 stick_mass_kg: 0.03,
101 stick_youngs: 5.0e9,
102 restitution: 0.55,
103 strike_velocity: 3.0,
104 strike_r: 0.94,
105 strike_angle_deg: 40.0,
106 strike_count: 1,
107 strike_rate_hz: 6.0,
108 roll_r2: 0.8,
109 timing_jitter_ms: 8.0,
110 velocity_spread: 0.18,
111 seed: 1,
112 cascade_gain: 800.0,
113 cascade_back: 0.45,
114 cascade_ratio: 1.19,
115 cascade_leak: 1.0,
116 cascade_step_ms: 0.5,
117 direct_gain: 1.0,
118 dipole_scale: 1.0,
119 n_azimuth: 96,
120 listener_x_m: 0.3,
121 listener_y_m: 0.0,
122 listener_z_m: 0.5,
123 speed_of_sound: 343.0,
124 air_density: 1.204,
125 sample_rate: 48000,
126 oversample: 2,
127 tail_s: 3.0,
128 pre_delay_ms: 3.0,
129 normalize: true,
130 peak_dbfs: -3.0,
131 full_scale_pa: 5.0,
132 bit_depth: 24,
133};
135export type Tier = 'solve' | 'radiate' | 'output';
137// Keys whose change invalidates the cached mode basis (the shell eigensolves).
138// The stick, the gesture, the cascade and the listener are deliberately
139// absent: striking somewhere else or moving the microphone reuses the modes.
140export const SOLVE_KEYS: (keyof CymbalParams)[] = [
141 'radius_m', 'hole_radius_m', 'bell_radius_m', 'bell_height_m', 'bow_height_m',
142 'thickness_bell_m', 'thickness_rim_m',
143 'density', 'youngs_modulus', 'poisson_ratio', 'stiffness_trim',
144 'n_elements', 'n_diameters', 'min_mode_hz', 'max_mode_hz',
145 'damp_rate', 'loss_factor', 'loss_slope', 'radiation_loss', 'mount_loss',
146 'dipole_scale', 'speed_of_sound', 'air_density',
147];
149export const RADIATE_KEYS: (keyof CymbalParams)[] = [
150 'tip_radius_m', 'stick_mass_kg', 'stick_youngs', 'restitution',
151 'strike_velocity', 'strike_r', 'strike_angle_deg',
152 'strike_count', 'strike_rate_hz', 'roll_r2',
153 'timing_jitter_ms', 'velocity_spread', 'seed',
154 'cascade_gain', 'cascade_back', 'cascade_ratio', 'cascade_leak', 'cascade_step_ms',
155 'direct_gain', 'n_azimuth',
156 'listener_x_m', 'listener_y_m', 'listener_z_m',
157 'sample_rate', 'tail_s',
158];
160export const OUTPUT_KEYS: (keyof CymbalParams)[] = [
161 'pre_delay_ms', 'normalize', 'peak_dbfs', 'full_scale_pa',
162];
164export interface ParamSpec {
165 key: keyof CymbalParams;
166 label: string;
167 unit?: string;
168 min: number;
169 max: number;
170 step?: number;
171 log?: boolean;
172 integer?: boolean;
173 help?: string;
174}
176export interface ParamGroup {
177 name: string;
178 specs: ParamSpec[];
179}
181export const PARAM_GROUPS: ParamGroup[] = [
182 {
183 name: 'Stroke',
184 specs: [
185 { key: 'strike_velocity', label: 'Stroke velocity', unit: 'm/s', min: 0.1, max: 8.0, step: 0.05, help: 'Stick speed at contact. Dynamics and timbre together: the cascade strength goes as the square of the amplitude, so a soft stroke pings and a hard one crashes.' },
186 { key: 'strike_r', label: 'Contact radius', min: 0.02, max: 0.995, step: 0.005, help: 'Arclength fraction from the hole (0) to the rim (1). ~0.95 is a crash on the edge, ~0.6 a ride on the bow, ~0.1 the bell.' },
187 { key: 'strike_angle_deg', label: 'Stroke angle', unit: '°', min: 0, max: 85, step: 1, help: '0 strikes square onto the surface, 85 is a glancing swipe along it. Only the normal component drives the plate strongly.' },
188 ],
189 },
190 {
191 name: 'Nonlinear cascade',
192 specs: [
193 { key: 'cascade_gain', label: 'Cascade gain', min: 0, max: 4000, step: 10, help: 'Coupling constant of the energy cascade. 0 leaves an exactly linear plate, which sounds like a gong; raising it opens the crash up. This is the one fitted number in the model.' },
194 { key: 'cascade_ratio', label: 'Step ratio', min: 1.03, max: 2.0, step: 0.01, help: 'Frequency ratio of one transfer step. Smaller means a slower, smoother climb; 1.19 is a quarter of an octave.' },
195 { key: 'cascade_back', label: 'Backward share', min: 0, max: 0.95, step: 0.01, help: 'Downward transfers per upward one. At 1 the flux stops and energy only diffuses.' },
196 { key: 'cascade_leak', label: 'Top-of-band leak', min: 0, max: 1, step: 0.05, help: 'Fraction of the flux leaving the highest retained mode that is lost, standing in for the truncated band above. 0 reflects it back.' },
197 ],
198 },
199 {
200 name: 'Repeats',
201 specs: [
202 { key: 'strike_count', label: 'Strokes', min: 1, max: 32, step: 1, integer: true, help: '1 is a single crash; more gives a repeated figure alternating between the two contact radii.' },
203 { key: 'strike_rate_hz', label: 'Rate', unit: 'Hz', min: 1, max: 20, step: 0.5, help: 'Strokes per second.' },
204 { key: 'roll_r2', label: 'Second radius', min: 0.02, max: 0.995, step: 0.005, help: 'Even-numbered strokes land here: a ride pattern moving between the bow and the edge.' },
205 { key: 'timing_jitter_ms', label: 'Timing jitter', unit: 'ms', min: 0, max: 40, step: 0.5, help: 'Gaussian jitter on each stroke after the first. 0 sounds mechanical.' },
206 { key: 'velocity_spread', label: 'Velocity spread', min: 0, max: 0.7, step: 0.01, help: 'Lognormal spread of stroke velocities.' },
207 { key: 'seed', label: 'Random seed', min: 1, max: 9999, step: 1, integer: true, help: 'Any change redraws the timing and velocities; the same seed always reproduces them.' },
208 ],
209 },
210 {
211 name: 'Stick',
212 specs: [
213 { key: 'tip_radius_m', label: 'Tip radius', unit: 'm', min: 0.001, max: 0.02, step: 0.0005, help: 'Radius of the stick tip; enters the Hertz contact stiffness.' },
214 { key: 'stick_mass_kg', label: 'Stick mass', unit: 'kg', min: 0.002, max: 0.3, log: true, help: 'A heavy stick or mallet lengthens the contact and darkens the stroke.' },
215 { key: 'stick_youngs', label: 'Tip modulus', unit: 'Pa', min: 1e8, max: 2e11, log: true, help: 'Nylon ~5e9, wood ~1.2e10, felt much lower. A soft tip lengthens the contact and rolls the attack off.' },
216 { key: 'restitution', label: 'Restitution', min: 0.05, max: 0.95, step: 0.01, help: 'Coefficient of restitution; scales the momentum each stroke transfers.' },
217 ],
218 },
219 {
220 name: 'Plate geometry',
221 specs: [
222 { key: 'radius_m', label: 'Radius', unit: 'm', min: 0.09, max: 0.32, step: 0.001, help: '0.2032 is a 16-inch cymbal. Mode frequencies scale roughly as 1/R².' },
223 { key: 'thickness_rim_m', label: 'Rim thickness', unit: 'm', min: 0.0003, max: 0.003, step: 0.00005, help: 'A thin edge is a crash; a thick one is a ride. Frequencies scale as the thickness.' },
224 { key: 'thickness_bell_m', label: 'Bell thickness', unit: 'm', min: 0.0004, max: 0.004, step: 0.00005, help: 'The taper from bell to rim is what spreads the partials of a cymbal apart.' },
225 { key: 'bell_radius_m', label: 'Bell radius', unit: 'm', min: 0.015, max: 0.09, step: 0.001, help: 'A larger bell stiffens the middle and pushes the low modes up.' },
226 { key: 'bell_height_m', label: 'Bell height', unit: 'm', min: 0.0, max: 0.05, step: 0.001, help: 'Height of the dome above the bow.' },
227 { key: 'bow_height_m', label: 'Bow rise', unit: 'm', min: 0.0, max: 0.06, step: 0.001, help: 'How far the bow rises from rim to bell. 0 is a flat disc, which rings quite differently: the curvature couples bending to stretching.' },
228 { key: 'hole_radius_m', label: 'Hole radius', unit: 'm', min: 0.002, max: 0.02, step: 0.0005 },
229 ],
230 },
231 {
232 name: 'Material',
233 specs: [
234 { key: 'density', label: 'Density', unit: 'kg/m³', min: 2000, max: 12000, log: true, help: 'B20 bronze 8700, brass ~8500, nickel silver ~8700, aluminium 2700.' },
235 { key: 'youngs_modulus', label: "Young's modulus", unit: 'Pa', min: 3e10, max: 3e11, log: true, help: 'Bell bronze ~1.05e11. Frequencies scale as sqrt(E).' },
236 { key: 'poisson_ratio', label: 'Poisson ratio', min: 0.0, max: 0.45, step: 0.01 },
237 { key: 'stiffness_trim', label: 'Stiffness trim', min: 0.4, max: 2.0, step: 0.01, help: 'Overall pitch trim for the hammering and lathing a real cymbal carries and this profile does not.' },
238 ],
239 },
240 {
241 name: 'Damping',
242 specs: [
243 { key: 'damp_rate', label: 'Decay rate', unit: 's⁻¹', min: 0.02, max: 5.0, log: true, help: 'Frequency-independent decay rate: T60 = 6.91 / this, before the other terms. It is what makes a cymbal ring for tens of seconds.' },
244 { key: 'loss_factor', label: 'Loss factor η₀', min: 1e-6, max: 3e-3, log: true, help: 'Constant material loss factor; gives a T60 falling as 1/f, so it shortens the shimmer before the hum.' },
245 { key: 'loss_slope', label: 'Loss slope η₁', unit: 's', min: 1e-11, max: 1e-7, log: true, help: 'Extra high-frequency damping; controls how quickly the top of the wash fades.' },
246 { key: 'mount_loss', label: 'Mount damping', unit: 'N s/m', min: 0, max: 200, step: 0.5, help: 'A dashpot on the axial motion at the hole, standing in for the felt washer and stand. It reaches the n = 0 and n = 1 modes and almost nothing else.' },
247 { key: 'radiation_loss', label: 'Radiation loss', min: 0.0, max: 3.0, step: 0.05, help: 'Scales the radiation damping computed from the radiated power. Metal plates are poor radiators, so this barely matters below a few kHz.' },
248 ],
249 },
250 {
251 name: 'Listener',
252 specs: [
253 { key: 'listener_x_m', label: 'Listener x', unit: 'm', min: -3.0, max: 3.0, step: 0.01, help: 'In the plane of the rim. The stroke is on the +x meridian.' },
254 { key: 'listener_y_m', label: 'Listener y', unit: 'm', min: -3.0, max: 3.0, step: 0.01 },
255 { key: 'listener_z_m', label: 'Listener z', unit: 'm', min: -3.0, max: 3.0, step: 0.01, help: 'Along the symmetry axis. Directly above the bell every mode with a nodal diameter cancels exactly, leaving only the n = 0 modes.' },
256 { key: 'direct_gain', label: 'Direct click gain', min: 0.0, max: 3.0, step: 0.05, help: 'Broadband local response at the contact, standing in for the truncated high modes. 0 leaves only the resolved modes.' },
257 { key: 'dipole_scale', label: 'Dipole length scale', min: 0.1, max: 3.0, step: 0.05, help: 'Scales the acoustic short-circuit length. The one phenomenological length in the radiation model.' },
258 ],
259 },
260 {
261 name: 'Output',
262 specs: [
263 { key: 'tail_s', label: 'Tail', unit: 's', min: 0.3, max: 12.0, step: 0.1, help: 'Ring time rendered after the last stroke.' },
264 { key: 'pre_delay_ms', label: 'Pre-delay', unit: 'ms', min: 0, max: 50, step: 0.5 },
265 { key: 'peak_dbfs', label: 'Peak level', unit: 'dBFS', min: -24, max: 0, step: 0.5, help: 'Used when normalize is on.' },
266 { key: 'full_scale_pa', label: 'Full scale', unit: 'Pa', min: 0.1, max: 50, step: 0.1, help: 'Used when normalize is off: this many pascals maps to 0 dBFS.' },
267 ],
268 },
269 {
270 name: 'Advanced / quality',
271 specs: [
272 { key: 'n_elements', label: 'Meridian elements', min: 12, max: 60, step: 1, integer: true, help: 'Shell elements along the meridian. More converges the high modes; each eigensolve costs its cube.' },
273 { key: 'n_diameters', label: 'Max nodal diameters', min: 4, max: 64, step: 1, integer: true, help: 'Highest circumferential order solved for. One eigensolve per order, so this is the main cost. Too low and the band above a couple of kHz is missing its high-order modes.' },
274 { key: 'min_mode_hz', label: 'Min mode freq', unit: 'Hz', min: 20, max: 400, step: 5 },
275 { key: 'max_mode_hz', label: 'Max mode freq', unit: 'Hz', min: 1000, max: 16000, step: 250, help: 'Everything above is truncated; the cascade treats it as a sink and the direct click stands in for it.' },
276 { key: 'cascade_step_ms', label: 'Cascade step', unit: 'ms', min: 0.1, max: 4.0, step: 0.1, help: 'Control-rate step of the energy solve, and the envelope resolution of the oscillator bank.' },
277 { key: 'n_azimuth', label: 'Azimuth samples', min: 24, max: 256, step: 8, integer: true, help: 'Azimuthal samples in the listener transfer. Must comfortably exceed twice the highest nodal-diameter order.' },
278 { key: 'oversample', label: 'Oversample', min: 1, max: 4, step: 1, integer: true, help: 'Synthesis rate multiple; 2 for render, 1 for preview. The mode bank needs none, the contact click does.' },
279 { key: 'speed_of_sound', label: 'Speed of sound', unit: 'm/s', min: 300, max: 400, step: 1 },
280 { key: 'air_density', label: 'Air density', unit: 'kg/m³', min: 0.9, max: 1.5, step: 0.01 },
281 ],
282 },
283];
285const STORAGE_KEY = 'cymbal-webapp-params-v1';
287export function loadParams(): CymbalParams {
288 try {
289 const raw = localStorage.getItem(STORAGE_KEY);
290 if (!raw) return { ...DEFAULTS };
291 const parsed = JSON.parse(raw);
292 const out = { ...DEFAULTS };
293 for (const k of Object.keys(DEFAULTS) as (keyof CymbalParams)[]) {
294 if (typeof parsed[k] === typeof DEFAULTS[k]) {
295 (out as Record<string, unknown>)[k] = parsed[k];
296 }
297 }
298 return out;
299 } catch {
300 return { ...DEFAULTS };
301 }
302}
304export function saveParams(p: CymbalParams): void {
305 try {
306 localStorage.setItem(STORAGE_KEY, JSON.stringify(p));
307 } catch {
308 // storage unavailable; persistence is a convenience only
309 }
310}