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