import { renderControls, type Section } from './controls'; import { LinePlot, css, fmtNum, type Marker } from './plot'; import { Spectrogram } from './spectrogram'; import { spectrum } from './fft'; import { encodeWav } from './wav'; import { BeadScene, type SceneSpec } from './scene3d'; import { Timeline } from './timeline'; import { omegaMaxBound } from '../sim/solver'; import { runInWorker, type Running } from '../sim/run'; import type { BeadSystem, Excitation, SimResult } from '../sim/types'; // The page every instrument shares: presets and control panel, 3D model, // setup summary, run summary, audio, waveform, spectrogram, spectrum, energy. // An instrument adds its own setup rows, extra result cards and views. export interface SimParams { duration: number; fs: number; cfl: number; frameRate: number; } export interface Built { sys: BeadSystem; exc: Excitation; pickup: Float64Array; scene: SceneSpec; } export interface Preset

{ name: string; description: string; params: P; } export interface Extras

{ show(p: P, built: Built, r: SimResult): void; } export interface InstrumentConfig

{ title: string; wavName: string; presets: Preset

[]; sections: Section

[]; // instrument-specific summary of the setup; built = null when the parameters are invalid setup(p: P): { rows: [string, string][]; warnings: string[]; built: Built | null }; summaryRows?(p: P, built: Built, r: SimResult): [string, string][]; spectrumMarkers?(p: P): { markers: Marker[]; legend: string }; extraCards?: string; // cards with data-r="out" hidden, shown after a run mountExtras?(q: (r: string) => T, timeline: Timeline): Extras

; } const statRows = (rows: [string, string][]) => rows.map(([k, v]) => `

${k}${v}
`).join(''); export function mountInstrumentPage

(root: HTMLElement, cfg: InstrumentConfig

) { root.innerHTML = `

Model

Setup

    ${cfg.extraCards ?? ''}
    `; const q = (r: string) => root.querySelector(`[data-r="${r}"]`) as T; const presetSel = q('preset'); cfg.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 errorBox = q('error'); 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 timeline = new Timeline(); const scene = new BeadScene(q('scene'), timeline); const extras = cfg.mountExtras?.(q, timeline); let waveRange = 0; let last: { p: P; r: SimResult } | null = null; let running: Running | null = null; let audioUrl: string | null = null; const controls = renderControls(q('controls'), cfg.sections, cfg.presets[0].params, () => updateSetup()); presetDesc.textContent = cfg.presets[0].description; presetSel.addEventListener('change', () => { const pr = cfg.presets[Number(presetSel.value)]; presetDesc.textContent = pr.description; controls.set(pr.params); }); function updateSetup() { const p = controls.get(); const { rows, warnings, built } = cfg.setup(p); if (built) { scene.showSetup(built.scene); const wmax = omegaMaxBound(built.sys, built.exc.mallet); 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 * built.sys.si.length; rows.push(['Spring updates', fmtNum(work)]); const frameMB = (Math.round(p.duration * p.frameRate) * 3 * built.sys.n * 4) / 1e6; if (frameMB > 400) warnings.push(`Replay frames would take ${fmtNum(frameMB)} MB; lower the replay frame rate or the duration.`); if (work > 3e9) warnings.push('This run is large (more than 3e9 spring updates) and may take minutes.'); } q('derived').innerHTML = statRows(rows); q('warnings').innerHTML = warnings.map((w) => `
  • ${w}
  • `).join(''); runBtn.disabled = !built || !!running; } updateSetup(); 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 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 m = cfg.spectrumMarkers?.(p); q('spec-legend').innerHTML = `pickup signal` + (m?.legend ?? ''); specPlot.set([{ x: s.freq, y: db, color: css('--series-1'), label: 'level (dB)' }], { xLabel: 'frequency (Hz)', yLabel: 'level (dB re max)', xRange: [0, Math.min(spec.fmax, r.fs / 2)], yRange: [-100, 5], markers: m?.markers, }); } function show(p: P, built: Built, r: SimResult) { last = { p, r }; root.querySelectorAll('[data-r="out"]').forEach((e) => (e.hidden = false)); 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 = statRows([ ['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` : '–'], ...(cfg.summaryRows?.(p, built, r) ?? []), ['Peak pickup acceleration', `${fmtNum(peak)} m/s²`], ]); 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(); timeline.setTimes(r.frameTimes); scene.showReplay({ ...built.scene, frames: r.frames, malletPos: r.malletPos }); extras?.show(p, built, r); 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 { built } = cfg.setup(p); if (!built) return; const opt = { duration: p.duration, fs: p.fs, cfl: p.cfl, frameRate: p.frameRate, pickup: built.pickup }; runBtn.disabled = true; errorBox.hidden = true; cancelBtn.hidden = false; progress.hidden = false; progress.value = 0; running = runInWorker(built.sys, built.exc, opt, (f) => (progress.value = f)); try { show(p, built, await running.promise); } catch (e) { if ((e as Error).message !== 'cancelled') { errorBox.textContent = `The simulation failed: ${(e as Error).message}`; errorBox.hidden = false; } } finally { running = null; runBtn.disabled = false; cancelBtn.hidden = true; progress.hidden = true; } }); cancelBtn.addEventListener('click', () => running?.cancel()); }