1import { renderControls, type Section } from './controls';
2import { LinePlot, css, fmtNum, type Marker } from './plot';
3import { Spectrogram } from './spectrogram';
4import { spectrum } from './fft';
5import { encodeWav } from './wav';
6import { BeadScene, type SceneSpec } from './scene3d';
7import { Timeline } from './timeline';
8import { omegaMaxBound } from '../sim/solver';
9import { runInWorker, type Running } from '../sim/run';
10import type { BeadSystem, Excitation, SimResult } from '../sim/types';
12// The page every instrument shares: presets and control panel, 3D model,
13// setup summary, run summary, audio, waveform, spectrogram, spectrum, energy.
14// An instrument adds its own setup rows, extra result cards and views.
16export interface SimParams {
17 duration: number;
18 fs: number;
19 cfl: number;
20 frameRate: number;
21}
23export interface Built {
24 sys: BeadSystem;
25 exc: Excitation;
26 pickup: Float64Array;
27 scene: SceneSpec;
28}
30export interface Preset<P> {
31 name: string;
32 description: string;
33 params: P;
34}
36export interface Extras<P> {
37 show(p: P, built: Built, r: SimResult): void;
38}
40export interface InstrumentConfig<P extends SimParams> {
41 title: string;
42 wavName: string;
43 presets: Preset<P>[];
44 sections: Section<P>[];
45 // instrument-specific summary of the setup; built = null when the parameters are invalid
46 setup(p: P): { rows: [string, string][]; warnings: string[]; built: Built | null };
47 summaryRows?(p: P, built: Built, r: SimResult): [string, string][];
48 spectrumMarkers?(p: P): { markers: Marker[]; legend: string };
49 extraCards?: string; // cards with data-r="out" hidden, shown after a run
50 mountExtras?(q: <T extends HTMLElement>(r: string) => T, timeline: Timeline): Extras<P>;
51}
53const statRows = (rows: [string, string][]) => rows.map(([k, v]) => `<div><span>${k}</span><b>${v}</b></div>`).join('');
55export function mountInstrumentPage<P extends SimParams>(root: HTMLElement, cfg: InstrumentConfig<P>) {
56 root.innerHTML = `
57 <div class="instrument">
58 <aside class="panel">
59 <a href="#/" class="back">← All instruments</a>
60 <h1>${cfg.title}</h1>
61 <label class="ctl preset"><span class="ctl-name">Preset</span><select data-r="preset"></select></label>
62 <p class="sub" data-r="preset-desc"></p>
63 <div data-r="controls"></div>
64 <div class="run-row">
65 <button class="btn" data-r="run">Run simulation</button>
66 <button class="btn-small" data-r="cancel" hidden>Cancel</button>
67 </div>
68 <progress data-r="progress" max="1" value="0" hidden></progress>
69 <p class="error" data-r="error" hidden></p>
70 </aside>
71 <main class="results">
72 <section class="card"><h2>Model</h2><div data-r="scene"></div></section>
73 <section class="card"><h2>Setup</h2><div data-r="derived" class="stats"></div><ul data-r="warnings" class="warnings"></ul></section>
74 <section class="card" data-r="out" hidden>
75 <h2>Run</h2><div data-r="summary" class="stats"></div>
76 <div class="audio-row"><audio controls data-r="audio"></audio><a class="btn-small" data-r="download" download="${cfg.wavName}">Download .wav</a></div>
77 <p class="note" data-r="audio-note"></p>
78 </section>
79 <section class="card" data-r="out" hidden>
80 <div class="card-head"><h2>Pickup acceleration</h2>
81 <div class="seg" data-r="wave-range"><button data-v="0" class="on">Full</button><button data-v="0.05">First 50 ms</button><button data-v="0.005">First 5 ms</button></div></div>
82 <div class="canvas-wrap"><canvas data-r="wave"></canvas></div>
83 </section>
84 <section class="card" data-r="out" hidden>
85 <div class="card-head"><h2>Spectrogram</h2>
86 <label class="inline">up to <select data-r="fmax"><option>1000</option><option>2500</option><option selected>5000</option><option>10000</option><option>24000</option></select> Hz</label></div>
87 <div class="canvas-wrap tall"><canvas data-r="spec"></canvas></div>
88 </section>
89 <section class="card" data-r="out" hidden>
90 <div class="card-head"><h2>Spectrum</h2><div class="legend" data-r="spec-legend"></div></div>
91 <div class="canvas-wrap"><canvas data-r="spectrum"></canvas></div>
92 </section>
93 ${cfg.extraCards ?? ''}
94 <section class="card" data-r="out" hidden>
95 <h2>Energy</h2>
96 <div class="canvas-wrap short"><canvas data-r="energy"></canvas></div>
97 <p class="note">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).</p>
98 </section>
99 </main>
100 </div>`;
101 const q = <T extends HTMLElement>(r: string) => root.querySelector(`[data-r="${r}"]`) as T;
102 const presetSel = q<HTMLSelectElement>('preset');
103 cfg.presets.forEach((p, i) => presetSel.add(new Option(p.name, String(i))));
104 const presetDesc = q<HTMLParagraphElement>('preset-desc');
105 const runBtn = q<HTMLButtonElement>('run');
106 const cancelBtn = q<HTMLButtonElement>('cancel');
107 const progress = q<HTMLProgressElement>('progress');
108 const errorBox = q<HTMLParagraphElement>('error');
110 const wave = new LinePlot(q('wave'));
111 const spec = new Spectrogram(q('spec'));
112 const specPlot = new LinePlot(q('spectrum'));
113 const energyPlot = new LinePlot(q('energy'));
114 const timeline = new Timeline();
115 const scene = new BeadScene(q('scene'), timeline);
116 const extras = cfg.mountExtras?.(q, timeline);
117 let waveRange = 0;
118 let last: { p: P; r: SimResult } | null = null;
119 let running: Running | null = null;
120 let audioUrl: string | null = null;
122 const controls = renderControls(q('controls'), cfg.sections, cfg.presets[0].params, () => updateSetup());
123 presetDesc.textContent = cfg.presets[0].description;
124 presetSel.addEventListener('change', () => {
125 const pr = cfg.presets[Number(presetSel.value)];
126 presetDesc.textContent = pr.description;
127 controls.set(pr.params);
128 });
130 function updateSetup() {
131 const p = controls.get();
132 const { rows, warnings, built } = cfg.setup(p);
133 if (built) {
134 scene.showSetup(built.scene);
135 const wmax = omegaMaxBound(built.sys, built.exc.mallet);
136 const spp = Math.max(1, Math.ceil(1 / (p.fs * ((p.cfl * 2) / wmax))));
137 rows.push(['Highest frequency bound', `${fmtNum(wmax / (2 * Math.PI))} Hz`]);
138 rows.push(['Time step', `${fmtNum(1 / (p.fs * spp))} s (${spp} per sample)`]);
139 const work = spp * p.fs * p.duration * built.sys.si.length;
140 rows.push(['Spring updates', fmtNum(work)]);
141 const frameMB = (Math.round(p.duration * p.frameRate) * 3 * built.sys.n * 4) / 1e6;
142 if (frameMB > 400) warnings.push(`Replay frames would take ${fmtNum(frameMB)} MB; lower the replay frame rate or the duration.`);
143 if (work > 3e9) warnings.push('This run is large (more than 3e9 spring updates) and may take minutes.');
144 }
145 q('derived').innerHTML = statRows(rows);
146 q('warnings').innerHTML = warnings.map((w) => `<li>${w}</li>`).join('');
147 runBtn.disabled = !built || !!running;
148 }
149 updateSetup();
151 q('wave-range').addEventListener('click', (e) => {
152 const b = (e.target as HTMLElement).closest('button');
153 if (!b) return;
154 waveRange = Number(b.dataset.v);
155 q('wave-range').querySelectorAll('button').forEach((x) => x.classList.toggle('on', x === b));
156 drawWave();
157 });
158 q<HTMLSelectElement>('fmax').addEventListener('change', (e) => {
159 spec.fmax = Number((e.target as HTMLSelectElement).value);
160 spec.draw();
161 drawSpectrum();
162 });
164 function drawWave() {
165 if (!last) return;
166 const { r } = last;
167 const t = new Float64Array(r.audio.length);
168 for (let i = 0; i < t.length; i++) t[i] = i / r.fs;
169 wave.set([{ x: t, y: r.audio, color: css('--series-1'), label: 'a' }], {
170 xLabel: 'time (s)',
171 yLabel: 'acceleration (m/s²)',
172 xRange: waveRange ? [0, Math.min(waveRange, t[t.length - 1])] : undefined,
173 });
174 }
176 function drawSpectrum() {
177 if (!last) return;
178 const { p, r } = last;
179 const s = spectrum(r.audio, r.fs);
180 let top = -Infinity;
181 for (const v of s.db) top = Math.max(top, v);
182 const db = s.db.map((v) => Math.max(v - top, -100));
183 const m = cfg.spectrumMarkers?.(p);
184 q('spec-legend').innerHTML =
185 `<span><i class="sw" style="background:var(--series-1)"></i>pickup signal</span>` + (m?.legend ?? '');
186 specPlot.set([{ x: s.freq, y: db, color: css('--series-1'), label: 'level (dB)' }], {
187 xLabel: 'frequency (Hz)',
188 yLabel: 'level (dB re max)',
189 xRange: [0, Math.min(spec.fmax, r.fs / 2)],
190 yRange: [-100, 5],
191 markers: m?.markers,
192 });
193 }
195 function show(p: P, built: Built, r: SimResult) {
196 last = { p, r };
197 root.querySelectorAll<HTMLElement>('[data-r="out"]').forEach((e) => (e.hidden = false));
198 let peak = 0;
199 for (const v of r.audio) peak = Math.max(peak, Math.abs(v));
200 const emax = Math.max(...r.energy);
201 let bal = 0;
202 for (const b of r.balance) bal = Math.max(bal, Math.abs(b));
203 const rt = p.duration / r.wallSeconds;
204 q('summary').innerHTML = statRows([
205 ['Time step', `${fmtNum(r.dt)} s, ${r.stepsPerSample} per sample`],
206 ['Wall time', `${fmtNum(r.wallSeconds)} s (${rt >= 1 ? fmtNum(rt) + '× faster than' : fmtNum(1 / rt) + '× slower than'} real time)`],
207 ['Energy balance error', emax > 0 ? `${fmtNum((bal / emax) * 100)}% of peak energy` : '–'],
208 ...(cfg.summaryRows?.(p, built, r) ?? []),
209 ['Peak pickup acceleration', `${fmtNum(peak)} m/s²`],
210 ]);
211 if (audioUrl) URL.revokeObjectURL(audioUrl);
212 audioUrl = URL.createObjectURL(encodeWav(r.audio, r.fs));
213 q<HTMLAudioElement>('audio').src = audioUrl;
214 q<HTMLAnchorElement>('download').href = audioUrl;
215 q('audio-note').textContent = `16-bit mono, ${r.fs} Hz, normalized so the peak is −1 dBFS.`;
216 drawWave();
217 spec.setSignal(r.audio, r.fs);
218 drawSpectrum();
219 timeline.setTimes(r.frameTimes);
220 scene.showReplay({ ...built.scene, frames: r.frames, malletPos: r.malletPos });
221 extras?.show(p, built, r);
222 energyPlot.set([{ x: r.frameTimes, y: r.energy, color: css('--series-1'), label: 'energy (J)' }], {
223 xLabel: 'time (s)',
224 yLabel: 'energy (J)',
225 yRange: [0, emax * 1.05 || 1],
226 });
227 }
229 runBtn.addEventListener('click', async () => {
230 const p = controls.get();
231 const { built } = cfg.setup(p);
232 if (!built) return;
233 const opt = { duration: p.duration, fs: p.fs, cfl: p.cfl, frameRate: p.frameRate, pickup: built.pickup };
234 runBtn.disabled = true;
235 errorBox.hidden = true;
236 cancelBtn.hidden = false;
237 progress.hidden = false;
238 progress.value = 0;
239 running = runInWorker(built.sys, built.exc, opt, (f) => (progress.value = f));
240 try {
241 show(p, built, await running.promise);
242 } catch (e) {
243 if ((e as Error).message !== 'cancelled') {
244 errorBox.textContent = `The simulation failed: ${(e as Error).message}`;
245 errorBox.hidden = false;
246 }
247 } finally {
248 running = null;
249 runBtn.disabled = false;
250 cancelBtn.hidden = true;
251 progress.hidden = true;
252 }
253 });
254 cancelBtn.addEventListener('click', () => running?.cancel());
255}