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