// Parameter definitions for the bass model, mirroring bass_defaults.m. // Each parameter is assigned a tier that determines how much recomputation a // change requires: // solve -> gesture + per-fingering coupled eigensolves + radiation + output // radiate -> pluck projection + synthesis + radiation + output only (the // cached eigensolves are reused; they are the expensive part) // output -> output stage only // // Note that the render duration is not a parameter: it is // (note_count - 1) * 60 / tempo_bpm + tail_s. export interface BassParams { // Strings scale_length_m: number; tension_g_n: number; tension_d_n: number; tension_a_n: number; tension_e_n: number; inharmonicity: number; tuning_trim: number; // String damping loss_factor: number; loss_slope: number; finger_loss: number; residual_y: number; // Body a0_hz: number; body_trim: number; body_q_scale: number; bridge_mobility: number; rad_area_scale: number; dipole_length_m: number; radiation_loss: number; // Pluck pluck_force_n: number; pluck_pos: number; finger_release_ms: number; choke_ms: number; // Gesture root_midi: number; note_count: number; tempo_bpm: number; force_spread: number; timing_jitter_ms: 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 max_mode_hz: number; 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: BassParams = { scale_length_m: 1.04, tension_g_n: 285.0, tension_d_n: 290.0, tension_a_n: 300.0, tension_e_n: 310.0, inharmonicity: 8.0e-5, tuning_trim: 1.0, loss_factor: 1.6e-3, loss_slope: 1.2e-7, finger_loss: 6.0e-3, residual_y: 0.015, a0_hz: 60.0, body_trim: 1.0, body_q_scale: 1.0, bridge_mobility: 1.0, rad_area_scale: 1.0, dipole_length_m: 0.35, radiation_loss: 1.0, pluck_force_n: 15.0, pluck_pos: 0.3, finger_release_ms: 0.9, choke_ms: 45.0, root_midi: 33, note_count: 1, tempo_bpm: 96.0, force_spread: 0.2, timing_jitter_ms: 4.0, seed: 1, direct_gain: 1.0, listener_x_m: 0.5, listener_y_m: 0.3, listener_z_m: 1.2, speed_of_sound: 343.0, air_density: 1.204, max_mode_hz: 3000.0, sample_rate: 48000, oversample: 2, tail_s: 6.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 coupled eigensolves. The gesture // keys that pick the pitches (root, count, seed) are here because a new set // of fingerings needs new solves; the pluck, the tempo, and the listener are // deliberately absent: plucking harder or moving the microphone reuses the // cached modes. export const SOLVE_KEYS: (keyof BassParams)[] = [ 'scale_length_m', 'tension_g_n', 'tension_d_n', 'tension_a_n', 'tension_e_n', 'inharmonicity', 'tuning_trim', 'loss_factor', 'loss_slope', 'finger_loss', 'residual_y', 'a0_hz', 'body_trim', 'body_q_scale', 'bridge_mobility', 'rad_area_scale', 'dipole_length_m', 'radiation_loss', 'root_midi', 'note_count', 'seed', 'max_mode_hz', 'speed_of_sound', 'air_density', ]; export const RADIATE_KEYS: (keyof BassParams)[] = [ 'pluck_force_n', 'pluck_pos', 'finger_release_ms', 'choke_ms', 'tempo_bpm', 'force_spread', 'timing_jitter_ms', 'direct_gain', 'listener_x_m', 'listener_y_m', 'listener_z_m', 'sample_rate', 'tail_s', ]; export const OUTPUT_KEYS: (keyof BassParams)[] = [ 'pre_delay_ms', 'normalize', 'peak_dbfs', 'full_scale_pa', ]; export interface ParamSpec { key: keyof BassParams; 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: 'Pluck', specs: [ { key: 'pluck_force_n', label: 'Pluck force', unit: 'N', min: 1, max: 60, step: 0.5, help: 'Transverse pull at release. Dynamics: the initial displacement is F xp (Lv - xp) / (T Lv), about 11 mm at the default.' }, { key: 'pluck_pos', label: 'Pluck position', min: 0.08, max: 0.6, step: 0.005, help: 'Fraction of the vibrating length from the bridge. ~0.3 is the end of the fingerboard, jazz territory; partials with a node there vanish.' }, { key: 'finger_release_ms', label: 'Finger release', unit: 'ms', min: 0.1, max: 4.0, step: 0.05, help: 'How long the string takes to leave the fingertip. Short = bright snap, long = fleshy thump.' }, { key: 'choke_ms', label: 'Choke time', unit: 'ms', min: 5, max: 200, step: 1, help: 'Extra decay applied to a note when the next one lands, standing in for the player stopping the string.' }, ], }, { name: 'Gesture', specs: [ { key: 'root_midi', label: 'Root note', unit: 'midi', min: 28, max: 50, step: 1, integer: true, help: '33 = A1 (open A string). The single note, and the key a walking line walks in. E1 is 28.' }, { key: 'note_count', label: 'Notes', min: 1, max: 32, step: 1, integer: true, help: '1 is a single deterministic pluck; more gives a seeded walking line on the major scale of the root.' }, { key: 'tempo_bpm', label: 'Tempo', unit: 'bpm', min: 40, max: 200, step: 1, help: 'Quarter-note tempo of the walking line.' }, { key: 'force_spread', label: 'Force spread', min: 0, max: 0.6, step: 0.01, help: 'Lognormal spread of the pluck force across a line. 0 sounds mechanical.' }, { key: 'timing_jitter_ms', label: 'Timing jitter', unit: 'ms', min: 0, max: 30, step: 0.5, help: 'Gaussian jitter on each onset after the first.' }, { key: 'seed', label: 'Random seed', min: 1, max: 9999, step: 1, integer: true, help: 'Any change redraws the walking line; the same seed always reproduces it.' }, ], }, { name: 'Strings', specs: [ { key: 'scale_length_m', label: 'Scale length', unit: 'm', min: 0.9, max: 1.15, step: 0.005, help: '1.04 m is a 3/4 bass, the common orchestral size. The linear density follows from tension and pitch.' }, { key: 'tension_g_n', label: 'G string tension', unit: 'N', min: 150, max: 400, step: 5, help: 'Higher tension = heavier string at the same pitch = louder and slower-speaking.' }, { key: 'tension_d_n', label: 'D string tension', unit: 'N', min: 150, max: 400, step: 5 }, { key: 'tension_a_n', label: 'A string tension', unit: 'N', min: 150, max: 400, step: 5 }, { key: 'tension_e_n', label: 'E string tension', unit: 'N', min: 150, max: 400, step: 5 }, { key: 'inharmonicity', label: 'Inharmonicity B', min: 1e-5, max: 1e-3, log: true, help: 'Bending stiffness of the wound string: f_n ~ n f0 sqrt(1 + B n^2). Stretches the high partials sharp; grows as the stop shortens the string.' }, { key: 'tuning_trim', label: 'Tuning trim', min: 0.9, max: 1.1, step: 0.001, help: 'Overall pitch trim; 1 is standard orchestral tuning.' }, ], }, { name: 'String damping', specs: [ { key: 'loss_factor', label: 'Loss factor η₀', min: 1e-4, max: 2e-2, log: true, help: 'Frequency-independent internal loss of the string.' }, { key: 'loss_slope', label: 'Loss slope η₁', unit: 's', min: 1e-8, max: 2e-6, log: true, help: 'Extra high-frequency damping; controls how fast the attack brightness fades into the ring.' }, { key: 'finger_loss', label: 'Finger loss', min: 1e-4, max: 5e-2, log: true, help: 'Extra loss on stopped notes from the fingertip termination. Open strings ring longer, as on the instrument.' }, { key: 'residual_y', label: 'Residual admittance', unit: 's/kg', min: 0, max: 0.06, step: 0.001, help: 'Resistive bridge conductance standing in for the dense body modes above the retained table. Damps the high partials and feeds the direct term.' }, ], }, { name: 'Body', specs: [ { key: 'a0_hz', label: 'A0 (f-holes)', unit: 'Hz', min: 45, max: 80, step: 0.5, help: 'The f-hole Helmholtz resonance, the one monopole of the body. Move it onto a fundamental to hear it take over.' }, { key: 'body_trim', label: 'Corpus pitch trim', min: 0.7, max: 1.4, step: 0.005, help: 'Scales the corpus modes (97 Hz up) together; a bigger body is a lower trim.' }, { key: 'body_q_scale', label: 'Body Q scale', min: 0.3, max: 3.0, step: 0.05, help: 'Scales all body Qs. Low = a dull, damped box; high = ringing, wolf-prone resonances.' }, { key: 'bridge_mobility', label: 'Bridge mobility', min: 0.05, max: 4.0, log: true, help: 'Scales the admittance the string sees. High mobility = loud, fast decay, split wolf pairs; low = quiet and long.' }, { key: 'rad_area_scale', label: 'Radiating area', min: 0.2, max: 3.0, step: 0.05, help: 'Scales how much air each body mode moves per unit bridge motion.' }, { key: 'dipole_length_m', label: 'Dipole length', unit: 'm', min: 0.1, max: 0.7, step: 0.01, help: 'Effective front-to-back path of the corpus dipoles; longer radiates the low corpus modes better.' }, { key: 'radiation_loss', label: 'Radiation loss', min: 0.0, max: 3.0, step: 0.05, help: 'Scales the radiation damping each coupled mode gets from its own monopole and dipole strength.' }, ], }, { name: 'Listener', specs: [ { key: 'listener_x_m', label: 'Listener x', unit: 'm', min: -3.0, max: 3.0, step: 0.01, help: 'Across the instrument. Radiation-only: no re-solve needed.' }, { key: 'listener_y_m', label: 'Listener y', unit: 'm', min: -3.0, max: 3.0, step: 0.01, help: 'Up the neck.' }, { key: 'listener_z_m', label: 'Listener z', unit: 'm', min: -3.0, max: 3.0, step: 0.01, help: 'Out of the top plate: where the corpus dipoles radiate best. Behind the bass they flip phase.' }, { key: 'direct_gain', label: 'Direct term gain', min: 0.0, max: 3.0, step: 0.05, help: 'Broadband radiation through the residual bridge admittance, standing in for the body modes above the table. 0 leaves only the modal body.' }, ], }, { name: 'Output', specs: [ { key: 'tail_s', label: 'Tail', unit: 's', min: 1.0, max: 12.0, step: 0.1, help: 'Ring time rendered after the last note. An open-string pizzicato wants 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: 'max_mode_hz', label: 'Max string mode', unit: 'Hz', min: 500, max: 6000, step: 100, help: 'String partials above this are truncated; their radiation falls on the direct term.' }, { 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 = 'bass-webapp-params-v1'; export function loadParams(): BassParams { 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 BassParams)[]) { if (typeof parsed[k] === typeof DEFAULTS[k]) { (out as Record)[k] = parsed[k]; } } return out; } catch { return { ...DEFAULTS }; } } export function saveParams(p: BassParams): void { try { localStorage.setItem(STORAGE_KEY, JSON.stringify(p)); } catch { // storage unavailable; persistence is a convenience only } } // MIDI helpers shared by the plots and panels. const NOTE_NAMES = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B']; export function midiName(m: number): string { const n = Math.round(m); return `${NOTE_NAMES[((n % 12) + 12) % 12]}${Math.floor(n / 12) - 1}`; } export function midiHz(m: number): number { return 440 * 2 ** ((m - 69) / 12); }