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;
7da0671Hammer the cymbal: hybridize near-degenerate modes, add the turbulent fluctuation about the mean cascade, and flatten the decay across the bandJeremy Magland 26 // Hammering and lathing
27 hammer_mix: number;
28 hammer_bw: number;
29 hammer_detune: number;
30 hammer_seed: number;
4ddcc1bCymbal simulator: shell-of-revolution modal model with a nonlinear energy cascade, numbl MATLAB reference, and interactive web appJeremy Magland 31 // Discretization / mode retention
32 n_elements: number;
33 n_diameters: number;
34 min_mode_hz: number;
35 max_mode_hz: number;
36 // Damping
37 damp_rate: number;
38 loss_factor: number;
39 loss_slope: number;
40 radiation_loss: number;
41 mount_loss: number;
42 // Stick
43 tip_radius_m: number;
44 stick_mass_kg: number;
45 stick_youngs: number;
46 restitution: number;
47 // Strike gesture
48 strike_velocity: number;
49 strike_r: number;
50 strike_angle_deg: number;
51 strike_count: number;
52 strike_rate_hz: number;
53 roll_r2: number;
54 timing_jitter_ms: number;
55 velocity_spread: number;
56 seed: number;
57 // Nonlinear cascade
58 cascade_gain: number;
59 cascade_back: number;
60 cascade_ratio: number;
61 cascade_leak: number;
62 cascade_step_ms: number;
7da0671Hammer the cymbal: hybridize near-degenerate modes, add the turbulent fluctuation about the mean cascade, and flatten the decay across the bandJeremy Magland 63 turbulence: number;
4ddcc1bCymbal simulator: shell-of-revolution modal model with a nonlinear energy cascade, numbl MATLAB reference, and interactive web appJeremy Magland 64 // Radiation / listener
65 direct_gain: number;
66 dipole_scale: number;
67 n_azimuth: number;
68 listener_x_m: number;
69 listener_y_m: number;
70 listener_z_m: number;
71 speed_of_sound: number;
72 air_density: number;
73 // Rendering
74 sample_rate: number;
75 oversample: number;
76 tail_s: number;
77 pre_delay_ms: number;
78 normalize: boolean;
79 peak_dbfs: number;
80 full_scale_pa: number;
81 bit_depth: number;
82}
84export const DEFAULTS: CymbalParams = {
85 radius_m: 0.2032,
86 hole_radius_m: 0.0064,
87 bell_radius_m: 0.045,
88 bell_height_m: 0.017,
89 bow_height_m: 0.02,
90 thickness_bell_m: 0.00115,
91 thickness_rim_m: 0.00075,
92 density: 8700.0,
93 youngs_modulus: 1.05e11,
94 poisson_ratio: 0.33,
95 stiffness_trim: 1.0,
7da0671Hammer the cymbal: hybridize near-degenerate modes, add the turbulent fluctuation about the mean cascade, and flatten the decay across the bandJeremy Magland 96 hammer_mix: 0.55,
97 hammer_bw: 0.05,
98 hammer_detune: 0.004,
99 hammer_seed: 7,
4ddcc1bCymbal simulator: shell-of-revolution modal model with a nonlinear energy cascade, numbl MATLAB reference, and interactive web appJeremy Magland 100 n_elements: 28,
7da0671Hammer the cymbal: hybridize near-degenerate modes, add the turbulent fluctuation about the mean cascade, and flatten the decay across the bandJeremy Magland 101 n_diameters: 56,
4ddcc1bCymbal simulator: shell-of-revolution modal model with a nonlinear energy cascade, numbl MATLAB reference, and interactive web appJeremy Magland 102 min_mode_hz: 40.0,
7da0671Hammer the cymbal: hybridize near-degenerate modes, add the turbulent fluctuation about the mean cascade, and flatten the decay across the bandJeremy Magland 103 max_mode_hz: 10000.0,
104 damp_rate: 1.7,
105 loss_factor: 2.0e-5,
106 loss_slope: 3.0e-10,
4ddcc1bCymbal simulator: shell-of-revolution modal model with a nonlinear energy cascade, numbl MATLAB reference, and interactive web appJeremy Magland 107 radiation_loss: 1.0,
108 mount_loss: 3.0,
109 tip_radius_m: 0.005,
110 stick_mass_kg: 0.03,
111 stick_youngs: 5.0e9,
112 restitution: 0.55,
113 strike_velocity: 3.0,
114 strike_r: 0.94,
115 strike_angle_deg: 40.0,
116 strike_count: 1,
117 strike_rate_hz: 6.0,
118 roll_r2: 0.8,
119 timing_jitter_ms: 8.0,
120 velocity_spread: 0.18,
121 seed: 1,
122 cascade_gain: 800.0,
123 cascade_back: 0.45,
124 cascade_ratio: 1.19,
125 cascade_leak: 1.0,
7da0671Hammer the cymbal: hybridize near-degenerate modes, add the turbulent fluctuation about the mean cascade, and flatten the decay across the bandJeremy Magland 126 cascade_step_ms: 1.0,
127 turbulence: 0.9,
4ddcc1bCymbal simulator: shell-of-revolution modal model with a nonlinear energy cascade, numbl MATLAB reference, and interactive web appJeremy Magland 128 direct_gain: 1.0,
129 dipole_scale: 1.0,
7da0671Hammer the cymbal: hybridize near-degenerate modes, add the turbulent fluctuation about the mean cascade, and flatten the decay across the bandJeremy Magland 130 n_azimuth: 224,
131 listener_x_m: 0.4,
4ddcc1bCymbal simulator: shell-of-revolution modal model with a nonlinear energy cascade, numbl MATLAB reference, and interactive web appJeremy Magland 132 listener_y_m: 0.0,
7da0671Hammer the cymbal: hybridize near-degenerate modes, add the turbulent fluctuation about the mean cascade, and flatten the decay across the bandJeremy Magland 133 listener_z_m: 0.4,
4ddcc1bCymbal simulator: shell-of-revolution modal model with a nonlinear energy cascade, numbl MATLAB reference, and interactive web appJeremy Magland 134 speed_of_sound: 343.0,
135 air_density: 1.204,
136 sample_rate: 48000,
137 oversample: 2,
138 tail_s: 3.0,
139 pre_delay_ms: 3.0,
140 normalize: true,
141 peak_dbfs: -3.0,
142 full_scale_pa: 5.0,
143 bit_depth: 24,
144};
146export type Tier = 'solve' | 'radiate' | 'output';
148// Keys whose change invalidates the cached mode basis (the shell eigensolves).
149// The stick, the gesture, the cascade and the listener are deliberately
150// absent: striking somewhere else or moving the microphone reuses the modes.
151export const SOLVE_KEYS: (keyof CymbalParams)[] = [
152 'radius_m', 'hole_radius_m', 'bell_radius_m', 'bell_height_m', 'bow_height_m',
153 'thickness_bell_m', 'thickness_rim_m',
154 'density', 'youngs_modulus', 'poisson_ratio', 'stiffness_trim',
7da0671Hammer the cymbal: hybridize near-degenerate modes, add the turbulent fluctuation about the mean cascade, and flatten the decay across the bandJeremy Magland 155 'hammer_mix', 'hammer_bw', 'hammer_detune', 'hammer_seed',
4ddcc1bCymbal simulator: shell-of-revolution modal model with a nonlinear energy cascade, numbl MATLAB reference, and interactive web appJeremy Magland 156 'n_elements', 'n_diameters', 'min_mode_hz', 'max_mode_hz',
157 'damp_rate', 'loss_factor', 'loss_slope', 'radiation_loss', 'mount_loss',
158 'dipole_scale', 'speed_of_sound', 'air_density',
159];
161export const RADIATE_KEYS: (keyof CymbalParams)[] = [
162 'tip_radius_m', 'stick_mass_kg', 'stick_youngs', 'restitution',
163 'strike_velocity', 'strike_r', 'strike_angle_deg',
164 'strike_count', 'strike_rate_hz', 'roll_r2',
165 'timing_jitter_ms', 'velocity_spread', 'seed',
166 'cascade_gain', 'cascade_back', 'cascade_ratio', 'cascade_leak', 'cascade_step_ms',
7da0671Hammer the cymbal: hybridize near-degenerate modes, add the turbulent fluctuation about the mean cascade, and flatten the decay across the bandJeremy Magland 167 'turbulence',
4ddcc1bCymbal simulator: shell-of-revolution modal model with a nonlinear energy cascade, numbl MATLAB reference, and interactive web appJeremy Magland 168 'direct_gain', 'n_azimuth',
169 'listener_x_m', 'listener_y_m', 'listener_z_m',
170 'sample_rate', 'tail_s',
171];
173export const OUTPUT_KEYS: (keyof CymbalParams)[] = [
174 'pre_delay_ms', 'normalize', 'peak_dbfs', 'full_scale_pa',
175];
177export interface ParamSpec {
178 key: keyof CymbalParams;
179 label: string;
180 unit?: string;
181 min: number;
182 max: number;
183 step?: number;
184 log?: boolean;
185 integer?: boolean;
186 help?: string;
187}
189export interface ParamGroup {
190 name: string;
191 specs: ParamSpec[];
192}
194export const PARAM_GROUPS: ParamGroup[] = [
195 {
196 name: 'Stroke',
197 specs: [
198 { 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.' },
199 { 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.' },
200 { 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.' },
201 ],
202 },
203 {
204 name: 'Nonlinear cascade',
205 specs: [
206 { 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.' },
207 { 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.' },
208 { 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.' },
7da0671Hammer the cymbal: hybridize near-degenerate modes, add the turbulent fluctuation about the mean cascade, and flatten the decay across the bandJeremy Magland 209 { key: 'turbulence', label: 'Fluctuation', min: 0, max: 2.0, step: 0.05, help: 'Log-amplitude spread of the turbulent wander about the mean modal energy. This is what broadens each partial into a band and turns a few hundred audible tones into a wash; at 0 the modes hold smooth envelopes and the cymbal chimes.' },
4ddcc1bCymbal simulator: shell-of-revolution modal model with a nonlinear energy cascade, numbl MATLAB reference, and interactive web appJeremy Magland 210 { 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.' },
211 ],
212 },
7da0671Hammer the cymbal: hybridize near-degenerate modes, add the turbulent fluctuation about the mean cascade, and flatten the decay across the bandJeremy Magland 213 {
214 name: 'Hammering',
215 specs: [
216 { key: 'hammer_mix', label: 'Hybridization', min: 0, max: 1.0, step: 0.01, help: 'How strongly near-degenerate modes hybridize. 0 is a perfectly round plate, whose high nodal-diameter modes cannot radiate at all and which therefore sounds like a small bell. Hammering is what lets the rest of the modes out.' },
217 { key: 'hammer_bw', label: 'Mixing window', min: 0.005, max: 0.3, step: 0.005, help: 'Relative frequency window the hybridization acts over. Wider means a coarser instrument; the sparse low modes stay pure either way.' },
218 { key: 'hammer_detune', label: 'Frequency scatter', min: 0, max: 0.03, step: 0.0005, help: 'Relative scatter of the mode frequencies, which is what makes near neighbours beat.' },
219 { key: 'hammer_seed', label: 'Hammer seed', min: 1, max: 9999, step: 1, integer: true, help: 'Which cymbal off the rack. Independent of the stroke seed, because the hammering belongs to the instrument.' },
220 ],
221 },
4ddcc1bCymbal simulator: shell-of-revolution modal model with a nonlinear energy cascade, numbl MATLAB reference, and interactive web appJeremy Magland 222 {
223 name: 'Repeats',
224 specs: [
225 { 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.' },
226 { key: 'strike_rate_hz', label: 'Rate', unit: 'Hz', min: 1, max: 20, step: 0.5, help: 'Strokes per second.' },
227 { 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.' },
228 { 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.' },
229 { key: 'velocity_spread', label: 'Velocity spread', min: 0, max: 0.7, step: 0.01, help: 'Lognormal spread of stroke velocities.' },
230 { 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.' },
231 ],
232 },
233 {
234 name: 'Stick',
235 specs: [
236 { 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.' },
237 { 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.' },
238 { 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.' },
239 { key: 'restitution', label: 'Restitution', min: 0.05, max: 0.95, step: 0.01, help: 'Coefficient of restitution; scales the momentum each stroke transfers.' },
240 ],
241 },
242 {
243 name: 'Plate geometry',
244 specs: [
245 { 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².' },
246 { 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.' },
247 { 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.' },
248 { 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.' },
249 { 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.' },
250 { 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.' },
251 { key: 'hole_radius_m', label: 'Hole radius', unit: 'm', min: 0.002, max: 0.02, step: 0.0005 },
252 ],
253 },
254 {
255 name: 'Material',
256 specs: [
257 { key: 'density', label: 'Density', unit: 'kg/m³', min: 2000, max: 12000, log: true, help: 'B20 bronze 8700, brass ~8500, nickel silver ~8700, aluminium 2700.' },
258 { 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).' },
259 { key: 'poisson_ratio', label: 'Poisson ratio', min: 0.0, max: 0.45, step: 0.01 },
260 { 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.' },
261 ],
262 },
263 {
264 name: 'Damping',
265 specs: [
7da0671Hammer the cymbal: hybridize near-degenerate modes, add the turbulent fluctuation about the mean cascade, and flatten the decay across the bandJeremy Magland 266 { key: 'damp_rate', label: 'Decay rate', unit: 's⁻¹', min: 0.02, max: 10.0, log: true, help: 'Frequency-independent decay rate: T60 = 6.91 / this, before the other terms. It sets the ring time of the whole band at once, which is what keeps the low modes from outlasting the wash and leaving a bell behind.' },
4ddcc1bCymbal simulator: shell-of-revolution modal model with a nonlinear energy cascade, numbl MATLAB reference, and interactive web appJeremy Magland 267 { 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.' },
268 { 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.' },
269 { 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.' },
270 { 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.' },
271 ],
272 },
273 {
274 name: 'Listener',
275 specs: [
276 { 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.' },
277 { key: 'listener_y_m', label: 'Listener y', unit: 'm', min: -3.0, max: 3.0, step: 0.01 },
278 { 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.' },
279 { 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.' },
280 { 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.' },
281 ],
282 },
283 {
284 name: 'Output',
285 specs: [
286 { key: 'tail_s', label: 'Tail', unit: 's', min: 0.3, max: 12.0, step: 0.1, help: 'Ring time rendered after the last stroke.' },
287 { key: 'pre_delay_ms', label: 'Pre-delay', unit: 'ms', min: 0, max: 50, step: 0.5 },
288 { key: 'peak_dbfs', label: 'Peak level', unit: 'dBFS', min: -24, max: 0, step: 0.5, help: 'Used when normalize is on.' },
289 { 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.' },
290 ],
291 },
292 {
293 name: 'Advanced / quality',
294 specs: [
295 { 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.' },
296 { 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.' },
297 { key: 'min_mode_hz', label: 'Min mode freq', unit: 'Hz', min: 20, max: 400, step: 5 },
7da0671Hammer the cymbal: hybridize near-degenerate modes, add the turbulent fluctuation about the mean cascade, and flatten the decay across the bandJeremy Magland 298 { key: 'max_mode_hz', label: 'Max mode freq', unit: 'Hz', min: 1000, max: 18000, step: 250, help: 'Everything above is truncated; the cascade treats it as a sink and the direct click stands in for it.' },
4ddcc1bCymbal simulator: shell-of-revolution modal model with a nonlinear energy cascade, numbl MATLAB reference, and interactive web appJeremy Magland 299 { 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.' },
7da0671Hammer the cymbal: hybridize near-degenerate modes, add the turbulent fluctuation about the mean cascade, and flatten the decay across the bandJeremy Magland 300 { key: 'n_azimuth', label: 'Azimuth samples', min: 24, max: 384, step: 8, integer: true, help: 'Azimuthal samples in the listener transfer. Must comfortably exceed twice the highest nodal-diameter order.' },
4ddcc1bCymbal simulator: shell-of-revolution modal model with a nonlinear energy cascade, numbl MATLAB reference, and interactive web appJeremy Magland 301 { 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.' },
302 { key: 'speed_of_sound', label: 'Speed of sound', unit: 'm/s', min: 300, max: 400, step: 1 },
303 { key: 'air_density', label: 'Air density', unit: 'kg/m³', min: 0.9, max: 1.5, step: 0.01 },
304 ],
305 },
306];
308const STORAGE_KEY = 'cymbal-webapp-params-v1';
310export function loadParams(): CymbalParams {
311 try {
312 const raw = localStorage.getItem(STORAGE_KEY);
313 if (!raw) return { ...DEFAULTS };
314 const parsed = JSON.parse(raw);
315 const out = { ...DEFAULTS };
316 for (const k of Object.keys(DEFAULTS) as (keyof CymbalParams)[]) {
317 if (typeof parsed[k] === typeof DEFAULTS[k]) {
318 (out as Record<string, unknown>)[k] = parsed[k];
319 }
320 }
321 return out;
322 } catch {
323 return { ...DEFAULTS };
324 }
325}
327export function saveParams(p: CymbalParams): void {
328 try {
329 localStorage.setItem(STORAGE_KEY, JSON.stringify(p));
330 } catch {
331 // storage unavailable; persistence is a convenience only
332 }
333}