import { renderControls, type Section } from '../../ui/controls'; import { LinePlot, css, fmtNum } from '../../ui/plot'; import { Spectrogram } from '../../ui/spectrogram'; import { ChainView } from '../../ui/chainview'; import { spectrum } from '../../ui/fft'; import { encodeWav } from '../../ui/wav'; import { omegaMaxBound } from '../../sim/solver'; import { runInWorker, type Running } from '../../sim/run'; import type { SimResult } from '../../sim/types'; 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: 'Frame rate', kind: 'number', unit: 'Hz', help: 'How often displacements of all beads are saved for the viewer.' }, ], }, ]; export function mountBeadString(root: HTMLElement) { root.innerHTML = `

Setup

    `; const q = (r: string) => root.querySelector(`[data-r="${r}"]`) as T; const presetSel = q('preset'); PRESETS.forEach((p, i) => presetSel.add(new Option(p.name, String(i)))); const presetDesc = q('preset-desc'); const runBtn = q('run'); const cancelBtn = q('cancel'); const progress = q('progress'); const controls = renderControls(q('controls'), SECTIONS, PRESETS[0].params, () => updateDerived()); presetDesc.textContent = PRESETS[0].description; presetSel.addEventListener('change', () => { const pr = PRESETS[Number(presetSel.value)]; presetDesc.textContent = pr.description; controls.set(pr.params); }); const wave = new LinePlot(q('wave')); const spec = new Spectrogram(q('spec')); const specPlot = new LinePlot(q('spectrum')); const energyPlot = new LinePlot(q('energy')); const viewer = new ChainView(q('viewer')); let waveRange = 0; let last: { p: StringParams; r: SimResult } | null = null; let running: Running | null = null; let audioUrl: string | null = null; function updateDerived() { const p = controls.get(); 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]; if (d.restLength > 0) { const sys = buildSystem(p, d); const wmax = omegaMaxBound(sys); const spp = Math.max(1, Math.ceil(1 / (p.fs * ((p.cfl * 2) / wmax)))); rows.push(['Highest frequency bound', `${fmtNum(wmax / (2 * Math.PI))} Hz`]); rows.push(['Time step', `${fmtNum(1 / (p.fs * spp))} s (${spp} per sample)`]); const work = spp * p.fs * p.duration * sys.si.length; rows.push(['Spring updates', fmtNum(work)]); const frameMB = (Math.round(p.duration * p.frameRate) * 3 * p.n * 4) / 1e6; if (frameMB > 400) warnings.push(`Displacement frames would take ${fmtNum(frameMB)} MB; lower the frame rate or duration.`); if (work > 3e9) warnings.push('This run is large (more than 3e9 spring updates) and may take minutes.'); } 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.`); q('derived').innerHTML = rows.map(([k, v]) => `
    ${k}${v}
    `).join(''); q('warnings').innerHTML = warnings.map((w) => `
  • ${w}
  • `).join(''); runBtn.disabled = !(d.restLength > 0) || !(p.n >= 1); } updateDerived(); q('wave-range').addEventListener('click', (e) => { const b = (e.target as HTMLElement).closest('button'); if (!b) return; waveRange = Number(b.dataset.v); q('wave-range').querySelectorAll('button').forEach((x) => x.classList.toggle('on', x === b)); drawWave(); }); q('fmax').addEventListener('change', (e) => { spec.fmax = Number((e.target as HTMLSelectElement).value); spec.draw(); drawSpectrum(); }); function drawWave() { if (!last) return; const { r } = last; const t = new Float64Array(r.audio.length); for (let i = 0; i < t.length; i++) t[i] = i / r.fs; wave.set([{ x: t, y: r.audio, color: css('--series-1'), label: 'a' }], { xLabel: 'time (s)', yLabel: 'acceleration (m/s²)', xRange: waveRange ? [0, Math.min(waveRange, t[t.length - 1])] : undefined, }); } function drawSpectrum() { if (!last) return; const { p, r } = last; const d = derive(p); const s = spectrum(r.audio, r.fs); let top = -Infinity; for (const v of s.db) top = Math.max(top, v); const db = s.db.map((v) => Math.max(v - top, -100)); const fmax = Math.min(spec.fmax, r.fs / 2); const 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` })), ]; specPlot.set([{ x: s.freq, y: db, color: css('--series-1'), label: 'level (dB)' }], { xLabel: 'frequency (Hz)', yLabel: 'level (dB re max)', xRange: [0, fmax], yRange: [-100, 5], markers, }); } function show(p: StringParams, r: SimResult) { last = { p, r }; root.querySelectorAll('[data-r="out"]').forEach((e) => (e.hidden = false)); const d = derive(p); let peak = 0; for (const v of r.audio) peak = Math.max(peak, Math.abs(v)); const emax = Math.max(...r.energy); let bal = 0; for (const b of r.balance) bal = Math.max(bal, Math.abs(b)); const rt = p.duration / r.wallSeconds; q('summary').innerHTML = [ ['Time step', `${fmtNum(r.dt)} s, ${r.stepsPerSample} per sample`], ['Wall time', `${fmtNum(r.wallSeconds)} s (${rt >= 1 ? fmtNum(rt) + '× faster than' : fmtNum(1 / rt) + '× slower than'} real time)`], ['Energy balance error', emax > 0 ? `${fmtNum((bal / emax) * 100)}% of peak energy` : '–'], ['Largest spring strain', `${fmtNum(r.maxStretch * 100)}% (pre-stretch ${fmtNum(d.prestretch * 100)}%)`], ['Peak pickup acceleration', `${fmtNum(peak)} m/s²`], ] .map(([k, v]) => `
    ${k}${v}
    `) .join(''); if (audioUrl) URL.revokeObjectURL(audioUrl); audioUrl = URL.createObjectURL(encodeWav(r.audio, r.fs)); q('audio').src = audioUrl; q('download').href = audioUrl; q('audio-note').textContent = `16-bit mono, ${r.fs} Hz, normalized so the peak is −1 dBFS.`; drawWave(); spec.setSignal(r.audio, r.fs); drawSpectrum(); const sys = buildSystem(p, d); 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 }, ); energyPlot.set([{ x: r.frameTimes, y: r.energy, color: css('--series-1'), label: 'energy (J)' }], { xLabel: 'time (s)', yLabel: 'energy (J)', yRange: [0, emax * 1.05 || 1], }); } runBtn.addEventListener('click', async () => { const p = controls.get(); const d = derive(p); const sys = buildSystem(p, d); const exc = buildExcitation(p); const opt = { duration: p.duration, fs: p.fs, cfl: p.cfl, frameRate: p.frameRate, pickup: buildPickup(p) }; runBtn.disabled = true; cancelBtn.hidden = false; progress.hidden = false; progress.value = 0; running = runInWorker(sys, exc, opt, (f) => (progress.value = f)); try { show(p, await running.promise); } catch (e) { if ((e as Error).message !== 'cancelled') alert(`Simulation failed: ${(e as Error).message}`); } finally { running = null; runBtn.disabled = false; cancelBtn.hidden = true; progress.hidden = true; } }); cancelBtn.addEventListener('click', () => running?.cancel()); }