4a8abcfString of beads: 3D bead-spring solver, control panel, waveform, spectrogram, wav, displacement viewerJeremy Magland 1import { renderControls, type Section } from '../../ui/controls';
2import { LinePlot, css, fmtNum } from '../../ui/plot';
3import { Spectrogram } from '../../ui/spectrogram';
4import { ChainView } from '../../ui/chainview';
5import { spectrum } from '../../ui/fft';
6import { encodeWav } from '../../ui/wav';
7import { omegaMaxBound } from '../../sim/solver';
8import { runInWorker, type Running } from '../../sim/run';
9import type { SimResult } from '../../sim/types';
10import { buildExcitation, buildPickup, buildSystem, derive, parsePins, type StringParams } from './model';
11import { PRESETS } from './presets';
13const endOptions: [string, string][] = [
14 ['clamped', 'Clamped (fixed point)'],
15 ['sliding', 'Sliding ring (free across, holds tension)'],
16 ['free', 'Free (no support)'],
17];
18const isExc = (t: string) => (p: StringParams) => p.excitation === t;
20const SECTIONS: Section<StringParams>[] = [
21 {
22 title: 'Geometry',
23 params: [
24 { key: 'n', label: 'Beads', kind: 'int', min: 1, help: 'Number of moving beads; there is one more spring than beads when both ends are supported.' },
25 { key: 'spacing', label: 'Spacing', kind: 'number', unit: 'mm', scale: 1000, help: 'Equilibrium distance between neighbouring beads (and from the end beads to the walls).' },
26 ],
27 },
28 {
29 title: 'Properties',
30 params: [
31 { key: 'mass', label: 'Bead mass', kind: 'number', unit: 'g', scale: 1000 },
32 { key: 'k', label: 'Spring stiffness', kind: 'number', unit: 'N/m', help: 'Axial stiffness k. Sets the longitudinal wave speed and, with the mass, the time step.' },
33 { key: 'tension', label: 'Tension', kind: 'number', unit: 'N', help: 'Equilibrium tension T. Springs are pre-stretched: rest length L0 = spacing − T/k. Sets the transverse wave speed.' },
34 ],
35 },
36 {
37 title: 'Ends and pins',
38 params: [
39 { key: 'leftEnd', label: 'Left end', kind: 'select', options: endOptions },
40 { key: 'rightEnd', label: 'Right end', kind: 'select', options: endOptions },
41 { key: 'pins', label: 'Pinned beads', kind: 'text', help: 'Bead numbers (1-based), comma separated. Pinned beads are held fixed, like a finger on a fret.' },
42 ],
43 },
44 {
45 title: 'Damping (0 = off)',
46 params: [
47 { key: 't60Low', label: 'T60 low', kind: 'number', unit: 's', help: 'Time for transverse vibration at the low frequency to decay by 60 dB.' },
48 { key: 'fLow', label: 'at', kind: 'number', unit: 'Hz' },
49 { key: 't60High', label: 'T60 high', kind: 'number', unit: 's' },
50 { key: 'fHigh', label: 'at', kind: 'number', unit: 'Hz' },
51 ],
52 },
53 {
54 title: 'Excitation',
55 params: [
56 {
57 key: 'excitation',
58 label: 'Type',
59 kind: 'select',
60 options: [
61 ['pluck', 'Pluck (triangle, released at rest)'],
62 ['strike', 'Strike (initial velocity)'],
63 ['hammer', 'Hammer (force pulse)'],
64 ],
65 },
66 { key: 'excBead', label: 'At bead', kind: 'int', min: 1 },
67 { key: 'excWidth', label: 'Width', kind: 'number', unit: 'beads', show: (p) => p.excitation !== 'pluck', help: 'Gaussian width (standard deviation) in beads; 0 = one bead.' },
68 { key: 'pluckAmp', label: 'Amplitude', kind: 'number', unit: 'mm', scale: 1000, show: isExc('pluck') },
69 { key: 'strikeVel', label: 'Velocity', kind: 'number', unit: 'm/s', show: isExc('strike'), help: 'Initial velocity of the centre bead.' },
70 { key: 'hammerForce', label: 'Peak force', kind: 'number', unit: 'N', show: isExc('hammer'), help: 'Peak of the total force, shared over the beads by the width profile.' },
71 { key: 'hammerMs', label: 'Duration', kind: 'number', unit: 'ms', show: isExc('hammer'), help: 'Raised-cosine pulse length. Shorter is brighter.' },
72 { key: 'excAngle', label: 'Direction', kind: 'number', unit: '° y→z', help: '0 = along y; 90 = along z. In between excites both transverse planes.' },
73 ],
74 },
75 {
76 title: 'Pickup (acceleration)',
77 params: [
78 { key: 'pickupMode', label: 'Pickup', kind: 'select', options: [['bead', 'One bead'], ['sum', 'Sum over all beads']] },
79 { key: 'pickupBead', label: 'Bead', kind: 'int', min: 1, show: (p) => p.pickupMode === 'bead' },
80 { key: 'pickupAxis', label: 'Component', kind: 'select', options: [['y', 'y (transverse)'], ['z', 'z (transverse)'], ['x', 'x (longitudinal)']] },
81 ],
82 },
83 {
84 title: 'Simulation',
85 params: [
86 { key: 'duration', label: 'Duration', kind: 'number', unit: 's' },
87 { key: 'fs', label: 'Sample rate', kind: 'select', options: [['44100', '44.1 kHz'], ['48000', '48 kHz'], ['96000', '96 kHz']] },
88 { key: 'cfl', label: 'Step safety', kind: 'number', help: 'Fraction of the leapfrog stability limit dt = 2/ω_max. 0.5 is a safe default; large-amplitude motion raises ω_max.' },
89 { key: 'frameRate', label: 'Frame rate', kind: 'number', unit: 'Hz', help: 'How often displacements of all beads are saved for the viewer.' },
90 ],
91 },
92];
94export function mountBeadString(root: HTMLElement) {
95 root.innerHTML = `
96 <div class="instrument">
97 <aside class="panel">
98 <a href="#/" class="back">← All instruments</a>
99 <h1>String of beads</h1>
100 <label class="ctl preset"><span class="ctl-name">Preset</span><select data-r="preset"></select></label>
101 <p class="sub" data-r="preset-desc"></p>
102 <div data-r="controls"></div>
103 <div class="run-row">
104 <button class="btn" data-r="run">Run simulation</button>
105 <button class="btn-small" data-r="cancel" hidden>Cancel</button>
106 </div>
107 <progress data-r="progress" max="1" value="0" hidden></progress>
108 </aside>
109 <main class="results">
110 <section class="card"><h2>Setup</h2><div data-r="derived" class="stats"></div><ul data-r="warnings" class="warnings"></ul></section>
111 <section class="card" data-r="out" hidden>
112 <h2>Run</h2><div data-r="summary" class="stats"></div>
113 <div class="audio-row"><audio controls data-r="audio"></audio><a class="btn-small" data-r="download" download="bead-string.wav">Download .wav</a></div>
114 <p class="note" data-r="audio-note"></p>
115 </section>
116 <section class="card" data-r="out" hidden>
117 <div class="card-head"><h2>Pickup acceleration</h2>
118 <div class="seg" data-r="wave-range"><button data-v="0" class="on">Full</button><button data-v="0.05">First 50 ms</button><button data-v="0.005">First 5 ms</button></div></div>
119 <div class="canvas-wrap"><canvas data-r="wave"></canvas></div>
120 </section>
121 <section class="card" data-r="out" hidden>
122 <div class="card-head"><h2>Spectrogram</h2>
123 <label class="inline">up to <select data-r="fmax"><option>1000</option><option>2500</option><option selected>5000</option><option>10000</option><option>24000</option></select> Hz</label></div>
124 <div class="canvas-wrap tall"><canvas data-r="spec"></canvas></div>
125 </section>
126 <section class="card" data-r="out" hidden>
127 <div class="card-head"><h2>Spectrum</h2>
128 <div class="legend"><span><i class="sw" style="background:var(--series-1)"></i>pickup signal</span><span><i class="dash" style="border-color:var(--series-1)"></i>linear transverse modes</span><span><i class="dash" style="border-color:var(--series-2)"></i>linear longitudinal modes</span></div></div>
129 <div class="canvas-wrap"><canvas data-r="spectrum"></canvas></div>
130 </section>
131 <section class="card" data-r="out" hidden><h2>Displacements</h2><div data-r="viewer"></div></section>
132 <section class="card" data-r="out" hidden>
133 <h2>Energy</h2>
134 <div class="canvas-wrap short"><canvas data-r="energy"></canvas></div>
135 <p class="note">Kinetic plus spring energy, minus the energy stored by pre-tension at equilibrium. Without damping or forcing it should stay flat; the run summary gives the error of the energy balance (energy + dissipated − work in − initial).</p>
136 </section>
137 </main>
138 </div>`;
139 const q = <T extends HTMLElement>(r: string) => root.querySelector(`[data-r="${r}"]`) as T;
140 const presetSel = q<HTMLSelectElement>('preset');
141 PRESETS.forEach((p, i) => presetSel.add(new Option(p.name, String(i))));
142 const presetDesc = q<HTMLParagraphElement>('preset-desc');
143 const runBtn = q<HTMLButtonElement>('run');
144 const cancelBtn = q<HTMLButtonElement>('cancel');
145 const progress = q<HTMLProgressElement>('progress');
147 const controls = renderControls(q('controls'), SECTIONS, PRESETS[0].params, () => updateDerived());
148 presetDesc.textContent = PRESETS[0].description;
149 presetSel.addEventListener('change', () => {
150 const pr = PRESETS[Number(presetSel.value)];
151 presetDesc.textContent = pr.description;
152 controls.set(pr.params);
153 });
155 const wave = new LinePlot(q('wave'));
156 const spec = new Spectrogram(q('spec'));
157 const specPlot = new LinePlot(q('spectrum'));
158 const energyPlot = new LinePlot(q('energy'));
159 const viewer = new ChainView(q('viewer'));
160 let waveRange = 0;
161 let last: { p: StringParams; r: SimResult } | null = null;
162 let running: Running | null = null;
163 let audioUrl: string | null = null;
165 function updateDerived() {
166 const p = controls.get();
167 const d = derive(p);
168 const tr = d.transverseHz;
169 const rows: [string, string][] = [
170 ['Length (wall to wall)', `${fmtNum(d.length)} m`],
171 ['Rest length L0', `${fmtNum(d.restLength * 1000)} mm (pre-stretch ${fmtNum(d.prestretch * 100)}%)`],
172 ['Linear density', `${fmtNum(d.linearDensity * 1000)} g/m`],
173 ['Wave speed (transverse, longitudinal)', `${fmtNum(d.waveSpeedT)}, ${fmtNum(d.waveSpeedL)} m/s`],
174 ['Lowest transverse mode', tr.length ? `${fmtNum(tr[0])} Hz` : 'none'],
175 ['Overtone ratios', tr.slice(1, 7).map((f) => fmtNum(f / tr[0])).join(', ') || '–'],
176 ['Lowest longitudinal mode', d.longitudinalHz.length ? `${fmtNum(d.longitudinalHz[0])} Hz` : 'none'],
177 ];
178 const warnings = [...d.warnings];
179 if (d.restLength > 0) {
180 const sys = buildSystem(p, d);
181 const wmax = omegaMaxBound(sys);
182 const spp = Math.max(1, Math.ceil(1 / (p.fs * ((p.cfl * 2) / wmax))));
183 rows.push(['Highest frequency bound', `${fmtNum(wmax / (2 * Math.PI))} Hz`]);
184 rows.push(['Time step', `${fmtNum(1 / (p.fs * spp))} s (${spp} per sample)`]);
185 const work = spp * p.fs * p.duration * sys.si.length;
186 rows.push(['Spring updates', fmtNum(work)]);
187 const frameMB = (Math.round(p.duration * p.frameRate) * 3 * p.n * 4) / 1e6;
188 if (frameMB > 400) warnings.push(`Displacement frames would take ${fmtNum(frameMB)} MB; lower the frame rate or duration.`);
189 if (work > 3e9) warnings.push('This run is large (more than 3e9 spring updates) and may take minutes.');
190 }
191 const pins = parsePins(p.pins, p.n);
192 if (pins.includes(Math.round(p.excBead) - 1)) warnings.push('The excited bead is pinned.');
193 if (p.pickupMode === 'bead' && pins.includes(Math.round(p.pickupBead) - 1)) warnings.push('The pickup bead is pinned: its acceleration is zero.');
194 if (tr.length && tr[0] < 20) warnings.push(`The lowest mode (${fmtNum(tr[0])} Hz) is below hearing; you will mostly hear overtones.`);
195 q('derived').innerHTML = rows.map(([k, v]) => `<div><span>${k}</span><b>${v}</b></div>`).join('');
196 q('warnings').innerHTML = warnings.map((w) => `<li>${w}</li>`).join('');
197 runBtn.disabled = !(d.restLength > 0) || !(p.n >= 1);
198 }
199 updateDerived();
201 q('wave-range').addEventListener('click', (e) => {
202 const b = (e.target as HTMLElement).closest('button');
203 if (!b) return;
204 waveRange = Number(b.dataset.v);
205 q('wave-range').querySelectorAll('button').forEach((x) => x.classList.toggle('on', x === b));
206 drawWave();
207 });
208 q<HTMLSelectElement>('fmax').addEventListener('change', (e) => {
209 spec.fmax = Number((e.target as HTMLSelectElement).value);
210 spec.draw();
211 drawSpectrum();
212 });
214 function drawWave() {
215 if (!last) return;
216 const { r } = last;
217 const t = new Float64Array(r.audio.length);
218 for (let i = 0; i < t.length; i++) t[i] = i / r.fs;
219 wave.set([{ x: t, y: r.audio, color: css('--series-1'), label: 'a' }], {
220 xLabel: 'time (s)',
221 yLabel: 'acceleration (m/s²)',
222 xRange: waveRange ? [0, Math.min(waveRange, t[t.length - 1])] : undefined,
223 });
224 }
226 function drawSpectrum() {
227 if (!last) return;
228 const { p, r } = last;
229 const d = derive(p);
230 const s = spectrum(r.audio, r.fs);
231 let top = -Infinity;
232 for (const v of s.db) top = Math.max(top, v);
233 const db = s.db.map((v) => Math.max(v - top, -100));
234 const fmax = Math.min(spec.fmax, r.fs / 2);
235 const markers = [
236 ...d.transverseHz.map((f, i) => ({ x: f, color: css('--series-1'), label: `transverse mode ${i + 1}: ${fmtNum(f)} Hz` })),
237 ...d.longitudinalHz.map((f, i) => ({ x: f, color: css('--series-2'), label: `longitudinal mode ${i + 1}: ${fmtNum(f)} Hz` })),
238 ];
239 specPlot.set([{ x: s.freq, y: db, color: css('--series-1'), label: 'level (dB)' }], {
240 xLabel: 'frequency (Hz)',
241 yLabel: 'level (dB re max)',
242 xRange: [0, fmax],
243 yRange: [-100, 5],
244 markers,
245 });
246 }
248 function show(p: StringParams, r: SimResult) {
249 last = { p, r };
250 root.querySelectorAll<HTMLElement>('[data-r="out"]').forEach((e) => (e.hidden = false));
251 const d = derive(p);
252 let peak = 0;
253 for (const v of r.audio) peak = Math.max(peak, Math.abs(v));
254 const emax = Math.max(...r.energy);
255 let bal = 0;
256 for (const b of r.balance) bal = Math.max(bal, Math.abs(b));
257 const rt = p.duration / r.wallSeconds;
258 q('summary').innerHTML = [
259 ['Time step', `${fmtNum(r.dt)} s, ${r.stepsPerSample} per sample`],
260 ['Wall time', `${fmtNum(r.wallSeconds)} s (${rt >= 1 ? fmtNum(rt) + '× faster than' : fmtNum(1 / rt) + '× slower than'} real time)`],
261 ['Energy balance error', emax > 0 ? `${fmtNum((bal / emax) * 100)}% of peak energy` : '–'],
262 ['Largest spring strain', `${fmtNum(r.maxStretch * 100)}% (pre-stretch ${fmtNum(d.prestretch * 100)}%)`],
263 ['Peak pickup acceleration', `${fmtNum(peak)} m/s²`],
264 ]
265 .map(([k, v]) => `<div><span>${k}</span><b>${v}</b></div>`)
266 .join('');
267 if (audioUrl) URL.revokeObjectURL(audioUrl);
268 audioUrl = URL.createObjectURL(encodeWav(r.audio, r.fs));
269 q<HTMLAudioElement>('audio').src = audioUrl;
270 q<HTMLAnchorElement>('download').href = audioUrl;
271 q('audio-note').textContent = `16-bit mono, ${r.fs} Hz, normalized so the peak is −1 dBFS.`;
272 drawWave();
273 spec.setSignal(r.audio, r.fs);
274 drawSpectrum();
275 const sys = buildSystem(p, d);
276 const rest = new Float64Array(p.n);
277 for (let i = 0; i < p.n; i++) rest[i] = sys.rest[3 * i];
278 viewer.set(
279 {
280 n: p.n,
281 restX: rest,
282 leftWall: p.leftEnd === 'free' ? null : 0,
283 rightWall: p.rightEnd === 'free' ? null : (p.n + 1) * p.spacing,
284 slideLeft: p.leftEnd === 'sliding',
285 slideRight: p.rightEnd === 'sliding',
286 pinned: sys.pinned,
287 },
288 { frames: r.frames, times: r.frameTimes },
289 );
290 energyPlot.set([{ x: r.frameTimes, y: r.energy, color: css('--series-1'), label: 'energy (J)' }], {
291 xLabel: 'time (s)',
292 yLabel: 'energy (J)',
293 yRange: [0, emax * 1.05 || 1],
294 });
295 }
297 runBtn.addEventListener('click', async () => {
298 const p = controls.get();
299 const d = derive(p);
300 const sys = buildSystem(p, d);
301 const exc = buildExcitation(p);
302 const opt = { duration: p.duration, fs: p.fs, cfl: p.cfl, frameRate: p.frameRate, pickup: buildPickup(p) };
303 runBtn.disabled = true;
304 cancelBtn.hidden = false;
305 progress.hidden = false;
306 progress.value = 0;
307 running = runInWorker(sys, exc, opt, (f) => (progress.value = f));
308 try {
309 show(p, await running.promise);
310 } catch (e) {
311 if ((e as Error).message !== 'cancelled') alert(`Simulation failed: ${(e as Error).message}`);
312 } finally {
313 running = null;
314 runBtn.disabled = false;
315 cancelBtn.hidden = true;
316 progress.hidden = true;
317 }
318 });
319 cancelBtn.addEventListener('click', () => running?.cancel());
320}