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 { runInWorker } from '../sim/run';
import { CPU_RATE, parseSettings, runCost, settingsQuery, simOptions, type Settings } from './settings';
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
[];
// 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
Kinetic plus spring energy of the instrument, minus the energy stored by pre-tension at equilibrium. Without damping or forcing it stays flat; the run summary gives the error of the energy balance (energy + dissipated − work in − initial, mallet included).
`;
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
// settings from a shared link, applied once and then removed from the address
const shared = readSharedLink(cfg);
if (shared) {
presetSel.value = String(shared.preset);
if (shared.gpu !== undefined && hasGpu) gpuBox.checked = shared.gpu;
}
const controls = renderControls(q('controls'), cfg.sections, shared?.params ?? cfg.presets[0].params, () => updateSetup());
presetDesc.textContent = cfg.presets[Number(presetSel.value)].description;
presetSel.addEventListener('change', () => {
const pr = cfg.presets[Number(presetSel.value)];
presetDesc.textContent = pr.description;
shareMsg.textContent = '';
controls.set(pr.params);
});
const shareMsg = q('share-msg');
const shareUrl = q('share-url');
if (shared) shareMsg.textContent = 'Settings loaded from a shared link.';
q('share').addEventListener('click', async () => {
const url = sharedLink(controls.get(), Number(presetSel.value), gpuBox.checked);
shareUrl.hidden = true;
try {
await navigator.clipboard.writeText(url);
shareMsg.textContent = 'Link copied.';
} catch {
// no clipboard access: show the link to copy by hand
shareMsg.textContent = 'Copy this link:';
shareUrl.value = url;
shareUrl.hidden = false;
shareUrl.select();
}
});
function updateSetup() {
const p = controls.get();
const { rows, warnings, built } = cfg.setup(p);
if (built) {
scene.showSetup(built.scene);
const { omegaMax: wmax, stepsPerSample: spp, work } = runCost(p, built);
rows.push(['Highest frequency', `${fmtNum(wmax / (2 * Math.PI))} Hz`]);
rows.push(['Time step', `${fmtNum(1 / (p.fs * spp))} s (${spp} per sample)`]);
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 / CPU_RATE / 60)} min on a desktop CPU (WebGPU is usually much faster).`);
}
q('derived').innerHTML = statRows(rows);
q('warnings').innerHTML = warnings.map((w) => `