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 { timeStep } 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; pickupDelay?: Float64Array; // per bead, in samples (a microphone) 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; audioQuantity?: { label: string; unit: string }; // default: acceleration, m/s² presets: Preset

[]; sections: Section

[]; // WebGPU on by default (default true); off where the systems are too small to gain from it webgpuDefault?: boolean; // 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 gpuBox = q('gpu'); const hasGpu = 'gpu' in navigator; const gpuKey = `toy-instruments.webgpu.${cfg.wavName}`; let gpuPref: string | null = null; try { gpuPref = localStorage.getItem(gpuKey); } catch { /* storage unavailable */ } gpuBox.checked = hasGpu && (gpuPref ? gpuPref === 'on' : cfg.webgpuDefault !== false); gpuBox.disabled = !hasGpu; q('gpu-label').title = hasGpu ? 'Run the solver on the GPU (single precision). Faster for meshes of a few hundred beads or more; slower for small systems. Falls back to the CPU if the GPU cannot run it.' : 'This browser does not offer WebGPU; the solver runs on the CPU.'; gpuBox.addEventListener('change', () => { try { localStorage.setItem(gpuKey, gpuBox.checked ? 'on' : 'off'); } catch { /* storage unavailable */ } }); 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 contactPlot = new LinePlot(q('contact')); const quantity = cfg.audioQuantity ?? { label: 'acceleration', unit: 'm/s²' }; 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 { omegaMax: wmax, stepsPerSample: spp } = timeStep(built.sys, built.exc.mallet, p.fs, p.cfl); rows.push(['Highest frequency', `${fmtNum(wmax / (2 * Math.PI))} Hz`]); rows.push(['Time step', `${fmtNum(1 / (p.fs * spp))} s (${spp} per sample)`]); // a bending hinge costs about four springs const elements = built.sys.si.length + 4 * (built.sys.bending?.w0.length ?? 0); const work = spp * p.fs * p.duration * elements; rows.push(['Element 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 (${fmtNum(work)} element updates): expect roughly ${fmtNum(work / 2.2e7 / 60)} min on a desktop CPU (WebGPU is usually much faster).`); } 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: `${quantity.label} (${quantity.unit})`, 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([ ['Solver', (r.backend ?? 'CPU') + (r.fallback ? ` (WebGPU failed: ${r.fallback})` : '')], ['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) ?? []), ...contactRows(r), [`Peak ${quantity.label}`, `${fmtNum(peak)} ${quantity.unit}`], ]); 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); showContact(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], }); } function showContact(r: SimResult) { const card = q('out-contact'); card.hidden = !r.contactForce; if (!r.contactForce) return; const cf = r.contactForce; const c = contacts(cf, r.fs); const end = c.length ? c[c.length - 1].start + c[c.length - 1].ms / 1000 : 0.01; const nShow = Math.min(cf.length, Math.ceil((end * 1.3 + 0.002) * r.fs)); const t = new Float64Array(nShow); for (let i = 0; i < nShow; i++) t[i] = (i / r.fs) * 1000; contactPlot.set([{ x: t, y: cf.subarray(0, nShow), color: css('--series-2'), label: 'force (N)' }], { xLabel: 'time (ms)', yLabel: 'contact force (N)', yRange: [0, Math.max(...cf.subarray(0, nShow)) * 1.08 || 1], }); q('contact-note').textContent = c.length ? `${c.length} contact${c.length > 1 ? 's' : ''}: ` + c.map((k) => `${fmtNum(k.ms)} ms from ${fmtNum(k.start * 1000)} ms, peak ${fmtNum(k.peak)} N`).join('; ') + '. During contact the instrument takes energy from the mallet and gives part of it back as the mallet rebounds, so the energy plot can spike first; the mallet then leaves at constant speed (no gravity).' : 'The mallet never touched the instrument.'; } 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, pickupDelay: built.pickupDelay }; runBtn.disabled = true; errorBox.hidden = true; cancelBtn.hidden = false; progress.hidden = false; progress.value = 0; running = runInWorker(built.sys, built.exc, opt, gpuBox.checked, (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()); } // separate contact intervals in the contact-force record function contacts(cf: Float32Array, fs: number): { start: number; ms: number; peak: number }[] { const out: { start: number; ms: number; peak: number }[] = []; let i = 0; while (i < cf.length) { if (cf[i] > 0) { const a = i; let peak = 0; while (i < cf.length && cf[i] > 0) peak = Math.max(peak, cf[i++]); out.push({ start: a / fs, ms: ((i - a) / fs) * 1000, peak }); } else i++; } return out; } function contactRows(r: SimResult): [string, string][] { if (!r.contactForce) return []; const c = contacts(r.contactForce, r.fs); return [ [ 'Contact', c.length ? `${fmtNum(c[0].ms)} ms, peak ${fmtNum(c[0].peak)} N${c.length > 1 ? ` (+${c.length - 1} more contact${c.length > 2 ? 's' : ''})` : ''}` : 'none', ], ]; } const cap = (s: string) => s[0].toUpperCase() + s.slice(1);