// Parameter definitions for the cymbal2 model, mirroring cymbal2_defaults.m. // Unlike the sibling cymbal app there are no recomputation tiers: the // expensive stage is the time integration itself, and everything feeds it. // The render duration is not a parameter: it is // (strike_count - 1) / strike_rate_hz + tail_s, plus the first stroke's // travel time. export interface Cymbal2Params { // Shell geometry radius_m: number; rise_m: number; thickness_m: number; // Material youngs_modulus: number; poisson_ratio: number; density: number; // Damping sigma0: number; sigma1: number; // Support washer_radius_m: number; washer_stiffness: number; washer_mass: number; washer_damping: number; support_clamped: boolean; // Mallet head head_radius_m: number; head_mass_kg: number; head_stiffness: number; head_alpha: number; // Mallet stick stick_length_m: number; stick_area_m2: number; stick_youngs: number; stick_density: number; stick_inertia_m4: number; stick_damping_rate: number; n_beam: number; // Grip grip_clamped: boolean; grip_spring: number; // Strike gesture strike_velocity: number; strike_accel: number; retract_accel: number; strike_hold_ms: number; strike_gap_m: number; strike_r: number; strike_theta_deg: number; strike_count: number; strike_rate_hz: number; velocity_spread: number; seed: number; // Pickup pickup_r: number; pickup_theta_deg: number; // Discretization n_radial: number; n_theta: number; g_param: number; phi_param: number; // Rendering sample_rate: number; oversample: number; tail_s: number; pre_delay_ms: number; normalize: boolean; peak_dbfs: number; full_scale_accel: number; bit_depth: number; } export const DEFAULTS: Cymbal2Params = { radius_m: 0.23, rise_m: 0.04, thickness_m: 0.001, youngs_modulus: 1.1e11, poisson_ratio: 0.33, density: 8860.0, sigma0: 1.34, sigma1: 1.2e-3, washer_radius_m: 0.029, washer_stiffness: 1.65e5, washer_mass: 0.0, washer_damping: 0.0, support_clamped: false, head_radius_m: 0.025, head_mass_kg: 0.028, head_stiffness: 1.6e8, head_alpha: 2.54, stick_length_m: 0.39, stick_area_m2: 1.43139e-4, stick_youngs: 8.8e9, stick_density: 400.0, stick_inertia_m4: 1.63044e-9, stick_damping_rate: 10.0, n_beam: 20, grip_clamped: false, grip_spring: 140.0, strike_velocity: 4.0, strike_accel: 250.0, retract_accel: 2500.0, strike_hold_ms: 0.5, strike_gap_m: -1, strike_r: 0.6, strike_theta_deg: 180.0, strike_count: 1, strike_rate_hz: 4.0, velocity_spread: 0.0, seed: 1, pickup_r: 0.6, pickup_theta_deg: 0.0, n_radial: 24, n_theta: 60, g_param: 0.2498, phi_param: 0.7, sample_rate: 48000, oversample: 2, tail_s: 6.0, pre_delay_ms: 3.0, normalize: true, peak_dbfs: -3.0, full_scale_accel: 4.0e4, bit_depth: 24, }; // Keys that determine the linear mode table (cymbal2_modes); the cheap modes // stage is cached on these. export const MODES_KEYS: (keyof Cymbal2Params)[] = [ 'radius_m', 'rise_m', 'thickness_m', 'youngs_modulus', 'poisson_ratio', 'density', 'washer_radius_m', 'washer_stiffness', 'support_clamped', 'n_radial', 'n_theta', ]; export interface ParamSpec { key: keyof Cymbal2Params; label: string; unit?: string; min: number; max: number; step?: number; log?: boolean; integer?: boolean; help?: string; } export interface ParamGroup { name: string; specs: ParamSpec[]; } export const PARAM_GROUPS: ParamGroup[] = [ { name: 'Stroke', specs: [ { 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.' }, { 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.' }, { 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.' }, { 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.' }, { 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.' }, ], }, { name: 'Repeats', specs: [ { 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.' }, { 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.' }, { key: 'velocity_spread', label: 'Velocity spread', min: 0, max: 0.7, step: 0.01, help: 'Relative scatter of the per-stroke hand speeds.' }, { key: 'seed', label: 'Random seed', min: 1, max: 9999, step: 1, integer: true, help: 'Redraws the velocity scatter; the same seed always reproduces it.' }, ], }, { name: 'Support (washer)', specs: [ { 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.' }, { 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.' }, { 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.' }, { 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.' }, ], }, { name: 'Mallet', specs: [ { 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.' }, { 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.' }, { 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.' }, { key: 'head_radius_m', label: 'Head radius', unit: 'm', min: 0.005, max: 0.05, step: 0.001 }, ], }, { name: 'Stick and grip', specs: [ { 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.' }, { 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.' }, { key: 'stick_length_m', label: 'Stick length', unit: 'm', min: 0.2, max: 0.6, step: 0.005 }, { key: 'stick_density', label: 'Stick density', unit: 'kg/m³', min: 200, max: 1200, step: 10 }, { 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.' }, ], }, { name: 'Shell geometry', specs: [ { 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².' }, { 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.' }, { 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.' }, ], }, { name: 'Material and damping', specs: [ { key: 'density', label: 'Density', unit: 'kg/m³', min: 2000, max: 12000, log: true, help: 'The paper\'s measured bronze is 8860.' }, { key: 'youngs_modulus', label: "Young's modulus", unit: 'Pa', min: 3e10, max: 3e11, log: true }, { key: 'poisson_ratio', label: 'Poisson ratio', min: 0.0, max: 0.45, step: 0.01 }, { 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.' }, { 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.' }, ], }, { name: 'Pickup', specs: [ { 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.' }, { 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.' }, ], }, { name: 'Output', specs: [ { 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.' }, { key: 'pre_delay_ms', label: 'Pre-delay', unit: 'ms', min: 0, max: 50, step: 0.5 }, { key: 'peak_dbfs', label: 'Peak level', unit: 'dBFS', min: -24, max: 0, step: 0.5, help: 'Used when normalize is on.' }, { 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.' }, ], }, { name: 'Advanced / quality', specs: [ { 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.' }, { 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.' }, { 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.' }, { 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.' }, { key: 'phi_param', label: 'Scheme φ', min: 0.5, max: 1.0, step: 0.01, help: 'Implicit weight of the washer and beam schemes.' }, { key: 'n_beam', label: 'Beam elements', min: 8, max: 40, step: 1, integer: true }, { 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.' }, ], }, ]; const STORAGE_KEY = 'cymbal2-webapp-params-v1'; export function loadParams(): Cymbal2Params { try { const raw = localStorage.getItem(STORAGE_KEY); if (!raw) return { ...DEFAULTS }; const parsed = JSON.parse(raw); const out = { ...DEFAULTS }; for (const k of Object.keys(DEFAULTS) as (keyof Cymbal2Params)[]) { if (typeof parsed[k] === typeof DEFAULTS[k]) { (out as Record)[k] = parsed[k]; } } return out; } catch { return { ...DEFAULTS }; } } export function saveParams(p: Cymbal2Params): void { try { localStorage.setItem(STORAGE_KEY, JSON.stringify(p)); } catch { // storage unavailable; persistence is a convenience only } }