/ proof-of-concept / soundbox
Sign in
proof-of-concept / soundbox
soundbox / src / studio / objects.ts
259 lines · 11.2 KBCodeBlameHistory
2 * The three kinds of vibrating object, as data: their parameters and slider
3 * ranges, how their grids are sized, and where their partials should land.
4 *
5 * The physics each type contributes to the generated model .m lives in
6 * src/studio/codegen.ts; this file is the registry around it. The split is
7 * the same one dulcimer draws between its .m files and its registries: what
8 * an object *is* (its scheme) is MATLAB the Lab view shows, and everything
9 * around it (defaults, ranges, hints, sizing) is host data.
10 *
11 * Grid sizing uses each type's stability condition at the sliders' worst
12 * case, so moving a parameter never forces a recompile — see src/grids.ts.
13 *
14 * - A **string** is the stiff damped string of the dulcimer project:
15 * u_tt = cs^2 u_xx - kap^2 u_xxxx - 2 sig0 u_t + 2 sig1 (u_xx)_t, pinned
16 * ends, plucked by a triangular initial displacement. It couples to the
17 * air two ways: radiating along its own line, and pulling on a bridge
18 * patch at its x = +L/2 end.
19 * - A **bar** is the cs = 0 case with the stiffness doing all the work — a
20 * glockenspiel bar, loosely — struck by a mallet-shaped initial velocity.
21 * Pinned ends (a real bar is free-free; see the README for what that
22 * changes). Its partials go as n^2 f1, which is the bell-like sound.
23 * - A **membrane** is the 2D wave equation with tension and damping, pinned
24 * at its rim, struck like the bar. Its partials are the inharmonic
25 * f0 * sqrt((m/L1)^2 + (n/L2)^2) / sqrt(1/L1^2 + 1/L2^2) set, which is
26 * why a drum goes "thud" rather than sounding a chord.
27 */
28import { makeLineGrid, makeRectGrid, type ObjectGrid } from '../grids.ts';
29import type { ObjectSpec, ObjectType, ParamSpec } from './types.ts';
31export interface ObjectTemplate {
32 type: ObjectType;
33 label: string;
34 /** What exciting this object is called ('Pluck', 'Strike'). */
35 verb: string;
36 blurb: string;
37 /** Baked extents and their ranges; editing one rebuilds. */
38 sizeSpecs: ParamSpec[];
39 /** Model parameters (compiled-side, free to move). */
40 paramSpecs: ParamSpec[];
41 /** Placement parameters (scene-side, cheap to move). */
42 placeSpecs: ParamSpec[];
43 /** Build this object's grid at timestep dt, stable for the sliders' worst
44 * case. */
45 makeGrid(size: { L1: number; L2?: number }, dt: number): ObjectGrid;
46 /** Where this object's partials should land, for the spectrum overlay. */
47 partials(spec: ObjectSpec, count: number): number[];
50const max = (specs: ParamSpec[], key: string): number =>
51 specs.find((p) => p.key === key)?.max ?? 0;
53const stringTemplate: ObjectTemplate = {
54 type: 'string',
55 label: 'String',
56 verb: 'Pluck',
57 blurb: 'A stiff, damped string, plucked; radiates along its line and drives a bridge patch at its far end.',
58 sizeSpecs: [
59 {
a97bec5Render any scene from precomputation, and split the Play knobs by costJeremy Magland 60 key: 'L1', label: 'length (m)', value: 0.6, min: 0.3, max: 0.9, step: 0.05, musical: true,
761af16soundbox: a scene-composition studio over the dulcimer engineJeremy Magland 61 hint: 'The speaking length. Changing it recompiles (the grid is sized from it).',
62 },
63 ],
64 paramSpecs: [
65 {
66 key: 'f0', label: 'pitch (Hz)', value: 294, min: 80, max: 880, step: 1, musical: true,
67 hint: 'The fundamental the string is tuned to. The wave speed follows: cs = 2·L·f0.',
68 },
69 {
70 key: 'B', label: 'inharmonicity', value: 0.0001, min: 0, max: 0.002, step: 0.00002,
71 hint: 'Bending stiffness, as the inharmonicity coefficient B: partial n sounds near n·f0·sqrt(1 + B·n²). Zero is an ideal string; ~1e-4 a light steel string.',
72 },
73 {
74 key: 't60', label: 'decay t60 (s)', value: 4, min: 0.2, max: 8, step: 0.1, musical: true,
75 hint: 'Seconds for the string to decay 60 dB.',
76 },
77 {
78 key: 'sig1', label: 'brightness decay', value: 0.005, min: 0, max: 0.05, step: 0.001, musical: true,
79 hint: 'Frequency-dependent damping (m²/s). High partials die faster than low, so the note starts bright and mellows; zero keeps it buzzing.',
80 },
81 {
82 key: 'pluckpos', label: 'pluck position', value: 0.22, min: 0.08, max: 0.92, step: 0.01, musical: true,
83 hint: 'Where along the string it is plucked, as a fraction of length. Takes effect on the next excite.',
84 },
85 {
86 key: 'amp', label: 'pluck height (m)', value: 0.002, min: 0.0002, max: 0.005, step: 0.0002,
87 hint: 'How far the string is pulled before release. Linear equations: this scales everything and changes nothing else.',
88 },
89 {
90 key: 'gair', label: 'line radiation', value: 0.3, min: 0, max: 2, step: 0.05,
91 hint: 'Gain on the string radiating directly along its length (idealized; a thin string barely does this in reality). 0 switches the route off.',
92 },
93 {
94 key: 'gbridge', label: 'bridge drive', value: 1, min: 0, max: 2, step: 0.05,
95 hint: 'Gain on the string’s pull at its far end driving a patch of air above the plate — the real instrument’s main route. 0 switches it off.',
96 },
97 ],
98 placeSpecs: [
99 { key: 'oy', label: 'across (m)', value: 0, min: -0.15, max: 0.15, step: 0.005,
100 hint: 'Sideways offset of the string’s line.' },
101 { key: 'oz', label: 'height (m)', value: 0.02, min: 0.005, max: 0.15, step: 0.005,
102 hint: 'Height of the string above z = 0 (the top of a body, if there is one).' },
103 ],
104 makeGrid(size, dt) {
105 const cs = 2 * size.L1 * max(this.paramSpecs, 'f0');
106 const kap2 = (max(this.paramSpecs, 'B') * cs * cs * size.L1 * size.L1) / (Math.PI * Math.PI);
107 return makeLineGrid(size.L1, dt, { cs, kap2, sig1: max(this.paramSpecs, 'sig1') });
108 },
109 partials(spec, count) {
110 const { f0 = 0, B = 0 } = spec.params;
111 return Array.from({ length: count }, (_, i) => {
112 const n = i + 1;
113 return n * f0 * Math.sqrt(1 + B * n * n);
114 });
115 },
116};
118const barTemplate: ObjectTemplate = {
119 type: 'bar',
120 label: 'Bar',
121 verb: 'Strike',
122 blurb: 'A stiff bar with no tension — a chime; struck with a mallet-shaped velocity, partials at n²·f1.',
123 sizeSpecs: [
124 {
a97bec5Render any scene from precomputation, and split the Play knobs by costJeremy Magland 125 key: 'L1', label: 'length (m)', value: 0.3, min: 0.15, max: 0.5, step: 0.05, musical: true,
761af16soundbox: a scene-composition studio over the dulcimer engineJeremy Magland 126 hint: 'Changing it recompiles (the grid is sized from it).',
127 },
128 ],
129 paramSpecs: [
130 {
131 key: 'f1', label: 'pitch (Hz)', value: 780, min: 200, max: 2000, step: 5, musical: true,
132 hint: 'The first partial. The stiffness follows: kap = 2·f1·L²/π; higher partials land at n²·f1 (4·f1, 9·f1, ...), which is the chime sound.',
133 },
134 {
135 key: 't60', label: 'decay t60 (s)', value: 6, min: 0.5, max: 12, step: 0.1, musical: true,
136 hint: 'Seconds to decay 60 dB. Metal rings long.',
137 },
138 {
139 key: 'sig1', label: 'brightness decay', value: 0.001, min: 0, max: 0.02, step: 0.0005, musical: true,
140 hint: 'Frequency-dependent damping (m²/s): the strike’s clang fades to a pure ring.',
141 },
142 {
143 key: 'strikepos', label: 'strike position', value: 0.3, min: 0.1, max: 0.9, step: 0.01, musical: true,
144 hint: 'Where the mallet lands, as a fraction of length. Takes effect on the next excite.',
145 },
146 {
147 key: 'striker', label: 'mallet width (m)', value: 0.025, min: 0.01, max: 0.08, step: 0.005,
148 hint: 'Width of the struck patch. A narrow, hard mallet excites more of the high partials.',
149 },
150 {
151 key: 'vel', label: 'strike speed (m/s)', value: 1, min: 0.1, max: 5, step: 0.1,
152 hint: 'Initial velocity the mallet imparts. Linear equations: sets scale only.',
153 },
154 {
155 key: 'gair', label: 'radiation', value: 1, min: 0, max: 2, step: 0.05,
156 hint: 'Gain on the bar radiating along its line. 0 mutes it.',
157 },
158 ],
159 placeSpecs: [
160 { key: 'oy', label: 'across (m)', value: 0, min: -0.15, max: 0.15, step: 0.005 },
161 { key: 'oz', label: 'height (m)', value: 0.05, min: 0.005, max: 0.15, step: 0.005 },
162 ],
163 makeGrid(size, dt) {
164 const kap = (2 * max(this.paramSpecs, 'f1') * size.L1 * size.L1) / Math.PI;
165 return makeLineGrid(size.L1, dt, {
166 cs: 0, kap2: kap * kap, sig1: max(this.paramSpecs, 'sig1'),
167 });
168 },
169 partials(spec, count) {
170 const { f1 = 0 } = spec.params;
171 return Array.from({ length: count }, (_, i) => (i + 1) * (i + 1) * f1);
172 },
173};
175const membraneTemplate: ObjectTemplate = {
176 type: 'membrane',
177 label: 'Membrane',
178 verb: 'Strike',
179 blurb: 'A 2D membrane pinned at its rim — a drum head; struck, with the inharmonic mode set that makes a drum thud rather than chord.',
180 sizeSpecs: [
181 {
a97bec5Render any scene from precomputation, and split the Play knobs by costJeremy Magland 182 key: 'L1', label: 'width (m)', value: 0.32, min: 0.15, max: 0.5, step: 0.01, musical: true,
761af16soundbox: a scene-composition studio over the dulcimer engineJeremy Magland 183 hint: 'Extent along x. Changing it recompiles.',
184 },
185 {
a97bec5Render any scene from precomputation, and split the Play knobs by costJeremy Magland 186 key: 'L2', label: 'depth (m)', value: 0.32, min: 0.15, max: 0.5, step: 0.01, musical: true,
761af16soundbox: a scene-composition studio over the dulcimer engineJeremy Magland 187 hint: 'Extent along y. Changing it recompiles.',
188 },
189 ],
190 paramSpecs: [
191 {
192 key: 'f0', label: 'pitch (Hz)', value: 110, min: 60, max: 300, step: 1, musical: true,
193 hint: 'The (1,1) fundamental the head is tuned to; the tension’s wave speed follows from it and the size.',
194 },
195 {
196 key: 't60', label: 'decay t60 (s)', value: 1.5, min: 0.2, max: 6, step: 0.1, musical: true,
197 hint: 'Seconds to decay 60 dB. Drums are short.',
198 },
199 {
200 key: 'sig1', label: 'brightness decay', value: 0.0005, min: 0, max: 0.01, step: 0.0002, musical: true,
201 hint: 'Frequency-dependent damping (m²/s): the attack’s slap fades first.',
202 },
203 {
204 key: 'strikex', label: 'strike x', value: 0.35, min: 0.1, max: 0.9, step: 0.01, musical: true,
205 hint: 'Where the stick lands along x, as a fraction. Off-centre excites more modes. Takes effect on the next excite.',
206 },
207 {
208 key: 'strikey', label: 'strike y', value: 0.5, min: 0.1, max: 0.9, step: 0.01,
209 hint: 'Where the stick lands along y, as a fraction.',
210 },
211 {
212 key: 'striker', label: 'stick width (m)', value: 0.04, min: 0.01, max: 0.1, step: 0.005,
213 hint: 'Radius of the struck patch.',
214 },
215 {
216 key: 'vel', label: 'strike speed (m/s)', value: 1.5, min: 0.1, max: 5, step: 0.1,
217 hint: 'Initial velocity the stick imparts. Sets scale only.',
218 },
219 {
220 key: 'gair', label: 'radiation', value: 1, min: 0, max: 2, step: 0.05,
221 hint: 'Gain on the head radiating from its plane. 0 mutes it.',
222 },
223 ],
224 placeSpecs: [
225 { key: 'oz', label: 'height (m)', value: 0.02, min: 0, max: 0.15, step: 0.005,
226 hint: 'Height of the head’s plane above z = 0 — just above a drum shell’s rim.' },
227 ],
228 makeGrid(size, dt) {
229 const L2 = size.L2 ?? size.L1;
230 const c =
231 (2 * max(this.paramSpecs, 'f0')) /
232 Math.sqrt(1 / (size.L1 * size.L1) + 1 / (L2 * L2));
233 return makeRectGrid(size.L1, L2, dt, { c, sig1: max(this.paramSpecs, 'sig1') });
234 },
235 partials(spec, count) {
236 const { f0 = 0 } = spec.params;
237 const L1 = spec.size.L1;
238 const L2 = spec.size.L2 ?? L1;
239 const base = Math.sqrt(1 / (L1 * L1) + 1 / (L2 * L2));
240 const out: number[] = [];
241 for (let m = 1; m <= 4; m++) {
242 for (let n = 1; n <= 4; n++) {
243 out.push((f0 * Math.sqrt((m / L1) ** 2 + (n / L2) ** 2)) / base);
244 }
245 }
246 return [...new Set(out.map((f) => Math.round(f * 100) / 100))]
247 .sort((a, b) => a - b)
248 .slice(0, count);
249 },
250};
252export const OBJECT_TEMPLATES: Record<ObjectType, ObjectTemplate> = {
253 string: stringTemplate,
254 bar: barTemplate,
255 membrane: membraneTemplate,
256};
258export const templateOf = (spec: ObjectSpec): ObjectTemplate =>
259 OBJECT_TEMPLATES[spec.type];
↑↓ move↵openescclose