/ proof-of-concept / soundbox
Sign in
proof-of-concept / soundbox
soundbox / src / main.ts
1286 lines · 42.4 KBCodeBlameHistory
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';
e18e57eRender scenes outside the browserJeremy Magland 30import { makeSceneFile, parseSceneFile, serializeSceneFile } from './studio/scenefile.ts';
a97bec5Render any scene from precomputation, and split the Play knobs by costJeremy Magland 31import { fastIneligible, prepareRoom, roomContributions, type FastRoom } from './studio/fast.ts';
32import { synthesize } from './studio/modal.ts';
33import { RoomCancelled } from './studio/room.ts';
35 drawSpectrum, objectColor, predictedPartials, type PartialSet,
36} from './studio/spectrum.ts';
37import { VolumeView, cameraFrame, type Camera } from './render/volume.ts';
38import { Overlay, type OverlayObject } from './render/overlay.ts';
39import { StringPlot } from './render/stringplot.ts';
40import { Colorbar, fmtValue } from './render/colorbar.ts';
41import { colormaps, colormapNames } from './render/colormaps.ts';
42import { CodeEditor } from './editor/codeEditor.ts';
43import { planPlayback, playTrace, stop as stopAudio } from './audio/play.ts';
44import { traceToWav } from './audio/wav.ts';
45import { C_AIR, POOR_RESOLUTION, fmtLength, fmtTime } from './units.ts';
47const el = <T extends HTMLElement>(id: string): T => {
48 const node = document.getElementById(id);
49 if (!node) throw new Error(`missing element #${id}`);
50 return node as T;
51};
53const errBox = el<HTMLParagraphElement>('err');
54const showError = (e: unknown, source: string): void => {
55 errBox.textContent = formatFailure(e, source);
56};
57const clearError = (): void => {
58 errBox.textContent = '';
59};
61/* ---------------------------------------------------------------- state -- */
63const initialPreset =
64 presetByKey(location.hash.replace(/^#preset\//, '')) ?? PRESETS[0];
65let presetKey: string | null = initialPreset.key;
66let spec: SceneSpec = cloneSpec(initialPreset.spec);
67let { params, sceneParams } = specParams(spec);
68/** The editor's hand-edited copies; null means "as generated". */
69const sources: { model: string | null; scene: string | null } = {
70 model: null,
71 scene: null,
72};
73let gridNx = 96;
74/** Timesteps per display frame. May be fractional — ¼× means one step every
75 * fourth frame — which is what `stepDebt` accumulates toward. */
76let stepsPerFrame = 16;
77let stepDebt = 0;
78const mic = { ...initialPreset.mic };
79/** Paused until asked. The page compiles and draws its silent initial state
80 * on load and nothing moves until Run (or Render clip). */
81let running = false;
82let session: StudioSession | null = null;
84const camera: Camera = { az: -2.2, el: 0.45, dist: 1.35 };
85const viewState = { opacity: 2, contrast: 1.6, quality: 192, clipFrac: 0.5, exaggerate: 25 };
86const show = { field: true, body: true, objects: true, wire: true, mic: true };
88/** Pressure the colormap saturates at, and whether it follows the field. */
89let scale = 1e-6;
90let autoScale = true;
91let peakSeen = 0;
92let colormapName = colormapNames[0];
93/** The last displacement read back per object, for the overlay and plot. */
94const lastFields = new Map<string, Float32Array>();
95/** Per-object plot scale: the largest |u| seen since the last excite. */
96const plotPeak = new Map<string, number>();
97let plotId: string | null = null;
98/** The last recording analyzed, so the spectrum can be redrawn. */
99let lastTrace: { trace: Float32Array; dt: number } | null = null;
100let dirty = true;
101let renderingClip = false;
a97bec5Render any scene from precomputation, and split the Play knobs by costJeremy Magland 102/** The fast path (src/studio/fast.ts): the cached room recording, keyed on
103 * everything that shapes it — the fourth cost tier. Model parameters reuse
104 * it; geometry, grid or mic edits key differently and re-record. */
105let fastRoom: { key: string; fr: FastRoom } | null = null;
106let fastBusy = false;
107let fastCancel = false;
108/** The last fast-synthesized clip. Live recording replaces it: whatever was
109 * recorded or synthesized most recently is what Listen and Save play. */
110let fastTrace: { trace: Float32Array; dt: number; note: string } | null = null;
112/* ------------------------------------------------------------- the page -- */
114const device = await requestAcousticDevice().catch((e: unknown) => {
115 showError(e, '');
116 return null;
117});
118if (!device) throw new Error('no GPU');
120void device.lost.then((info) => {
121 showError(
122 new Error(
123 `the GPU device was lost (${info.reason}): ${info.message}\n` +
124 'Reload the page to start again.',
125 ),
126 '',
127 );
128});
130const canvas = el<HTMLCanvasElement>('view');
131const view = new VolumeView(device, canvas);
132view.setColormap(colormaps[colormapName]);
133const overlay = new Overlay(el<HTMLCanvasElement>('overlay'));
134const plot = new StringPlot(el<HTMLCanvasElement>('objplot'));
135const colorbar = new Colorbar(el('colorbar'));
136colorbar.setColormap(colormaps[colormapName]);
137const darkMedia = matchMedia('(prefers-color-scheme: dark)');
139new ResizeObserver(() => {
140 view.resize();
141 overlay.resize();
142 plot.resize();
143 dirty = true;
144}).observe(canvas);
146const editorFile = el<HTMLSelectElement>('editor-file');
8333c76Highlight each scene's own op names in the Lab editor; add browser smoke testJeremy Magland 147/** The host-provided names the editor highlights; refilled per scene, since
148 * the op set (dxx_s1, lap_m1, ...) is the scene's own. */
149const externalNames = new Set(['sponge3']);
761af16soundbox: a scene-composition studio over the dulcimer engineJeremy Magland 150const editor = new CodeEditor({
151 textarea: el<HTMLTextAreaElement>('source'),
152 overlay: el('highlight'),
761af16soundbox: a scene-composition studio over the dulcimer engineJeremy Magland 154 onInput: (value) => {
155 sources[editorFile.value as 'model' | 'scene'] = value;
156 el<HTMLButtonElement>('recompile').classList.add('primary');
157 },
158});
160const colormapSelect = el<HTMLSelectElement>('colormap');
161for (const name of colormapNames) colormapSelect.append(new Option(name, name));
162colormapSelect.value = colormapName;
164/* ---------------------------------------------------------------- utils -- */
166const objectIds = (): string[] => spec.objects.map((o) => o.id);
168function nextId(type: ObjectType): string {
169 const prefix = type === 'string' ? 's' : type === 'bar' ? 'b' : 'm';
170 for (let n = 1; ; n++) {
171 const id = `${prefix}${n}`;
172 if (!objectIds().includes(id)) return id;
173 }
176/** What the excite button should say for this scene. */
177function verb(): string {
178 const verbs = new Set(spec.objects.map((o) => templateOf(o).verb));
179 return verbs.size === 1 ? [...verbs][0] : 'Excite';
182/** Reconcile the parameter dicts with the current spec: defaults for new
183 * keys, current values kept where they still apply. */
184function syncParams(): void {
185 const d = specParams(spec);
186 params = { ...d.params, ...pick(params, Object.keys(d.params)) };
187 sceneParams = { ...d.sceneParams, ...pick(sceneParams, Object.keys(d.sceneParams)) };
189const pick = (o: Params, keys: string[]): Params =>
190 Object.fromEntries(keys.filter((k) => k in o).map((k) => [k, o[k]]));
192/** Structure changed: keep the builder's values in the spec so presets and
193 * future codegen see them. */
194function writeBack(): void {
195 for (const o of spec.objects) {
196 const t = templateOf(o);
197 o.params = Object.fromEntries(
198 t.paramSpecs.map((s) => [s.key, params[suffixed(s.key, o.id)] ?? s.value]),
199 );
200 o.place = Object.fromEntries(
201 t.placeSpecs.map((s) => [s.key, sceneParams[suffixed(s.key, o.id)] ?? s.value]),
202 );
203 }
204 spec.body.params = pick(
205 sceneParams,
206 BODY_TEMPLATES[spec.body.type].paramSpecs.map((s) => s.key),
207 );
210/* ------------------------------------------------------------- controls -- */
212/** One slider per parameter spec. */
213function buildSliders(
214 host: HTMLElement,
215 specs: ParamSpec[],
216 values: Params,
217 onChange: (key: string, value: number) => void,
218): void {
219 host.textContent = '';
220 addSliders(host, specs, values, onChange);
223function addSliders(
224 host: HTMLElement,
225 specs: ParamSpec[],
226 values: Params,
227 onChange: (key: string, value: number) => void,
228): void {
229 for (const spec of specs) {
230 const row = document.createElement('label');
231 row.className = 'slider';
232 if (spec.hint) row.title = spec.hint;
233 const name = document.createElement('span');
234 name.textContent = spec.label;
235 const input = document.createElement('input');
236 input.type = 'range';
237 input.min = String(spec.min);
238 input.max = String(spec.max);
239 input.step = String(spec.step);
240 input.value = String(values[spec.key] ?? spec.value);
241 const out = document.createElement('output');
242 out.textContent = fmtValue(values[spec.key] ?? spec.value);
243 input.addEventListener('input', () => {
244 const v = Number(input.value);
245 out.textContent = fmtValue(v);
246 onChange(spec.key, v);
247 });
248 row.append(name, input, out);
249 host.append(row);
250 }
253/** Scene edits go through the MATLAB interpreter, which is fast but not free;
254 * a drag should not queue up one evaluation per pixel. */
255let sceneTimer = 0;
256const scheduleSceneUpdate = (): void => {
257 clearTimeout(sceneTimer);
258 sceneTimer = window.setTimeout(applyScene, 150);
259};
261function applyScene(): void {
262 if (!session) return;
263 try {
264 session.setScene(sceneParams, sources.scene ?? undefined);
265 clearError();
266 // A scene edit that raises the fastest speed invalidates the timestep
267 // (and with it every object grid), which only a rebuild can fix.
268 if (Math.abs(session.dtWanted - session.dt) > 1e-12 * session.dt) {
269 void rebuild();
270 return;
271 }
272 dirty = true;
273 } catch (e) {
274 showError(e, sources.scene ?? session.sceneSource);
275 }
278const onModelParam = (key: string, value: number): void => {
279 params = { ...params, [key]: value };
280 session?.setParams(params);
281};
282const onSceneParam = (key: string, value: number): void => {
283 sceneParams = { ...sceneParams, [key]: value };
284 scheduleSceneUpdate();
285};
287function buildPresetCards(): void {
288 const host = el('presets');
289 host.textContent = '';
290 for (const p of PRESETS) {
291 const card = document.createElement('button');
292 card.className = `preset${p.key === presetKey ? ' active' : ''}`;
293 card.innerHTML = `<b>${p.label}</b><span>${p.blurb}</span>`;
294 card.addEventListener('click', () => {
295 presetKey = p.key;
296 location.hash = `preset/${p.key}`;
297 spec = cloneSpec(p.spec);
298 Object.assign(mic, p.mic);
299 ({ params, sceneParams } = specParams(spec));
300 sources.model = null;
301 sources.scene = null;
302 refreshControls();
303 void rebuild();
304 });
305 host.append(card);
306 }
309function buildMusicalSliders(): void {
310 const host = el('musicalparams');
311 host.textContent = '';
312 for (const o of spec.objects) {
313 const t = templateOf(o);
314 const specs = suffixedSpecs(t.paramSpecs, o.id)
315 .filter((s) => s.musical)
316 .map((s) => ({ ...s, label: `${o.id} · ${s.label}` }));
317 addSliders(host, specs, params, onModelParam);
318 }
a97bec5Render any scene from precomputation, and split the Play knobs by costJeremy Magland 321/** The Play tab's geometry knobs — deliberately a separate group from the
322 * tuning sliders, because they cost what tuning never does: an object size
323 * recompiles, a body shape re-evaluates the scene, and either way the next
324 * Fast render re-records its room. Only the specs marked musical appear;
325 * the full set lives in the Build tab. */
326function buildShapeSliders(): void {
327 const host = el('shapeparams');
328 host.textContent = '';
329 for (const o of spec.objects) {
330 const t = templateOf(o);
331 const sizeSpecs = t.sizeSpecs
332 .filter((s) => s.musical)
333 .map((s) => ({ ...s, label: `${o.id} · ${s.label}` }));
334 addSliders(
335 host,
336 sizeSpecs,
337 Object.fromEntries(t.sizeSpecs.map((s) => [s.key, (o.size as Params)[s.key] ?? s.value])),
338 (key, value) => {
339 writeBack();
340 (o.size as Params)[key] = value;
341 presetKey = null;
342 scheduleRebuild();
343 },
344 );
345 }
346 const body = BODY_TEMPLATES[spec.body.type];
347 const bodySpecs = body.paramSpecs
348 .filter((s) => s.musical)
349 .map((s) => ({ ...s, label: `body · ${s.label}` }));
350 addSliders(host, bodySpecs, sceneParams, onSceneParam);
761af16soundbox: a scene-composition studio over the dulcimer engineJeremy Magland 353function buildObjectCards(): void {
354 const host = el('objects');
355 host.textContent = '';
356 spec.objects.forEach((o, index) => {
357 const t = templateOf(o);
358 const card = document.createElement('div');
359 card.className = 'objcard';
360 const head = document.createElement('div');
361 head.className = 'objcard-head';
362 const swatch = document.createElement('i');
363 swatch.className = 'swatch';
364 swatch.style.background = objectColor(index);
365 const title = document.createElement('b');
366 title.textContent = `${t.label} ${o.id}`;
367 const blurb = document.createElement('span');
368 blurb.className = 'sub';
369 blurb.style.margin = '0';
370 blurb.textContent = t.blurb;
371 const remove = document.createElement('button');
372 remove.textContent = 'remove';
373 remove.disabled = spec.objects.length === 1;
374 remove.title = remove.disabled
375 ? 'A scene needs at least one object'
376 : 'Remove this object (recompiles)';
377 remove.addEventListener('click', () => {
378 writeBack();
379 spec.objects = spec.objects.filter((x) => x.id !== o.id);
380 presetKey = null;
381 syncParams();
382 refreshControls();
383 void rebuild();
384 });
385 head.append(swatch, title, blurb, remove);
386 card.append(head);
388 const sliders = document.createElement('div');
389 sliders.className = 'sliders';
390 // Sizes first: they rebuild. Then placement (scene) and parameters.
391 addSliders(
392 sliders,
393 t.sizeSpecs,
394 { ...Object.fromEntries(t.sizeSpecs.map((s) => [s.key, (o.size as Params)[s.key] ?? s.value])) },
395 (key, value) => {
396 writeBack();
397 (o.size as Params)[key] = value;
398 presetKey = null;
399 scheduleRebuild();
400 },
401 );
402 addSliders(sliders, suffixedSpecs(t.placeSpecs, o.id), sceneParams, onSceneParam);
403 addSliders(sliders, suffixedSpecs(t.paramSpecs, o.id), params, onModelParam);
404 card.append(sliders);
405 host.append(card);
406 });
409function buildBodyControls(): void {
410 const select = el<HTMLSelectElement>('bodytype');
411 select.value = spec.body.type;
412 el('bodyblurb').textContent = BODY_TEMPLATES[spec.body.type].blurb;
413 buildSliders(
414 el('bodyparams'),
415 BODY_TEMPLATES[spec.body.type].paramSpecs,
416 sceneParams,
417 onSceneParam,
418 );
421function buildMicControls(): void {
422 const specs: ParamSpec[] = [
423 { key: 'x', label: 'mic x (m)', value: mic.x, min: -0.45, max: 0.45, step: 0.01 },
424 { key: 'y', label: 'mic y (m)', value: mic.y, min: -0.22, max: 0.22, step: 0.01 },
425 { key: 'z', label: 'mic z (m)', value: mic.z, min: -0.22, max: 0.22, step: 0.01 },
426 ];
427 buildSliders(el('micparams'), specs, { x: mic.x, y: mic.y, z: mic.z }, (key, value) => {
428 mic[key as 'x' | 'y' | 'z'] = value;
429 session?.setMic(mic.x, mic.y, mic.z);
430 dirty = true;
431 });
434function buildObjPick(): void {
435 const pick = el<HTMLSelectElement>('objpick');
436 pick.textContent = '';
437 for (const o of spec.objects) {
438 pick.append(new Option(`${templateOf(o).label} ${o.id}`, o.id));
439 }
440 if (!plotId || !objectIds().includes(plotId)) plotId = spec.objects[0]?.id ?? null;
441 if (plotId) pick.value = plotId;
444function refreshControls(): void {
445 el<HTMLButtonElement>('excite').textContent = verb();
446 buildPresetCards();
447 buildMusicalSliders();
450 buildBodyControls();
451 buildMicControls();
452 buildObjPick();
455/* ------------------------------------------------------------- the loop -- */
457let frames = 0;
458let lastFpsAt = performance.now();
459let msPerFrame = 0;
460/** A readback in flight; only one at a time, since they share a buffer. */
461let reading = false;
463function excite(): void {
464 if (!session) return;
465 session.excite();
468 if (autoScale) scale = 1e-6;
469 plotPeak.clear();
470 dirty = true;
471 showRecording();
474/**
475 * Compile the current scene into a new session and swap it in.
476 *
477 * The old session keeps running until the new one exists, and is only torn
478 * down once the swap has happened: a compile that fails leaves something on
479 * screen and something to edit rather than a dead page.
480 */
481let building = false;
482let rebuildAgain = false;
483async function rebuild(): Promise<void> {
484 if (building) {
485 rebuildAgain = true;
486 return;
487 }
488 building = true;
489 renderingClip = false;
490 const old = session;
491 try {
492 syncParams();
493 const next = await StudioSession.create({
494 device: device!,
495 spec,
496 params,
497 sceneParams,
498 modelSource: sources.model ?? undefined,
499 sceneSource: sources.scene ?? undefined,
500 nx: gridNx,
501 });
502 next.excite();
503 session = next;
504 view.setSource(
505 next.gpu.stateBuffer('p')!,
506 next.gpu.stateBuffer('wall')!,
507 {
508 nx: next.air.nx, ny: next.air.ny, nz: next.air.nz,
509 Lx: next.air.Lx, Ly: next.air.Ly, Lz: next.air.Lz, h: next.air.h,
510 },
511 );
512 next.setMic(mic.x, mic.y, mic.z);
513 old?.destroy();
514 lastFields.clear();
515 plotPeak.clear();
516 peakSeen = 0;
517 scale = 1e-6;
518 lastTrace = null;
521 clearError();
522 showRecording();
523 showStatics();
524 el<HTMLButtonElement>('recompile').classList.remove('primary');
525 el('compiled').textContent = describe(next);
527 externalNames.add('sponge3');
528 for (const op of next.generated.ops) externalNames.add(op.name);
761af16soundbox: a scene-composition studio over the dulcimer engineJeremy Magland 529 showFile(editorFile.value as 'model' | 'scene');
530 drawSpec();
531 } catch (e) {
532 const which = failingFile(e);
533 showError(e, sources[which] ?? '');
534 if (e instanceof ModelCompileError && e.start !== undefined && sources[which]) {
535 editorFile.value = which;
536 showFile(which);
537 editor.select(e.start, e.end ?? e.start + 1);
538 }
539 } finally {
540 building = false;
541 if (rebuildAgain) {
542 rebuildAgain = false;
543 void rebuild();
544 }
545 }
548/** Size-slider drags would rebuild per pixel; give them a moment to settle. */
549let rebuildTimer = 0;
550const scheduleRebuild = (): void => {
551 clearTimeout(rebuildTimer);
552 rebuildTimer = window.setTimeout(() => void rebuild(), 250);
553};
555/** A scene failure is reported against `medium`, everything else against the
556 * model's own functions. */
557const failingFile = (e: unknown): 'model' | 'scene' =>
558 e instanceof ModelCompileError && e.fn === 'medium' ? 'scene' : 'model';
560const describe = (s: StudioSession): string => {
561 const { init, step } = s.describe();
562 return [
563 `% init — one ${verb().toLowerCase()}`,
564 ...init.map((l) => ` ${l}`),
565 ``,
566 `% step — every timestep`,
567 ...step.map((l) => ` ${l}`),
568 ].join('\n');
569};
571/** What the microphone has, and what it would sound like. */
572function showRecording(): void {
573 const info = el('recinfo');
575 const { trace, dt, note } = fastTrace;
576 const plan = planPlayback(trace.length, dt);
577 info.textContent =
578 `${fmtTime(trace.length * dt)} synthesized ${note} · plays at ` +
579 `${Math.round(plan.rate).toLocaleString()} Hz` +
580 (plan.realTime ? '' : ' (rate clamped)');
581 return;
582 }
584 info.textContent = '';
585 return;
586 }
587 const n = session.recorder.count;
588 if (n === 0) {
589 info.textContent = 'nothing recorded yet — Run, or Render clip';
590 return;
591 }
592 const plan = planPlayback(n, session.dt);
593 info.textContent =
594 `${fmtTime(n * session.dt)} recorded · plays at ` +
595 `${Math.round(plan.rate).toLocaleString()} Hz` +
596 (plan.realTime ? '' : ' (rate clamped)') +
597 (session.recorder.full ? ' · buffer full' : '');
600/** The facts that only change on a rebuild. */
601function showStatics(): void {
602 if (!session) return;
603 const { air } = session;
604 const fmax = C_AIR / (POOR_RESOLUTION * air.h);
605 const objects = spec.objects
606 .map((o) => {
607 const g = session!.grids[o.id];
608 return `${o.id}: ${g.kind === 'line' ? `${g.n1} nodes` : `${g.n1}×${g.n2} nodes`}`;
609 })
610 .join(', ');
611 el('domaininfo').textContent =
612 `The air is a ${fmtLength(air.Lx)} × ${fmtLength(air.Ly)} × ${fmtLength(air.Lz)} box ` +
613 `at ${air.nx}×${air.ny}×${air.nz} cells of ${fmtLength(air.h)} — honest to about ` +
614 `${(fmax / 1000).toFixed(1)} kHz. Objects: ${objects}. ` +
615 `dt = ${fmtTime(session.dt)} (${Math.round(1 / session.dt / 1000)} kHz).`;
618function stats(): void {
619 if (!session) return;
620 const rate =
621 running && msPerFrame > 0 ? `${msPerFrame.toFixed(1)} ms/frame` : 'paused';
622 el('stats').innerHTML =
623 `t = <b>${fmtTime(session.t)}</b> · step <b>${session.steps.toLocaleString()}</b> · ` +
624 `${spec.objects.length} object${spec.objects.length === 1 ? '' : 's'} · ${rate}`;
627/**
628 * Follow the field with the colour scale, and keep the overlay and plot fed.
629 * The only readbacks in the app, a few times a second rather than every
630 * frame, and strictly one at a time — they share a staging buffer.
631 */
632async function pollFields(): Promise<void> {
633 if (!session || reading) return;
634 reading = true;
635 try {
636 for (const o of spec.objects) {
637 const u = await session.read(`u_${o.id}`);
638 lastFields.set(o.id, u);
639 let peak = plotPeak.get(o.id) ?? 0;
640 for (const v of u) peak = Math.max(peak, Math.abs(v));
641 plotPeak.set(o.id, peak);
642 }
643 if (autoScale) {
644 const p = await session.read('p');
645 let peak = 0;
646 for (const v of p) peak = Math.max(peak, Math.abs(v));
647 peakSeen = Math.max(peakSeen, peak);
648 scale = peak > scale ? peak : 0.97 * scale + 0.03 * peak;
649 scale = Math.max(scale, 0.05 * peakSeen, 1e-12);
650 }
651 dirty = true;
652 } catch {
653 // A rebuild can destroy the buffers mid-read; the next poll recovers.
654 } finally {
655 reading = false;
656 }
659/** The overlay's view of the scene, from the builder state. */
660function overlayObjects(): OverlayObject[] {
661 return spec.objects.map((o, index) => {
662 const g = session?.grids[o.id];
663 const color = objectColor(index);
664 if (o.type === 'membrane') {
665 return {
666 kind: 'rect' as const,
667 color,
668 L1: o.size.L1,
669 L2: o.size.L2 ?? o.size.L1,
670 n1: g?.n1 ?? 2,
671 n2: g?.n2 ?? 2,
672 oz: sceneParams[`oz_${o.id}`] ?? 0,
673 u: lastFields.get(o.id) ?? null,
674 };
675 }
676 return {
677 kind: 'line' as const,
678 color,
679 L1: o.size.L1,
680 oy: sceneParams[`oy_${o.id}`] ?? 0,
681 oz: sceneParams[`oz_${o.id}`] ?? 0,
682 u: lastFields.get(o.id) ?? null,
683 posts: o.type === 'string',
684 };
685 });
688/** The selected object's displacement, as a 1D slice for the plot. */
689function plotSlice(): { u: Float32Array | null; label: string } {
690 if (!plotId || !session) return { u: null, label: '' };
691 const o = spec.objects.find((x) => x.id === plotId);
692 const u = lastFields.get(plotId) ?? null;
693 const g = session.grids[plotId];
694 if (!o || !g) return { u: null, label: '' };
695 let slice = u;
696 let where = '';
697 if (o.type === 'membrane' && u) {
698 const midJ = Math.floor(g.n2 / 2);
699 slice = u.subarray(g.n1 * midJ, g.n1 * (midJ + 1));
700 where = ' (centre row)';
701 }
702 return {
703 u: slice,
704 label: `${plotId} displacement${where}, full scale ±${fmtValue(1100 * plotScale())} mm`,
705 };
707const plotScale = (): number =>
708 Math.max(plotPeak.get(plotId ?? '') ?? 0, 1e-6) * 1.05;
710let lastPollAt = 0;
711function frame(): void {
712 if (session && (running || dirty)) {
713 if (running && !renderingClip) {
714 // Fractional speeds accumulate a debt and step when it reaches a whole
715 // timestep, so ¼× is one step every fourth frame rather than nothing.
716 stepDebt += stepsPerFrame;
717 const n = Math.floor(stepDebt);
718 if (n > 0) {
719 session.step(n);
720 stepDebt -= n;
721 }
722 }
723 const cf = cameraFrame(camera, canvas.width / Math.max(canvas.height, 1));
724 view.draw({
725 frame: cf,
726 scale: Math.max(scale, 1e-20),
727 opacity: show.field ? viewState.opacity : 0,
728 contrast: viewState.contrast,
729 steps: viewState.quality,
730 clipY: viewState.clipFrac * session.air.Ly,
731 body: show.body ? 0.6 : 0,
732 mic: show.mic ? mic : null,
733 });
734 overlay.draw({
735 frame: cf,
736 objects: overlayObjects(),
737 body: { type: spec.body.type, params: sceneParams },
738 exaggerate: viewState.exaggerate,
739 wireframe: show.wire,
740 showObjects: show.objects,
741 });
742 const slice = plotSlice();
743 plot.draw(slice.u, plotScale(), darkMedia.matches);
744 el('objlabel').textContent = slice.label;
745 dirty = false;
746 colorbar.setRange(-scale, scale);
747 frames++;
749 const now = performance.now();
750 if ((running || renderingClip) && now - lastPollAt > 250) {
751 lastPollAt = now;
752 void pollFields();
753 }
754 }
755 const now = performance.now();
756 if (now - lastFpsAt > 400) {
757 msPerFrame = frames > 0 ? (now - lastFpsAt) / frames : 0;
758 frames = 0;
759 lastFpsAt = now;
760 stats();
761 showRecording();
762 }
763 requestAnimationFrame(frame);
766/* ---------------------------------------------------------- the spectrum -- */
768function drawSpec(): void {
769 const canvasEl = el<HTMLCanvasElement>('spectrum');
770 const legend = el('speclegend');
771 legend.textContent = '';
772 if (!session || !lastTrace) {
773 const ctx = canvasEl.getContext('2d');
774 ctx?.clearRect(0, 0, canvasEl.width, canvasEl.height);
775 return;
776 }
777 const fair = C_AIR / (POOR_RESOLUTION * session.air.h);
778 const fmax = Math.min(6000, Math.max(1000, 1.3 * fair));
779 const partials: PartialSet[] = spec.objects.map((o) =>
780 predictedPartials(o, session!.grids[o.id], params, fmax),
781 );
782 drawSpectrum(canvasEl, {
783 trace: lastTrace.trace,
784 dt: lastTrace.dt,
785 fmax,
786 partials,
787 dark: darkMedia.matches,
788 });
789 partials.forEach((set, i) => {
790 const item = document.createElement('i');
791 item.style.color = objectColor(i);
792 item.textContent = `■ ${set.label}`;
793 legend.append(item);
794 });
797async function analyzeTrace(): Promise<Float32Array | null> {
798 if (!session || session.recorder.count === 0) return null;
799 const s = session;
800 const trace = await s.recorder.read();
801 lastTrace = { trace, dt: s.dt };
802 drawSpec();
803 return trace;
806/* ------------------------------------------------------- rendering a clip -- */
808/**
809 * Excite, then run the solver as fast as the GPU will go — no display frames
810 * in the way, just batches of steps with a sync between them so the queue
811 * never runs unboundedly ahead — until the requested duration of audio is in
812 * the trace. Then play it.
813 */
814async function renderClip(): Promise<void> {
a97bec5Render any scene from precomputation, and split the Play knobs by costJeremy Magland 815 if (!session || building || fastBusy) return;
761af16soundbox: a scene-composition studio over the dulcimer engineJeremy Magland 816 const btn = el<HTMLButtonElement>('renderclip');
817 if (renderingClip) {
818 renderingClip = false; // cancel: the loop below notices and stops
819 return;
820 }
821 renderingClip = true;
822 setRunning(false);
823 btn.textContent = 'Cancel';
824 const prog = el<HTMLProgressElement>('renderprog');
825 prog.hidden = false;
826 prog.value = 0;
827 const seconds = Number(el<HTMLSelectElement>('renderdur').value);
828 try {
829 excite();
830 const s = session;
831 const total = Math.min(Math.ceil(seconds / s.dt), s.recorder.capacity);
832 const batch = 512;
833 let done = 0;
834 while (done < total && renderingClip && session === s) {
835 const n = Math.min(batch, total - done);
836 s.step(n);
837 done += n;
838 await s.sync();
839 prog.value = done / total;
840 }
841 if (renderingClip && session === s) {
842 const trace = await analyzeTrace();
843 if (trace) {
844 stopAudio();
845 await playTrace(trace, planPlayback(trace.length, s.dt));
846 }
847 clearError();
848 }
849 } catch (e) {
850 showError(e, '');
851 } finally {
852 renderingClip = false;
853 prog.hidden = true;
854 btn.textContent = 'Render clip';
855 dirty = true;
856 showRecording();
857 }
a97bec5Render any scene from precomputation, and split the Play knobs by costJeremy Magland 860/* ------------------------------------------------- the fast render path -- */
862/** Everything the room recording depends on. A matching key means the cached
863 * kernels are still the truth; model parameters are deliberately absent. */
864const fastKey = (): string =>
865 JSON.stringify({
866 objects: spec.objects.map((o) => ({ id: o.id, type: o.type, size: o.size })),
867 body: spec.body.type,
868 sceneParams,
869 nx: gridNx,
870 mic: { x: mic.x, y: mic.y, z: mic.z },
871 scene: sources.scene,
872 });
874/**
875 * Render the clip from precomputation: record the room once (the only slow
876 * step — about half a second of ordinary rendering), then synthesize the mic
877 * trace from the membrane's closed-form modes against the cached kernels.
878 * Any later change to pitch, decay, brightness, the strike or the gains
879 * reuses the room and lands here in about a second; geometry, grid and mic
880 * edits change the key and re-record. The volume view is untouched: the fast
881 * path produces the trace and nothing else, so the field on screen stays
882 * whatever the live solver last drew.
883 */
884async function fastRenderClip(): Promise<void> {
885 const btn = el<HTMLButtonElement>('fastrender');
886 if (fastBusy) {
887 fastCancel = true; // cancel: the recording and synthesis loops notice
888 return;
889 }
890 if (building || renderingClip) return;
891 const bad = fastIneligible(spec, sources.model ?? undefined);
892 if (bad) {
893 showError(new Error(`Fast render: ${bad}.`), '');
894 return;
895 }
896 fastBusy = true;
897 fastCancel = false;
898 setRunning(false);
899 btn.textContent = 'Cancel';
900 const prog = el<HTMLProgressElement>('renderprog');
901 prog.hidden = false;
902 prog.value = 0;
903 const seconds = Number(el<HTMLSelectElement>('renderdur').value);
904 try {
905 syncParams();
906 const P = { ...params };
907 const key = fastKey();
909 // Two attempts at most: the second only if a new strike reaches well
910 // outside the cached recording's membrane mode set, which discards it.
911 for (let attempt = 0; attempt < 2; attempt++) {
912 let synthBase = 0;
913 if (!fastRoom || fastRoom.key !== key) {
914 fastRoom = null;
915 synthBase = 0.9;
916 const fr = await prepareRoom({
917 device: device!,
918 spec,
919 params: P,
920 sceneParams: { ...sceneParams },
921 sceneSource: sources.scene ?? undefined,
922 nx: gridNx,
923 mic: { ...mic },
924 irSeconds: 0.5,
925 // Membranes recorded a decade deeper than synthesis keeps, so
926 // later strikes rarely want modes the recording lacks (strings
927 // and bars record every mode and never can).
928 modeOpts: { eps: 1e-7 },
929 onProgress: (done, total) => {
930 prog.value = 0.9 * (done / total);
931 },
932 cancelled: () => fastCancel,
933 });
934 fastRoom = { key, fr };
935 }
937 const { fr } = fastRoom;
938 const total = Math.ceil(seconds / fr.dt);
939 const { contribs, used, wanted, missing } = roomContributions(fr, spec, P, { eps: 1e-6 });
940 if (missing > 0.02 * wanted && attempt === 0) {
941 fastRoom = null; // the strike moved too far; record afresh
942 continue;
943 }
944 const master = new Float64Array(total);
945 const batch = 64;
946 for (let q0 = 0; q0 < contribs.length; q0 += batch) {
947 if (fastCancel) throw new RoomCancelled();
948 const part = synthesize(contribs.slice(q0, q0 + batch), fr.room.steps, total);
949 for (let t = 0; t < total; t++) master[t] += part[t];
950 prog.value = synthBase + (1 - synthBase) * Math.min(1, (q0 + batch) / contribs.length);
951 await new Promise((r) => setTimeout(r, 0));
952 }
954 const note =
955 `(fast, ${used} modes)` +
956 (fr.kernelDecay > 1e-3
957 ? ' · the room was still ringing where its recording ends; the clip truncates it'
958 : '');
959 fastTrace = { trace: Float32Array.from(master), dt: fr.dt, note };
960 lastTrace = { trace: fastTrace.trace, dt: fr.dt };
961 drawSpec();
962 clearError();
963 showRecording();
964 stopAudio();
965 await playTrace(fastTrace.trace, planPlayback(fastTrace.trace.length, fr.dt));
966 break;
967 }
968 } catch (e) {
969 if (!(e instanceof RoomCancelled)) showError(e, '');
970 } finally {
971 fastBusy = false;
972 prog.hidden = true;
973 btn.textContent = 'Fast render';
974 showRecording();
975 }
761af16soundbox: a scene-composition studio over the dulcimer engineJeremy Magland 978/* -------------------------------------------------------------- wiring --- */
980const TABS = ['play', 'build', 'lab'] as const;
981for (const tab of TABS) {
982 el(`tab-${tab}`).addEventListener('click', () => {
a97bec5Render any scene from precomputation, and split the Play knobs by costJeremy Magland 983 // The Play shape knobs and the Build cards are two views of the same
984 // values; rebuilding the controls on a tab switch keeps them in step.
985 refreshControls();
761af16soundbox: a scene-composition studio over the dulcimer engineJeremy Magland 986 for (const t of TABS) {
987 el(`tab-${t}`).classList.toggle('active', t === tab);
988 el(`panel-${t}`).classList.toggle('active', t === tab);
989 }
990 if (tab === 'lab') drawSpec();
991 });
994function showFile(which: 'model' | 'scene'): void {
995 if (!session) return;
996 editor.value =
997 sources[which] ?? (which === 'model' ? session.generated.modelSource : session.generated.sceneSource);
998 const edited = sources[which] !== null ? ' — edited by hand' : ' — generated from the scene';
999 el('editor-title').textContent =
1000 (which === 'model'
1001 ? 'init and step, all objects together, compiled to WebGPU'
1002 : 'medium(x, y, z, …) → walls and coupling, evaluated once on the CPU') + edited;
1005el('gridsize').addEventListener('change', (e) => {
1006 gridNx = Number((e.target as HTMLSelectElement).value);
1007 void rebuild();
1008});
e18e57eRender scenes outside the browserJeremy Magland 1010el('savescene').addEventListener('click', () => {
1011 writeBack();
1012 const file = makeSceneFile({
1013 spec, params, sceneParams, mic, nx: gridNx,
1014 modelSource: sources.model, sceneSource: sources.scene,
1015 });
1016 const url = URL.createObjectURL(
1017 new Blob([serializeSceneFile(file)], { type: 'application/json' }),
1018 );
1019 const a = document.createElement('a');
1020 a.href = url;
1021 a.download = 'soundbox-scene.json';
1022 a.click();
1023 setTimeout(() => URL.revokeObjectURL(url), 10_000);
1025 // Show what to do with the file: the exact command that renders it
1026 // outside the browser, ready to copy.
1027 const cmd = 'npm run render -- soundbox-scene.json --seconds 10 --out clip.wav';
1028 const hint = el('scenehint');
1029 hint.hidden = false;
1030 hint.textContent =
1031 'Saved soundbox-scene.json. To render it on another machine ' +
1032 '(a checkout of this repo, numbl beside it, any GPU): ';
1033 const code = document.createElement('code');
1034 code.textContent = cmd;
1035 const copy = document.createElement('button');
1036 copy.textContent = 'copy';
1037 copy.addEventListener('click', () => {
1038 void navigator.clipboard.writeText(cmd).then(() => {
1039 copy.textContent = 'copied';
1040 setTimeout(() => (copy.textContent = 'copy'), 1500);
1041 });
1042 });
1043 const tail = document.createElement('span');
1044 tail.textContent =
1045 ' — add --nx 192 or higher for more bandwidth than the browser grid; see the README.';
1046 hint.append(code, copy, tail);
1049const loadFile = el<HTMLInputElement>('loadfile');
1050el('loadscene').addEventListener('click', () => loadFile.click());
1051loadFile.addEventListener('change', () => {
1052 const f = loadFile.files?.[0];
1053 loadFile.value = '';
1054 if (!f) return;
1055 void f.text().then((text) => {
1056 try {
1057 const file = parseSceneFile(text);
1058 spec = file.spec;
1059 presetKey = null;
1060 ({ params, sceneParams } = specParams(spec));
1061 Object.assign(params, file.params);
1062 Object.assign(sceneParams, file.sceneParams);
1063 Object.assign(mic, file.mic);
1064 gridNx = file.nx;
1065 const select = el<HTMLSelectElement>('gridsize');
1066 if (![...select.options].some((o) => o.value === String(gridNx))) {
1067 select.append(new Option(String(gridNx), String(gridNx)));
1069 select.value = String(gridNx);
1070 sources.model = file.sources?.model ?? null;
1071 sources.scene = file.sources?.scene ?? null;
1072 refreshControls();
1073 clearError();
1074 void rebuild();
1075 } catch (e) {
1076 showError(e, '');
1078 });
1079});
761af16soundbox: a scene-composition studio over the dulcimer engineJeremy Magland 1081el('bodytype').addEventListener('change', (e) => {
1082 writeBack();
1083 spec.body = { type: (e.target as HTMLSelectElement).value as SceneSpec['body']['type'], params: {} };
1084 presetKey = null;
1085 syncParams();
1086 Object.assign(sceneParams, defaultsOf(BODY_TEMPLATES[spec.body.type].paramSpecs));
1087 refreshControls();
1088 void rebuild();
1089});
1091const addObject = (type: ObjectType): void => {
1092 writeBack();
1093 const t = OBJECT_TEMPLATES[type];
1094 const size: { L1: number; L2?: number } = { L1: t.sizeSpecs[0].value };
1095 if (type === 'membrane') size.L2 = t.sizeSpecs[1].value;
1096 spec.objects.push({ id: nextId(type), type, size, params: {}, place: {} });
1097 presetKey = null;
1098 syncParams();
1099 refreshControls();
1100 void rebuild();
1102el('add-string').addEventListener('click', () => addObject('string'));
1103el('add-bar').addEventListener('click', () => addObject('bar'));
1104el('add-membrane').addEventListener('click', () => addObject('membrane'));
1106const runPause = el<HTMLButtonElement>('runpause');
1107const setRunning = (r: boolean): void => {
1109 // Live stepping records over the microphone again; the synthesized clip
1110 // stops being "the latest recording" the moment the solver moves.
1111 fastTrace = null;
1112 showRecording();
1115 runPause.textContent = r ? 'Pause' : 'Run';
1116 frames = 0;
1117 lastFpsAt = performance.now();
1118 dirty = true;
a97bec5Render any scene from precomputation, and split the Play knobs by costJeremy Magland 1120runPause.addEventListener('click', () => setRunning(!running && !renderingClip && !fastBusy));
1122el('excite').addEventListener('click', excite);
1124el('spf').addEventListener('change', (e) => {
1125 stepsPerFrame = Number((e.target as HTMLSelectElement).value);
1126});
1128el('renderclip').addEventListener('click', () => void renderClip());
a97bec5Render any scene from precomputation, and split the Play knobs by costJeremy Magland 1130el('fastrender').addEventListener('click', () => void fastRenderClip());
761af16soundbox: a scene-composition studio over the dulcimer engineJeremy Magland 1132const listen = el<HTMLButtonElement>('listen');
1133listen.addEventListener('click', () => {
1135 const { trace, dt } = fastTrace;
1136 listen.disabled = true;
1137 stopAudio();
1138 void playTrace(trace, planPlayback(trace.length, dt))
1139 .then(() => clearError())
1140 .catch((e: unknown) => showError(e, ''))
1141 .finally(() => {
1142 listen.disabled = false;
1143 });
1144 return;
761af16soundbox: a scene-composition studio over the dulcimer engineJeremy Magland 1146 if (!session) return;
1147 const s = session;
1148 if (s.recorder.count === 0) {
1149 showError(new Error('the microphone has not recorded anything yet — Run, or Render clip'), '');
1150 return;
1152 listen.disabled = true;
1153 stopAudio();
1154 void analyzeTrace()
1155 .then((trace) => (trace ? playTrace(trace, planPlayback(trace.length, s.dt)) : null))
1156 .then(() => clearError())
1157 .catch((e: unknown) => showError(e, ''))
1158 .finally(() => {
1159 listen.disabled = false;
1160 });
1161});
1163el('download').addEventListener('click', () => {
1165 const url = URL.createObjectURL(traceToWav(fastTrace.trace, fastTrace.dt));
1166 const a = document.createElement('a');
1167 a.href = url;
1168 a.download = 'soundbox.wav';
1169 a.click();
1170 setTimeout(() => URL.revokeObjectURL(url), 10_000);
1171 return;
761af16soundbox: a scene-composition studio over the dulcimer engineJeremy Magland 1173 if (!session) return;
1174 const s = session;
1175 if (s.recorder.count === 0) {
1176 showError(new Error('the microphone has not recorded anything yet — Run, or Render clip'), '');
1177 return;
1179 void s.recorder
1180 .read()
1181 .then((trace) => {
1182 const url = URL.createObjectURL(traceToWav(trace, s.dt));
1183 const a = document.createElement('a');
1184 a.href = url;
1185 a.download = 'soundbox.wav';
1186 a.click();
1187 setTimeout(() => URL.revokeObjectURL(url), 10_000);
1188 })
1189 .catch((e: unknown) => showError(e, ''));
1190});
1192colormapSelect.addEventListener('change', () => {
1193 colormapName = colormapSelect.value;
1194 view.setColormap(colormaps[colormapName]);
1195 colorbar.setColormap(colormaps[colormapName]);
1196 dirty = true;
1197});
1199el('scalemode').addEventListener('change', (e) => {
1200 autoScale = (e.target as HTMLSelectElement).value === 'auto';
1201});
1203const bindRange = (id: string, apply: (v: number) => void): void => {
1204 el<HTMLInputElement>(id).addEventListener('input', (e) => {
1205 apply(Number((e.target as HTMLInputElement).value));
1206 dirty = true;
1207 });
1209bindRange('opacity', (v) => (viewState.opacity = v));
1210bindRange('contrast', (v) => (viewState.contrast = v));
1211bindRange('clipy', (v) => (viewState.clipFrac = v));
1212bindRange('exaggerate', (v) => (viewState.exaggerate = v));
1213el('quality').addEventListener('change', (e) => {
1214 viewState.quality = Number((e.target as HTMLSelectElement).value);
1215 dirty = true;
1216});
1218const bindCheck = (id: string, apply: (v: boolean) => void): void => {
1219 el<HTMLInputElement>(id).addEventListener('change', (e) => {
1220 apply((e.target as HTMLInputElement).checked);
1221 dirty = true;
1222 });
1224bindCheck('showfield', (v) => (show.field = v));
1225bindCheck('showbody', (v) => (show.body = v));
1226bindCheck('showobjects', (v) => (show.objects = v));
1227bindCheck('showwire', (v) => (show.wire = v));
1228bindCheck('showmic', (v) => (show.mic = v));
1230el<HTMLSelectElement>('objpick').addEventListener('change', (e) => {
1231 plotId = (e.target as HTMLSelectElement).value;
1232 dirty = true;
1233});
1235// --- orbit ---------------------------------------------------------------
1236let dragging = false;
1237let last = [0, 0];
1238canvas.addEventListener('pointerdown', (e) => {
1239 dragging = true;
1240 last = [e.clientX, e.clientY];
1241 canvas.setPointerCapture(e.pointerId);
1242});
1243canvas.addEventListener('pointermove', (e) => {
1244 if (!dragging) return;
1245 camera.az -= (e.clientX - last[0]) * 0.008;
1246 camera.el = Math.min(1.5, Math.max(-1.5, camera.el + (e.clientY - last[1]) * 0.008));
1247 last = [e.clientX, e.clientY];
1248 dirty = true;
1249});
1250canvas.addEventListener('pointerup', () => (dragging = false));
1251canvas.addEventListener('pointercancel', () => (dragging = false));
1252canvas.addEventListener(
1253 'wheel',
1254 (e) => {
1255 e.preventDefault();
1256 camera.dist = Math.min(5, Math.max(0.7, camera.dist * Math.exp(e.deltaY * 0.001)));
1257 dirty = true;
1258 },
1259 { passive: false },
1262editorFile.addEventListener('change', () => {
1263 showFile(editorFile.value as 'model' | 'scene');
1264});
1266el('recompile').addEventListener('click', () => {
1267 void rebuild();
1268});
1270el('revert').addEventListener('click', () => {
1271 sources[editorFile.value as 'model' | 'scene'] = null;
1272 void rebuild();
1273});
1275darkMedia.addEventListener('change', () => {
1276 dirty = true;
1277 drawSpec();
1278});
1280/* ---------------------------------------------------------------- start -- */
1282refreshControls();
1283await rebuild();
1284// The initial excitation shapes, so the page opens showing something.
1285void pollFields();
1286requestAnimationFrame(frame);
↑↓ move↵openescclose