/ simulated_instruments / bass
Sign in
simulated_instruments / bass
bass / webapp / src / params.ts
276 lines · 12.2 KBCodeBlameHistory
0e92d35Pizzicato standup bass: coupled string-body modal model, numbl MATLAB reference, and interactive web appJeremy Magland 1// Parameter definitions for the bass model, mirroring bass_defaults.m.
2// Each parameter is assigned a tier that determines how much recomputation a
3// change requires:
4// solve -> gesture + per-fingering coupled eigensolves + radiation + output
5// radiate -> pluck projection + synthesis + radiation + output only (the
6// cached eigensolves are reused; they are the expensive part)
7// output -> output stage only
8//
9// Note that the render duration is not a parameter: it is
10// (note_count - 1) * 60 / tempo_bpm + tail_s.
12export interface BassParams {
13 // Strings
14 scale_length_m: number;
15 tension_g_n: number;
16 tension_d_n: number;
17 tension_a_n: number;
18 tension_e_n: number;
19 inharmonicity: number;
20 tuning_trim: number;
21 // String damping
22 loss_factor: number;
23 loss_slope: number;
24 finger_loss: number;
25 residual_y: number;
26 // Body
27 a0_hz: number;
28 body_trim: number;
29 body_q_scale: number;
30 bridge_mobility: number;
31 rad_area_scale: number;
32 dipole_length_m: number;
33 radiation_loss: number;
34 // Pluck
35 pluck_force_n: number;
36 pluck_pos: number;
37 finger_release_ms: number;
38 choke_ms: number;
39 // Gesture
40 root_midi: number;
41 note_count: number;
42 tempo_bpm: number;
43 force_spread: number;
44 timing_jitter_ms: number;
45 seed: number;
46 // Radiation / listener
47 direct_gain: number;
48 listener_x_m: number;
49 listener_y_m: number;
50 listener_z_m: number;
51 speed_of_sound: number;
52 air_density: number;
53 // Rendering
54 max_mode_hz: number;
55 sample_rate: number;
56 oversample: number;
57 tail_s: number;
58 pre_delay_ms: number;
59 normalize: boolean;
60 peak_dbfs: number;
61 full_scale_pa: number;
62 bit_depth: number;
65export const DEFAULTS: BassParams = {
66 scale_length_m: 1.04,
67 tension_g_n: 285.0,
68 tension_d_n: 290.0,
69 tension_a_n: 300.0,
70 tension_e_n: 310.0,
71 inharmonicity: 8.0e-5,
72 tuning_trim: 1.0,
73 loss_factor: 1.6e-3,
cfafae8Retilt the body radiativity toward the low registerJeremy Magland 74 loss_slope: 2.5e-7,
76 residual_y: 0.015,
77 a0_hz: 60.0,
78 body_trim: 1.0,
79 body_q_scale: 1.0,
80 bridge_mobility: 1.0,
81 rad_area_scale: 1.0,
82 dipole_length_m: 0.35,
83 radiation_loss: 1.0,
84 pluck_force_n: 15.0,
85 pluck_pos: 0.3,
cfafae8Retilt the body radiativity toward the low registerJeremy Magland 86 finger_release_ms: 1.3,
88 root_midi: 33,
89 note_count: 1,
90 tempo_bpm: 96.0,
91 force_spread: 0.2,
92 timing_jitter_ms: 4.0,
93 seed: 1,
94 direct_gain: 1.0,
95 listener_x_m: 0.5,
96 listener_y_m: 0.3,
97 listener_z_m: 1.2,
98 speed_of_sound: 343.0,
99 air_density: 1.204,
100 max_mode_hz: 3000.0,
101 sample_rate: 48000,
102 oversample: 2,
103 tail_s: 6.0,
104 pre_delay_ms: 3.0,
105 normalize: true,
106 peak_dbfs: -3.0,
107 full_scale_pa: 2.0,
108 bit_depth: 24,
109};
111export type Tier = 'solve' | 'radiate' | 'output';
113// Keys whose change invalidates the cached coupled eigensolves. The gesture
114// keys that pick the pitches (root, count, seed) are here because a new set
115// of fingerings needs new solves; the pluck, the tempo, and the listener are
116// deliberately absent: plucking harder or moving the microphone reuses the
117// cached modes.
118export const SOLVE_KEYS: (keyof BassParams)[] = [
119 'scale_length_m', 'tension_g_n', 'tension_d_n', 'tension_a_n', 'tension_e_n',
120 'inharmonicity', 'tuning_trim',
121 'loss_factor', 'loss_slope', 'finger_loss', 'residual_y',
122 'a0_hz', 'body_trim', 'body_q_scale', 'bridge_mobility',
123 'rad_area_scale', 'dipole_length_m', 'radiation_loss',
124 'root_midi', 'note_count', 'seed',
125 'max_mode_hz', 'speed_of_sound', 'air_density',
126];
128export const RADIATE_KEYS: (keyof BassParams)[] = [
129 'pluck_force_n', 'pluck_pos', 'finger_release_ms', 'choke_ms',
130 'tempo_bpm', 'force_spread', 'timing_jitter_ms',
131 'direct_gain', 'listener_x_m', 'listener_y_m', 'listener_z_m',
132 'sample_rate', 'tail_s',
133];
135export const OUTPUT_KEYS: (keyof BassParams)[] = [
136 'pre_delay_ms', 'normalize', 'peak_dbfs', 'full_scale_pa',
137];
139export interface ParamSpec {
140 key: keyof BassParams;
141 label: string;
142 unit?: string;
143 min: number;
144 max: number;
145 step?: number;
146 log?: boolean;
147 integer?: boolean;
148 help?: string;
151export interface ParamGroup {
152 name: string;
153 specs: ParamSpec[];
156export const PARAM_GROUPS: ParamGroup[] = [
157 {
158 name: 'Pluck',
159 specs: [
160 { 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.' },
161 { 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.' },
162 { 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.' },
163 { 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.' },
164 ],
165 },
166 {
167 name: 'Gesture',
168 specs: [
169 { 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.' },
170 { 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.' },
171 { key: 'tempo_bpm', label: 'Tempo', unit: 'bpm', min: 40, max: 200, step: 1, help: 'Quarter-note tempo of the walking line.' },
172 { 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.' },
173 { 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.' },
174 { 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.' },
175 ],
176 },
177 {
178 name: 'Strings',
179 specs: [
180 { 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.' },
181 { 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.' },
182 { key: 'tension_d_n', label: 'D string tension', unit: 'N', min: 150, max: 400, step: 5 },
183 { key: 'tension_a_n', label: 'A string tension', unit: 'N', min: 150, max: 400, step: 5 },
184 { key: 'tension_e_n', label: 'E string tension', unit: 'N', min: 150, max: 400, step: 5 },
185 { 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.' },
186 { key: 'tuning_trim', label: 'Tuning trim', min: 0.9, max: 1.1, step: 0.001, help: 'Overall pitch trim; 1 is standard orchestral tuning.' },
187 ],
188 },
189 {
190 name: 'String damping',
191 specs: [
192 { key: 'loss_factor', label: 'Loss factor η₀', min: 1e-4, max: 2e-2, log: true, help: 'Frequency-independent internal loss of the string.' },
193 { 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.' },
194 { 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.' },
195 { 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.' },
196 ],
197 },
198 {
199 name: 'Body',
200 specs: [
201 { 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.' },
202 { 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.' },
203 { 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.' },
204 { 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.' },
205 { 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.' },
206 { 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.' },
207 { 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.' },
208 ],
209 },
210 {
211 name: 'Listener',
212 specs: [
213 { 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.' },
214 { key: 'listener_y_m', label: 'Listener y', unit: 'm', min: -3.0, max: 3.0, step: 0.01, help: 'Up the neck.' },
215 { 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.' },
216 { 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.' },
217 ],
218 },
219 {
220 name: 'Output',
221 specs: [
222 { 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.' },
223 { key: 'pre_delay_ms', label: 'Pre-delay', unit: 'ms', min: 0, max: 50, step: 0.5 },
224 { key: 'peak_dbfs', label: 'Peak level', unit: 'dBFS', min: -24, max: 0, step: 0.5, help: 'Used when normalize is on.' },
225 { 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.' },
226 ],
227 },
228 {
229 name: 'Advanced / quality',
230 specs: [
231 { 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.' },
232 { key: 'oversample', label: 'Oversample', min: 1, max: 4, step: 1, integer: true, help: 'Synthesis rate multiple; 2 for render, 1 for preview.' },
233 { key: 'speed_of_sound', label: 'Speed of sound', unit: 'm/s', min: 300, max: 400, step: 1 },
234 { key: 'air_density', label: 'Air density', unit: 'kg/m³', min: 0.9, max: 1.5, step: 0.01 },
235 ],
236 },
237];
239const STORAGE_KEY = 'bass-webapp-params-v1';
241export function loadParams(): BassParams {
242 try {
243 const raw = localStorage.getItem(STORAGE_KEY);
244 if (!raw) return { ...DEFAULTS };
245 const parsed = JSON.parse(raw);
246 const out = { ...DEFAULTS };
247 for (const k of Object.keys(DEFAULTS) as (keyof BassParams)[]) {
248 if (typeof parsed[k] === typeof DEFAULTS[k]) {
249 (out as Record<string, unknown>)[k] = parsed[k];
250 }
251 }
252 return out;
253 } catch {
254 return { ...DEFAULTS };
255 }
258export function saveParams(p: BassParams): void {
259 try {
260 localStorage.setItem(STORAGE_KEY, JSON.stringify(p));
261 } catch {
262 // storage unavailable; persistence is a convenience only
263 }
266// MIDI helpers shared by the plots and panels.
267const NOTE_NAMES = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'];
269export function midiName(m: number): string {
270 const n = Math.round(m);
271 return `${NOTE_NAMES[((n % 12) + 12) % 12]}${Math.floor(n / 12) - 1}`;
274export function midiHz(m: number): number {
275 return 440 * 2 ** ((m - 69) / 12);
↑↓ move↵openescclose