/ proof-of-concept / soundbox
Sign in
proof-of-concept / soundbox
soundbox / src / main.ts
1008 lines · 32.3 KBBlameHistoryRaw
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');
134/** The host-provided names the editor highlights; refilled per scene, since
135 * the op set (dxx_s1, lap_m1, ...) is the scene's own. */
136const externalNames = new Set(['sponge3']);
137const editor = new CodeEditor({
138 textarea: el<HTMLTextAreaElement>('source'),
139 overlay: el('highlight'),
140 external: externalNames,
141 onInput: (value) => {
142 sources[editorFile.value as 'model' | 'scene'] = value;
143 el<HTMLButtonElement>('recompile').classList.add('primary');
144 },
145});
147const colormapSelect = el<HTMLSelectElement>('colormap');
148for (const name of colormapNames) colormapSelect.append(new Option(name, name));
149colormapSelect.value = colormapName;
151/* ---------------------------------------------------------------- utils -- */
153const objectIds = (): string[] => spec.objects.map((o) => o.id);
155function nextId(type: ObjectType): string {
156 const prefix = type === 'string' ? 's' : type === 'bar' ? 'b' : 'm';
157 for (let n = 1; ; n++) {
158 const id = `${prefix}${n}`;
159 if (!objectIds().includes(id)) return id;
160 }
163/** What the excite button should say for this scene. */
164function verb(): string {
165 const verbs = new Set(spec.objects.map((o) => templateOf(o).verb));
166 return verbs.size === 1 ? [...verbs][0] : 'Excite';
169/** Reconcile the parameter dicts with the current spec: defaults for new
170 * keys, current values kept where they still apply. */
171function syncParams(): void {
172 const d = specParams(spec);
173 params = { ...d.params, ...pick(params, Object.keys(d.params)) };
174 sceneParams = { ...d.sceneParams, ...pick(sceneParams, Object.keys(d.sceneParams)) };
176const pick = (o: Params, keys: string[]): Params =>
177 Object.fromEntries(keys.filter((k) => k in o).map((k) => [k, o[k]]));
179/** Structure changed: keep the builder's values in the spec so presets and
180 * future codegen see them. */
181function writeBack(): void {
182 for (const o of spec.objects) {
183 const t = templateOf(o);
184 o.params = Object.fromEntries(
185 t.paramSpecs.map((s) => [s.key, params[suffixed(s.key, o.id)] ?? s.value]),
186 );
187 o.place = Object.fromEntries(
188 t.placeSpecs.map((s) => [s.key, sceneParams[suffixed(s.key, o.id)] ?? s.value]),
189 );
190 }
191 spec.body.params = pick(
192 sceneParams,
193 BODY_TEMPLATES[spec.body.type].paramSpecs.map((s) => s.key),
194 );
197/* ------------------------------------------------------------- controls -- */
199/** One slider per parameter spec. */
200function buildSliders(
201 host: HTMLElement,
202 specs: ParamSpec[],
203 values: Params,
204 onChange: (key: string, value: number) => void,
205): void {
206 host.textContent = '';
207 addSliders(host, specs, values, onChange);
210function addSliders(
211 host: HTMLElement,
212 specs: ParamSpec[],
213 values: Params,
214 onChange: (key: string, value: number) => void,
215): void {
216 for (const spec of specs) {
217 const row = document.createElement('label');
218 row.className = 'slider';
219 if (spec.hint) row.title = spec.hint;
220 const name = document.createElement('span');
221 name.textContent = spec.label;
222 const input = document.createElement('input');
223 input.type = 'range';
224 input.min = String(spec.min);
225 input.max = String(spec.max);
226 input.step = String(spec.step);
227 input.value = String(values[spec.key] ?? spec.value);
228 const out = document.createElement('output');
229 out.textContent = fmtValue(values[spec.key] ?? spec.value);
230 input.addEventListener('input', () => {
231 const v = Number(input.value);
232 out.textContent = fmtValue(v);
233 onChange(spec.key, v);
234 });
235 row.append(name, input, out);
236 host.append(row);
237 }
240/** Scene edits go through the MATLAB interpreter, which is fast but not free;
241 * a drag should not queue up one evaluation per pixel. */
242let sceneTimer = 0;
243const scheduleSceneUpdate = (): void => {
244 clearTimeout(sceneTimer);
245 sceneTimer = window.setTimeout(applyScene, 150);
246};
248function applyScene(): void {
249 if (!session) return;
250 try {
251 session.setScene(sceneParams, sources.scene ?? undefined);
252 clearError();
253 // A scene edit that raises the fastest speed invalidates the timestep
254 // (and with it every object grid), which only a rebuild can fix.
255 if (Math.abs(session.dtWanted - session.dt) > 1e-12 * session.dt) {
256 void rebuild();
257 return;
258 }
259 dirty = true;
260 } catch (e) {
261 showError(e, sources.scene ?? session.sceneSource);
262 }
265const onModelParam = (key: string, value: number): void => {
266 params = { ...params, [key]: value };
267 session?.setParams(params);
268};
269const onSceneParam = (key: string, value: number): void => {
270 sceneParams = { ...sceneParams, [key]: value };
271 scheduleSceneUpdate();
272};
274function buildPresetCards(): void {
275 const host = el('presets');
276 host.textContent = '';
277 for (const p of PRESETS) {
278 const card = document.createElement('button');
279 card.className = `preset${p.key === presetKey ? ' active' : ''}`;
280 card.innerHTML = `<b>${p.label}</b><span>${p.blurb}</span>`;
281 card.addEventListener('click', () => {
282 presetKey = p.key;
283 location.hash = `preset/${p.key}`;
284 spec = cloneSpec(p.spec);
285 Object.assign(mic, p.mic);
286 ({ params, sceneParams } = specParams(spec));
287 sources.model = null;
288 sources.scene = null;
289 refreshControls();
290 void rebuild();
291 });
292 host.append(card);
293 }
296function buildMusicalSliders(): void {
297 const host = el('musicalparams');
298 host.textContent = '';
299 for (const o of spec.objects) {
300 const t = templateOf(o);
301 const specs = suffixedSpecs(t.paramSpecs, o.id)
302 .filter((s) => s.musical)
303 .map((s) => ({ ...s, label: `${o.id} · ${s.label}` }));
304 addSliders(host, specs, params, onModelParam);
305 }
308function buildObjectCards(): void {
309 const host = el('objects');
310 host.textContent = '';
311 spec.objects.forEach((o, index) => {
312 const t = templateOf(o);
313 const card = document.createElement('div');
314 card.className = 'objcard';
315 const head = document.createElement('div');
316 head.className = 'objcard-head';
317 const swatch = document.createElement('i');
318 swatch.className = 'swatch';
319 swatch.style.background = objectColor(index);
320 const title = document.createElement('b');
321 title.textContent = `${t.label} ${o.id}`;
322 const blurb = document.createElement('span');
323 blurb.className = 'sub';
324 blurb.style.margin = '0';
325 blurb.textContent = t.blurb;
326 const remove = document.createElement('button');
327 remove.textContent = 'remove';
328 remove.disabled = spec.objects.length === 1;
329 remove.title = remove.disabled
330 ? 'A scene needs at least one object'
331 : 'Remove this object (recompiles)';
332 remove.addEventListener('click', () => {
333 writeBack();
334 spec.objects = spec.objects.filter((x) => x.id !== o.id);
335 presetKey = null;
336 syncParams();
337 refreshControls();
338 void rebuild();
339 });
340 head.append(swatch, title, blurb, remove);
341 card.append(head);
343 const sliders = document.createElement('div');
344 sliders.className = 'sliders';
345 // Sizes first: they rebuild. Then placement (scene) and parameters.
346 addSliders(
347 sliders,
348 t.sizeSpecs,
349 { ...Object.fromEntries(t.sizeSpecs.map((s) => [s.key, (o.size as Params)[s.key] ?? s.value])) },
350 (key, value) => {
351 writeBack();
352 (o.size as Params)[key] = value;
353 presetKey = null;
354 scheduleRebuild();
355 },
356 );
357 addSliders(sliders, suffixedSpecs(t.placeSpecs, o.id), sceneParams, onSceneParam);
358 addSliders(sliders, suffixedSpecs(t.paramSpecs, o.id), params, onModelParam);
359 card.append(sliders);
360 host.append(card);
361 });
364function buildBodyControls(): void {
365 const select = el<HTMLSelectElement>('bodytype');
366 select.value = spec.body.type;
367 el('bodyblurb').textContent = BODY_TEMPLATES[spec.body.type].blurb;
368 buildSliders(
369 el('bodyparams'),
370 BODY_TEMPLATES[spec.body.type].paramSpecs,
371 sceneParams,
372 onSceneParam,
373 );
376function buildMicControls(): void {
377 const specs: ParamSpec[] = [
378 { key: 'x', label: 'mic x (m)', value: mic.x, min: -0.45, max: 0.45, step: 0.01 },
379 { key: 'y', label: 'mic y (m)', value: mic.y, min: -0.22, max: 0.22, step: 0.01 },
380 { key: 'z', label: 'mic z (m)', value: mic.z, min: -0.22, max: 0.22, step: 0.01 },
381 ];
382 buildSliders(el('micparams'), specs, { x: mic.x, y: mic.y, z: mic.z }, (key, value) => {
383 mic[key as 'x' | 'y' | 'z'] = value;
384 session?.setMic(mic.x, mic.y, mic.z);
385 dirty = true;
386 });
389function buildObjPick(): void {
390 const pick = el<HTMLSelectElement>('objpick');
391 pick.textContent = '';
392 for (const o of spec.objects) {
393 pick.append(new Option(`${templateOf(o).label} ${o.id}`, o.id));
394 }
395 if (!plotId || !objectIds().includes(plotId)) plotId = spec.objects[0]?.id ?? null;
396 if (plotId) pick.value = plotId;
399function refreshControls(): void {
400 el<HTMLButtonElement>('excite').textContent = verb();
401 buildPresetCards();
402 buildMusicalSliders();
403 buildObjectCards();
404 buildBodyControls();
405 buildMicControls();
406 buildObjPick();
409/* ------------------------------------------------------------- the loop -- */
411let frames = 0;
412let lastFpsAt = performance.now();
413let msPerFrame = 0;
414/** A readback in flight; only one at a time, since they share a buffer. */
415let reading = false;
417function excite(): void {
418 if (!session) return;
419 session.excite();
420 peakSeen = 0;
421 if (autoScale) scale = 1e-6;
422 plotPeak.clear();
423 dirty = true;
424 showRecording();
427/**
428 * Compile the current scene into a new session and swap it in.
429 *
430 * The old session keeps running until the new one exists, and is only torn
431 * down once the swap has happened: a compile that fails leaves something on
432 * screen and something to edit rather than a dead page.
433 */
434let building = false;
435let rebuildAgain = false;
436async function rebuild(): Promise<void> {
437 if (building) {
438 rebuildAgain = true;
439 return;
440 }
441 building = true;
442 renderingClip = false;
443 const old = session;
444 try {
445 syncParams();
446 const next = await StudioSession.create({
447 device: device!,
448 spec,
449 params,
450 sceneParams,
451 modelSource: sources.model ?? undefined,
452 sceneSource: sources.scene ?? undefined,
453 nx: gridNx,
454 });
455 next.excite();
456 session = next;
457 view.setSource(
458 next.gpu.stateBuffer('p')!,
459 next.gpu.stateBuffer('wall')!,
460 {
461 nx: next.air.nx, ny: next.air.ny, nz: next.air.nz,
462 Lx: next.air.Lx, Ly: next.air.Ly, Lz: next.air.Lz, h: next.air.h,
463 },
464 );
465 next.setMic(mic.x, mic.y, mic.z);
466 old?.destroy();
467 lastFields.clear();
468 plotPeak.clear();
469 peakSeen = 0;
470 scale = 1e-6;
471 lastTrace = null;
472 dirty = true;
473 clearError();
474 showRecording();
475 showStatics();
476 el<HTMLButtonElement>('recompile').classList.remove('primary');
477 el('compiled').textContent = describe(next);
478 externalNames.clear();
479 externalNames.add('sponge3');
480 for (const op of next.generated.ops) externalNames.add(op.name);
481 showFile(editorFile.value as 'model' | 'scene');
482 drawSpec();
483 } catch (e) {
484 const which = failingFile(e);
485 showError(e, sources[which] ?? '');
486 if (e instanceof ModelCompileError && e.start !== undefined && sources[which]) {
487 editorFile.value = which;
488 showFile(which);
489 editor.select(e.start, e.end ?? e.start + 1);
490 }
491 } finally {
492 building = false;
493 if (rebuildAgain) {
494 rebuildAgain = false;
495 void rebuild();
496 }
497 }
500/** Size-slider drags would rebuild per pixel; give them a moment to settle. */
501let rebuildTimer = 0;
502const scheduleRebuild = (): void => {
503 clearTimeout(rebuildTimer);
504 rebuildTimer = window.setTimeout(() => void rebuild(), 250);
505};
507/** A scene failure is reported against `medium`, everything else against the
508 * model's own functions. */
509const failingFile = (e: unknown): 'model' | 'scene' =>
510 e instanceof ModelCompileError && e.fn === 'medium' ? 'scene' : 'model';
512const describe = (s: StudioSession): string => {
513 const { init, step } = s.describe();
514 return [
515 `% init — one ${verb().toLowerCase()}`,
516 ...init.map((l) => ` ${l}`),
517 ``,
518 `% step — every timestep`,
519 ...step.map((l) => ` ${l}`),
520 ].join('\n');
521};
523/** What the microphone has, and what it would sound like. */
524function showRecording(): void {
525 const info = el('recinfo');
526 if (!session) {
527 info.textContent = '';
528 return;
529 }
530 const n = session.recorder.count;
531 if (n === 0) {
532 info.textContent = 'nothing recorded yet — Run, or Render clip';
533 return;
534 }
535 const plan = planPlayback(n, session.dt);
536 info.textContent =
537 `${fmtTime(n * session.dt)} recorded · plays at ` +
538 `${Math.round(plan.rate).toLocaleString()} Hz` +
539 (plan.realTime ? '' : ' (rate clamped)') +
540 (session.recorder.full ? ' · buffer full' : '');
543/** The facts that only change on a rebuild. */
544function showStatics(): void {
545 if (!session) return;
546 const { air } = session;
547 const fmax = C_AIR / (POOR_RESOLUTION * air.h);
548 const objects = spec.objects
549 .map((o) => {
550 const g = session!.grids[o.id];
551 return `${o.id}: ${g.kind === 'line' ? `${g.n1} nodes` : `${g.n1}×${g.n2} nodes`}`;
552 })
553 .join(', ');
554 el('domaininfo').textContent =
555 `The air is a ${fmtLength(air.Lx)} × ${fmtLength(air.Ly)} × ${fmtLength(air.Lz)} box ` +
556 `at ${air.nx}×${air.ny}×${air.nz} cells of ${fmtLength(air.h)} — honest to about ` +
557 `${(fmax / 1000).toFixed(1)} kHz. Objects: ${objects}. ` +
558 `dt = ${fmtTime(session.dt)} (${Math.round(1 / session.dt / 1000)} kHz).`;
561function stats(): void {
562 if (!session) return;
563 const rate =
564 running && msPerFrame > 0 ? `${msPerFrame.toFixed(1)} ms/frame` : 'paused';
565 el('stats').innerHTML =
566 `t = <b>${fmtTime(session.t)}</b> · step <b>${session.steps.toLocaleString()}</b> · ` +
567 `${spec.objects.length} object${spec.objects.length === 1 ? '' : 's'} · ${rate}`;
570/**
571 * Follow the field with the colour scale, and keep the overlay and plot fed.
572 * The only readbacks in the app, a few times a second rather than every
573 * frame, and strictly one at a time — they share a staging buffer.
574 */
575async function pollFields(): Promise<void> {
576 if (!session || reading) return;
577 reading = true;
578 try {
579 for (const o of spec.objects) {
580 const u = await session.read(`u_${o.id}`);
581 lastFields.set(o.id, u);
582 let peak = plotPeak.get(o.id) ?? 0;
583 for (const v of u) peak = Math.max(peak, Math.abs(v));
584 plotPeak.set(o.id, peak);
585 }
586 if (autoScale) {
587 const p = await session.read('p');
588 let peak = 0;
589 for (const v of p) peak = Math.max(peak, Math.abs(v));
590 peakSeen = Math.max(peakSeen, peak);
591 scale = peak > scale ? peak : 0.97 * scale + 0.03 * peak;
592 scale = Math.max(scale, 0.05 * peakSeen, 1e-12);
593 }
594 dirty = true;
595 } catch {
596 // A rebuild can destroy the buffers mid-read; the next poll recovers.
597 } finally {
598 reading = false;
599 }
602/** The overlay's view of the scene, from the builder state. */
603function overlayObjects(): OverlayObject[] {
604 return spec.objects.map((o, index) => {
605 const g = session?.grids[o.id];
606 const color = objectColor(index);
607 if (o.type === 'membrane') {
608 return {
609 kind: 'rect' as const,
610 color,
611 L1: o.size.L1,
612 L2: o.size.L2 ?? o.size.L1,
613 n1: g?.n1 ?? 2,
614 n2: g?.n2 ?? 2,
615 oz: sceneParams[`oz_${o.id}`] ?? 0,
616 u: lastFields.get(o.id) ?? null,
617 };
618 }
619 return {
620 kind: 'line' as const,
621 color,
622 L1: o.size.L1,
623 oy: sceneParams[`oy_${o.id}`] ?? 0,
624 oz: sceneParams[`oz_${o.id}`] ?? 0,
625 u: lastFields.get(o.id) ?? null,
626 posts: o.type === 'string',
627 };
628 });
631/** The selected object's displacement, as a 1D slice for the plot. */
632function plotSlice(): { u: Float32Array | null; label: string } {
633 if (!plotId || !session) return { u: null, label: '' };
634 const o = spec.objects.find((x) => x.id === plotId);
635 const u = lastFields.get(plotId) ?? null;
636 const g = session.grids[plotId];
637 if (!o || !g) return { u: null, label: '' };
638 let slice = u;
639 let where = '';
640 if (o.type === 'membrane' && u) {
641 const midJ = Math.floor(g.n2 / 2);
642 slice = u.subarray(g.n1 * midJ, g.n1 * (midJ + 1));
643 where = ' (centre row)';
644 }
645 return {
646 u: slice,
647 label: `${plotId} displacement${where}, full scale ±${fmtValue(1100 * plotScale())} mm`,
648 };
650const plotScale = (): number =>
651 Math.max(plotPeak.get(plotId ?? '') ?? 0, 1e-6) * 1.05;
653let lastPollAt = 0;
654function frame(): void {
655 if (session && (running || dirty)) {
656 if (running && !renderingClip) {
657 // Fractional speeds accumulate a debt and step when it reaches a whole
658 // timestep, so ¼× is one step every fourth frame rather than nothing.
659 stepDebt += stepsPerFrame;
660 const n = Math.floor(stepDebt);
661 if (n > 0) {
662 session.step(n);
663 stepDebt -= n;
664 }
665 }
666 const cf = cameraFrame(camera, canvas.width / Math.max(canvas.height, 1));
667 view.draw({
668 frame: cf,
669 scale: Math.max(scale, 1e-20),
670 opacity: show.field ? viewState.opacity : 0,
671 contrast: viewState.contrast,
672 steps: viewState.quality,
673 clipY: viewState.clipFrac * session.air.Ly,
674 body: show.body ? 0.6 : 0,
675 mic: show.mic ? mic : null,
676 });
677 overlay.draw({
678 frame: cf,
679 objects: overlayObjects(),
680 body: { type: spec.body.type, params: sceneParams },
681 exaggerate: viewState.exaggerate,
682 wireframe: show.wire,
683 showObjects: show.objects,
684 });
685 const slice = plotSlice();
686 plot.draw(slice.u, plotScale(), darkMedia.matches);
687 el('objlabel').textContent = slice.label;
688 dirty = false;
689 colorbar.setRange(-scale, scale);
690 frames++;
692 const now = performance.now();
693 if ((running || renderingClip) && now - lastPollAt > 250) {
694 lastPollAt = now;
695 void pollFields();
696 }
697 }
698 const now = performance.now();
699 if (now - lastFpsAt > 400) {
700 msPerFrame = frames > 0 ? (now - lastFpsAt) / frames : 0;
701 frames = 0;
702 lastFpsAt = now;
703 stats();
704 showRecording();
705 }
706 requestAnimationFrame(frame);
709/* ---------------------------------------------------------- the spectrum -- */
711function drawSpec(): void {
712 const canvasEl = el<HTMLCanvasElement>('spectrum');
713 const legend = el('speclegend');
714 legend.textContent = '';
715 if (!session || !lastTrace) {
716 const ctx = canvasEl.getContext('2d');
717 ctx?.clearRect(0, 0, canvasEl.width, canvasEl.height);
718 return;
719 }
720 const fair = C_AIR / (POOR_RESOLUTION * session.air.h);
721 const fmax = Math.min(6000, Math.max(1000, 1.3 * fair));
722 const partials: PartialSet[] = spec.objects.map((o) =>
723 predictedPartials(o, session!.grids[o.id], params, fmax),
724 );
725 drawSpectrum(canvasEl, {
726 trace: lastTrace.trace,
727 dt: lastTrace.dt,
728 fmax,
729 partials,
730 dark: darkMedia.matches,
731 });
732 partials.forEach((set, i) => {
733 const item = document.createElement('i');
734 item.style.color = objectColor(i);
735 item.textContent = `■ ${set.label}`;
736 legend.append(item);
737 });
740async function analyzeTrace(): Promise<Float32Array | null> {
741 if (!session || session.recorder.count === 0) return null;
742 const s = session;
743 const trace = await s.recorder.read();
744 lastTrace = { trace, dt: s.dt };
745 drawSpec();
746 return trace;
749/* ------------------------------------------------------- rendering a clip -- */
751/**
752 * Excite, then run the solver as fast as the GPU will go — no display frames
753 * in the way, just batches of steps with a sync between them so the queue
754 * never runs unboundedly ahead — until the requested duration of audio is in
755 * the trace. Then play it.
756 */
757async function renderClip(): Promise<void> {
758 if (!session || building) return;
759 const btn = el<HTMLButtonElement>('renderclip');
760 if (renderingClip) {
761 renderingClip = false; // cancel: the loop below notices and stops
762 return;
763 }
764 renderingClip = true;
765 setRunning(false);
766 btn.textContent = 'Cancel';
767 const prog = el<HTMLProgressElement>('renderprog');
768 prog.hidden = false;
769 prog.value = 0;
770 const seconds = Number(el<HTMLSelectElement>('renderdur').value);
771 try {
772 excite();
773 const s = session;
774 const total = Math.min(Math.ceil(seconds / s.dt), s.recorder.capacity);
775 const batch = 512;
776 let done = 0;
777 while (done < total && renderingClip && session === s) {
778 const n = Math.min(batch, total - done);
779 s.step(n);
780 done += n;
781 await s.sync();
782 prog.value = done / total;
783 }
784 if (renderingClip && session === s) {
785 const trace = await analyzeTrace();
786 if (trace) {
787 stopAudio();
788 await playTrace(trace, planPlayback(trace.length, s.dt));
789 }
790 clearError();
791 }
792 } catch (e) {
793 showError(e, '');
794 } finally {
795 renderingClip = false;
796 prog.hidden = true;
797 btn.textContent = 'Render clip';
798 dirty = true;
799 showRecording();
800 }
803/* -------------------------------------------------------------- wiring --- */
805const TABS = ['play', 'build', 'lab'] as const;
806for (const tab of TABS) {
807 el(`tab-${tab}`).addEventListener('click', () => {
808 for (const t of TABS) {
809 el(`tab-${t}`).classList.toggle('active', t === tab);
810 el(`panel-${t}`).classList.toggle('active', t === tab);
811 }
812 if (tab === 'lab') drawSpec();
813 });
816function showFile(which: 'model' | 'scene'): void {
817 if (!session) return;
818 editor.value =
819 sources[which] ?? (which === 'model' ? session.generated.modelSource : session.generated.sceneSource);
820 const edited = sources[which] !== null ? ' — edited by hand' : ' — generated from the scene';
821 el('editor-title').textContent =
822 (which === 'model'
823 ? 'init and step, all objects together, compiled to WebGPU'
824 : 'medium(x, y, z, …) → walls and coupling, evaluated once on the CPU') + edited;
827el('gridsize').addEventListener('change', (e) => {
828 gridNx = Number((e.target as HTMLSelectElement).value);
829 void rebuild();
830});
832el('bodytype').addEventListener('change', (e) => {
833 writeBack();
834 spec.body = { type: (e.target as HTMLSelectElement).value as SceneSpec['body']['type'], params: {} };
835 presetKey = null;
836 syncParams();
837 Object.assign(sceneParams, defaultsOf(BODY_TEMPLATES[spec.body.type].paramSpecs));
838 refreshControls();
839 void rebuild();
840});
842const addObject = (type: ObjectType): void => {
843 writeBack();
844 const t = OBJECT_TEMPLATES[type];
845 const size: { L1: number; L2?: number } = { L1: t.sizeSpecs[0].value };
846 if (type === 'membrane') size.L2 = t.sizeSpecs[1].value;
847 spec.objects.push({ id: nextId(type), type, size, params: {}, place: {} });
848 presetKey = null;
849 syncParams();
850 refreshControls();
851 void rebuild();
852};
853el('add-string').addEventListener('click', () => addObject('string'));
854el('add-bar').addEventListener('click', () => addObject('bar'));
855el('add-membrane').addEventListener('click', () => addObject('membrane'));
857const runPause = el<HTMLButtonElement>('runpause');
858const setRunning = (r: boolean): void => {
859 running = r;
860 runPause.textContent = r ? 'Pause' : 'Run';
861 frames = 0;
862 lastFpsAt = performance.now();
863 dirty = true;
864};
865runPause.addEventListener('click', () => setRunning(!running && !renderingClip));
867el('excite').addEventListener('click', excite);
869el('spf').addEventListener('change', (e) => {
870 stepsPerFrame = Number((e.target as HTMLSelectElement).value);
871});
873el('renderclip').addEventListener('click', () => void renderClip());
875const listen = el<HTMLButtonElement>('listen');
876listen.addEventListener('click', () => {
877 if (!session) return;
878 const s = session;
879 if (s.recorder.count === 0) {
880 showError(new Error('the microphone has not recorded anything yet — Run, or Render clip'), '');
881 return;
882 }
883 listen.disabled = true;
884 stopAudio();
885 void analyzeTrace()
886 .then((trace) => (trace ? playTrace(trace, planPlayback(trace.length, s.dt)) : null))
887 .then(() => clearError())
888 .catch((e: unknown) => showError(e, ''))
889 .finally(() => {
890 listen.disabled = false;
891 });
892});
894el('download').addEventListener('click', () => {
895 if (!session) return;
896 const s = session;
897 if (s.recorder.count === 0) {
898 showError(new Error('the microphone has not recorded anything yet — Run, or Render clip'), '');
899 return;
900 }
901 void s.recorder
902 .read()
903 .then((trace) => {
904 const url = URL.createObjectURL(traceToWav(trace, s.dt));
905 const a = document.createElement('a');
906 a.href = url;
907 a.download = 'soundbox.wav';
908 a.click();
909 setTimeout(() => URL.revokeObjectURL(url), 10_000);
910 })
911 .catch((e: unknown) => showError(e, ''));
912});
914colormapSelect.addEventListener('change', () => {
915 colormapName = colormapSelect.value;
916 view.setColormap(colormaps[colormapName]);
917 colorbar.setColormap(colormaps[colormapName]);
918 dirty = true;
919});
921el('scalemode').addEventListener('change', (e) => {
922 autoScale = (e.target as HTMLSelectElement).value === 'auto';
923});
925const bindRange = (id: string, apply: (v: number) => void): void => {
926 el<HTMLInputElement>(id).addEventListener('input', (e) => {
927 apply(Number((e.target as HTMLInputElement).value));
928 dirty = true;
929 });
930};
931bindRange('opacity', (v) => (viewState.opacity = v));
932bindRange('contrast', (v) => (viewState.contrast = v));
933bindRange('clipy', (v) => (viewState.clipFrac = v));
934bindRange('exaggerate', (v) => (viewState.exaggerate = v));
935el('quality').addEventListener('change', (e) => {
936 viewState.quality = Number((e.target as HTMLSelectElement).value);
937 dirty = true;
938});
940const bindCheck = (id: string, apply: (v: boolean) => void): void => {
941 el<HTMLInputElement>(id).addEventListener('change', (e) => {
942 apply((e.target as HTMLInputElement).checked);
943 dirty = true;
944 });
945};
946bindCheck('showfield', (v) => (show.field = v));
947bindCheck('showbody', (v) => (show.body = v));
948bindCheck('showobjects', (v) => (show.objects = v));
949bindCheck('showwire', (v) => (show.wire = v));
950bindCheck('showmic', (v) => (show.mic = v));
952el<HTMLSelectElement>('objpick').addEventListener('change', (e) => {
953 plotId = (e.target as HTMLSelectElement).value;
954 dirty = true;
955});
957// --- orbit ---------------------------------------------------------------
958let dragging = false;
959let last = [0, 0];
960canvas.addEventListener('pointerdown', (e) => {
961 dragging = true;
962 last = [e.clientX, e.clientY];
963 canvas.setPointerCapture(e.pointerId);
964});
965canvas.addEventListener('pointermove', (e) => {
966 if (!dragging) return;
967 camera.az -= (e.clientX - last[0]) * 0.008;
968 camera.el = Math.min(1.5, Math.max(-1.5, camera.el + (e.clientY - last[1]) * 0.008));
969 last = [e.clientX, e.clientY];
970 dirty = true;
971});
972canvas.addEventListener('pointerup', () => (dragging = false));
973canvas.addEventListener('pointercancel', () => (dragging = false));
974canvas.addEventListener(
975 'wheel',
976 (e) => {
977 e.preventDefault();
978 camera.dist = Math.min(5, Math.max(0.7, camera.dist * Math.exp(e.deltaY * 0.001)));
979 dirty = true;
980 },
981 { passive: false },
982);
984editorFile.addEventListener('change', () => {
985 showFile(editorFile.value as 'model' | 'scene');
986});
988el('recompile').addEventListener('click', () => {
989 void rebuild();
990});
992el('revert').addEventListener('click', () => {
993 sources[editorFile.value as 'model' | 'scene'] = null;
994 void rebuild();
995});
997darkMedia.addEventListener('change', () => {
998 dirty = true;
999 drawSpec();
1000});
1002/* ---------------------------------------------------------------- start -- */
1004refreshControls();
1005await rebuild();
1006// The initial excitation shapes, so the page opens showing something.
1007void pollFields();
1008requestAnimationFrame(frame);
↑↓ move↵openescclose