// Parameter definitions for the recorder model, mirroring recorder_defaults.m. // Each parameter is assigned a tier that determines how much recomputation a // change requires: // basis -> bore transmission-line solve + modal fit (per note), then jet, // radiation, and output // jet -> the time-domain jet integration (the expensive stage of this // model), then radiation and output // radiate -> radiation + output only (moving the listener is cheap) // output -> output stage only // // Note that the render duration is not a parameter: it is // note_count * note_dur_s + (note_count - 1) * gap_ms + tail_s. export interface RecorderParams { // Bore and tone holes bore_length_m: number; bore_r_head_m: number; bore_r_foot_m: number; wall_m: number; hole_scale: number; thumb_pinch: number; pinch_loss: number; // Window, windway, and jet cutup_m: number; window_width_m: number; flue_h_m: number; flue_w_m: number; labium_offset_m: number; jet_gain: number; jet_growth: number; jet_conv: number; jet_stcut: number; vortex_loss: number; noise_level: number; direct_gain: number; // Breath and gesture breath_pa: number; breath_track: number; attack_ms: number; release_ms: number; vibrato_hz: number; vibrato_depth: number; note_count: number; note1: number; note2: number; note3: number; note_dur_s: number; gap_ms: number; seed: number; // Modal fit max_mode_hz: number; max_modes: number; grid_hz: number; // Radiation / listener 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: RecorderParams = { bore_length_m: 0.3015, bore_r_head_m: 0.0067, bore_r_foot_m: 0.0052, wall_m: 0.003, hole_scale: 1.0, thumb_pinch: 0.35, pinch_loss: 0.5, cutup_m: 0.0045, window_width_m: 0.01, flue_h_m: 0.001, flue_w_m: 0.01, labium_offset_m: 0.0004, jet_gain: 1.0, jet_growth: 0.4, jet_conv: 0.55, jet_stcut: 0.45, vortex_loss: 1.0, noise_level: 0.03, direct_gain: 1.0, breath_pa: 350.0, breath_track: 1.6, attack_ms: 12.0, release_ms: 25.0, vibrato_hz: 5.0, vibrato_depth: 0.0, note_count: 1, note1: 5, note2: 6, note3: 7, note_dur_s: 1.6, gap_ms: 60.0, seed: 1, max_mode_hz: 10000.0, max_modes: 12, grid_hz: 2.5, listener_x_m: 0.25, listener_y_m: -0.1, listener_z_m: 0.35, 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: 0.5, bit_depth: 24, }; export const NOTE_NAMES = [ 'C5', 'D5', 'E5', 'F5', 'G5', 'A5', 'B5', 'C6', 'C♯6', 'D6', 'E6', 'F6', 'G6', 'A6', ]; export type Tier = 'basis' | 'jet' | 'radiate' | 'output'; // Keys whose change invalidates the cached bore basis of every note. The // jet, the breath, and the listener are deliberately absent: changing how // the instrument is blown or where the microphone stands reuses the bore. export const BASIS_KEYS: (keyof RecorderParams)[] = [ 'bore_length_m', 'bore_r_head_m', 'bore_r_foot_m', 'wall_m', 'hole_scale', 'thumb_pinch', 'pinch_loss', 'cutup_m', 'window_width_m', 'max_mode_hz', 'max_modes', 'grid_hz', 'speed_of_sound', 'air_density', ]; // Keys that re-run the time-domain jet integration (per note). export const JET_KEYS: (keyof RecorderParams)[] = [ 'flue_h_m', 'flue_w_m', 'labium_offset_m', 'jet_gain', 'jet_growth', 'jet_conv', 'jet_stcut', 'vortex_loss', 'noise_level', 'breath_pa', 'breath_track', 'attack_ms', 'release_ms', 'vibrato_hz', 'vibrato_depth', 'note_dur_s', 'tail_s', 'seed', 'sample_rate', ]; export const RADIATE_KEYS: (keyof RecorderParams)[] = [ 'listener_x_m', 'listener_y_m', 'listener_z_m', 'direct_gain', 'gap_ms', ]; export const OUTPUT_KEYS: (keyof RecorderParams)[] = [ 'pre_delay_ms', 'normalize', 'peak_dbfs', 'full_scale_pa', ]; export interface ParamSpec { key: keyof RecorderParams; label: string; unit?: string; min: number; max: number; step?: number; log?: boolean; integer?: boolean; note?: boolean; // render as a note-name dropdown help?: string; } export interface ParamGroup { name: string; specs: ParamSpec[]; } export const PARAM_GROUPS: ParamGroup[] = [ { name: 'Notes', specs: [ { key: 'note1', label: 'Note', min: 1, max: 14, step: 1, integer: true, note: true, help: 'Fingering from the baroque chart. E6 and above are pinched-thumb second-register fingerings.' }, { key: 'note_count', label: 'Notes played', min: 1, max: 3, step: 1, integer: true, help: '1 plays a single note; 2 or 3 play a tongued figure.' }, { key: 'note2', label: 'Second note', min: 1, max: 14, step: 1, integer: true, note: true }, { key: 'note3', label: 'Third note', min: 1, max: 14, step: 1, integer: true, note: true }, { key: 'note_dur_s', label: 'Note length', unit: 's', min: 0.2, max: 4.0, step: 0.05 }, { key: 'gap_ms', label: 'Gap', unit: 'ms', min: 0, max: 300, step: 5, help: 'Silence between tongued notes.' }, ], }, { name: 'Breath', specs: [ { key: 'breath_pa', label: 'Breath pressure', unit: 'Pa', min: 80, max: 3000, log: true, help: 'Blowing pressure for C5; ~350 Pa speaks the first register. Blow harder and the note sharpens, then overblows to the octave.' }, { key: 'breath_track', label: 'Breath tracking', min: 0, max: 2.5, step: 0.05, help: 'How much harder higher notes are blown, as (f/523 Hz)^track. 0 blows every fingering identically; high notes then underblow, as on the instrument.' }, { key: 'attack_ms', label: 'Attack', unit: 'ms', min: 2, max: 60, step: 1, help: 'Breath rise time. The attack chiff is the jet sweeping through its lock range as the pressure builds.' }, { key: 'release_ms', label: 'Release', unit: 'ms', min: 5, max: 120, step: 1 }, { key: 'vibrato_hz', label: 'Vibrato rate', unit: 'Hz', min: 0.5, max: 8, step: 0.1 }, { key: 'vibrato_depth', label: 'Vibrato depth', min: 0, max: 0.2, step: 0.005, help: 'Breath pressure modulation; a recorder vibrato is breath, not pitch, so it is mostly loudness and a little pitch.' }, { key: 'noise_level', label: 'Breathiness', min: 0, max: 0.15, step: 0.005, help: 'Turbulence displacement noise on the jet.' }, { key: 'seed', label: 'Noise seed', min: 1, max: 9999, step: 1, integer: true, help: 'Any change redraws the turbulence; the same seed reproduces it exactly.' }, ], }, { name: 'Windway & jet', specs: [ { key: 'cutup_m', label: 'Cutup', unit: 'm', min: 0.003, max: 0.007, step: 0.0001, help: 'Flue exit to labium. Longer needs more breath and darkens the tone.' }, { key: 'flue_h_m', label: 'Flue height', unit: 'm', min: 0.0005, max: 0.002, step: 0.00005, help: 'Windway exit slot height; sets the jet thickness.' }, { key: 'flue_w_m', label: 'Flue width', unit: 'm', min: 0.006, max: 0.014, step: 0.0005 }, { key: 'labium_offset_m', label: 'Labium offset', unit: 'm', min: -0.001, max: 0.0015, step: 0.00005, help: 'Labium position relative to the jet centerline; sets the even-harmonic content and the ease of speech.' }, { key: 'jet_gain', label: 'Jet receptivity', min: 0.3, max: 3.0, step: 0.05, help: 'Trim on how strongly the mouth field deflects the jet. Too low and notes fail to speak.' }, { key: 'jet_growth', label: 'Jet growth', min: 0.2, max: 0.7, step: 0.01, help: 'Instability amplification per flue height of travel.' }, { key: 'jet_conv', label: 'Jet wave speed', min: 0.3, max: 0.7, step: 0.01, help: 'Displacement wave speed as a fraction of the jet velocity; sets which breath pressure centers each note.' }, { key: 'jet_stcut', label: 'Jet cutoff', min: 0.25, max: 0.7, step: 0.01, help: 'Instability cutoff (Strouhal-like). Lower favors low registers; raise it and low fingerings squeak.' }, { key: 'vortex_loss', label: 'Vortex loss', min: 0, max: 3, step: 0.05, help: 'Nonlinear loss at the window; with the jet saturation it caps the amplitude.' }, ], }, { name: 'Bore & holes', specs: [ { key: 'bore_length_m', label: 'Bore length', unit: 'm', min: 0.2, max: 0.45, step: 0.001, help: 'Window to foot. The hole lattice scales with it, so this transposes the instrument.' }, { key: 'bore_r_head_m', label: 'Bore radius (head)', unit: 'm', min: 0.004, max: 0.01, step: 0.0001 }, { key: 'bore_r_foot_m', label: 'Bore radius (foot)', unit: 'm', min: 0.003, max: 0.009, step: 0.0001, help: 'The taper tunes the octaves against each other.' }, { key: 'wall_m', label: 'Wall thickness', unit: 'm', min: 0.002, max: 0.005, step: 0.0001, help: 'Tone hole chimney height.' }, { key: 'hole_scale', label: 'Hole size scale', min: 0.7, max: 1.3, step: 0.01, help: 'Scales every tone hole radius. Smaller holes flatten and darken the scale.' }, { key: 'thumb_pinch', label: 'Thumb pinch', min: 0.15, max: 0.7, step: 0.01, help: 'Effective radius fraction of the pinched thumb hole (the second-register vent).' }, { key: 'pinch_loss', label: 'Pinch loss', min: 0, max: 2, step: 0.05, help: 'Viscous resistance of the thumb crack; damps the fundamental so the second register speaks.' }, ], }, { name: 'Listener', specs: [ { key: 'listener_x_m', label: 'Listener x', unit: 'm', min: -2.0, max: 2.0, step: 0.005, help: 'To the player’s side. Radiation-only: no re-solve needed.' }, { key: 'listener_y_m', label: 'Listener y', unit: 'm', min: -2.0, max: 2.0, step: 0.005, help: 'Along the instrument; the foot points down (-y).' }, { key: 'listener_z_m', label: 'Listener z', unit: 'm', min: -2.0, max: 2.0, step: 0.005, help: 'In front of the instrument, the side the holes open toward.' }, { key: 'direct_gain', label: 'Direct breath gain', min: 0.0, max: 3.0, step: 0.05, help: 'Broadband monopole from the window: breath noise and attack transient. 0 leaves only the resonant modes.' }, ], }, { name: 'Output', specs: [ { key: 'tail_s', label: 'Tail', unit: 's', min: 0.1, max: 2.0, step: 0.05, help: 'Rendered after the last release. A recorder stops quickly; half a second is plenty.' }, { 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.05, max: 10, step: 0.05, help: 'Used when normalize is off: this many pascals maps to 0 dBFS.' }, ], }, { name: 'Advanced / quality', specs: [ { key: 'max_mode_hz', label: 'Max mode freq', unit: 'Hz', min: 4000, max: 14000, step: 250, help: 'Bore resonances above this are dropped; their broadband remainder rides on the direct term.' }, { key: 'max_modes', label: 'Max modes', min: 4, max: 16, step: 1, integer: true }, { key: 'grid_hz', label: 'Impedance grid', unit: 'Hz', min: 1, max: 5, step: 0.25, help: 'Frequency step of the transmission-line solve the modal fit reads.' }, { key: 'oversample', label: 'Oversample', min: 1, max: 4, step: 1, integer: true, help: 'Jet integration rate as a multiple of the sample rate; 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 = 'recorder-webapp-params-v1'; export function loadParams(): RecorderParams { 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 RecorderParams)[]) { if (typeof parsed[k] === typeof DEFAULTS[k]) { (out as Record)[k] = parsed[k]; } } return out; } catch { return { ...DEFAULTS }; } } export function saveParams(p: RecorderParams): void { try { localStorage.setItem(STORAGE_KEY, JSON.stringify(p)); } catch { // storage unavailable; persistence is a convenience only } }