// Parameter definitions for the maraca model, mirroring maraca_defaults.m. // Each parameter is assigned a tier that determines how much recomputation a // change requires: // solve -> mode basis + impact process + radiation + output // radiate -> radiation + output only (cached basis and impacts are reused) // output -> output stage only (decimation product is reused) // // Note that the render duration is not a parameter: it is // stroke_count / stroke_rate_hz + tail_s. export interface MaracaParams { // Shell geometry shell_radius_m: number; shell_thickness_m: number; // Shell material density: number; youngs_modulus: number; poisson_ratio: number; stiffness_trim: number; max_mode_l: number; max_mode_hz: number; // Damping loss_factor: number; loss_slope: number; radiation_loss: number; // Grains grain_radius_m: number; grain_density: number; grain_youngs: number; grain_size_spread: number; restitution: number; // Shake gesture / impact process stroke_rate_hz: number; stroke_count: number; accent_period: number; accent_gain: number; grain_count: number; bounces_per_grain: number; shake_velocity: number; slosh_lag_ms: number; burst_width_ms: number; impact_spread_deg: number; seed: number; // Air cavity cavity_coupling: number; // Radiation / listener direct_gain: 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: MaracaParams = { shell_radius_m: 0.034, shell_thickness_m: 0.0022, density: 720.0, youngs_modulus: 2.5e9, poisson_ratio: 0.3, stiffness_trim: 1.0, max_mode_l: 16, max_mode_hz: 22000.0, loss_factor: 0.015, loss_slope: 6.0e-7, radiation_loss: 1.0, grain_radius_m: 0.0015, grain_density: 1300.0, grain_youngs: 6.0e8, grain_size_spread: 0.25, restitution: 0.55, stroke_rate_hz: 4.5, stroke_count: 8, accent_period: 2, accent_gain: 1.5, grain_count: 320, bounces_per_grain: 1.3, shake_velocity: 2.2, slosh_lag_ms: 14.0, burst_width_ms: 45.0, impact_spread_deg: 35.0, seed: 1, cavity_coupling: 1.0, direct_gain: 1.0, listener_x_m: 0.15, listener_y_m: 0.25, listener_z_m: 0.3, speed_of_sound: 343.0, air_density: 1.204, sample_rate: 48000, oversample: 2, tail_s: 0.4, pre_delay_ms: 3.0, normalize: true, peak_dbfs: -3.0, full_scale_pa: 20.0, bit_depth: 24, }; export type Tier = 'solve' | 'radiate' | 'output'; // Keys whose change invalidates the cached mode basis or impact process. The // listener position and direct_gain enter only the radiation stage, so they // are deliberately absent. export const SOLVE_KEYS: (keyof MaracaParams)[] = [ 'shell_radius_m', 'shell_thickness_m', 'density', 'youngs_modulus', 'poisson_ratio', 'stiffness_trim', 'max_mode_l', 'max_mode_hz', 'loss_factor', 'loss_slope', 'radiation_loss', 'grain_radius_m', 'grain_density', 'grain_youngs', 'grain_size_spread', 'restitution', 'stroke_rate_hz', 'stroke_count', 'accent_period', 'accent_gain', 'grain_count', 'bounces_per_grain', 'shake_velocity', 'slosh_lag_ms', 'burst_width_ms', 'impact_spread_deg', 'seed', 'cavity_coupling', 'speed_of_sound', 'air_density', 'sample_rate', 'tail_s', ]; export const RADIATE_KEYS: (keyof MaracaParams)[] = [ 'direct_gain', 'listener_x_m', 'listener_y_m', 'listener_z_m', ]; export const OUTPUT_KEYS: (keyof MaracaParams)[] = [ 'pre_delay_ms', 'normalize', 'peak_dbfs', 'full_scale_pa', ]; export interface ParamSpec { key: keyof MaracaParams; 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: 'Shake gesture', specs: [ { key: 'stroke_rate_hz', label: 'Stroke rate', unit: 'Hz', min: 0.5, max: 12, step: 0.1, help: 'Strokes per second. Sets the rhythm and the render length.' }, { key: 'stroke_count', label: 'Strokes', min: 1, max: 32, step: 1, integer: true, help: 'Number of strokes in the pattern.' }, { key: 'accent_period', label: 'Accent every', unit: 'strokes', min: 1, max: 8, step: 1, integer: true, help: '1 accents every stroke, 2 alternates, 4 gives a bar-like pulse.' }, { key: 'accent_gain', label: 'Accent strength', min: 1.0, max: 3.0, step: 0.05, help: 'Velocity and count multiplier on accented strokes.' }, { key: 'shake_velocity', label: 'Shake velocity', unit: 'm/s', min: 0.2, max: 6.0, step: 0.05, help: 'Mean grain impact speed. Dynamics and timbre together: faster grains give shorter Hertz contacts and a brighter hiss.' }, { key: 'slosh_lag_ms', label: 'Slosh lag', unit: 'ms', min: 0, max: 80, step: 0.5, help: 'Delay from the hand reversing to the grain mass arriving at the far wall.' }, { key: 'burst_width_ms', label: 'Burst width', unit: 'ms', min: 3, max: 200, step: 1, help: 'Spread of one stroke’s impacts in time. Small = a tight chick, large = a wash.' }, { key: 'seed', label: 'Random seed', min: 1, max: 9999, step: 1, integer: true, help: 'Any change redraws the whole impact process; the same seed always reproduces it.' }, ], }, { name: 'Grains', specs: [ { key: 'grain_count', label: 'Grain count', min: 5, max: 3000, step: 1, integer: true, help: 'How many seeds or beads are inside the shell. With bounces per grain, this sets the expected impacts per stroke.' }, { key: 'bounces_per_grain', label: 'Bounces per grain', unit: '/stroke', min: 0.2, max: 6.0, step: 0.05, help: 'Mean wall contacts each grain makes in one stroke: one arrival plus whatever rattle follows before the hand reverses.' }, { key: 'grain_radius_m', label: 'Grain radius', unit: 'm', min: 0.0003, max: 0.006, step: 0.0001, help: 'Seed or bead size. Bigger grains give heavier, slower, lower-pitched clicks.' }, { key: 'grain_density', label: 'Grain density', unit: 'kg/m³', min: 300, max: 8000, log: true, help: 'Seed ~1300, glass ~2500, steel shot ~7800.' }, { key: 'grain_youngs', label: 'Grain modulus', unit: 'Pa', min: 1e7, max: 2e11, log: true, help: 'Contact hardness; sets the Hertz contact time and therefore the brightness.' }, { key: 'grain_size_spread', label: 'Size spread', min: 0.0, max: 0.8, step: 0.01, help: 'Lognormal spread of the grain radius. 0 = identical grains, which sounds mechanical.' }, { key: 'restitution', label: 'Restitution', min: 0.05, max: 0.95, step: 0.01, help: 'Coefficient of restitution; scales the momentum each impact transfers.' }, { key: 'impact_spread_deg', label: 'Impact spread', unit: '°', min: 5, max: 90, step: 1, help: 'Angular scatter of the impact points about the pole the grains are thrown at.' }, ], }, { name: 'Shell geometry', specs: [ { key: 'shell_radius_m', label: 'Shell radius', unit: 'm', min: 0.015, max: 0.09, step: 0.001, help: 'Mode frequencies scale as 1/R.' }, { key: 'shell_thickness_m', label: 'Shell thickness', unit: 'm', min: 0.0005, max: 0.008, step: 0.0001, help: 'Sets the bending term that spreads the high-degree modes, and the wall mass.' }, ], }, { name: 'Shell material', specs: [ { key: 'density', label: 'Density', unit: 'kg/m³', min: 200, max: 2500, step: 10, help: 'Dried gourd ~700. Frequencies scale as 1/sqrt(rho).' }, { key: 'youngs_modulus', label: "Young's modulus", unit: 'Pa', min: 2e8, max: 2e11, log: true, help: 'Frequencies scale as sqrt(E).' }, { key: 'poisson_ratio', label: 'Poisson ratio', min: 0.0, max: 0.48, step: 0.01 }, { key: 'stiffness_trim', label: 'Stiffness trim', min: 0.4, max: 2.0, step: 0.01, help: 'Overall pitch trim for the departures of a real gourd from a perfect sphere.' }, { key: 'cavity_coupling', label: 'Cavity coupling', min: 0.0, max: 4.0, step: 0.05, help: 'Air stiffness inside the closed shell, which raises the breathing mode only.' }, ], }, { name: 'Damping', specs: [ { key: 'loss_factor', label: 'Loss factor η₀', min: 0.001, max: 0.12, step: 0.001, help: 'Frequency-independent wall loss; sets how long each impact rings.' }, { key: 'loss_slope', label: 'Loss slope η₁', unit: 's', min: 1e-8, max: 4e-6, log: true, help: 'Extra high-frequency damping; the main brightness control.' }, { key: 'radiation_loss', label: 'Radiation loss', min: 0.0, max: 3.0, step: 0.05, help: 'Scales the acoustic radiation damping computed from the multipole impedance.' }, ], }, { name: 'Listener', specs: [ { key: 'listener_x_m', label: 'Listener x', unit: 'm', min: -1.5, max: 1.5, step: 0.005, help: 'Along the shake axis. Radiation-only: no re-solve needed.' }, { key: 'listener_y_m', label: 'Listener y', unit: 'm', min: -1.5, max: 1.5, step: 0.005 }, { key: 'listener_z_m', label: 'Listener z', unit: 'm', min: -1.5, max: 1.5, step: 0.005 }, { key: 'direct_gain', label: 'Direct click gain', min: 0.0, max: 3.0, step: 0.05, help: 'Broadband forced response at the contact point, standing in for the truncated high-degree modes. 0 leaves only the resolved modes.' }, ], }, { name: 'Output', specs: [ { key: 'tail_s', label: 'Tail', unit: 's', min: 0.05, max: 2.0, step: 0.05, help: 'Silence-and-decay appended 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: 1, max: 200, step: 1, help: 'Used when normalize is off: this many pascals maps to 0 dBFS.' }, ], }, { name: 'Advanced / quality', specs: [ { key: 'max_mode_l', label: 'Max degree l', min: 2, max: 40, step: 1, integer: true, help: 'Highest spherical-harmonic degree retained.' }, { key: 'max_mode_hz', label: 'Max mode freq', unit: 'Hz', min: 2000, max: 24000, step: 500 }, { key: 'oversample', label: 'Oversample', min: 1, max: 8, step: 1, integer: true, help: 'Solver rate multiple; 2 for render, 1 for preview.' }, { 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 = 'maraca-webapp-params-v1'; export function loadParams(): MaracaParams { 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 MaracaParams)[]) { if (typeof parsed[k] === typeof DEFAULTS[k]) { (out as Record)[k] = parsed[k]; } } return out; } catch { return { ...DEFAULTS }; } } export function saveParams(p: MaracaParams): void { try { localStorage.setItem(STORAGE_KEY, JSON.stringify(p)); } catch { // storage unavailable; persistence is a convenience only } }