/ proof-of-concept / toy-instruments
Sign in
proof-of-concept / toy-instruments
toy-instruments / src / ui / instrumentPage.ts
344 lines · 15.1 KBBlameHistoryRaw
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 { timeStep } 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;
23export interface Built {
24 sys: BeadSystem;
25 exc: Excitation;
26 pickup: Float64Array;
27 pickupDelay?: Float64Array; // per bead, in samples (a microphone)
28 scene: SceneSpec;
31export interface Preset<P> {
32 name: string;
33 description: string;
34 params: P;
37export interface Extras<P> {
38 show(p: P, built: Built, r: SimResult): void;
41export interface InstrumentConfig<P extends SimParams> {
42 title: string;
43 wavName: string;
44 audioQuantity?: { label: string; unit: string }; // default: acceleration, m/s²
45 presets: Preset<P>[];
46 sections: Section<P>[];
47 // WebGPU on by default (default true); off where the systems are too small to gain from it
48 webgpuDefault?: boolean;
49 // instrument-specific summary of the setup; built = null when the parameters are invalid
50 setup(p: P): { rows: [string, string][]; warnings: string[]; built: Built | null };
51 summaryRows?(p: P, built: Built, r: SimResult): [string, string][];
52 spectrumMarkers?(p: P): { markers: Marker[]; legend: string };
53 extraCards?: string; // cards with data-r="out" hidden, shown after a run
54 mountExtras?(q: <T extends HTMLElement>(r: string) => T, timeline: Timeline): Extras<P>;
57const statRows = (rows: [string, string][]) => rows.map(([k, v]) => `<div><span>${k}</span><b>${v}</b></div>`).join('');
59export function mountInstrumentPage<P extends SimParams>(root: HTMLElement, cfg: InstrumentConfig<P>) {
60 root.innerHTML = `
61 <div class="instrument">
62 <aside class="panel">
63 <a href="#/" class="back">← All instruments</a>
64 <h1>${cfg.title}</h1>
65 <label class="ctl preset"><span class="ctl-name">Preset</span><select data-r="preset"></select></label>
66 <p class="sub" data-r="preset-desc"></p>
67 <div class="run-row top">
68 <button class="btn" data-r="run">Run simulation</button>
69 <button class="btn-small" data-r="cancel" hidden>Cancel</button>
70 <label class="inline gpu-toggle" data-r="gpu-label"><input type="checkbox" data-r="gpu"> WebGPU</label>
71 </div>
72 <progress data-r="progress" max="1" value="0" hidden></progress>
73 <p class="error" data-r="error" hidden></p>
74 <div data-r="controls"></div>
75 </aside>
76 <main class="results">
77 <section class="card"><h2>Model</h2><div data-r="scene"></div></section>
78 <section class="card"><h2>Setup</h2><div data-r="derived" class="stats"></div><ul data-r="warnings" class="warnings"></ul></section>
79 <section class="card" data-r="out" hidden>
80 <h2>Run</h2><div data-r="summary" class="stats"></div>
81 <div class="audio-row"><audio controls data-r="audio"></audio><a class="btn-small" data-r="download" download="${cfg.wavName}">Download .wav</a></div>
82 <p class="note" data-r="audio-note"></p>
83 </section>
84 <section class="card" data-r="out" hidden>
85 <div class="card-head"><h2>${cap((cfg.audioQuantity ?? { label: 'pickup acceleration' }).label)}</h2>
86 <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>
87 <div class="canvas-wrap"><canvas data-r="wave"></canvas></div>
88 </section>
89 <section class="card" data-r="out" hidden>
90 <div class="card-head"><h2>Spectrogram</h2>
91 <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>
92 <div class="canvas-wrap tall"><canvas data-r="spec"></canvas></div>
93 </section>
94 <section class="card" data-r="out" hidden>
95 <div class="card-head"><h2>Spectrum</h2><div class="legend" data-r="spec-legend"></div></div>
96 <div class="canvas-wrap"><canvas data-r="spectrum"></canvas></div>
97 </section>
98 ${cfg.extraCards ?? ''}
99 <section class="card" data-r="out-contact" hidden>
100 <h2>Contact force</h2>
101 <div class="canvas-wrap short"><canvas data-r="contact"></canvas></div>
102 <p class="note" data-r="contact-note"></p>
103 </section>
104 <section class="card" data-r="out" hidden>
105 <h2>Energy</h2>
106 <div class="canvas-wrap short"><canvas data-r="energy"></canvas></div>
107 <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>
108 </section>
109 </main>
110 </div>`;
111 const q = <T extends HTMLElement>(r: string) => root.querySelector(`[data-r="${r}"]`) as T;
112 const presetSel = q<HTMLSelectElement>('preset');
113 cfg.presets.forEach((p, i) => presetSel.add(new Option(p.name, String(i))));
114 const presetDesc = q<HTMLParagraphElement>('preset-desc');
115 const runBtn = q<HTMLButtonElement>('run');
116 const cancelBtn = q<HTMLButtonElement>('cancel');
117 const progress = q<HTMLProgressElement>('progress');
118 const errorBox = q<HTMLParagraphElement>('error');
119 const gpuBox = q<HTMLInputElement>('gpu');
120 const hasGpu = 'gpu' in navigator;
121 const gpuKey = `toy-instruments.webgpu.${cfg.wavName}`;
122 let gpuPref: string | null = null;
123 try {
124 gpuPref = localStorage.getItem(gpuKey);
125 } catch {
126 /* storage unavailable */
127 }
128 gpuBox.checked = hasGpu && (gpuPref ? gpuPref === 'on' : cfg.webgpuDefault !== false);
129 gpuBox.disabled = !hasGpu;
130 q('gpu-label').title = hasGpu
131 ? '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.'
132 : 'This browser does not offer WebGPU; the solver runs on the CPU.';
133 gpuBox.addEventListener('change', () => {
134 try {
135 localStorage.setItem(gpuKey, gpuBox.checked ? 'on' : 'off');
136 } catch {
137 /* storage unavailable */
138 }
139 });
141 const wave = new LinePlot(q('wave'));
142 const spec = new Spectrogram(q('spec'));
143 const specPlot = new LinePlot(q('spectrum'));
144 const energyPlot = new LinePlot(q('energy'));
145 const contactPlot = new LinePlot(q('contact'));
146 const quantity = cfg.audioQuantity ?? { label: 'acceleration', unit: 'm/s²' };
147 const timeline = new Timeline();
148 const scene = new BeadScene(q('scene'), timeline);
149 const extras = cfg.mountExtras?.(q, timeline);
150 let waveRange = 0;
151 let last: { p: P; r: SimResult } | null = null;
152 let running: Running | null = null;
153 let audioUrl: string | null = null;
155 const controls = renderControls(q('controls'), cfg.sections, cfg.presets[0].params, () => updateSetup());
156 presetDesc.textContent = cfg.presets[0].description;
157 presetSel.addEventListener('change', () => {
158 const pr = cfg.presets[Number(presetSel.value)];
159 presetDesc.textContent = pr.description;
160 controls.set(pr.params);
161 });
163 function updateSetup() {
164 const p = controls.get();
165 const { rows, warnings, built } = cfg.setup(p);
166 if (built) {
167 scene.showSetup(built.scene);
168 const { omegaMax: wmax, stepsPerSample: spp } = timeStep(built.sys, built.exc.mallet, p.fs, p.cfl);
169 rows.push(['Highest frequency', `${fmtNum(wmax / (2 * Math.PI))} Hz`]);
170 rows.push(['Time step', `${fmtNum(1 / (p.fs * spp))} s (${spp} per sample)`]);
171 // a bending hinge costs about four springs
172 const elements = built.sys.si.length + 4 * (built.sys.bending?.w0.length ?? 0);
173 const work = spp * p.fs * p.duration * elements;
174 rows.push(['Element updates', fmtNum(work)]);
175 const frameMB = (Math.round(p.duration * p.frameRate) * 3 * built.sys.n * 4) / 1e6;
176 if (frameMB > 400) warnings.push(`Replay frames would take ${fmtNum(frameMB)} MB; lower the replay frame rate or the duration.`);
177 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).`);
178 }
179 q('derived').innerHTML = statRows(rows);
180 q('warnings').innerHTML = warnings.map((w) => `<li>${w}</li>`).join('');
181 runBtn.disabled = !built || !!running;
182 }
183 updateSetup();
185 q('wave-range').addEventListener('click', (e) => {
186 const b = (e.target as HTMLElement).closest('button');
187 if (!b) return;
188 waveRange = Number(b.dataset.v);
189 q('wave-range').querySelectorAll('button').forEach((x) => x.classList.toggle('on', x === b));
190 drawWave();
191 });
192 q<HTMLSelectElement>('fmax').addEventListener('change', (e) => {
193 spec.fmax = Number((e.target as HTMLSelectElement).value);
194 spec.draw();
195 drawSpectrum();
196 });
198 function drawWave() {
199 if (!last) return;
200 const { r } = last;
201 const t = new Float64Array(r.audio.length);
202 for (let i = 0; i < t.length; i++) t[i] = i / r.fs;
203 wave.set([{ x: t, y: r.audio, color: css('--series-1'), label: 'a' }], {
204 xLabel: 'time (s)',
205 yLabel: `${quantity.label} (${quantity.unit})`,
206 xRange: waveRange ? [0, Math.min(waveRange, t[t.length - 1])] : undefined,
207 });
208 }
210 function drawSpectrum() {
211 if (!last) return;
212 const { p, r } = last;
213 const s = spectrum(r.audio, r.fs);
214 let top = -Infinity;
215 for (const v of s.db) top = Math.max(top, v);
216 const db = s.db.map((v) => Math.max(v - top, -100));
217 const m = cfg.spectrumMarkers?.(p);
218 q('spec-legend').innerHTML =
219 `<span><i class="sw" style="background:var(--series-1)"></i>pickup signal</span>` + (m?.legend ?? '');
220 specPlot.set([{ x: s.freq, y: db, color: css('--series-1'), label: 'level (dB)' }], {
221 xLabel: 'frequency (Hz)',
222 yLabel: 'level (dB re max)',
223 xRange: [0, Math.min(spec.fmax, r.fs / 2)],
224 yRange: [-100, 5],
225 markers: m?.markers,
226 });
227 }
229 function show(p: P, built: Built, r: SimResult) {
230 last = { p, r };
231 root.querySelectorAll<HTMLElement>('[data-r="out"]').forEach((e) => (e.hidden = false));
232 let peak = 0;
233 for (const v of r.audio) peak = Math.max(peak, Math.abs(v));
234 const emax = Math.max(...r.energy);
235 let bal = 0;
236 for (const b of r.balance) bal = Math.max(bal, Math.abs(b));
237 const rt = p.duration / r.wallSeconds;
238 q('summary').innerHTML = statRows([
239 ['Solver', (r.backend ?? 'CPU') + (r.fallback ? ` (WebGPU failed: ${r.fallback})` : '')],
240 ['Time step', `${fmtNum(r.dt)} s, ${r.stepsPerSample} per sample`],
241 ['Wall time', `${fmtNum(r.wallSeconds)} s (${rt >= 1 ? fmtNum(rt) + '× faster than' : fmtNum(1 / rt) + '× slower than'} real time)`],
242 ['Energy balance error', emax > 0 ? `${fmtNum((bal / emax) * 100)}% of peak energy` : '–'],
243 ...(cfg.summaryRows?.(p, built, r) ?? []),
244 ...contactRows(r),
245 [`Peak ${quantity.label}`, `${fmtNum(peak)} ${quantity.unit}`],
246 ]);
247 if (audioUrl) URL.revokeObjectURL(audioUrl);
248 audioUrl = URL.createObjectURL(encodeWav(r.audio, r.fs));
249 q<HTMLAudioElement>('audio').src = audioUrl;
250 q<HTMLAnchorElement>('download').href = audioUrl;
251 q('audio-note').textContent = `16-bit mono, ${r.fs} Hz, normalized so the peak is −1 dBFS.`;
252 drawWave();
253 spec.setSignal(r.audio, r.fs);
254 drawSpectrum();
255 timeline.setTimes(r.frameTimes);
256 scene.showReplay({ ...built.scene, frames: r.frames, malletPos: r.malletPos });
257 extras?.show(p, built, r);
258 showContact(r);
259 energyPlot.set([{ x: r.frameTimes, y: r.energy, color: css('--series-1'), label: 'energy (J)' }], {
260 xLabel: 'time (s)',
261 yLabel: 'energy (J)',
262 yRange: [0, emax * 1.05 || 1],
263 });
264 }
266 function showContact(r: SimResult) {
267 const card = q('out-contact');
268 card.hidden = !r.contactForce;
269 if (!r.contactForce) return;
270 const cf = r.contactForce;
271 const c = contacts(cf, r.fs);
272 const end = c.length ? c[c.length - 1].start + c[c.length - 1].ms / 1000 : 0.01;
273 const nShow = Math.min(cf.length, Math.ceil((end * 1.3 + 0.002) * r.fs));
274 const t = new Float64Array(nShow);
275 for (let i = 0; i < nShow; i++) t[i] = (i / r.fs) * 1000;
276 contactPlot.set([{ x: t, y: cf.subarray(0, nShow), color: css('--series-2'), label: 'force (N)' }], {
277 xLabel: 'time (ms)',
278 yLabel: 'contact force (N)',
279 yRange: [0, Math.max(...cf.subarray(0, nShow)) * 1.08 || 1],
280 });
281 q('contact-note').textContent = c.length
282 ? `${c.length} contact${c.length > 1 ? 's' : ''}: ` +
283 c.map((k) => `${fmtNum(k.ms)} ms from ${fmtNum(k.start * 1000)} ms, peak ${fmtNum(k.peak)} N`).join('; ') +
284 '. 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).'
285 : 'The mallet never touched the instrument.';
286 }
288 runBtn.addEventListener('click', async () => {
289 const p = controls.get();
290 const { built } = cfg.setup(p);
291 if (!built) return;
292 const opt = { duration: p.duration, fs: p.fs, cfl: p.cfl, frameRate: p.frameRate, pickup: built.pickup, pickupDelay: built.pickupDelay };
293 runBtn.disabled = true;
294 errorBox.hidden = true;
295 cancelBtn.hidden = false;
296 progress.hidden = false;
297 progress.value = 0;
298 running = runInWorker(built.sys, built.exc, opt, gpuBox.checked, (f) => (progress.value = f));
299 try {
300 show(p, built, await running.promise);
301 } catch (e) {
302 if ((e as Error).message !== 'cancelled') {
303 errorBox.textContent = `The simulation failed: ${(e as Error).message}`;
304 errorBox.hidden = false;
305 }
306 } finally {
307 running = null;
308 runBtn.disabled = false;
309 cancelBtn.hidden = true;
310 progress.hidden = true;
311 }
312 });
313 cancelBtn.addEventListener('click', () => running?.cancel());
316// separate contact intervals in the contact-force record
317function contacts(cf: Float32Array, fs: number): { start: number; ms: number; peak: number }[] {
318 const out: { start: number; ms: number; peak: number }[] = [];
319 let i = 0;
320 while (i < cf.length) {
321 if (cf[i] > 0) {
322 const a = i;
323 let peak = 0;
324 while (i < cf.length && cf[i] > 0) peak = Math.max(peak, cf[i++]);
325 out.push({ start: a / fs, ms: ((i - a) / fs) * 1000, peak });
326 } else i++;
327 }
328 return out;
331function contactRows(r: SimResult): [string, string][] {
332 if (!r.contactForce) return [];
333 const c = contacts(r.contactForce, r.fs);
334 return [
335 [
336 'Contact',
337 c.length
338 ? `${fmtNum(c[0].ms)} ms, peak ${fmtNum(c[0].peak)} N${c.length > 1 ? ` (+${c.length - 1} more contact${c.length > 2 ? 's' : ''})` : ''}`
339 : 'none',
340 ],
341 ];
344const cap = (s: string) => s[0].toUpperCase() + s.slice(1);
↑↓ move↵openescclose