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 } from '../sim/run'; import { checkPasscode, getPasscode, loadResult, probeResult, resultKey, setPasscode, storeResult, type Backend } from '../sim/cache'; 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 loadBtn = q('load-cached'); const availNote = q('cached-avail'); const cacheNote = q('cache-note'); const instrumentId = cfg.wavName.replace(/\.wav$/, ''); // e.g. bead-cymbal 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 */ } checkCache(); }); 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 cancelRun: (() => void) | null = null; // set while a run (or a cache download) is in progress // the cached result for the current settings, if there is one let cached: { p: P; built: Built; key: string; stored?: Date } | null = null; let probeSeq = 0; let probeTimer = 0; let audioUrl: string | null = null; let pendingAudio: Blob | null = null; // a newer partial result's audio, held back while the player plays 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 || !!cancelRun; checkCache(); } // what the solver gets for parameters p; null when they are invalid function inputs(p: P) { const { built } = cfg.setup(p); if (!built) return null; const opt = { duration: p.duration, fs: p.fs, cfl: p.cfl, frameRate: p.frameRate, pickup: built.pickup, pickupDelay: built.pickupDelay }; return { built, opt, backend: (gpuBox.checked ? 'gpu' : 'cpu') as Backend }; } // Look up (debounced) whether the shared cache has a result for the current settings, // and offer to load it next to Run. function checkCache() { const seq = ++probeSeq; cached = null; loadBtn.hidden = availNote.hidden = true; clearTimeout(probeTimer); probeTimer = window.setTimeout(async () => { const p = controls.get(); const inp = inputs(p); if (!inp) return; try { const key = await resultKey(instrumentId, inp.built.sys, inp.built.exc, inp.opt, inp.backend); const hit = await probeResult(key); if (seq !== probeSeq || !hit) return; cached = { p, built: inp.built, key, stored: hit.stored }; const when = hit.stored ? `computed ${hit.stored.toISOString().slice(0, 10)}, ` : ''; availNote.textContent = `A cached result exists for these settings (${when}${fmtNum(hit.bytes / 1e6)} MB). Load it, or run the simulation again.`; loadBtn.hidden = availNote.hidden = false; loadBtn.disabled = !!cancelRun; } catch { /* cache unreachable: just don't offer it */ } }, 250); } 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 { p, 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})`, // a partial result is drawn on the axis of the whole run xRange: [0, waveRange ? Math.min(waveRange, p.duration) : Math.max(p.duration, t[t.length - 1])], }); } 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, }); } // partial: r covers only the start of a run still in progress. update: r // extends the result already shown (same run), so the replay position, the // exaggeration and the audio player's position are kept. function show(p: P, built: Built, r: SimResult, partial = false, update = false) { last = { p, r }; const simulated = r.audio.length / r.fs; cacheNote.textContent = partial ? `Partial result: ${fmtNum(simulated)} s of ${fmtNum(p.duration)} s simulated so far. It updates every few seconds while the run continues; the audio player picks up the newer sound when it is paused.` : ''; 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 = simulated / r.wallSeconds; q('summary').innerHTML = statRows([ ...(partial ? ([['Simulated so far', `${fmtNum(simulated)} s of ${fmtNum(p.duration)} s`]] as [string, string][]) : []), ['Solver', (r.backend ?? 'CPU') + (r.fallback ? ` (WebGPU failed: ${r.fallback})` : '')], ['Time step', `${fmtNum(r.dt)} s, ${r.stepsPerSample} per sample`], [partial ? 'Wall time so far' : '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}`], ]); setAudio(encodeWav(r.audio, r.fs), update); q('audio-note').textContent = `16-bit mono, ${r.fs} Hz, normalized so the peak is −1 dBFS${partial ? ' (of the sound so far)' : ''}.`; drawWave(); spec.setSignal(r.audio, r.fs); drawSpectrum(); if (update) timeline.extend(r.frameTimes); else timeline.setTimes(r.frameTimes); scene.showReplay({ ...built.scene, frames: r.frames, malletPos: r.malletPos }, update); 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)', xRange: [0, Math.max(p.duration, r.frameTimes[r.frameTimes.length - 1])], yLabel: 'energy (J)', yRange: [0, emax * 1.05 || 1], }); } // Give the player new audio. While it plays, a partial result's audio waits // until it pauses or ends (swapping the source would stop it); when it is // paused partway, the new audio resumes from the same point. const player = q('audio'); function setAudio(wav: Blob, update: boolean) { pendingAudio = null; if (update && audioUrl && !player.paused && !player.ended) { pendingAudio = wav; return; } const at = update && !player.ended ? player.currentTime : 0; if (audioUrl) URL.revokeObjectURL(audioUrl); audioUrl = URL.createObjectURL(wav); player.src = audioUrl; q('download').href = audioUrl; if (at > 0) player.addEventListener('loadedmetadata', () => (player.currentTime = Math.min(at, player.duration)), { once: true }); } const flushAudio = () => { if (pendingAudio) setAudio(pendingAudio, true); }; player.addEventListener('pause', flushAudio); player.addEventListener('ended', flushAudio); 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.'; } function setBusy(cancel: (() => void) | null) { cancelRun = cancel; runBtn.disabled = loadBtn.disabled = !!cancel; cancelBtn.hidden = !cancel; progress.hidden = !cancel; progress.value = 0; if (cancel) errorBox.hidden = true; } // Run the simulation and, if this browser has the passcode, store the result. async function run() { const p = controls.get(); const inp = inputs(p); if (!inp) return; const { built, opt, backend } = inp; // Partial results are shown as they come; when they arrive faster than the // page draws them, only the newest is drawn. let shown = false; // a partial result of this run is on the page let latest: SimResult | null = null; let finished = false; const onPartial = (r: SimResult) => { if (!latest) setTimeout(() => { if (!latest || finished) return; show(p, built, latest, true, shown); shown = true; latest = null; }); latest = r; }; const running = runInWorker(built.sys, built.exc, opt, backend === 'gpu', (f) => (progress.value = f), onPartial); setBusy(running.cancel); try { const r = await running.promise; finished = true; show(p, built, r, false, shown); if (getPasscode()) { // a WebGPU run that fell back to the CPU is stored as a CPU result const actual: Backend = r.backend?.startsWith('WebGPU') ? 'gpu' : 'cpu'; const key = await resultKey(instrumentId, built.sys, built.exc, opt, actual); cacheNote.textContent = 'Storing in the shared cache…'; storeResult(key, r, { instrument: instrumentId, backend: r.backend, wallSeconds: r.wallSeconds }) .then(() => { if (last?.r === r) cacheNote.textContent = 'Stored in the shared cache.'; checkCache(); }) .catch((e) => { if (last?.r === r) cacheNote.textContent = `Not stored in the shared cache: ${(e as Error).message}`; }); } } catch (e) { finished = true; if (shown) cacheNote.textContent = `The run ${(e as Error).message === 'cancelled' ? 'was cancelled' : 'stopped'}; this is the partial result up to ${fmtNum(last!.r.audio.length / last!.r.fs)} s.`; if ((e as Error).message !== 'cancelled') { errorBox.textContent = `The simulation failed: ${(e as Error).message}`; errorBox.hidden = false; } } finally { setBusy(null); } } // Download and show the cached result for the current settings. async function loadCached() { if (!cached) return; const { p, built, key } = cached; const abort = new AbortController(); setBusy(() => abort.abort()); try { const hit = await loadResult(key, (f) => (progress.value = f), abort.signal); if (!hit) throw new Error('the entry is no longer in the cache'); show(p, built, hit.result); const when = hit.stored ? ` on ${hit.stored.toISOString().slice(0, 10)}` : ''; cacheNote.textContent = `Loaded from the shared cache (computed${when}; the wall time is that of the original run).`; } catch (e) { if (!abort.signal.aborted) { errorBox.textContent = `Could not load the cached result: ${(e as Error).message}`; errorBox.hidden = false; checkCache(); } } finally { setBusy(null); } } runBtn.addEventListener('click', run); loadBtn.addEventListener('click', loadCached); cancelBtn.addEventListener('click', () => cancelRun?.()); // passcode for writing to the shared cache const passInput = q('cache-pass'); const cacheMsg = q('cache-msg'); const showCacheState = () => (q('cache-state').textContent = getPasscode() ? 'Shared cache: runs are stored' : 'Shared cache: read only'); showCacheState(); q('cache-save').addEventListener('click', async () => { const pass = passInput.value.trim(); if (!pass) return; cacheMsg.textContent = 'Checking…'; try { const { valid, reason } = await checkPasscode(pass); if (!valid) { cacheMsg.textContent = `Not saved: ${reason ?? 'invalid passcode'}.`; return; } setPasscode(pass); passInput.value = ''; cacheMsg.textContent = 'Saved in this browser. Runs from now on are stored in the cache.'; } catch (e) { cacheMsg.textContent = `Could not check the passcode: ${(e as Error).message}`; } showCacheState(); }); q('cache-forget').addEventListener('click', () => { setPasscode(null); cacheMsg.textContent = 'Passcode removed from this browser.'; showCacheState(); }); } // 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);