1/**
2 * The app: a scene you compose, the MATLAB it generates, a GPU, and a
3 * microphone.
4 *
5 * Three altitudes, one simulation. *Play* is presets and the handful of
6 * musical knobs. *Build* is the studio: add strings, bars and membranes,
7 * pick a body, place the microphone. *Lab* is the truth: the generated (and
8 * editable) .m files, what they compiled to, and the recording's spectrum
9 * against where each object's partials should land.
10 *
11 * Everything the page does falls into three motions, and the tabs are
12 * honest about them. Moving a *model parameter* (a pitch, a decay) writes a
13 * GPU uniform, which is free. Moving a *placement or body parameter*
14 * re-evaluates the scene .m on the CPU and re-uploads a few arrays, which is
15 * cheap and needs no recompile. Changing the *structure* — objects added or
16 * removed, a size, the air grid, or either file's text — recompiles: a fresh
17 * session, from source to shaders.
18 */
19import { requestAcousticDevice } from './device.ts';
20import { StudioSession, specParams } from './studio/session.ts';
21import { formatFailure, ModelCompileError } from './mgpu/errors.ts';
22import { OBJECT_TEMPLATES, templateOf } from './studio/objects.ts';
23import { BODY_TEMPLATES } from './studio/bodies.ts';
24import { PRESETS, cloneSpec, presetByKey } from './studio/presets.ts';
25import {
26 defaultsOf, suffixed,
27 type ObjectType, type ParamSpec, type Params, type SceneSpec,
28} from './studio/types.ts';
29import { suffixedSpecs } from './studio/codegen.ts';
30import {
31 drawSpectrum, objectColor, predictedPartials, type PartialSet,
32} from './studio/spectrum.ts';
33import { VolumeView, cameraFrame, type Camera } from './render/volume.ts';
34import { Overlay, type OverlayObject } from './render/overlay.ts';
35import { StringPlot } from './render/stringplot.ts';
36import { Colorbar, fmtValue } from './render/colorbar.ts';
37import { colormaps, colormapNames } from './render/colormaps.ts';
38import { CodeEditor } from './editor/codeEditor.ts';
39import { planPlayback, playTrace, stop as stopAudio } from './audio/play.ts';
40import { traceToWav } from './audio/wav.ts';
41import { C_AIR, POOR_RESOLUTION, fmtLength, fmtTime } from './units.ts';
43const el = <T extends HTMLElement>(id: string): T => {
44 const node = document.getElementById(id);
45 if (!node) throw new Error(`missing element #${id}`);
46 return node as T;
47};
49const errBox = el<HTMLParagraphElement>('err');
50const showError = (e: unknown, source: string): void => {
51 errBox.textContent = formatFailure(e, source);
52};
53const clearError = (): void => {
54 errBox.textContent = '';
55};
57/* ---------------------------------------------------------------- state -- */
59const initialPreset =
60 presetByKey(location.hash.replace(/^#preset\//, '')) ?? PRESETS[0];
61let presetKey: string | null = initialPreset.key;
62let spec: SceneSpec = cloneSpec(initialPreset.spec);
63let { params, sceneParams } = specParams(spec);
64/** The editor's hand-edited copies; null means "as generated". */
65const sources: { model: string | null; scene: string | null } = {
66 model: null,
67 scene: null,
68};
69let gridNx = 96;
70/** Timesteps per display frame. May be fractional — ¼× means one step every
71 * fourth frame — which is what `stepDebt` accumulates toward. */
72let stepsPerFrame = 16;
73let stepDebt = 0;
74const mic = { ...initialPreset.mic };
75/** Paused until asked. The page compiles and draws its silent initial state
76 * on load and nothing moves until Run (or Render clip). */
77let running = false;
78let session: StudioSession | null = null;
80const camera: Camera = { az: -2.2, el: 0.45, dist: 1.35 };
81const viewState = { opacity: 2, contrast: 1.6, quality: 192, clipFrac: 0.5, exaggerate: 25 };
82const show = { field: true, body: true, objects: true, wire: true, mic: true };
84/** Pressure the colormap saturates at, and whether it follows the field. */
85let scale = 1e-6;
86let autoScale = true;
87let peakSeen = 0;
88let colormapName = colormapNames[0];
89/** The last displacement read back per object, for the overlay and plot. */
90const lastFields = new Map<string, Float32Array>();
91/** Per-object plot scale: the largest |u| seen since the last excite. */
92const plotPeak = new Map<string, number>();
93let plotId: string | null = null;
94/** The last recording analyzed, so the spectrum can be redrawn. */
95let lastTrace: { trace: Float32Array; dt: number } | null = null;
96let dirty = true;
97let renderingClip = false;
99/* ------------------------------------------------------------- the page -- */
101const device = await requestAcousticDevice().catch((e: unknown) => {
102 showError(e, '');
103 return null;
104});
105if (!device) throw new Error('no GPU');
107void device.lost.then((info) => {
108 showError(
109 new Error(
110 `the GPU device was lost (${info.reason}): ${info.message}\n` +
111 'Reload the page to start again.',
112 ),
113 '',
114 );
115});
117const canvas = el<HTMLCanvasElement>('view');
118const view = new VolumeView(device, canvas);
119view.setColormap(colormaps[colormapName]);
120const overlay = new Overlay(el<HTMLCanvasElement>('overlay'));
121const plot = new StringPlot(el<HTMLCanvasElement>('objplot'));
122const colorbar = new Colorbar(el('colorbar'));
123colorbar.setColormap(colormaps[colormapName]);
124const darkMedia = matchMedia('(prefers-color-scheme: dark)');
126new ResizeObserver(() => {
127 view.resize();
128 overlay.resize();
129 plot.resize();
130 dirty = true;
131}).observe(canvas);
133const editorFile = el<HTMLSelectElement>('editor-file');
134const editor = new CodeEditor({
135 textarea: el<HTMLTextAreaElement>('source'),
136 overlay: el('highlight'),
137 external: new Set(['sponge3']),
138 onInput: (value) => {
139 sources[editorFile.value as 'model' | 'scene'] = value;
140 el<HTMLButtonElement>('recompile').classList.add('primary');
141 },
142});
144const colormapSelect = el<HTMLSelectElement>('colormap');
145for (const name of colormapNames) colormapSelect.append(new Option(name, name));
146colormapSelect.value = colormapName;
148/* ---------------------------------------------------------------- utils -- */
150const objectIds = (): string[] => spec.objects.map((o) => o.id);
152function nextId(type: ObjectType): string {
153 const prefix = type === 'string' ? 's' : type === 'bar' ? 'b' : 'm';
154 for (let n = 1; ; n++) {
155 const id = `${prefix}${n}`;
156 if (!objectIds().includes(id)) return id;
157 }
158}
160/** What the excite button should say for this scene. */
161function verb(): string {
162 const verbs = new Set(spec.objects.map((o) => templateOf(o).verb));
163 return verbs.size === 1 ? [...verbs][0] : 'Excite';
164}
166/** Reconcile the parameter dicts with the current spec: defaults for new
167 * keys, current values kept where they still apply. */
168function syncParams(): void {
169 const d = specParams(spec);
170 params = { ...d.params, ...pick(params, Object.keys(d.params)) };
171 sceneParams = { ...d.sceneParams, ...pick(sceneParams, Object.keys(d.sceneParams)) };
172}
173const pick = (o: Params, keys: string[]): Params =>
174 Object.fromEntries(keys.filter((k) => k in o).map((k) => [k, o[k]]));
176/** Structure changed: keep the builder's values in the spec so presets and
177 * future codegen see them. */
178function writeBack(): void {
179 for (const o of spec.objects) {
180 const t = templateOf(o);
181 o.params = Object.fromEntries(
182 t.paramSpecs.map((s) => [s.key, params[suffixed(s.key, o.id)] ?? s.value]),
183 );
184 o.place = Object.fromEntries(
185 t.placeSpecs.map((s) => [s.key, sceneParams[suffixed(s.key, o.id)] ?? s.value]),
186 );
187 }
188 spec.body.params = pick(
189 sceneParams,
190 BODY_TEMPLATES[spec.body.type].paramSpecs.map((s) => s.key),
191 );
192}
194/* ------------------------------------------------------------- controls -- */
196/** One slider per parameter spec. */
197function buildSliders(
198 host: HTMLElement,
199 specs: ParamSpec[],
200 values: Params,
201 onChange: (key: string, value: number) => void,
202): void {
203 host.textContent = '';
204 addSliders(host, specs, values, onChange);
205}
207function addSliders(
208 host: HTMLElement,
209 specs: ParamSpec[],
210 values: Params,
211 onChange: (key: string, value: number) => void,
212): void {
213 for (const spec of specs) {
214 const row = document.createElement('label');
215 row.className = 'slider';
216 if (spec.hint) row.title = spec.hint;
217 const name = document.createElement('span');
218 name.textContent = spec.label;
219 const input = document.createElement('input');
220 input.type = 'range';
221 input.min = String(spec.min);
222 input.max = String(spec.max);
223 input.step = String(spec.step);
224 input.value = String(values[spec.key] ?? spec.value);
225 const out = document.createElement('output');
226 out.textContent = fmtValue(values[spec.key] ?? spec.value);
227 input.addEventListener('input', () => {
228 const v = Number(input.value);
229 out.textContent = fmtValue(v);
230 onChange(spec.key, v);
231 });
232 row.append(name, input, out);
233 host.append(row);
234 }
235}
237/** Scene edits go through the MATLAB interpreter, which is fast but not free;
238 * a drag should not queue up one evaluation per pixel. */
239let sceneTimer = 0;
240const scheduleSceneUpdate = (): void => {
241 clearTimeout(sceneTimer);
242 sceneTimer = window.setTimeout(applyScene, 150);
243};
245function applyScene(): void {
246 if (!session) return;
247 try {
248 session.setScene(sceneParams, sources.scene ?? undefined);
249 clearError();
250 // A scene edit that raises the fastest speed invalidates the timestep
251 // (and with it every object grid), which only a rebuild can fix.
252 if (Math.abs(session.dtWanted - session.dt) > 1e-12 * session.dt) {
253 void rebuild();
254 return;
255 }
256 dirty = true;
257 } catch (e) {
258 showError(e, sources.scene ?? session.sceneSource);
259 }
260}
262const onModelParam = (key: string, value: number): void => {
263 params = { ...params, [key]: value };
264 session?.setParams(params);
265};
266const onSceneParam = (key: string, value: number): void => {
267 sceneParams = { ...sceneParams, [key]: value };
268 scheduleSceneUpdate();
269};
271function buildPresetCards(): void {
272 const host = el('presets');
273 host.textContent = '';
274 for (const p of PRESETS) {
275 const card = document.createElement('button');
276 card.className = `preset${p.key === presetKey ? ' active' : ''}`;
277 card.innerHTML = `<b>${p.label}</b><span>${p.blurb}</span>`;
278 card.addEventListener('click', () => {
279 presetKey = p.key;
280 location.hash = `preset/${p.key}`;
281 spec = cloneSpec(p.spec);
282 Object.assign(mic, p.mic);
283 ({ params, sceneParams } = specParams(spec));
284 sources.model = null;
285 sources.scene = null;
286 refreshControls();
287 void rebuild();
288 });
289 host.append(card);
290 }
291}
293function buildMusicalSliders(): void {
294 const host = el('musicalparams');
295 host.textContent = '';
296 for (const o of spec.objects) {
297 const t = templateOf(o);
298 const specs = suffixedSpecs(t.paramSpecs, o.id)
299 .filter((s) => s.musical)
300 .map((s) => ({ ...s, label: `${o.id} · ${s.label}` }));
301 addSliders(host, specs, params, onModelParam);
302 }
303}
305function buildObjectCards(): void {
306 const host = el('objects');
307 host.textContent = '';
308 spec.objects.forEach((o, index) => {
309 const t = templateOf(o);
310 const card = document.createElement('div');
311 card.className = 'objcard';
312 const head = document.createElement('div');
313 head.className = 'objcard-head';
314 const swatch = document.createElement('i');
315 swatch.className = 'swatch';
316 swatch.style.background = objectColor(index);
317 const title = document.createElement('b');
318 title.textContent = `${t.label} ${o.id}`;
319 const blurb = document.createElement('span');
320 blurb.className = 'sub';
321 blurb.style.margin = '0';
322 blurb.textContent = t.blurb;
323 const remove = document.createElement('button');
324 remove.textContent = 'remove';
325 remove.disabled = spec.objects.length === 1;
326 remove.title = remove.disabled
327 ? 'A scene needs at least one object'
328 : 'Remove this object (recompiles)';
329 remove.addEventListener('click', () => {
330 writeBack();
331 spec.objects = spec.objects.filter((x) => x.id !== o.id);
332 presetKey = null;
333 syncParams();
334 refreshControls();
335 void rebuild();
336 });
337 head.append(swatch, title, blurb, remove);
338 card.append(head);
340 const sliders = document.createElement('div');
341 sliders.className = 'sliders';
342 // Sizes first: they rebuild. Then placement (scene) and parameters.
343 addSliders(
344 sliders,
345 t.sizeSpecs,
346 { ...Object.fromEntries(t.sizeSpecs.map((s) => [s.key, (o.size as Params)[s.key] ?? s.value])) },
347 (key, value) => {
348 writeBack();
349 (o.size as Params)[key] = value;
350 presetKey = null;
351 scheduleRebuild();
352 },
353 );
354 addSliders(sliders, suffixedSpecs(t.placeSpecs, o.id), sceneParams, onSceneParam);
355 addSliders(sliders, suffixedSpecs(t.paramSpecs, o.id), params, onModelParam);
356 card.append(sliders);
357 host.append(card);
358 });
359}
361function buildBodyControls(): void {
362 const select = el<HTMLSelectElement>('bodytype');
363 select.value = spec.body.type;
364 el('bodyblurb').textContent = BODY_TEMPLATES[spec.body.type].blurb;
365 buildSliders(
366 el('bodyparams'),
367 BODY_TEMPLATES[spec.body.type].paramSpecs,
368 sceneParams,
369 onSceneParam,
370 );
371}
373function buildMicControls(): void {
374 const specs: ParamSpec[] = [
375 { key: 'x', label: 'mic x (m)', value: mic.x, min: -0.45, max: 0.45, step: 0.01 },
376 { key: 'y', label: 'mic y (m)', value: mic.y, min: -0.22, max: 0.22, step: 0.01 },
377 { key: 'z', label: 'mic z (m)', value: mic.z, min: -0.22, max: 0.22, step: 0.01 },
378 ];
379 buildSliders(el('micparams'), specs, { x: mic.x, y: mic.y, z: mic.z }, (key, value) => {
380 mic[key as 'x' | 'y' | 'z'] = value;
381 session?.setMic(mic.x, mic.y, mic.z);
382 dirty = true;
383 });
384}
386function buildObjPick(): void {
387 const pick = el<HTMLSelectElement>('objpick');
388 pick.textContent = '';
389 for (const o of spec.objects) {
390 pick.append(new Option(`${templateOf(o).label} ${o.id}`, o.id));
391 }
392 if (!plotId || !objectIds().includes(plotId)) plotId = spec.objects[0]?.id ?? null;
393 if (plotId) pick.value = plotId;
394}
396function refreshControls(): void {
397 el<HTMLButtonElement>('excite').textContent = verb();
398 buildPresetCards();
399 buildMusicalSliders();
400 buildObjectCards();
401 buildBodyControls();
402 buildMicControls();
403 buildObjPick();
404}
406/* ------------------------------------------------------------- the loop -- */
408let frames = 0;
409let lastFpsAt = performance.now();
410let msPerFrame = 0;
411/** A readback in flight; only one at a time, since they share a buffer. */
412let reading = false;
414function excite(): void {
415 if (!session) return;
416 session.excite();
417 peakSeen = 0;
418 if (autoScale) scale = 1e-6;
419 plotPeak.clear();
420 dirty = true;
421 showRecording();
422}
424/**
425 * Compile the current scene into a new session and swap it in.
426 *
427 * The old session keeps running until the new one exists, and is only torn
428 * down once the swap has happened: a compile that fails leaves something on
429 * screen and something to edit rather than a dead page.
430 */
431let building = false;
432let rebuildAgain = false;
433async function rebuild(): Promise<void> {
434 if (building) {
435 rebuildAgain = true;
436 return;
437 }
438 building = true;
439 renderingClip = false;
440 const old = session;
441 try {
442 syncParams();
443 const next = await StudioSession.create({
444 device: device!,
445 spec,
446 params,
447 sceneParams,
448 modelSource: sources.model ?? undefined,
449 sceneSource: sources.scene ?? undefined,
450 nx: gridNx,
451 });
452 next.excite();
453 session = next;
454 view.setSource(
455 next.gpu.stateBuffer('p')!,
456 next.gpu.stateBuffer('wall')!,
457 {
458 nx: next.air.nx, ny: next.air.ny, nz: next.air.nz,
459 Lx: next.air.Lx, Ly: next.air.Ly, Lz: next.air.Lz, h: next.air.h,
460 },
461 );
462 next.setMic(mic.x, mic.y, mic.z);
463 old?.destroy();
464 lastFields.clear();
465 plotPeak.clear();
466 peakSeen = 0;
467 scale = 1e-6;
468 lastTrace = null;
469 dirty = true;
470 clearError();
471 showRecording();
472 showStatics();
473 el<HTMLButtonElement>('recompile').classList.remove('primary');
474 el('compiled').textContent = describe(next);
475 showFile(editorFile.value as 'model' | 'scene');
476 drawSpec();
477 } catch (e) {
478 const which = failingFile(e);
479 showError(e, sources[which] ?? '');
480 if (e instanceof ModelCompileError && e.start !== undefined && sources[which]) {
481 editorFile.value = which;
482 showFile(which);
483 editor.select(e.start, e.end ?? e.start + 1);
484 }
485 } finally {
486 building = false;
487 if (rebuildAgain) {
488 rebuildAgain = false;
489 void rebuild();
490 }
491 }
492}
494/** Size-slider drags would rebuild per pixel; give them a moment to settle. */
495let rebuildTimer = 0;
496const scheduleRebuild = (): void => {
497 clearTimeout(rebuildTimer);
498 rebuildTimer = window.setTimeout(() => void rebuild(), 250);
499};
501/** A scene failure is reported against `medium`, everything else against the
502 * model's own functions. */
503const failingFile = (e: unknown): 'model' | 'scene' =>
504 e instanceof ModelCompileError && e.fn === 'medium' ? 'scene' : 'model';
506const describe = (s: StudioSession): string => {
507 const { init, step } = s.describe();
508 return [
509 `% init — one ${verb().toLowerCase()}`,
510 ...init.map((l) => ` ${l}`),
511 ``,
512 `% step — every timestep`,
513 ...step.map((l) => ` ${l}`),
514 ].join('\n');
515};
517/** What the microphone has, and what it would sound like. */
518function showRecording(): void {
519 const info = el('recinfo');
520 if (!session) {
521 info.textContent = '';
522 return;
523 }
524 const n = session.recorder.count;
525 if (n === 0) {
526 info.textContent = 'nothing recorded yet — Run, or Render clip';
527 return;
528 }
529 const plan = planPlayback(n, session.dt);
530 info.textContent =
531 `${fmtTime(n * session.dt)} recorded · plays at ` +
532 `${Math.round(plan.rate).toLocaleString()} Hz` +
533 (plan.realTime ? '' : ' (rate clamped)') +
534 (session.recorder.full ? ' · buffer full' : '');
535}
537/** The facts that only change on a rebuild. */
538function showStatics(): void {
539 if (!session) return;
540 const { air } = session;
541 const fmax = C_AIR / (POOR_RESOLUTION * air.h);
542 const objects = spec.objects
543 .map((o) => {
544 const g = session!.grids[o.id];
545 return `${o.id}: ${g.kind === 'line' ? `${g.n1} nodes` : `${g.n1}×${g.n2} nodes`}`;
546 })
547 .join(', ');
548 el('domaininfo').textContent =
549 `The air is a ${fmtLength(air.Lx)} × ${fmtLength(air.Ly)} × ${fmtLength(air.Lz)} box ` +
550 `at ${air.nx}×${air.ny}×${air.nz} cells of ${fmtLength(air.h)} — honest to about ` +
551 `${(fmax / 1000).toFixed(1)} kHz. Objects: ${objects}. ` +
552 `dt = ${fmtTime(session.dt)} (${Math.round(1 / session.dt / 1000)} kHz).`;
553}
555function stats(): void {
556 if (!session) return;
557 const rate =
558 running && msPerFrame > 0 ? `${msPerFrame.toFixed(1)} ms/frame` : 'paused';
559 el('stats').innerHTML =
560 `t = <b>${fmtTime(session.t)}</b> · step <b>${session.steps.toLocaleString()}</b> · ` +
561 `${spec.objects.length} object${spec.objects.length === 1 ? '' : 's'} · ${rate}`;
562}
564/**
565 * Follow the field with the colour scale, and keep the overlay and plot fed.
566 * The only readbacks in the app, a few times a second rather than every
567 * frame, and strictly one at a time — they share a staging buffer.
568 */
569async function pollFields(): Promise<void> {
570 if (!session || reading) return;
571 reading = true;
572 try {
573 for (const o of spec.objects) {
574 const u = await session.read(`u_${o.id}`);
575 lastFields.set(o.id, u);
576 let peak = plotPeak.get(o.id) ?? 0;
577 for (const v of u) peak = Math.max(peak, Math.abs(v));
578 plotPeak.set(o.id, peak);
579 }
580 if (autoScale) {
581 const p = await session.read('p');
582 let peak = 0;
583 for (const v of p) peak = Math.max(peak, Math.abs(v));
584 peakSeen = Math.max(peakSeen, peak);
585 scale = peak > scale ? peak : 0.97 * scale + 0.03 * peak;
586 scale = Math.max(scale, 0.05 * peakSeen, 1e-12);
587 }
588 dirty = true;
589 } catch {
590 // A rebuild can destroy the buffers mid-read; the next poll recovers.
591 } finally {
592 reading = false;
593 }
594}
596/** The overlay's view of the scene, from the builder state. */
597function overlayObjects(): OverlayObject[] {
598 return spec.objects.map((o, index) => {
599 const g = session?.grids[o.id];
600 const color = objectColor(index);
601 if (o.type === 'membrane') {
602 return {
603 kind: 'rect' as const,
604 color,
605 L1: o.size.L1,
606 L2: o.size.L2 ?? o.size.L1,
607 n1: g?.n1 ?? 2,
608 n2: g?.n2 ?? 2,
609 oz: sceneParams[`oz_${o.id}`] ?? 0,
610 u: lastFields.get(o.id) ?? null,
611 };
612 }
613 return {
614 kind: 'line' as const,
615 color,
616 L1: o.size.L1,
617 oy: sceneParams[`oy_${o.id}`] ?? 0,
618 oz: sceneParams[`oz_${o.id}`] ?? 0,
619 u: lastFields.get(o.id) ?? null,
620 posts: o.type === 'string',
621 };
622 });
623}
625/** The selected object's displacement, as a 1D slice for the plot. */
626function plotSlice(): { u: Float32Array | null; label: string } {
627 if (!plotId || !session) return { u: null, label: '' };
628 const o = spec.objects.find((x) => x.id === plotId);
629 const u = lastFields.get(plotId) ?? null;
630 const g = session.grids[plotId];
631 if (!o || !g) return { u: null, label: '' };
632 let slice = u;
633 let where = '';
634 if (o.type === 'membrane' && u) {
635 const midJ = Math.floor(g.n2 / 2);
636 slice = u.subarray(g.n1 * midJ, g.n1 * (midJ + 1));
637 where = ' (centre row)';
638 }
639 return {
640 u: slice,
641 label: `${plotId} displacement${where}, full scale ±${fmtValue(1100 * plotScale())} mm`,
642 };
643}
644const plotScale = (): number =>
645 Math.max(plotPeak.get(plotId ?? '') ?? 0, 1e-6) * 1.05;
647let lastPollAt = 0;
648function frame(): void {
649 if (session && (running || dirty)) {
650 if (running && !renderingClip) {
651 // Fractional speeds accumulate a debt and step when it reaches a whole
652 // timestep, so ¼× is one step every fourth frame rather than nothing.
653 stepDebt += stepsPerFrame;
654 const n = Math.floor(stepDebt);
655 if (n > 0) {
656 session.step(n);
657 stepDebt -= n;
658 }
659 }
660 const cf = cameraFrame(camera, canvas.width / Math.max(canvas.height, 1));
661 view.draw({
662 frame: cf,
663 scale: Math.max(scale, 1e-20),
664 opacity: show.field ? viewState.opacity : 0,
665 contrast: viewState.contrast,
666 steps: viewState.quality,
667 clipY: viewState.clipFrac * session.air.Ly,
668 body: show.body ? 0.6 : 0,
669 mic: show.mic ? mic : null,
670 });
671 overlay.draw({
672 frame: cf,
673 objects: overlayObjects(),
674 body: { type: spec.body.type, params: sceneParams },
675 exaggerate: viewState.exaggerate,
676 wireframe: show.wire,
677 showObjects: show.objects,
678 });
679 const slice = plotSlice();
680 plot.draw(slice.u, plotScale(), darkMedia.matches);
681 el('objlabel').textContent = slice.label;
682 dirty = false;
683 colorbar.setRange(-scale, scale);
684 frames++;
686 const now = performance.now();
687 if ((running || renderingClip) && now - lastPollAt > 250) {
688 lastPollAt = now;
689 void pollFields();
690 }
691 }
692 const now = performance.now();
693 if (now - lastFpsAt > 400) {
694 msPerFrame = frames > 0 ? (now - lastFpsAt) / frames : 0;
695 frames = 0;
696 lastFpsAt = now;
697 stats();
698 showRecording();
699 }
700 requestAnimationFrame(frame);
701}
703/* ---------------------------------------------------------- the spectrum -- */
705function drawSpec(): void {
706 const canvasEl = el<HTMLCanvasElement>('spectrum');
707 const legend = el('speclegend');
708 legend.textContent = '';
709 if (!session || !lastTrace) {
710 const ctx = canvasEl.getContext('2d');
711 ctx?.clearRect(0, 0, canvasEl.width, canvasEl.height);
712 return;
713 }
714 const fair = C_AIR / (POOR_RESOLUTION * session.air.h);
715 const fmax = Math.min(6000, Math.max(1000, 1.3 * fair));
716 const partials: PartialSet[] = spec.objects.map((o) =>
717 predictedPartials(o, session!.grids[o.id], params, fmax),
718 );
719 drawSpectrum(canvasEl, {
720 trace: lastTrace.trace,
721 dt: lastTrace.dt,
722 fmax,
723 partials,
724 dark: darkMedia.matches,
725 });
726 partials.forEach((set, i) => {
727 const item = document.createElement('i');
728 item.style.color = objectColor(i);
729 item.textContent = `■ ${set.label}`;
730 legend.append(item);
731 });
732}
734async function analyzeTrace(): Promise<Float32Array | null> {
735 if (!session || session.recorder.count === 0) return null;
736 const s = session;
737 const trace = await s.recorder.read();
738 lastTrace = { trace, dt: s.dt };
739 drawSpec();
740 return trace;
741}
743/* ------------------------------------------------------- rendering a clip -- */
745/**
746 * Excite, then run the solver as fast as the GPU will go — no display frames
747 * in the way, just batches of steps with a sync between them so the queue
748 * never runs unboundedly ahead — until the requested duration of audio is in
749 * the trace. Then play it.
750 */
751async function renderClip(): Promise<void> {
752 if (!session || building) return;
753 const btn = el<HTMLButtonElement>('renderclip');
754 if (renderingClip) {
755 renderingClip = false; // cancel: the loop below notices and stops
756 return;
757 }
758 renderingClip = true;
759 setRunning(false);
760 btn.textContent = 'Cancel';
761 const prog = el<HTMLProgressElement>('renderprog');
762 prog.hidden = false;
763 prog.value = 0;
764 const seconds = Number(el<HTMLSelectElement>('renderdur').value);
765 try {
766 excite();
767 const s = session;
768 const total = Math.min(Math.ceil(seconds / s.dt), s.recorder.capacity);
769 const batch = 512;
770 let done = 0;
771 while (done < total && renderingClip && session === s) {
772 const n = Math.min(batch, total - done);
773 s.step(n);
774 done += n;
775 await s.sync();
776 prog.value = done / total;
777 }
778 if (renderingClip && session === s) {
779 const trace = await analyzeTrace();
780 if (trace) {
781 stopAudio();
782 await playTrace(trace, planPlayback(trace.length, s.dt));
783 }
784 clearError();
785 }
786 } catch (e) {
787 showError(e, '');
788 } finally {
789 renderingClip = false;
790 prog.hidden = true;
791 btn.textContent = 'Render clip';
792 dirty = true;
793 showRecording();
794 }
795}
797/* -------------------------------------------------------------- wiring --- */
799const TABS = ['play', 'build', 'lab'] as const;
800for (const tab of TABS) {
801 el(`tab-${tab}`).addEventListener('click', () => {
802 for (const t of TABS) {
803 el(`tab-${t}`).classList.toggle('active', t === tab);
804 el(`panel-${t}`).classList.toggle('active', t === tab);
805 }
806 if (tab === 'lab') drawSpec();
807 });
808}
810function showFile(which: 'model' | 'scene'): void {
811 if (!session) return;
812 editor.value =
813 sources[which] ?? (which === 'model' ? session.generated.modelSource : session.generated.sceneSource);
814 const edited = sources[which] !== null ? ' — edited by hand' : ' — generated from the scene';
815 el('editor-title').textContent =
816 (which === 'model'
817 ? 'init and step, all objects together, compiled to WebGPU'
818 : 'medium(x, y, z, …) → walls and coupling, evaluated once on the CPU') + edited;
819}
821el('gridsize').addEventListener('change', (e) => {
822 gridNx = Number((e.target as HTMLSelectElement).value);
823 void rebuild();
824});
826el('bodytype').addEventListener('change', (e) => {
827 writeBack();
828 spec.body = { type: (e.target as HTMLSelectElement).value as SceneSpec['body']['type'], params: {} };
829 presetKey = null;
830 syncParams();
831 Object.assign(sceneParams, defaultsOf(BODY_TEMPLATES[spec.body.type].paramSpecs));
832 refreshControls();
833 void rebuild();
834});
836const addObject = (type: ObjectType): void => {
837 writeBack();
838 const t = OBJECT_TEMPLATES[type];
839 const size: { L1: number; L2?: number } = { L1: t.sizeSpecs[0].value };
840 if (type === 'membrane') size.L2 = t.sizeSpecs[1].value;
841 spec.objects.push({ id: nextId(type), type, size, params: {}, place: {} });
842 presetKey = null;
843 syncParams();
844 refreshControls();
845 void rebuild();
846};
847el('add-string').addEventListener('click', () => addObject('string'));
848el('add-bar').addEventListener('click', () => addObject('bar'));
849el('add-membrane').addEventListener('click', () => addObject('membrane'));
851const runPause = el<HTMLButtonElement>('runpause');
852const setRunning = (r: boolean): void => {
853 running = r;
854 runPause.textContent = r ? 'Pause' : 'Run';
855 frames = 0;
856 lastFpsAt = performance.now();
857 dirty = true;
858};
859runPause.addEventListener('click', () => setRunning(!running && !renderingClip));
861el('excite').addEventListener('click', excite);
863el('spf').addEventListener('change', (e) => {
864 stepsPerFrame = Number((e.target as HTMLSelectElement).value);
865});
867el('renderclip').addEventListener('click', () => void renderClip());
869const listen = el<HTMLButtonElement>('listen');
870listen.addEventListener('click', () => {
871 if (!session) return;
872 const s = session;
873 if (s.recorder.count === 0) {
874 showError(new Error('the microphone has not recorded anything yet — Run, or Render clip'), '');
875 return;
876 }
877 listen.disabled = true;
878 stopAudio();
879 void analyzeTrace()
880 .then((trace) => (trace ? playTrace(trace, planPlayback(trace.length, s.dt)) : null))
881 .then(() => clearError())
882 .catch((e: unknown) => showError(e, ''))
883 .finally(() => {
884 listen.disabled = false;
885 });
886});
888el('download').addEventListener('click', () => {
889 if (!session) return;
890 const s = session;
891 if (s.recorder.count === 0) {
892 showError(new Error('the microphone has not recorded anything yet — Run, or Render clip'), '');
893 return;
894 }
895 void s.recorder
896 .read()
897 .then((trace) => {
898 const url = URL.createObjectURL(traceToWav(trace, s.dt));
899 const a = document.createElement('a');
900 a.href = url;
901 a.download = 'soundbox.wav';
902 a.click();
903 setTimeout(() => URL.revokeObjectURL(url), 10_000);
904 })
905 .catch((e: unknown) => showError(e, ''));
906});
908colormapSelect.addEventListener('change', () => {
909 colormapName = colormapSelect.value;
910 view.setColormap(colormaps[colormapName]);
911 colorbar.setColormap(colormaps[colormapName]);
912 dirty = true;
913});
915el('scalemode').addEventListener('change', (e) => {
916 autoScale = (e.target as HTMLSelectElement).value === 'auto';
917});
919const bindRange = (id: string, apply: (v: number) => void): void => {
920 el<HTMLInputElement>(id).addEventListener('input', (e) => {
921 apply(Number((e.target as HTMLInputElement).value));
922 dirty = true;
923 });
924};
925bindRange('opacity', (v) => (viewState.opacity = v));
926bindRange('contrast', (v) => (viewState.contrast = v));
927bindRange('clipy', (v) => (viewState.clipFrac = v));
928bindRange('exaggerate', (v) => (viewState.exaggerate = v));
929el('quality').addEventListener('change', (e) => {
930 viewState.quality = Number((e.target as HTMLSelectElement).value);
931 dirty = true;
932});
934const bindCheck = (id: string, apply: (v: boolean) => void): void => {
935 el<HTMLInputElement>(id).addEventListener('change', (e) => {
936 apply((e.target as HTMLInputElement).checked);
937 dirty = true;
938 });
939};
940bindCheck('showfield', (v) => (show.field = v));
941bindCheck('showbody', (v) => (show.body = v));
942bindCheck('showobjects', (v) => (show.objects = v));
943bindCheck('showwire', (v) => (show.wire = v));
944bindCheck('showmic', (v) => (show.mic = v));
946el<HTMLSelectElement>('objpick').addEventListener('change', (e) => {
947 plotId = (e.target as HTMLSelectElement).value;
948 dirty = true;
949});
951// --- orbit ---------------------------------------------------------------
952let dragging = false;
953let last = [0, 0];
954canvas.addEventListener('pointerdown', (e) => {
955 dragging = true;
956 last = [e.clientX, e.clientY];
957 canvas.setPointerCapture(e.pointerId);
958});
959canvas.addEventListener('pointermove', (e) => {
960 if (!dragging) return;
961 camera.az -= (e.clientX - last[0]) * 0.008;
962 camera.el = Math.min(1.5, Math.max(-1.5, camera.el + (e.clientY - last[1]) * 0.008));
963 last = [e.clientX, e.clientY];
964 dirty = true;
965});
966canvas.addEventListener('pointerup', () => (dragging = false));
967canvas.addEventListener('pointercancel', () => (dragging = false));
968canvas.addEventListener(
969 'wheel',
970 (e) => {
971 e.preventDefault();
972 camera.dist = Math.min(5, Math.max(0.7, camera.dist * Math.exp(e.deltaY * 0.001)));
973 dirty = true;
974 },
975 { passive: false },
976);
978editorFile.addEventListener('change', () => {
979 showFile(editorFile.value as 'model' | 'scene');
980});
982el('recompile').addEventListener('click', () => {
983 void rebuild();
984});
986el('revert').addEventListener('click', () => {
987 sources[editorFile.value as 'model' | 'scene'] = null;
988 void rebuild();
989});
991darkMedia.addEventListener('change', () => {
992 dirty = true;
993 drawSpec();
994});
996/* ---------------------------------------------------------------- start -- */
998refreshControls();
999await rebuild();
1000// The initial excitation shapes, so the page opens showing something.
1001void pollFields();
1002requestAnimationFrame(frame);