/ proof-of-concept / toy-instruments
Sign in
proof-of-concept / toy-instruments
toy-instruments / src / instruments / bead-string / config.ts
170 lines · 8.3 KBBlameHistoryRaw
1import type { Section } from '../../ui/controls';
2import { css, fmtNum } from '../../ui/plot';
3import { ChainView } from '../../ui/chainview';
4import type { Built, InstrumentConfig } from '../../ui/instrumentPage';
5import { buildExcitation, buildPickup, buildSystem, derive, parsePins, type StringParams } from './model';
6import { PRESETS } from './presets';
8const endOptions: [string, string][] = [
9 ['clamped', 'Clamped (fixed point)'],
10 ['sliding', 'Sliding ring (free across, holds tension)'],
11 ['free', 'Free (no support)'],
12];
13const isExc = (t: string) => (p: StringParams) => p.excitation === t;
15const SECTIONS: Section<StringParams>[] = [
16 {
17 title: 'Geometry',
18 params: [
19 { 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.' },
20 { key: 'spacing', label: 'Spacing', kind: 'number', unit: 'mm', scale: 1000, help: 'Equilibrium distance between neighbouring beads (and from the end beads to the walls).' },
21 ],
22 },
23 {
24 title: 'Properties',
25 params: [
26 { key: 'mass', label: 'Bead mass', kind: 'number', unit: 'g', scale: 1000 },
27 { 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.' },
28 { 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.' },
29 ],
30 },
31 {
32 title: 'Ends and pins',
33 params: [
34 { key: 'leftEnd', label: 'Left end', kind: 'select', options: endOptions },
35 { key: 'rightEnd', label: 'Right end', kind: 'select', options: endOptions },
36 { 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.' },
37 ],
38 },
39 {
40 title: 'Damping (0 = off)',
41 params: [
42 { key: 't60Low', label: 'T60 low', kind: 'number', unit: 's', help: 'Time for transverse vibration at the low frequency to decay by 60 dB.' },
43 { key: 'fLow', label: 'at', kind: 'number', unit: 'Hz' },
44 { key: 't60High', label: 'T60 high', kind: 'number', unit: 's' },
45 { key: 'fHigh', label: 'at', kind: 'number', unit: 'Hz' },
46 ],
47 },
48 {
49 title: 'Excitation',
50 params: [
51 {
52 key: 'excitation',
53 label: 'Type',
54 kind: 'select',
55 options: [
56 ['pluck', 'Pluck (triangle, released at rest)'],
57 ['strike', 'Strike (initial velocity)'],
58 ['hammer', 'Hammer (force pulse)'],
59 ],
60 },
61 { key: 'excBead', label: 'At bead', kind: 'int', min: 1 },
62 { key: 'excWidth', label: 'Width', kind: 'number', unit: 'beads', show: (p) => p.excitation !== 'pluck', help: 'Gaussian width (standard deviation) in beads; 0 = one bead.' },
63 { key: 'pluckAmp', label: 'Amplitude', kind: 'number', unit: 'mm', scale: 1000, show: isExc('pluck') },
64 { key: 'strikeVel', label: 'Velocity', kind: 'number', unit: 'm/s', show: isExc('strike'), help: 'Initial velocity of the centre bead.' },
65 { 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.' },
66 { key: 'hammerMs', label: 'Duration', kind: 'number', unit: 'ms', show: isExc('hammer'), help: 'Raised-cosine pulse length. Shorter is brighter.' },
67 { key: 'excAngle', label: 'Direction', kind: 'number', unit: '° y→z', help: '0 = along y; 90 = along z. In between excites both transverse planes.' },
68 ],
69 },
70 {
71 title: 'Pickup (acceleration)',
72 params: [
73 { key: 'pickupMode', label: 'Pickup', kind: 'select', options: [['sum', 'Sum over all beads'], ['bead', 'One bead']], help: 'The sum of accelerations is the monopole term of the radiated sound. On a uniform string with both ends clamped it cancels the even modes, whose shapes are antisymmetric.' },
74 { key: 'pickupBead', label: 'Bead', kind: 'int', min: 1, show: (p) => p.pickupMode === 'bead' },
75 { key: 'pickupAxis', label: 'Component', kind: 'select', options: [['y', 'y (transverse)'], ['z', 'z (transverse)'], ['x', 'x (longitudinal)']] },
76 ],
77 },
78 {
79 title: 'Simulation',
80 params: [
81 { key: 'duration', label: 'Duration', kind: 'number', unit: 's' },
82 { key: 'fs', label: 'Sample rate', kind: 'select', options: [['44100', '44.1 kHz'], ['48000', '48 kHz'], ['96000', '96 kHz']] },
83 { 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.' },
84 { 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).' },
85 ],
86 },
87];
89const build = (p: StringParams): Built | null => {
90 const d = derive(p);
91 if (!(d.restLength > 0) || !(p.n >= 1)) return null;
92 const sys = buildSystem(p, d);
93 const exc = buildExcitation(p);
94 const pickup = buildPickup(p);
95 return {
96 sys,
97 exc,
98 pickup,
99 scene: {
100 sys,
101 exc,
102 excKind: p.excitation,
103 pickup,
104 pickupLabel: `${p.pickupMode === 'sum' ? 'sum over all beads' : `bead ${Math.round(p.pickupBead)}`}, ${p.pickupAxis} acceleration`,
105 },
106 };
107};
109export const STRING_CONFIG: InstrumentConfig<StringParams> = {
110 title: 'String of beads',
111 wavName: 'bead-string.wav',
112 webgpuDefault: false, // a few dozen beads: the CPU is faster
113 presets: PRESETS,
114 sections: SECTIONS,
115 setup(p) {
116 const d = derive(p);
117 const tr = d.transverseHz;
118 const rows: [string, string][] = [
119 ['Length (wall to wall)', `${fmtNum(d.length)} m`],
120 ['Rest length L0', `${fmtNum(d.restLength * 1000)} mm (pre-stretch ${fmtNum(d.prestretch * 100)}%)`],
121 ['Linear density', `${fmtNum(d.linearDensity * 1000)} g/m`],
122 ['Wave speed (transverse, longitudinal)', `${fmtNum(d.waveSpeedT)}, ${fmtNum(d.waveSpeedL)} m/s`],
123 ['Lowest transverse mode', tr.length ? `${fmtNum(tr[0])} Hz` : 'none'],
124 ['Overtone ratios', tr.slice(1, 7).map((f) => fmtNum(f / tr[0])).join(', ') || '–'],
125 ['Lowest longitudinal mode', d.longitudinalHz.length ? `${fmtNum(d.longitudinalHz[0])} Hz` : 'none'],
126 ];
127 const warnings = [...d.warnings];
128 const pins = parsePins(p.pins, p.n);
129 if (pins.includes(Math.round(p.excBead) - 1)) warnings.push('The excited bead is pinned.');
130 if (p.pickupMode === 'bead' && pins.includes(Math.round(p.pickupBead) - 1)) warnings.push('The pickup bead is pinned: its acceleration is zero.');
131 if (tr.length && tr[0] < 20) warnings.push(`The lowest mode (${fmtNum(tr[0])} Hz) is below hearing; you will mostly hear overtones.`);
132 return { rows, warnings, built: build(p) };
133 },
134 summaryRows: (p, _b, r) => [['Largest spring strain', `${fmtNum(r.maxStretch * 100)}% (pre-stretch ${fmtNum(derive(p).prestretch * 100)}%)`]],
135 spectrumMarkers(p) {
136 const d = derive(p);
137 return {
138 markers: [
139 ...d.transverseHz.map((f, i) => ({ x: f, color: css('--series-1'), label: `transverse mode ${i + 1}: ${fmtNum(f)} Hz` })),
140 ...d.longitudinalHz.map((f, i) => ({ x: f, color: css('--series-2'), label: `longitudinal mode ${i + 1}: ${fmtNum(f)} Hz` })),
141 ],
142 legend:
143 '<span><i class="dash" style="border-color:var(--series-1)"></i>linear transverse modes</span>' +
144 '<span><i class="dash" style="border-color:var(--series-2)"></i>linear longitudinal modes</span>',
145 };
146 },
147 extraCards: '<section class="card" data-r="out" hidden><h2>Displacements (2D)</h2><div data-r="viewer"></div></section>',
148 mountExtras(q, timeline) {
149 const viewer = new ChainView(q('viewer'), timeline);
150 return {
151 show(p, built, r) {
152 const { sys } = built;
153 const rest = new Float64Array(p.n);
154 for (let i = 0; i < p.n; i++) rest[i] = sys.rest[3 * i];
155 viewer.set(
156 {
157 n: p.n,
158 restX: rest,
159 leftWall: p.leftEnd === 'free' ? null : 0,
160 rightWall: p.rightEnd === 'free' ? null : (p.n + 1) * p.spacing,
161 slideLeft: p.leftEnd === 'sliding',
162 slideRight: p.rightEnd === 'sliding',
163 pinned: sys.pinned,
164 },
165 { frames: r.frames, times: r.frameTimes },
166 );
167 },
168 };
169 },
170};
↑↓ move↵openescclose