import type { Section } from '../../ui/controls'; import { css, fmtNum } from '../../ui/plot'; import { ChainView } from '../../ui/chainview'; import { mountInstrumentPage, type Built } from '../../ui/instrumentPage'; import { buildExcitation, buildPickup, buildSystem, derive, parsePins, type StringParams } from './model'; import { PRESETS } from './presets'; const endOptions: [string, string][] = [ ['clamped', 'Clamped (fixed point)'], ['sliding', 'Sliding ring (free across, holds tension)'], ['free', 'Free (no support)'], ]; const isExc = (t: string) => (p: StringParams) => p.excitation === t; const SECTIONS: Section[] = [ { title: 'Geometry', params: [ { 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.' }, { key: 'spacing', label: 'Spacing', kind: 'number', unit: 'mm', scale: 1000, help: 'Equilibrium distance between neighbouring beads (and from the end beads to the walls).' }, ], }, { title: 'Properties', params: [ { key: 'mass', label: 'Bead mass', kind: 'number', unit: 'g', scale: 1000 }, { 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.' }, { 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.' }, ], }, { title: 'Ends and pins', params: [ { key: 'leftEnd', label: 'Left end', kind: 'select', options: endOptions }, { key: 'rightEnd', label: 'Right end', kind: 'select', options: endOptions }, { 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.' }, ], }, { title: 'Damping (0 = off)', params: [ { key: 't60Low', label: 'T60 low', kind: 'number', unit: 's', help: 'Time for transverse vibration at the low frequency to decay by 60 dB.' }, { key: 'fLow', label: 'at', kind: 'number', unit: 'Hz' }, { key: 't60High', label: 'T60 high', kind: 'number', unit: 's' }, { key: 'fHigh', label: 'at', kind: 'number', unit: 'Hz' }, ], }, { title: 'Excitation', params: [ { key: 'excitation', label: 'Type', kind: 'select', options: [ ['pluck', 'Pluck (triangle, released at rest)'], ['strike', 'Strike (initial velocity)'], ['hammer', 'Hammer (force pulse)'], ], }, { key: 'excBead', label: 'At bead', kind: 'int', min: 1 }, { key: 'excWidth', label: 'Width', kind: 'number', unit: 'beads', show: (p) => p.excitation !== 'pluck', help: 'Gaussian width (standard deviation) in beads; 0 = one bead.' }, { key: 'pluckAmp', label: 'Amplitude', kind: 'number', unit: 'mm', scale: 1000, show: isExc('pluck') }, { key: 'strikeVel', label: 'Velocity', kind: 'number', unit: 'm/s', show: isExc('strike'), help: 'Initial velocity of the centre bead.' }, { 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.' }, { key: 'hammerMs', label: 'Duration', kind: 'number', unit: 'ms', show: isExc('hammer'), help: 'Raised-cosine pulse length. Shorter is brighter.' }, { key: 'excAngle', label: 'Direction', kind: 'number', unit: '° y→z', help: '0 = along y; 90 = along z. In between excites both transverse planes.' }, ], }, { title: 'Pickup (acceleration)', params: [ { key: 'pickupMode', label: 'Pickup', kind: 'select', options: [['bead', 'One bead'], ['sum', 'Sum over all beads']] }, { key: 'pickupBead', label: 'Bead', kind: 'int', min: 1, show: (p) => p.pickupMode === 'bead' }, { key: 'pickupAxis', label: 'Component', kind: 'select', options: [['y', 'y (transverse)'], ['z', 'z (transverse)'], ['x', 'x (longitudinal)']] }, ], }, { title: 'Simulation', params: [ { key: 'duration', label: 'Duration', kind: 'number', unit: 's' }, { key: 'fs', label: 'Sample rate', kind: 'select', options: [['44100', '44.1 kHz'], ['48000', '48 kHz'], ['96000', '96 kHz']] }, { 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.' }, { 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).' }, ], }, ]; export function mountBeadString(root: HTMLElement) { const build = (p: StringParams): Built | null => { const d = derive(p); if (!(d.restLength > 0) || !(p.n >= 1)) return null; const sys = buildSystem(p, d); const exc = buildExcitation(p); const pickup = buildPickup(p); return { sys, exc, pickup, scene: { sys, exc, excKind: p.excitation, pickup, pickupLabel: `${p.pickupMode === 'sum' ? 'sum over all beads' : `bead ${Math.round(p.pickupBead)}`}, ${p.pickupAxis} acceleration`, }, }; }; mountInstrumentPage(root, { title: 'String of beads', wavName: 'bead-string.wav', presets: PRESETS, sections: SECTIONS, setup(p) { const d = derive(p); const tr = d.transverseHz; const rows: [string, string][] = [ ['Length (wall to wall)', `${fmtNum(d.length)} m`], ['Rest length L0', `${fmtNum(d.restLength * 1000)} mm (pre-stretch ${fmtNum(d.prestretch * 100)}%)`], ['Linear density', `${fmtNum(d.linearDensity * 1000)} g/m`], ['Wave speed (transverse, longitudinal)', `${fmtNum(d.waveSpeedT)}, ${fmtNum(d.waveSpeedL)} m/s`], ['Lowest transverse mode', tr.length ? `${fmtNum(tr[0])} Hz` : 'none'], ['Overtone ratios', tr.slice(1, 7).map((f) => fmtNum(f / tr[0])).join(', ') || '–'], ['Lowest longitudinal mode', d.longitudinalHz.length ? `${fmtNum(d.longitudinalHz[0])} Hz` : 'none'], ]; const warnings = [...d.warnings]; const pins = parsePins(p.pins, p.n); if (pins.includes(Math.round(p.excBead) - 1)) warnings.push('The excited bead is pinned.'); if (p.pickupMode === 'bead' && pins.includes(Math.round(p.pickupBead) - 1)) warnings.push('The pickup bead is pinned: its acceleration is zero.'); if (tr.length && tr[0] < 20) warnings.push(`The lowest mode (${fmtNum(tr[0])} Hz) is below hearing; you will mostly hear overtones.`); return { rows, warnings, built: build(p) }; }, summaryRows: (p, _b, r) => [['Largest spring strain', `${fmtNum(r.maxStretch * 100)}% (pre-stretch ${fmtNum(derive(p).prestretch * 100)}%)`]], spectrumMarkers(p) { const d = derive(p); return { markers: [ ...d.transverseHz.map((f, i) => ({ x: f, color: css('--series-1'), label: `transverse mode ${i + 1}: ${fmtNum(f)} Hz` })), ...d.longitudinalHz.map((f, i) => ({ x: f, color: css('--series-2'), label: `longitudinal mode ${i + 1}: ${fmtNum(f)} Hz` })), ], legend: 'linear transverse modes' + 'linear longitudinal modes', }; }, extraCards: '', mountExtras(q, timeline) { const viewer = new ChainView(q('viewer'), timeline); return { show(p, built, r) { const { sys } = built; const rest = new Float64Array(p.n); for (let i = 0; i < p.n; i++) rest[i] = sys.rest[3 * i]; viewer.set( { n: p.n, restX: rest, leftWall: p.leftEnd === 'free' ? null : 0, rightWall: p.rightEnd === 'free' ? null : (p.n + 1) * p.spacing, slideLeft: p.leftEnd === 'sliding', slideRight: p.rightEnd === 'sliding', pinned: sys.pinned, }, { frames: r.frames, times: r.frameTimes }, ); }, }; }, }); }