/ simulated_instruments / triangle
Sign in
simulated_instruments / triangle
triangle / webapp / src / params.ts
259 lines · 11.0 KBBlameHistoryRaw
1// Parameter definitions for the triangle model, mirroring triangle_defaults.m.
2// Each parameter is assigned a tier that determines how much recomputation a
3// change requires:
4// solve -> FEM assembly + eigensolve + strikes + radiation + output
5// radiate -> strikes + radiation + output only (the cached mode basis is
6// reused; the eigensolve is the expensive part of this model)
7// output -> output stage only
8//
9// Note that the render duration is not a parameter: it is
10// (strike_count - 1) / strike_rate_hz + tail_s.
12export interface TriangleParams {
13 // Rod geometry
14 side_length_m: number;
15 gap_m: number;
16 rod_diameter_m: number;
17 // Rod material
18 density: number;
19 youngs_modulus: number;
20 poisson_ratio: number;
21 stiffness_trim: number;
22 // Discretization / mode retention
23 n_elements: number;
24 min_mode_hz: number;
25 max_mode_hz: number;
26 // Damping
27 loss_factor: number;
28 loss_slope: number;
29 radiation_loss: number;
30 // Beater
31 beater_radius_m: number;
32 beater_mass_kg: number;
33 beater_youngs: number;
34 restitution: number;
35 // Strike / roll gesture
36 strike_velocity: number;
37 strike_pos: number;
38 strike_angle_deg: number;
39 strike_count: number;
40 strike_rate_hz: number;
41 roll_pos2: number;
42 timing_jitter_ms: number;
43 velocity_spread: number;
44 seed: number;
45 // Radiation / listener
46 direct_gain: number;
47 listener_x_m: number;
48 listener_y_m: number;
49 listener_z_m: number;
50 speed_of_sound: number;
51 air_density: number;
52 // Rendering
53 sample_rate: number;
54 oversample: number;
55 tail_s: number;
56 pre_delay_ms: number;
57 normalize: boolean;
58 peak_dbfs: number;
59 full_scale_pa: number;
60 bit_depth: number;
63export const DEFAULTS: TriangleParams = {
64 side_length_m: 0.152,
65 gap_m: 0.015,
66 rod_diameter_m: 0.008,
67 density: 7850.0,
68 youngs_modulus: 2.0e11,
69 poisson_ratio: 0.3,
70 stiffness_trim: 1.0,
71 n_elements: 60,
72 min_mode_hz: 20.0,
73 max_mode_hz: 22000.0,
74 loss_factor: 6.0e-5,
75 loss_slope: 4.0e-10,
76 radiation_loss: 1.0,
77 beater_radius_m: 0.0045,
78 beater_mass_kg: 0.022,
79 beater_youngs: 2.0e11,
80 restitution: 0.6,
81 strike_velocity: 1.5,
82 strike_pos: 0.17,
83 strike_angle_deg: 20.0,
84 strike_count: 1,
85 strike_rate_hz: 12.0,
86 roll_pos2: 0.4,
87 timing_jitter_ms: 6.0,
88 velocity_spread: 0.15,
89 seed: 1,
90 direct_gain: 1.0,
91 listener_x_m: 0.2,
92 listener_y_m: 0.1,
93 listener_z_m: 0.4,
94 speed_of_sound: 343.0,
95 air_density: 1.204,
96 sample_rate: 48000,
97 oversample: 2,
98 tail_s: 5.0,
99 pre_delay_ms: 3.0,
100 normalize: true,
101 peak_dbfs: -3.0,
102 full_scale_pa: 2.0,
103 bit_depth: 24,
104};
106export type Tier = 'solve' | 'radiate' | 'output';
108// Keys whose change invalidates the cached mode basis (the eigensolve). The
109// strike process, the listener, and everything downstream are deliberately
110// absent: striking somewhere else or moving the microphone reuses the modes.
111export const SOLVE_KEYS: (keyof TriangleParams)[] = [
112 'side_length_m', 'gap_m', 'rod_diameter_m',
113 'density', 'youngs_modulus', 'poisson_ratio', 'stiffness_trim',
114 'n_elements', 'min_mode_hz', 'max_mode_hz',
115 'loss_factor', 'loss_slope', 'radiation_loss',
116 'speed_of_sound', 'air_density',
117];
119export const RADIATE_KEYS: (keyof TriangleParams)[] = [
120 'beater_radius_m', 'beater_mass_kg', 'beater_youngs', 'restitution',
121 'strike_velocity', 'strike_pos', 'strike_angle_deg',
122 'strike_count', 'strike_rate_hz', 'roll_pos2',
123 'timing_jitter_ms', 'velocity_spread', 'seed',
124 'direct_gain', 'listener_x_m', 'listener_y_m', 'listener_z_m',
125 'sample_rate', 'tail_s',
126];
128export const OUTPUT_KEYS: (keyof TriangleParams)[] = [
129 'pre_delay_ms', 'normalize', 'peak_dbfs', 'full_scale_pa',
130];
132export interface ParamSpec {
133 key: keyof TriangleParams;
134 label: string;
135 unit?: string;
136 min: number;
137 max: number;
138 step?: number;
139 log?: boolean;
140 integer?: boolean;
141 help?: string;
144export interface ParamGroup {
145 name: string;
146 specs: ParamSpec[];
149export const PARAM_GROUPS: ParamGroup[] = [
150 {
151 name: 'Strike',
152 specs: [
153 { 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.' },
154 { 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.' },
155 { 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.' },
156 ],
157 },
158 {
159 name: 'Roll',
160 specs: [
161 { 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.' },
162 { key: 'strike_rate_hz', label: 'Roll rate', unit: 'Hz', min: 2, max: 30, step: 0.5, help: 'Hits per second during a roll.' },
163 { 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).' },
164 { 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.' },
165 { key: 'velocity_spread', label: 'Velocity spread', min: 0, max: 0.6, step: 0.01, help: 'Lognormal spread of hit velocities during a roll.' },
166 { 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.' },
167 ],
168 },
169 {
170 name: 'Beater',
171 specs: [
172 { 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.' },
173 { 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.' },
174 { 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.' },
175 { key: 'restitution', label: 'Restitution', min: 0.05, max: 0.95, step: 0.01, help: 'Coefficient of restitution; scales the momentum each strike transfers.' },
176 ],
177 },
178 {
179 name: 'Rod geometry',
180 specs: [
181 { 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².' },
182 { 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.' },
183 { 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.' },
184 ],
185 },
186 {
187 name: 'Rod material',
188 specs: [
189 { key: 'density', label: 'Density', unit: 'kg/m³', min: 2000, max: 20000, log: true, help: 'Steel 7850, brass ~8500, aluminum 2700.' },
190 { key: 'youngs_modulus', label: "Young's modulus", unit: 'Pa', min: 5e10, max: 4e11, log: true, help: 'Steel 2e11. Frequencies scale as sqrt(E).' },
191 { 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.' },
192 { 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.' },
193 ],
194 },
195 {
196 name: 'Damping',
197 specs: [
198 { 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.' },
199 { 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.' },
200 { 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.' },
201 ],
202 },
203 {
204 name: 'Listener',
205 specs: [
206 { 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.' },
207 { key: 'listener_y_m', label: 'Listener y', unit: 'm', min: -2.0, max: 2.0, step: 0.005 },
208 { 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.' },
209 { 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.' },
210 ],
211 },
212 {
213 name: 'Output',
214 specs: [
215 { 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.' },
216 { key: 'pre_delay_ms', label: 'Pre-delay', unit: 'ms', min: 0, max: 50, step: 0.5 },
217 { key: 'peak_dbfs', label: 'Peak level', unit: 'dBFS', min: -24, max: 0, step: 0.5, help: 'Used when normalize is on.' },
218 { 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.' },
219 ],
220 },
221 {
222 name: 'Advanced / quality',
223 specs: [
224 { 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.' },
225 { key: 'min_mode_hz', label: 'Min mode freq', unit: 'Hz', min: 20, max: 500, step: 10 },
226 { key: 'max_mode_hz', label: 'Max mode freq', unit: 'Hz', min: 2000, max: 24000, step: 500 },
227 { key: 'oversample', label: 'Oversample', min: 1, max: 4, step: 1, integer: true, help: 'Synthesis rate multiple; 2 for render, 1 for preview.' },
228 { key: 'speed_of_sound', label: 'Speed of sound', unit: 'm/s', min: 300, max: 400, step: 1 },
229 { key: 'air_density', label: 'Air density', unit: 'kg/m³', min: 0.9, max: 1.5, step: 0.01 },
230 ],
231 },
232];
234const STORAGE_KEY = 'triangle-webapp-params-v1';
236export function loadParams(): TriangleParams {
237 try {
238 const raw = localStorage.getItem(STORAGE_KEY);
239 if (!raw) return { ...DEFAULTS };
240 const parsed = JSON.parse(raw);
241 const out = { ...DEFAULTS };
242 for (const k of Object.keys(DEFAULTS) as (keyof TriangleParams)[]) {
243 if (typeof parsed[k] === typeof DEFAULTS[k]) {
244 (out as Record<string, unknown>)[k] = parsed[k];
245 }
246 }
247 return out;
248 } catch {
249 return { ...DEFAULTS };
250 }
253export function saveParams(p: TriangleParams): void {
254 try {
255 localStorage.setItem(STORAGE_KEY, JSON.stringify(p));
256 } catch {
257 // storage unavailable; persistence is a convenience only
258 }
↑↓ move↵openescclose