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
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 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) => `