// Parameter definitions for the cymbal model, mirroring cymbal_defaults.m. // Each parameter is assigned a tier that determines how much recomputation a // change requires: // solve -> shell eigensolves + strikes + cascade + radiation + output // radiate -> strikes + cascade + radiation + output only (the cached mode // basis is reused; the eigensolves are the expensive part here) // output -> output stage only // // Note that the render duration is not a parameter: it is // (strike_count - 1) / strike_rate_hz + tail_s. export interface CymbalParams { // Plate geometry radius_m: number; hole_radius_m: number; bell_radius_m: number; bell_height_m: number; bow_height_m: number; thickness_bell_m: number; thickness_rim_m: number; // Material density: number; youngs_modulus: number; poisson_ratio: number; stiffness_trim: number; // Discretization / mode retention n_elements: number; n_diameters: number; min_mode_hz: number; max_mode_hz: number; // Damping damp_rate: number; loss_factor: number; loss_slope: number; radiation_loss: number; mount_loss: number; // Stick tip_radius_m: number; stick_mass_kg: number; stick_youngs: number; restitution: number; // Strike gesture strike_velocity: number; strike_r: number; strike_angle_deg: number; strike_count: number; strike_rate_hz: number; roll_r2: number; timing_jitter_ms: number; velocity_spread: number; seed: number; // Nonlinear cascade cascade_gain: number; cascade_back: number; cascade_ratio: number; cascade_leak: number; cascade_step_ms: number; // Radiation / listener direct_gain: number; dipole_scale: number; n_azimuth: number; listener_x_m: number; listener_y_m: number; listener_z_m: number; speed_of_sound: number; air_density: number; // Rendering sample_rate: number; oversample: number; tail_s: number; pre_delay_ms: number; normalize: boolean; peak_dbfs: number; full_scale_pa: number; bit_depth: number; } export const DEFAULTS: CymbalParams = { radius_m: 0.2032, hole_radius_m: 0.0064, bell_radius_m: 0.045, bell_height_m: 0.017, bow_height_m: 0.02, thickness_bell_m: 0.00115, thickness_rim_m: 0.00075, density: 8700.0, youngs_modulus: 1.05e11, poisson_ratio: 0.33, stiffness_trim: 1.0, n_elements: 28, n_diameters: 48, min_mode_hz: 40.0, max_mode_hz: 8000.0, damp_rate: 0.35, loss_factor: 1.5e-4, loss_slope: 2.0e-9, radiation_loss: 1.0, mount_loss: 3.0, tip_radius_m: 0.005, stick_mass_kg: 0.03, stick_youngs: 5.0e9, restitution: 0.55, strike_velocity: 3.0, strike_r: 0.94, strike_angle_deg: 40.0, strike_count: 1, strike_rate_hz: 6.0, roll_r2: 0.8, timing_jitter_ms: 8.0, velocity_spread: 0.18, seed: 1, cascade_gain: 800.0, cascade_back: 0.45, cascade_ratio: 1.19, cascade_leak: 1.0, cascade_step_ms: 0.5, direct_gain: 1.0, dipole_scale: 1.0, n_azimuth: 96, listener_x_m: 0.3, listener_y_m: 0.0, listener_z_m: 0.5, speed_of_sound: 343.0, air_density: 1.204, sample_rate: 48000, oversample: 2, tail_s: 3.0, pre_delay_ms: 3.0, normalize: true, peak_dbfs: -3.0, full_scale_pa: 5.0, bit_depth: 24, }; export type Tier = 'solve' | 'radiate' | 'output'; // Keys whose change invalidates the cached mode basis (the shell eigensolves). // The stick, the gesture, the cascade and the listener are deliberately // absent: striking somewhere else or moving the microphone reuses the modes. export const SOLVE_KEYS: (keyof CymbalParams)[] = [ 'radius_m', 'hole_radius_m', 'bell_radius_m', 'bell_height_m', 'bow_height_m', 'thickness_bell_m', 'thickness_rim_m', 'density', 'youngs_modulus', 'poisson_ratio', 'stiffness_trim', 'n_elements', 'n_diameters', 'min_mode_hz', 'max_mode_hz', 'damp_rate', 'loss_factor', 'loss_slope', 'radiation_loss', 'mount_loss', 'dipole_scale', 'speed_of_sound', 'air_density', ]; export const RADIATE_KEYS: (keyof CymbalParams)[] = [ 'tip_radius_m', 'stick_mass_kg', 'stick_youngs', 'restitution', 'strike_velocity', 'strike_r', 'strike_angle_deg', 'strike_count', 'strike_rate_hz', 'roll_r2', 'timing_jitter_ms', 'velocity_spread', 'seed', 'cascade_gain', 'cascade_back', 'cascade_ratio', 'cascade_leak', 'cascade_step_ms', 'direct_gain', 'n_azimuth', 'listener_x_m', 'listener_y_m', 'listener_z_m', 'sample_rate', 'tail_s', ]; export const OUTPUT_KEYS: (keyof CymbalParams)[] = [ 'pre_delay_ms', 'normalize', 'peak_dbfs', 'full_scale_pa', ]; export interface ParamSpec { key: keyof CymbalParams; 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: '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.' }, { 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.' }, { 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.' }, ], }, { name: 'Nonlinear cascade', specs: [ { 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.' }, { 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.' }, { 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.' }, { 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.' }, ], }, { name: 'Repeats', specs: [ { 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.' }, { key: 'strike_rate_hz', label: 'Rate', unit: 'Hz', min: 1, max: 20, step: 0.5, help: 'Strokes per second.' }, { 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.' }, { 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.' }, { key: 'velocity_spread', label: 'Velocity spread', min: 0, max: 0.7, step: 0.01, help: 'Lognormal spread of stroke velocities.' }, { 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.' }, ], }, { name: 'Stick', specs: [ { 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.' }, { 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.' }, { 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.' }, { key: 'restitution', label: 'Restitution', min: 0.05, max: 0.95, step: 0.01, help: 'Coefficient of restitution; scales the momentum each stroke transfers.' }, ], }, { name: 'Plate geometry', specs: [ { 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².' }, { 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.' }, { 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.' }, { 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.' }, { 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.' }, { 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.' }, { key: 'hole_radius_m', label: 'Hole radius', unit: 'm', min: 0.002, max: 0.02, step: 0.0005 }, ], }, { name: 'Material', specs: [ { key: 'density', label: 'Density', unit: 'kg/m³', min: 2000, max: 12000, log: true, help: 'B20 bronze 8700, brass ~8500, nickel silver ~8700, aluminium 2700.' }, { 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).' }, { key: 'poisson_ratio', label: 'Poisson ratio', min: 0.0, max: 0.45, step: 0.01 }, { 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.' }, ], }, { name: 'Damping', specs: [ { 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.' }, { 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.' }, { 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.' }, { 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.' }, { 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.' }, ], }, { name: 'Listener', specs: [ { 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.' }, { key: 'listener_y_m', label: 'Listener y', unit: 'm', min: -3.0, max: 3.0, step: 0.01 }, { 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.' }, { 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.' }, { 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.' }, ], }, { name: 'Output', specs: [ { key: 'tail_s', label: 'Tail', unit: 's', min: 0.3, max: 12.0, step: 0.1, help: 'Ring time rendered after the last stroke.' }, { 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_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.' }, ], }, { name: 'Advanced / quality', specs: [ { 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.' }, { 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.' }, { key: 'min_mode_hz', label: 'Min mode freq', unit: 'Hz', min: 20, max: 400, step: 5 }, { 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.' }, { 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.' }, { 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.' }, { 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.' }, { key: 'speed_of_sound', label: 'Speed of sound', unit: 'm/s', min: 300, max: 400, step: 1 }, { key: 'air_density', label: 'Air density', unit: 'kg/m³', min: 0.9, max: 1.5, step: 0.01 }, ], }, ]; const STORAGE_KEY = 'cymbal-webapp-params-v1'; export function loadParams(): CymbalParams { 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 CymbalParams)[]) { if (typeof parsed[k] === typeof DEFAULTS[k]) { (out as Record)[k] = parsed[k]; } } return out; } catch { return { ...DEFAULTS }; } } export function saveParams(p: CymbalParams): void { try { localStorage.setItem(STORAGE_KEY, JSON.stringify(p)); } catch { // storage unavailable; persistence is a convenience only } }