1/**
2 * The app: two MATLAB files, a GPU, and a canvas.
3 *
4 * Everything the page does falls into three motions. Changing a *parameter*
5 * writes a uniform, which is free and does not interrupt the run. Changing the
6 * *scene* re-evaluates its .m on the CPU and re-uploads two arrays, which is
7 * cheap and needs no recompile. Changing the *model*, the grid, or either
8 * file's text recompiles — a fresh session, from source to shaders.
9 */
10import { requestAcousticDevice } from './device.ts';
11import { ModelSession } from './mgpu/session.ts';
12import { EXTERNAL_OPS } from './mgpu/externals.ts';
13import { formatFailure, ModelCompileError } from './mgpu/errors.ts';
14import {
15 mModels,
16 mModelByKey,
17 defaultParams,
18 type MModel,
19 type Params,
20 type ParamSpec,
21} from './mgpu/registry.ts';
22import {
23 mScenes,
24 mSceneByKey,
25 defaultSceneParams,
26 type MScene,
27} from './scene/registry.ts';
28import { FieldView } from './render/field.ts';
29import { Colorbar, fmtValue } from './render/colorbar.ts';
30import { colormaps, colormapNames } from './render/colormaps.ts';
31import { CodeEditor } from './editor/codeEditor.ts';
32import { planPlayback, playTrace, stop as stopAudio } from './audio/play.ts';
33import { C_AIR, DOMAIN, POOR_RESOLUTION, fmtLength, fmtTime } from './units.ts';
35const el = <T extends HTMLElement>(id: string): T => {
36 const node = document.getElementById(id);
37 if (!node) throw new Error(`missing element #${id}`);
38 return node as T;
39};
41const errBox = el<HTMLParagraphElement>('err');
42const showError = (e: unknown, source: string): void => {
43 errBox.textContent = formatFailure(e, source);
44};
45const clearError = (): void => {
46 errBox.textContent = '';
47};
49/* ---------------------------------------------------------------- state -- */
51let model: MModel = mModels[0];
52let scene: MScene = mScenes[0];
53// The starting scene may ask for source settings and a microphone position,
54// the same way it does when picked from the dropdown later.
55let params: Params = { ...defaultParams(model), ...scene.suggest };
56let sceneParams: Params = defaultSceneParams(scene);
57/** The editor's working copies, which may differ from the presets. */
58const sources = { model: model.source, scene: scene.source };
59let gridN = 512;
60let stepsPerFrame = 4;
61/** Timestep, as a fraction of the largest one the scheme is stable at. */
62let cfl = 0.5;
63/** Where the microphone sits, in metres. */
64let mic = { ...(scene.mic ?? { x: DOMAIN * 0.2, y: 0 }) };
65/** Paused until asked. The page compiles and draws its silent initial state on
66 * load, so what is on screen is the medium about to be sounded, and nothing
67 * moves until Run. */
68let running = false;
69let session: ModelSession | null = null;
71/** Pressure the colormap saturates at, and whether it follows the field. */
72let scale = 1;
73let autoScale = true;
74/** Largest pressure seen since the last restart. The colour scale is not
75 * allowed to fall far below it, so that once a pulse has left the grid what
76 * is drawn is an empty grid rather than roundoff at full contrast. */
77let peakSeen = 0;
78let colormapName = colormapNames[0];
79let showMedium = true;
80/**
81 * Something on screen would change if we drew now.
82 *
83 * A running simulation is dirty every frame by definition. A paused one is
84 * dirty only when told to be — a new colour scale, a new medium, a resize —
85 * and otherwise draws nothing at all. Skipping the draw is safe because a
86 * WebGPU canvas keeps the last frame it presented; without it a paused page
87 * would still run a full-screen fragment pass sixty times a second, which is
88 * real GPU work in aid of an unchanging picture.
89 */
90let dirty = true;
92/* ------------------------------------------------------------- the page -- */
94const device = await requestAcousticDevice().catch((e: unknown) => {
95 showError(e, '');
96 return null;
97});
98if (!device) throw new Error('no GPU');
100// A lost device takes everything with it and nothing afterwards will work, so
101// say so rather than leaving a frozen picture and no explanation.
102void device.lost.then((info) => {
103 showError(
104 new Error(
105 `the GPU device was lost (${info.reason}): ${info.message}\n` +
106 'Reload the page to start again.',
107 ),
108 '',
109 );
110});
112const canvas = el<HTMLCanvasElement>('view');
113const view = new FieldView({ device, canvas, nx: gridN, ny: gridN });
114view.setColormap(colormaps[colormapName]);
115const colorbar = new Colorbar(el('colorbar'));
116colorbar.setColormap(colormaps[colormapName]);
118// The canvas is sized by CSS; match its backing store when that changes
119// rather than measuring it every frame.
120// Resizing reallocates the canvas's backing store, which clears it.
121new ResizeObserver(() => {
122 view.resize();
123 dirty = true;
124}).observe(canvas);
126const editor = new CodeEditor({
127 textarea: el<HTMLTextAreaElement>('source'),
128 overlay: el('highlight'),
129 external: EXTERNAL_OPS,
130 onInput: (value) => {
131 sources[editorFile.value as 'model' | 'scene'] = value;
132 el<HTMLButtonElement>('recompile').classList.add('primary');
133 },
134});
136const modelSelect = el<HTMLSelectElement>('model');
137const sceneSelect = el<HTMLSelectElement>('scene');
138const editorFile = el<HTMLSelectElement>('editor-file');
139const colormapSelect = el<HTMLSelectElement>('colormap');
141for (const m of mModels) {
142 modelSelect.append(new Option(m.label, m.key));
143}
144for (const s of mScenes) {
145 sceneSelect.append(new Option(s.label, s.key));
146}
147for (const name of colormapNames) {
148 colormapSelect.append(new Option(name, name));
149}
151/* ------------------------------------------------------------- controls -- */
153/**
154 * One slider per parameter the registry declares. A parameter that is a seed
155 * gets a button instead: its value picks a draw and means nothing on its own,
156 * so there is nothing to slide along.
157 */
158function buildSliders(
159 host: HTMLElement,
160 specs: ParamSpec[],
161 values: Params,
162 onChange: (key: string, value: number) => void,
163): Map<string, (value: number) => void> {
164 const setters = new Map<string, (value: number) => void>();
165 host.textContent = '';
166 for (const spec of specs) {
167 const row = document.createElement('label');
168 row.className = 'slider';
169 if (spec.hint) row.title = spec.hint;
170 const name = document.createElement('span');
171 name.textContent = spec.label;
172 row.append(name);
174 if (spec.reseed) {
175 const button = document.createElement('button');
176 button.type = 'button';
177 button.textContent = 'new draw';
178 const out = document.createElement('output');
179 out.textContent = String(values[spec.key]);
180 button.addEventListener('click', () => {
181 const next = 1 + Math.floor(Math.random() * (spec.max - spec.min));
182 out.textContent = String(next);
183 onChange(spec.key, next);
184 });
185 row.append(button, out);
186 } else {
187 const input = document.createElement('input');
188 input.type = 'range';
189 input.min = String(spec.min);
190 input.max = String(spec.max);
191 input.step = String(spec.step);
192 input.value = String(values[spec.key]);
193 const out = document.createElement('output');
194 out.textContent = fmtValue(values[spec.key]);
195 input.addEventListener('input', () => {
196 const v = Number(input.value);
197 out.textContent = fmtValue(v);
198 onChange(spec.key, v);
199 });
200 row.append(input, out);
201 setters.set(spec.key, (value) => {
202 input.value = String(value);
203 out.textContent = fmtValue(value);
204 });
205 }
206 host.append(row);
207 }
208 return setters;
209}
211/** Scene edits go through the MATLAB interpreter, which is fast but not free;
212 * a drag should not queue up one evaluation per pixel. */
213let sceneTimer = 0;
214const scheduleSceneUpdate = (): void => {
215 clearTimeout(sceneTimer);
216 sceneTimer = window.setTimeout(applyScene, 120);
217};
219function applyScene(): void {
220 if (!session) return;
221 try {
222 const before = session.dt;
223 session.setScene(scene, sceneParams, sources.scene);
224 clearError();
225 // A different timestep leaves the leapfrog's two histories half a step
226 // apart, so that is the one scene change the run cannot survive.
227 if (Math.abs(session.dt - before) > 1e-12 * before) restart();
228 showDt();
229 dirty = true;
230 view.setSource(
231 session.gpu.stateBuffer(session.pressureName)!,
232 session.gpu.stateBuffer('c')!,
233 session.grid.nx,
234 session.grid.ny,
235 );
236 } catch (e) {
237 showError(e, sources.scene);
238 }
239}
241/** Sliders for the microphone's position, kept so a drag on the canvas can
242 * move them too. */
243let micSliders = new Map<string, (value: number) => void>();
245/** The microphone's position. Two sliders, in scene coordinates, since that
246 * is what the scene's own parameters are in — and the canvas itself, which
247 * is the direct way to place it. */
248function buildMicControls(): void {
249 const half = DOMAIN / 2;
250 const specs: ParamSpec[] = [
251 { key: 'x', label: 'mic x (m)', value: mic.x, min: -half, max: half, step: 0.05 },
252 { key: 'y', label: 'mic y (m)', value: mic.y, min: -half, max: half, step: 0.05 },
253 ];
254 micSliders = buildSliders(el('micparams'), specs, { x: mic.x, y: mic.y }, (key, value) => {
255 setMic(key === 'x' ? value : mic.x, key === 'y' ? value : mic.y, false);
256 });
257}
259/** Move the microphone, from wherever the instruction came. */
260function setMic(x: number, y: number, syncSliders = true): void {
261 const half = (session?.grid.L ?? DOMAIN) / 2;
262 mic = {
263 x: Math.max(-half, Math.min(half, x)),
264 y: Math.max(-half, Math.min(half, y)),
265 };
266 session?.setMic(mic.x, mic.y);
267 if (syncSliders) {
268 micSliders.get('x')?.(mic.x);
269 micSliders.get('y')?.(mic.y);
270 }
271 dirty = true;
272}
274/**
275 * Drag the microphone around the picture.
276 *
277 * The canvas shows the whole domain, so a pixel maps to a point with nothing
278 * more than a scale: pointer down puts the microphone where it landed and
279 * begins a drag, and pointer capture keeps that drag alive if it wanders off
280 * the canvas.
281 */
282function micFromEvent(e: PointerEvent): { x: number; y: number } {
283 const rect = canvas.getBoundingClientRect();
284 const L = session?.grid.L ?? DOMAIN;
285 const u = (e.clientX - rect.left) / rect.width;
286 // Screen y runs down; the grid's runs up.
287 const v = (e.clientY - rect.top) / rect.height;
288 return { x: -L / 2 + u * L, y: L / 2 - v * L };
289}
291canvas.addEventListener('pointerdown', (e) => {
292 try {
293 canvas.setPointerCapture(e.pointerId);
294 } catch {
295 // Best effort: a drag that leaves the canvas just stops tracking.
296 }
297 const at = micFromEvent(e);
298 setMic(at.x, at.y);
299 e.preventDefault();
300});
301canvas.addEventListener('pointermove', (e) => {
302 if (!canvas.hasPointerCapture(e.pointerId)) return;
303 const at = micFromEvent(e);
304 setMic(at.x, at.y);
305});
306canvas.addEventListener('pointerup', (e) => {
307 if (canvas.hasPointerCapture(e.pointerId)) canvas.releasePointerCapture(e.pointerId);
308});
310function buildParamControls(): void {
311 buildSliders(el('params'), model.params, params, (key, value) => {
312 params = { ...params, [key]: value };
313 session?.setParams(params);
314 });
315 el('scene-title').textContent = `scene — ${scene.blurb}`;
316 buildSliders(el('sceneparams'), scene.params, sceneParams, (key, value) => {
317 sceneParams = { ...sceneParams, [key]: value };
318 scheduleSceneUpdate();
319 });
320}
322/* ------------------------------------------------------------- the loop -- */
324let frames = 0;
325let lastFpsAt = performance.now();
326let msPerFrame = 0;
327/** A readback in flight; only one at a time, since they share a buffer. */
328let reading = false;
330function restart(): void {
331 if (!session) return;
332 session.reset();
333 peakSeen = 0;
334 scale = autoScale ? 1e-6 : scale;
335 dirty = true;
336}
338/**
339 * Compile the current sources into a new session and swap it in.
340 *
341 * The old session keeps running until the new one exists, and is only torn
342 * down once the swap has happened. Two reasons: a compile that fails leaves
343 * something on screen and something to edit rather than a dead page, and
344 * destroying GPU resources while the next lot of shaders are still compiling
345 * is exactly the sort of thing a browser is entitled to answer with a lost
346 * device.
347 */
348let building = false;
349async function rebuild(): Promise<void> {
350 if (building) return;
351 building = true;
352 const old = session;
353 try {
354 const next = await ModelSession.create({
355 device: device!,
356 model,
357 params,
358 source: sources.model,
359 scene,
360 sceneParams,
361 sceneSource: sources.scene,
362 n: gridN,
363 L: DOMAIN,
364 cfl,
365 });
366 next.reset();
367 session = next;
368 view.setSource(
369 next.gpu.stateBuffer(next.pressureName)!,
370 next.gpu.stateBuffer('c')!,
371 next.grid.nx,
372 next.grid.ny,
373 );
374 next.setMic(mic.x, mic.y);
375 old?.destroy();
376 peakSeen = 0;
377 scale = 1e-6;
378 dirty = true;
379 clearError();
380 showDt();
381 showRecording();
382 el<HTMLButtonElement>('recompile').classList.remove('primary');
383 el('compiled').textContent = describe(next);
384 } catch (e) {
385 // Which file the failure belongs to decides which one to show it against.
386 const which = failingFile(e);
387 showError(e, sources[which]);
388 if (e instanceof ModelCompileError && e.start !== undefined) {
389 editorFile.value = which;
390 showFile(which);
391 editor.select(e.start, e.end ?? e.start + 1);
392 }
393 } finally {
394 building = false;
395 }
396}
398/** A scene failure is reported against `medium`, everything else against the
399 * model's own functions. */
400const failingFile = (e: unknown): 'model' | 'scene' =>
401 e instanceof ModelCompileError && e.fn === 'medium' ? 'scene' : 'model';
403const describe = (s: ModelSession): string => {
404 const { init, step } = s.describe();
405 return [
406 `% init — run once`,
407 ...init.map((l) => ` ${l}`),
408 ``,
409 `% step — run ${stepsPerFrame}x per frame`,
410 ...step.map((l) => ` ${l}`),
411 ].join('\n');
412};
414/** Where the microphone sits, in grid-index coordinates, for the marker. */
415function micIndex(s: ModelSession): { ix: number; iy: number } {
416 const { L, h } = s.grid;
417 return { ix: (mic.x + L / 2) / h - 0.5, iy: (mic.y + L / 2) / h - 0.5 };
418}
420/**
421 * What the microphone has, and what it would sound like.
422 *
423 * The playback rate is just 1/dt — real time, real pitch, no translation —
424 * because dt is already a real duration. The duration of the clip is still
425 * worth watching: a pulse is a few dozen cycles, which at an audible pitch is
426 * a few tens of milliseconds however long the simulation runs. Sustained
427 * sound needs a sustained source (turn `continuous` up) and a long enough
428 * run, which is what the speed control is for.
429 */
430function showRecording(): void {
431 const info = el('recinfo');
432 if (!session) {
433 info.textContent = '';
434 return;
435 }
436 const n = session.recorder.count;
437 if (n === 0) {
438 info.textContent = 'nothing recorded yet — press Run';
439 return;
440 }
441 const plan = planPlayback(n, session.dt);
442 info.textContent =
443 `${n.toLocaleString()} samples · ${fmtTime(n * session.dt)} of simulated time · ` +
444 `${fmtTime(plan.duration)} of audio at ${Math.round(plan.rate).toLocaleString()} Hz` +
445 (plan.realTime ? '' : ' (sped up — outside the audio range)') +
446 (session.recorder.full ? ' · buffer full' : '');
447}
449/** The timestep the slider currently asks for, as a real duration. */
450function showDt(): void {
451 const out = el<HTMLOutputElement>('dtout');
452 out.textContent = session ? fmtTime(session.dt) : '—';
453 out.classList.toggle('unstable', cfl >= 1);
454 el('cfl-label').title =
455 `The timestep, as a fraction of the largest one this scheme is stable at ` +
456 `on this grid` +
457 (session ? ` (dt < ${fmtTime(session.dtLimit)} here)` : '') +
458 `. At 1 and above it diverges, which is worth seeing once.`;
459}
461/**
462 * How well the grid resolves the current source frequency: the wavelength at
463 * the background speed, divided by the cell size. Below `POOR_RESOLUTION`
464 * cells per wavelength, what is on screen is as much grid dispersion as it is
465 * sound — worth surfacing rather than leaving as a silent limitation.
466 */
467function resolutionInfo(s: ModelSession): { text: string; poor: boolean } {
468 const f = params.f ?? 1;
469 const wavelength = s.scene.cref / Math.max(f, 1e-9);
470 const cells = wavelength / s.grid.h;
471 const poor = cells < POOR_RESOLUTION;
472 return { text: `λ = ${fmtLength(wavelength)} (${cells.toFixed(1)} cells)`, poor };
473}
475function stats(): void {
476 if (!session) return;
477 const { grid } = session;
478 // The rate is reported only when it means something. Paused, the loop takes
479 // no steps and draws nothing, so any figure here would be measuring the
480 // display's refresh interval and calling it solver throughput.
481 const rate =
482 running && msPerFrame > 0
483 ? `${msPerFrame.toFixed(1)} ms/frame · ` +
484 `${((stepsPerFrame * grid.npts) / (msPerFrame * 1e3)).toFixed(0)} Mpoint/s`
485 : 'paused';
486 const res = resolutionInfo(session);
487 el('stats').innerHTML =
488 `t = <b>${fmtTime(session.t)}</b> · step <b>${session.steps}</b> · ` +
489 `dt = ${fmtTime(session.dt)} · ${grid.nx}×${grid.ny} over ${fmtLength(grid.L)} ` +
490 `(${fmtLength(grid.h)} cells) · ` +
491 `c ∈ [${fmtValue(session.scene.cmin)}, ${fmtValue(session.scene.cmax)}] m/s · ` +
492 `<span${res.poor ? ' class="warn"' : ''}>${res.text}</span> · ${rate}`;
493}
495/**
496 * Follow the field with the colour scale.
497 *
498 * The only readback in the app, and it happens a few times a second rather
499 * than every frame. Rising fast and falling slowly, so that a pulse arriving
500 * is not clipped and a pulse leaving does not make the remaining ripples
501 * flare up to full contrast.
502 */
503async function autoscaleStep(): Promise<void> {
504 if (!session || reading || !autoScale) return;
505 reading = true;
506 try {
507 const p = await session.read(session.pressureName);
508 let peak = 0;
509 for (const v of p) peak = Math.max(peak, Math.abs(v));
510 peakSeen = Math.max(peakSeen, peak);
511 scale = peak > scale ? peak : 0.97 * scale + 0.03 * peak;
512 scale = Math.max(scale, 0.02 * peakSeen, 1e-9);
513 dirty = true;
514 } catch {
515 // A rebuild can destroy the buffers mid-read; the next frame recovers.
516 } finally {
517 reading = false;
518 }
519}
521let sinceScale = 0;
522function frame(): void {
523 if (session && (running || dirty)) {
524 if (running) session.step(stepsPerFrame);
525 view.draw({
526 scale,
527 cref: session.scene.cref,
528 cdev: session.scene.cdev,
529 medium: showMedium ? 0.5 : 0,
530 mic: micIndex(session),
531 });
532 dirty = false;
533 // Only while running: a paused field cannot have changed since the last
534 // time its peak was measured, so the readback would be pure waste.
535 if (running && ++sinceScale >= 6) {
536 sinceScale = 0;
537 void autoscaleStep();
538 }
539 colorbar.setRange(-scale, scale);
540 frames++;
541 }
542 const now = performance.now();
543 if (now - lastFpsAt > 400) {
544 // Frames that drew nothing are not frames; a paused page reports no rate
545 // rather than the display's refresh interval dressed up as one.
546 msPerFrame = frames > 0 ? (now - lastFpsAt) / frames : 0;
547 frames = 0;
548 lastFpsAt = now;
549 stats();
550 showRecording();
551 }
552 requestAnimationFrame(frame);
553}
555/* -------------------------------------------------------------- wiring --- */
557function showFile(which: 'model' | 'scene'): void {
558 editor.value = sources[which];
559 el('editor-title').textContent =
560 which === 'model'
561 ? 'init and step, compiled to WebGPU'
562 : 'medium(x, y, …) → sound speed and absorption, evaluated once on the CPU';
563}
565modelSelect.addEventListener('change', () => {
566 model = mModelByKey(modelSelect.value) ?? mModels[0];
567 params = { ...defaultParams(model), ...params };
568 sources.model = model.source;
569 if (editorFile.value === 'model') showFile('model');
570 el('blurb').textContent = `${model.blurb} ${scene.blurb}`;
571 buildParamControls();
572 void rebuild();
573});
575sceneSelect.addEventListener('change', () => {
576 scene = mSceneByKey(sceneSelect.value) ?? mScenes[0];
577 sceneParams = defaultSceneParams(scene);
578 // A room is no use with the source outside it, so a scene may ask for
579 // source settings; they land in the sliders like any other value.
580 if (scene.suggest) params = { ...params, ...scene.suggest };
581 if (scene.mic) mic = { ...scene.mic };
582 sources.scene = scene.source;
583 if (editorFile.value === 'scene') showFile('scene');
584 el('blurb').textContent = `${model.blurb} ${scene.blurb}`;
585 buildParamControls();
586 session?.setParams(params);
587 applyScene();
588 restart();
589});
591el('gridsize').addEventListener('change', (e) => {
592 gridN = Number((e.target as HTMLSelectElement).value);
593 void rebuild();
594});
596const listen = el<HTMLButtonElement>('listen');
597listen.addEventListener('click', () => {
598 if (!session) return;
599 const s = session;
600 const n = s.recorder.count;
601 if (n === 0) {
602 showError(
603 new Error('the microphone has not recorded anything yet — press Run first'),
604 '',
605 );
606 return;
607 }
608 listen.disabled = true;
609 stopAudio();
610 void s.recorder
611 .read()
612 .then((trace) => playTrace(trace, planPlayback(trace.length, s.dt)))
613 .then(() => clearError())
614 .catch((e: unknown) => showError(e, ''))
615 .finally(() => {
616 listen.disabled = false;
617 });
618});
620el('cfl').addEventListener('input', (e) => {
621 cfl = Number((e.target as HTMLInputElement).value);
622 session?.setCfl(cfl);
623 showDt();
624});
626el('spf').addEventListener('change', (e) => {
627 stepsPerFrame = Number((e.target as HTMLSelectElement).value);
628 if (session) el('compiled').textContent = describe(session);
629});
631colormapSelect.addEventListener('change', () => {
632 colormapName = colormapSelect.value;
633 view.setColormap(colormaps[colormapName]);
634 colorbar.setColormap(colormaps[colormapName]);
635 dirty = true;
636});
638el('scalemode').addEventListener('change', (e) => {
639 autoScale = (e.target as HTMLSelectElement).value === 'auto';
640 dirty = true;
641});
643el('showmedium').addEventListener('change', (e) => {
644 showMedium = (e.target as HTMLInputElement).checked;
645 dirty = true;
646});
649const runPause = el<HTMLButtonElement>('runpause');
650runPause.addEventListener('click', () => {
651 running = !running;
652 runPause.textContent = running ? 'Pause' : 'Run';
653 // Start the frame-rate window fresh, so a resumed run is not averaged
654 // against the paused frames before it.
655 frames = 0;
656 lastFpsAt = performance.now();
657 dirty = true;
658});
660el('restart').addEventListener('click', restart);
662editorFile.addEventListener('change', () => {
663 showFile(editorFile.value as 'model' | 'scene');
664});
666el('recompile').addEventListener('click', () => {
667 void rebuild();
668});
670el('revert').addEventListener('click', () => {
671 const which = editorFile.value as 'model' | 'scene';
672 sources[which] = which === 'model' ? model.source : scene.source;
673 showFile(which);
674 void rebuild();
675});
677/* ---------------------------------------------------------------- start -- */
679// The grid dropdown's options are written statically in index.html; annotate
680// each with the cell size it implies on the actual domain, rather than
681// hardcoding a number that would drift if DOMAIN ever changed.
682for (const opt of el<HTMLSelectElement>('gridsize').options) {
683 const n = Number(opt.value);
684 opt.textContent = `${n}² (${fmtLength(DOMAIN / n)} cells)`;
685}
686el('domaininfo').textContent =
687 `Domain: ${fmtLength(DOMAIN)} × ${fmtLength(DOMAIN)}, background speed ` +
688 `${C_AIR} m/s (air).`;
690modelSelect.value = model.key;
691sceneSelect.value = scene.key;
692colormapSelect.value = colormapName;
693el('blurb').textContent = `${model.blurb} ${scene.blurb}`;
694buildParamControls();
695buildMicControls();
696showFile('model');
697el<HTMLInputElement>('cfl').value = String(cfl);
698showDt();
699showRecording();
700await rebuild();
701requestAnimationFrame(frame);