// Parameter definitions for the triangle model, mirroring triangle_defaults.m. // Each parameter is assigned a tier that determines how much recomputation a // change requires: // solve -> FEM assembly + eigensolve + strikes + radiation + output // radiate -> strikes + radiation + output only (the cached mode basis is // reused; the eigensolve is the expensive part of this model) // 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 TriangleParams { // Rod geometry side_length_m: number; gap_m: number; rod_diameter_m: number; // Rod material density: number; youngs_modulus: number; poisson_ratio: number; stiffness_trim: number; // Discretization / mode retention n_elements: number; min_mode_hz: number; max_mode_hz: number; // Damping loss_factor: number; loss_slope: number; radiation_loss: number; // Beater beater_radius_m: number; beater_mass_kg: number; beater_youngs: number; restitution: number; // Strike / roll gesture strike_velocity: number; strike_pos: number; strike_angle_deg: number; strike_count: number; strike_rate_hz: number; roll_pos2: number; timing_jitter_ms: number; velocity_spread: number; seed: 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: TriangleParams = { side_length_m: 0.152, gap_m: 0.015, rod_diameter_m: 0.008, density: 7850.0, youngs_modulus: 2.0e11, poisson_ratio: 0.3, stiffness_trim: 1.0, n_elements: 60, min_mode_hz: 20.0, max_mode_hz: 22000.0, loss_factor: 6.0e-5, loss_slope: 4.0e-10, radiation_loss: 1.0, beater_radius_m: 0.0045, beater_mass_kg: 0.022, beater_youngs: 2.0e11, restitution: 0.6, strike_velocity: 1.5, strike_pos: 0.17, strike_angle_deg: 20.0, strike_count: 1, strike_rate_hz: 12.0, roll_pos2: 0.4, timing_jitter_ms: 6.0, velocity_spread: 0.15, seed: 1, direct_gain: 1.0, listener_x_m: 0.2, listener_y_m: 0.1, listener_z_m: 0.4, speed_of_sound: 343.0, air_density: 1.204, sample_rate: 48000, oversample: 2, tail_s: 5.0, pre_delay_ms: 3.0, normalize: true, peak_dbfs: -3.0, full_scale_pa: 2.0, bit_depth: 24, }; export type Tier = 'solve' | 'radiate' | 'output'; // Keys whose change invalidates the cached mode basis (the eigensolve). The // strike process, the listener, and everything downstream are deliberately // absent: striking somewhere else or moving the microphone reuses the modes. export const SOLVE_KEYS: (keyof TriangleParams)[] = [ 'side_length_m', 'gap_m', 'rod_diameter_m', 'density', 'youngs_modulus', 'poisson_ratio', 'stiffness_trim', 'n_elements', 'min_mode_hz', 'max_mode_hz', 'loss_factor', 'loss_slope', 'radiation_loss', 'speed_of_sound', 'air_density', ]; export const RADIATE_KEYS: (keyof TriangleParams)[] = [ 'beater_radius_m', 'beater_mass_kg', 'beater_youngs', 'restitution', 'strike_velocity', 'strike_pos', 'strike_angle_deg', 'strike_count', 'strike_rate_hz', 'roll_pos2', 'timing_jitter_ms', 'velocity_spread', 'seed', 'direct_gain', 'listener_x_m', 'listener_y_m', 'listener_z_m', 'sample_rate', 'tail_s', ]; export const OUTPUT_KEYS: (keyof TriangleParams)[] = [ 'pre_delay_ms', 'normalize', 'peak_dbfs', 'full_scale_pa', ]; export interface ParamSpec { key: keyof TriangleParams; 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: 'Strike', specs: [ { key: 'strike_velocity', label: 'Strike velocity', unit: 'm/s', min: 0.1, max: 6.0, step: 0.05, help: 'Beater speed at contact. Dynamics and timbre together: a faster strike gives a shorter Hertz contact and a brighter attack.' }, { key: 'strike_pos', label: 'Strike position', min: 0.01, max: 0.99, step: 0.005, help: 'Arclength fraction along the rod, 0 at the free end on the base. ~0.17 is the middle of the base; a node of a mode silences that mode.' }, { key: 'strike_angle_deg', label: 'Strike angle', unit: '°', min: 0, max: 90, step: 1, help: '0 strikes in the plane of the triangle, 90 normal to it. The two mode families are excited in this proportion.' }, ], }, { name: 'Roll', specs: [ { key: 'strike_count', label: 'Strikes', min: 1, max: 64, step: 1, integer: true, help: '1 is a single strike; more gives a roll, the beater rattling in a corner.' }, { key: 'strike_rate_hz', label: 'Roll rate', unit: 'Hz', min: 2, max: 30, step: 0.5, help: 'Hits per second during a roll.' }, { key: 'roll_pos2', label: 'Second contact', min: 0.01, max: 0.99, step: 0.005, help: 'A roll alternates between the strike position and this one (the other leg of the corner).' }, { key: 'timing_jitter_ms', label: 'Timing jitter', unit: 'ms', min: 0, max: 20, step: 0.5, help: 'Gaussian jitter on each hit after the first. 0 sounds mechanical.' }, { key: 'velocity_spread', label: 'Velocity spread', min: 0, max: 0.6, step: 0.01, help: 'Lognormal spread of hit velocities during a roll.' }, { key: 'seed', label: 'Random seed', min: 1, max: 9999, step: 1, integer: true, help: 'Any change redraws the roll timing and velocities; the same seed always reproduces them.' }, ], }, { name: 'Beater', specs: [ { key: 'beater_radius_m', label: 'Tip radius', unit: 'm', min: 0.001, max: 0.012, step: 0.0005, help: 'Radius of the beater tip; enters the Hertz contact stiffness.' }, { key: 'beater_mass_kg', label: 'Beater mass', unit: 'kg', min: 0.002, max: 0.12, log: true, help: 'A heavy beater lengthens the contact and darkens the strike; a light one brightens it.' }, { key: 'beater_youngs', label: 'Beater modulus', unit: 'Pa', min: 1e9, max: 4e11, log: true, help: 'Steel ~2e11. Lower it toward wood (~1e10) for a soft, dark contact.' }, { key: 'restitution', label: 'Restitution', min: 0.05, max: 0.95, step: 0.01, help: 'Coefficient of restitution; scales the momentum each strike transfers.' }, ], }, { name: 'Rod geometry', specs: [ { key: 'side_length_m', label: 'Side length', unit: 'm', min: 0.08, max: 0.3, step: 0.001, help: '0.152 is a 6-inch triangle. Mode frequencies scale roughly as 1/L².' }, { key: 'gap_m', label: 'Corner gap', unit: 'm', min: 0.004, max: 0.05, step: 0.001, help: 'Arclength removed at the open corner. The opening is what frees the ends and detunes the near-degenerate pairs.' }, { key: 'rod_diameter_m', label: 'Rod diameter', unit: 'm', min: 0.004, max: 0.016, step: 0.0005, help: 'Bending frequencies scale as d; a thicker rod is also a louder radiator.' }, ], }, { name: 'Rod material', specs: [ { key: 'density', label: 'Density', unit: 'kg/m³', min: 2000, max: 20000, log: true, help: 'Steel 7850, brass ~8500, aluminum 2700.' }, { key: 'youngs_modulus', label: "Young's modulus", unit: 'Pa', min: 5e10, max: 4e11, log: true, help: 'Steel 2e11. Frequencies scale as sqrt(E).' }, { key: 'poisson_ratio', label: 'Poisson ratio', min: 0.0, max: 0.45, step: 0.01, help: 'Sets the shear modulus and with it the torsional mode family.' }, { key: 'stiffness_trim', label: 'Stiffness trim', min: 0.4, max: 2.0, step: 0.01, help: 'Overall pitch trim for departures of a real triangle from the ideal geometry.' }, ], }, { name: 'Damping', specs: [ { key: 'loss_factor', label: 'Loss factor η₀', min: 1e-6, max: 2e-3, log: true, help: 'Frequency-independent material loss. Steel is tiny, which is why a triangle rings for seconds.' }, { key: 'loss_slope', label: 'Loss slope η₁', unit: 's', min: 1e-11, max: 1e-8, log: true, help: 'Extra high-frequency damping; controls how fast the shimmer fades into the ring.' }, { key: 'radiation_loss', label: 'Radiation loss', min: 0.0, max: 3.0, step: 0.05, help: 'Scales the acoustic radiation damping estimated from the element dipole powers.' }, ], }, { name: 'Listener', specs: [ { key: 'listener_x_m', label: 'Listener x', unit: 'm', min: -2.0, max: 2.0, step: 0.005, help: 'In the plane of the triangle. Radiation-only: no re-solve needed.' }, { key: 'listener_y_m', label: 'Listener y', unit: 'm', min: -2.0, max: 2.0, step: 0.005 }, { key: 'listener_z_m', label: 'Listener z', unit: 'm', min: -2.0, max: 2.0, step: 0.005, help: 'Normal to the plane: where the out-of-plane modes radiate best.' }, { 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.' }, ], }, { 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 strike. A triangle needs seconds.' }, { 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: 20, step: 0.1, help: 'Used when normalize is off: this many pascals maps to 0 dBFS.' }, ], }, { name: 'Advanced / quality', specs: [ { key: 'n_elements', label: 'Elements', min: 24, max: 120, step: 1, integer: true, help: 'Finite elements along the rod. More converges the high modes; the eigensolve cost grows as its cube.' }, { key: 'min_mode_hz', label: 'Min mode freq', unit: 'Hz', min: 20, max: 500, step: 10 }, { key: 'max_mode_hz', label: 'Max mode freq', unit: 'Hz', min: 2000, max: 24000, step: 500 }, { key: 'oversample', label: 'Oversample', min: 1, max: 4, step: 1, integer: true, help: 'Synthesis 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 = 'triangle-webapp-params-v1'; export function loadParams(): TriangleParams { 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 TriangleParams)[]) { if (typeof parsed[k] === typeof DEFAULTS[k]) { (out as Record)[k] = parsed[k]; } } return out; } catch { return { ...DEFAULTS }; } } export function saveParams(p: TriangleParams): void { try { localStorage.setItem(STORAGE_KEY, JSON.stringify(p)); } catch { // storage unavailable; persistence is a convenience only } }