1// Parameter definitions for the recorder model, mirroring recorder_defaults.m.
2// Each parameter is assigned a tier that determines how much recomputation a
3// change requires:
4// basis -> bore transmission-line solve + modal fit (per note), then jet,
5// radiation, and output
6// jet -> the time-domain jet integration (the expensive stage of this
7// model), then radiation and output
8// radiate -> radiation + output only (moving the listener is cheap)
9// output -> output stage only
10//
11// Note that the render duration is not a parameter: it is
12// note_count * note_dur_s + (note_count - 1) * gap_ms + tail_s.
14export interface RecorderParams {
15 // Bore and tone holes
16 bore_length_m: number;
17 bore_r_head_m: number;
18 bore_r_foot_m: number;
19 wall_m: number;
20 hole_scale: number;
21 thumb_pinch: number;
22 pinch_loss: number;
23 // Window, windway, and jet
24 cutup_m: number;
25 window_width_m: number;
26 flue_h_m: number;
27 flue_w_m: number;
28 labium_offset_m: number;
29 jet_gain: number;
30 jet_growth: number;
31 jet_conv: number;
32 jet_stcut: number;
33 vortex_loss: number;
34 noise_level: number;
35 direct_gain: number;
36 // Breath and gesture
37 breath_pa: number;
38 breath_track: number;
39 attack_ms: number;
40 release_ms: number;
41 vibrato_hz: number;
42 vibrato_depth: number;
43 note_count: number;
44 note1: number;
45 note2: number;
46 note3: number;
47 note_dur_s: number;
48 gap_ms: number;
49 seed: number;
50 // Modal fit
51 max_mode_hz: number;
52 max_modes: number;
53 grid_hz: number;
54 // Radiation / listener
55 listener_x_m: number;
56 listener_y_m: number;
57 listener_z_m: number;
58 speed_of_sound: number;
59 air_density: number;
60 // Rendering
61 sample_rate: number;
62 oversample: number;
63 tail_s: number;
64 pre_delay_ms: number;
65 normalize: boolean;
66 peak_dbfs: number;
67 full_scale_pa: number;
68 bit_depth: number;
69}
71export const DEFAULTS: RecorderParams = {
72 bore_length_m: 0.3015,
73 bore_r_head_m: 0.0067,
74 bore_r_foot_m: 0.0052,
75 wall_m: 0.003,
76 hole_scale: 1.0,
77 thumb_pinch: 0.35,
78 pinch_loss: 0.5,
79 cutup_m: 0.0045,
80 window_width_m: 0.01,
81 flue_h_m: 0.001,
82 flue_w_m: 0.01,
83 labium_offset_m: 0.0004,
84 jet_gain: 1.0,
85 jet_growth: 0.4,
86 jet_conv: 0.55,
87 jet_stcut: 0.45,
88 vortex_loss: 1.0,
89 noise_level: 0.03,
90 direct_gain: 1.0,
91 breath_pa: 350.0,
92 breath_track: 1.6,
93 attack_ms: 12.0,
94 release_ms: 25.0,
95 vibrato_hz: 5.0,
96 vibrato_depth: 0.0,
97 note_count: 1,
98 note1: 5,
99 note2: 6,
100 note3: 7,
101 note_dur_s: 1.6,
102 gap_ms: 60.0,
103 seed: 1,
104 max_mode_hz: 10000.0,
105 max_modes: 12,
106 grid_hz: 2.5,
107 listener_x_m: 0.25,
108 listener_y_m: -0.1,
109 listener_z_m: 0.35,
110 speed_of_sound: 343.0,
111 air_density: 1.204,
112 sample_rate: 48000,
113 oversample: 2,
114 tail_s: 0.4,
115 pre_delay_ms: 3.0,
116 normalize: true,
117 peak_dbfs: -3.0,
118 full_scale_pa: 0.5,
119 bit_depth: 24,
120};
122export const NOTE_NAMES = [
123 'C5', 'D5', 'E5', 'F5', 'G5', 'A5', 'B5', 'C6', 'C♯6', 'D6',
124 'E6', 'F6', 'G6', 'A6',
125];
127export type Tier = 'basis' | 'jet' | 'radiate' | 'output';
129// Keys whose change invalidates the cached bore basis of every note. The
130// jet, the breath, and the listener are deliberately absent: changing how
131// the instrument is blown or where the microphone stands reuses the bore.
132export const BASIS_KEYS: (keyof RecorderParams)[] = [
133 'bore_length_m', 'bore_r_head_m', 'bore_r_foot_m', 'wall_m',
134 'hole_scale', 'thumb_pinch', 'pinch_loss',
135 'cutup_m', 'window_width_m',
136 'max_mode_hz', 'max_modes', 'grid_hz',
137 'speed_of_sound', 'air_density',
138];
140// Keys that re-run the time-domain jet integration (per note).
141export const JET_KEYS: (keyof RecorderParams)[] = [
142 'flue_h_m', 'flue_w_m', 'labium_offset_m',
143 'jet_gain', 'jet_growth', 'jet_conv', 'jet_stcut', 'vortex_loss',
144 'noise_level',
145 'breath_pa', 'breath_track', 'attack_ms', 'release_ms',
146 'vibrato_hz', 'vibrato_depth',
147 'note_dur_s', 'tail_s', 'seed',
148 'sample_rate',
149];
151export const RADIATE_KEYS: (keyof RecorderParams)[] = [
152 'listener_x_m', 'listener_y_m', 'listener_z_m', 'direct_gain', 'gap_ms',
153];
155export const OUTPUT_KEYS: (keyof RecorderParams)[] = [
156 'pre_delay_ms', 'normalize', 'peak_dbfs', 'full_scale_pa',
157];
159export interface ParamSpec {
160 key: keyof RecorderParams;
161 label: string;
162 unit?: string;
163 min: number;
164 max: number;
165 step?: number;
166 log?: boolean;
167 integer?: boolean;
168 note?: boolean; // render as a note-name dropdown
169 help?: string;
170}
172export interface ParamGroup {
173 name: string;
174 specs: ParamSpec[];
175}
177export const PARAM_GROUPS: ParamGroup[] = [
178 {
179 name: 'Notes',
180 specs: [
181 { 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.' },
182 { 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.' },
183 { key: 'note2', label: 'Second note', min: 1, max: 14, step: 1, integer: true, note: true },
184 { key: 'note3', label: 'Third note', min: 1, max: 14, step: 1, integer: true, note: true },
185 { key: 'note_dur_s', label: 'Note length', unit: 's', min: 0.2, max: 4.0, step: 0.05 },
186 { key: 'gap_ms', label: 'Gap', unit: 'ms', min: 0, max: 300, step: 5, help: 'Silence between tongued notes.' },
187 ],
188 },
189 {
190 name: 'Breath',
191 specs: [
192 { 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.' },
193 { 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.' },
194 { 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.' },
195 { key: 'release_ms', label: 'Release', unit: 'ms', min: 5, max: 120, step: 1 },
196 { key: 'vibrato_hz', label: 'Vibrato rate', unit: 'Hz', min: 0.5, max: 8, step: 0.1 },
197 { 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.' },
198 { key: 'noise_level', label: 'Breathiness', min: 0, max: 0.15, step: 0.005, help: 'Turbulence displacement noise on the jet.' },
199 { 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.' },
200 ],
201 },
202 {
203 name: 'Windway & jet',
204 specs: [
205 { 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.' },
206 { 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.' },
207 { key: 'flue_w_m', label: 'Flue width', unit: 'm', min: 0.006, max: 0.014, step: 0.0005 },
208 { 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.' },
209 { 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.' },
210 { key: 'jet_growth', label: 'Jet growth', min: 0.2, max: 0.7, step: 0.01, help: 'Instability amplification per flue height of travel.' },
211 { 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.' },
212 { 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.' },
213 { 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.' },
214 ],
215 },
216 {
217 name: 'Bore & holes',
218 specs: [
219 { 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.' },
220 { key: 'bore_r_head_m', label: 'Bore radius (head)', unit: 'm', min: 0.004, max: 0.01, step: 0.0001 },
221 { 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.' },
222 { key: 'wall_m', label: 'Wall thickness', unit: 'm', min: 0.002, max: 0.005, step: 0.0001, help: 'Tone hole chimney height.' },
223 { 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.' },
224 { 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).' },
225 { 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.' },
226 ],
227 },
228 {
229 name: 'Listener',
230 specs: [
231 { 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.' },
232 { 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).' },
233 { 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.' },
234 { 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.' },
235 ],
236 },
237 {
238 name: 'Output',
239 specs: [
240 { 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.' },
241 { key: 'pre_delay_ms', label: 'Pre-delay', unit: 'ms', min: 0, max: 50, step: 0.5 },
242 { key: 'peak_dbfs', label: 'Peak level', unit: 'dBFS', min: -24, max: 0, step: 0.5, help: 'Used when normalize is on.' },
243 { 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.' },
244 ],
245 },
246 {
247 name: 'Advanced / quality',
248 specs: [
249 { 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.' },
250 { key: 'max_modes', label: 'Max modes', min: 4, max: 16, step: 1, integer: true },
251 { 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.' },
252 { 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.' },
253 { key: 'speed_of_sound', label: 'Speed of sound', unit: 'm/s', min: 300, max: 400, step: 1 },
254 { key: 'air_density', label: 'Air density', unit: 'kg/m³', min: 0.9, max: 1.5, step: 0.01 },
255 ],
256 },
257];
259const STORAGE_KEY = 'recorder-webapp-params-v1';
261export function loadParams(): RecorderParams {
262 try {
263 const raw = localStorage.getItem(STORAGE_KEY);
264 if (!raw) return { ...DEFAULTS };
265 const parsed = JSON.parse(raw);
266 const out = { ...DEFAULTS };
267 for (const k of Object.keys(DEFAULTS) as (keyof RecorderParams)[]) {
268 if (typeof parsed[k] === typeof DEFAULTS[k]) {
269 (out as Record<string, unknown>)[k] = parsed[k];
270 }
271 }
272 return out;
273 } catch {
274 return { ...DEFAULTS };
275 }
276}
278export function saveParams(p: RecorderParams): void {
279 try {
280 localStorage.setItem(STORAGE_KEY, JSON.stringify(p));
281 } catch {
282 // storage unavailable; persistence is a convenience only
283 }
284}