/ concept-collection / dulcimer
concept-collection / dulcimer
dulcimer / src / main.ts
693 lines · 22.5 KBBlameHistoryRaw
1/**
2 * The app: two MATLAB files, a GPU, a string, a box, and a microphone.
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
6 * the *body* re-evaluates the scene .m on the CPU and re-uploads five arrays,
7 * which is cheap and needs no recompile. Changing the grid, the string
8 * length, or either file's text recompiles — a fresh session, from source to
9 * shaders.
10 *
11 * There are two ways to run. *Watching*: a few timesteps per frame, the wave
12 * crawling in slow motion. *Rendering a note*: the solver flat out with no
13 * display until a chosen duration of audio exists, then playback. Both feed
14 * the same GPU-side microphone.
15 */
16import { requestAcousticDevice } from './device.ts';
17import { ModelSession } from './mgpu/session.ts';
18import { EXTERNAL_OPS } from './mgpu/externals.ts';
19import { formatFailure, ModelCompileError } from './mgpu/errors.ts';
20import {
21 dulcimerModel,
22 defaultParams,
23 type Params,
24 type ParamSpec,
25} from './mgpu/registry.ts';
26import { boxScene, defaultSceneParams, type MScene } from './scene/registry.ts';
27import { VolumeView, cameraFrame, type Camera } from './render/volume.ts';
28import { Overlay } from './render/overlay.ts';
29import { StringPlot } from './render/stringplot.ts';
30import { Colorbar, fmtValue } from './render/colorbar.ts';
31import { colormaps, colormapNames } from './render/colormaps.ts';
32import { CodeEditor } from './editor/codeEditor.ts';
33import { planPlayback, playTrace, stop as stopAudio } from './audio/play.ts';
34import { traceToWav } from './audio/wav.ts';
35import { C_AIR, POOR_RESOLUTION, fmtLength, fmtTime } from './units.ts';
37const el = <T extends HTMLElement>(id: string): T => {
38 const node = document.getElementById(id);
39 if (!node) throw new Error(`missing element #${id}`);
40 return node as T;
41};
43const errBox = el<HTMLParagraphElement>('err');
44const showError = (e: unknown, source: string): void => {
45 errBox.textContent = formatFailure(e, source);
46};
47const clearError = (): void => {
48 errBox.textContent = '';
49};
51/* ---------------------------------------------------------------- state -- */
53const model = dulcimerModel;
54const scene: MScene = boxScene;
55let params: Params = defaultParams(model);
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 gridNx = 128;
60let Ls = 0.6;
61/** Timesteps per display frame. May be fractional — ¼× means one step every
62 * fourth frame — which is what `stepDebt` accumulates toward. */
63let stepsPerFrame = 16;
64let stepDebt = 0;
65/** Where the microphone sits, in metres. Off to the side and above, out in
66 * the air the instrument radiates into. */
67const mic = { x: 0.12, y: 0.08, z: 0.1 };
68/** Paused until asked. The page compiles and draws its silent initial state
69 * on load — the string drawn back into its pluck — and nothing moves until
70 * Run (or Render note). */
71let running = false;
72let session: ModelSession | null = null;
74const camera: Camera = { az: -2.2, el: 0.45, dist: 1.35 };
75const viewState = { opacity: 2, contrast: 1.6, quality: 192, clipFrac: 0.5, exaggerate: 25 };
76const show = { field: true, body: true, string: true, wire: true, mic: true };
78/** Pressure the colormap saturates at, and whether it follows the field. */
79let scale = 1e-6;
80let autoScale = true;
81/** Largest pressure seen since the last pluck. The colour scale is not
82 * allowed to fall far below it, so that once the note has faded what is
83 * drawn is quiet air rather than roundoff at full contrast. */
84let peakSeen = 0;
85let colormapName = colormapNames[0];
86/** The last string displacement read back, for the plot and the overlay. */
87let lastString: Float32Array | null = null;
88/** Something on screen would change if we drew now. A running simulation is
89 * dirty every frame by definition; a paused one only when told. */
90let dirty = true;
91/** A note render in progress (the flag doubles as its cancel switch). */
92let renderingNote = false;
94/* ------------------------------------------------------------- the page -- */
96const device = await requestAcousticDevice().catch((e: unknown) => {
97 showError(e, '');
98 return null;
99});
100if (!device) throw new Error('no GPU');
102// A lost device takes everything with it and nothing afterwards will work, so
103// say so rather than leaving a frozen picture and no explanation.
104void device.lost.then((info) => {
105 showError(
106 new Error(
107 `the GPU device was lost (${info.reason}): ${info.message}\n` +
108 'Reload the page to start again.',
109 ),
110 '',
111 );
112});
114const canvas = el<HTMLCanvasElement>('view');
115const view = new VolumeView(device, canvas);
116view.setColormap(colormaps[colormapName]);
117const overlay = new Overlay(el<HTMLCanvasElement>('overlay'));
118const plot = new StringPlot(el<HTMLCanvasElement>('stringplot'));
119const colorbar = new Colorbar(el('colorbar'));
120colorbar.setColormap(colormaps[colormapName]);
121const darkMedia = matchMedia('(prefers-color-scheme: dark)');
123new ResizeObserver(() => {
124 view.resize();
125 overlay.resize();
126 plot.resize();
127 dirty = true;
128}).observe(canvas);
130const editor = new CodeEditor({
131 textarea: el<HTMLTextAreaElement>('source'),
132 overlay: el('highlight'),
133 external: new Set([...EXTERNAL_OPS.keys(), 'sponge3']),
134 onInput: (value) => {
135 sources[editorFile.value as 'model' | 'scene'] = value;
136 el<HTMLButtonElement>('recompile').classList.add('primary');
137 },
138});
139const editorFile = el<HTMLSelectElement>('editor-file');
141const colormapSelect = el<HTMLSelectElement>('colormap');
142for (const name of colormapNames) colormapSelect.append(new Option(name, name));
143colormapSelect.value = colormapName;
145/* ------------------------------------------------------------- controls -- */
147/** One slider per parameter the registry declares. */
148function buildSliders(
149 host: HTMLElement,
150 specs: ParamSpec[],
151 values: Params,
152 onChange: (key: string, value: number) => void,
153): Map<string, (value: number) => void> {
154 const setters = new Map<string, (value: number) => void>();
155 host.textContent = '';
156 for (const spec of specs) {
157 const row = document.createElement('label');
158 row.className = 'slider';
159 if (spec.hint) row.title = spec.hint;
160 const name = document.createElement('span');
161 name.textContent = spec.label;
162 const input = document.createElement('input');
163 input.type = 'range';
164 input.min = String(spec.min);
165 input.max = String(spec.max);
166 input.step = String(spec.step);
167 input.value = String(values[spec.key]);
168 const out = document.createElement('output');
169 out.textContent = fmtValue(values[spec.key]);
170 input.addEventListener('input', () => {
171 const v = Number(input.value);
172 out.textContent = fmtValue(v);
173 onChange(spec.key, v);
174 });
175 row.append(name, input, out);
176 host.append(row);
177 setters.set(spec.key, (value) => {
178 input.value = String(value);
179 out.textContent = fmtValue(value);
180 });
181 }
182 return setters;
185/** Scene edits go through the MATLAB interpreter, which is fast but not free;
186 * a drag should not queue up one evaluation per pixel. */
187let sceneTimer = 0;
188const scheduleSceneUpdate = (): void => {
189 clearTimeout(sceneTimer);
190 sceneTimer = window.setTimeout(applyScene, 150);
191};
193function applyScene(): void {
194 if (!session) return;
195 try {
196 session.setScene(scene, sceneParams, sources.scene);
197 clearError();
198 // A scene edit that raises the fastest speed invalidates the timestep
199 // (and with it the string grid), which only a rebuild can fix.
200 if (Math.abs(session.dtWanted - session.dt) > 1e-12 * session.dt) {
201 void rebuild();
202 return;
203 }
204 dirty = true;
205 } catch (e) {
206 showError(e, sources.scene);
207 }
210let micSliders = new Map<string, (value: number) => void>();
212function buildMicControls(): void {
213 const specs: ParamSpec[] = [
214 { key: 'x', label: 'mic x (m)', value: mic.x, min: -0.45, max: 0.45, step: 0.01 },
215 { key: 'y', label: 'mic y (m)', value: mic.y, min: -0.22, max: 0.22, step: 0.01 },
216 { key: 'z', label: 'mic z (m)', value: mic.z, min: -0.22, max: 0.22, step: 0.01 },
217 ];
218 micSliders = buildSliders(
219 el('micparams'),
220 specs,
221 { x: mic.x, y: mic.y, z: mic.z },
222 (key, value) => {
223 mic[key as 'x' | 'y' | 'z'] = value;
224 session?.setMic(mic.x, mic.y, mic.z);
225 dirty = true;
226 },
227 );
230function buildParamControls(): void {
231 buildSliders(el('params'), model.params, params, (key, value) => {
232 params = { ...params, [key]: value };
233 session?.setParams(params);
234 });
235 el('scene-title').textContent = `body — ${scene.blurb}`;
236 buildSliders(el('sceneparams'), scene.params, sceneParams, (key, value) => {
237 sceneParams = { ...sceneParams, [key]: value };
238 scheduleSceneUpdate();
239 });
242/* ------------------------------------------------------------- the loop -- */
244let frames = 0;
245let lastFpsAt = performance.now();
246let msPerFrame = 0;
247/** A readback in flight; only one at a time, since they share a buffer. */
248let reading = false;
250function pluck(): void {
251 if (!session) return;
252 session.pluck();
253 peakSeen = 0;
254 if (autoScale) scale = 1e-6;
255 dirty = true;
256 showRecording();
259/**
260 * Compile the current sources into a new session and swap it in.
261 *
262 * The old session keeps running until the new one exists, and is only torn
263 * down once the swap has happened: a compile that fails leaves something on
264 * screen and something to edit rather than a dead page, and destroying GPU
265 * resources while the next lot of shaders are still compiling is exactly the
266 * sort of thing a browser is entitled to answer with a lost device.
267 */
268let building = false;
269async function rebuild(): Promise<void> {
270 if (building) return;
271 building = true;
272 renderingNote = false;
273 const old = session;
274 try {
275 const next = await ModelSession.create({
276 device: device!,
277 model,
278 params,
279 source: sources.model,
280 scene,
281 sceneParams,
282 sceneSource: sources.scene,
283 nx: gridNx,
284 Ls,
285 });
286 next.pluck();
287 session = next;
288 view.setSource(
289 next.gpu.stateBuffer(next.pressureName)!,
290 next.gpu.stateBuffer('wall')!,
291 {
292 nx: next.air.nx, ny: next.air.ny, nz: next.air.nz,
293 Lx: next.air.Lx, Ly: next.air.Ly, Lz: next.air.Lz, h: next.air.h,
294 },
295 );
296 next.setMic(mic.x, mic.y, mic.z);
297 old?.destroy();
298 lastString = null;
299 peakSeen = 0;
300 scale = 1e-6;
301 dirty = true;
302 clearError();
303 showRecording();
304 showStatics();
305 el<HTMLButtonElement>('recompile').classList.remove('primary');
306 el('compiled').textContent = describe(next);
307 } catch (e) {
308 // Which file the failure belongs to decides which one to show it against.
309 const which = failingFile(e);
310 showError(e, sources[which]);
311 if (e instanceof ModelCompileError && e.start !== undefined) {
312 editorFile.value = which;
313 showFile(which);
314 editor.select(e.start, e.end ?? e.start + 1);
315 }
316 } finally {
317 building = false;
318 }
321/** A scene failure is reported against `medium`, everything else against the
322 * model's own functions. */
323const failingFile = (e: unknown): 'model' | 'scene' =>
324 e instanceof ModelCompileError && e.fn === 'medium' ? 'scene' : 'model';
326const describe = (s: ModelSession): string => {
327 const { init, step } = s.describe();
328 return [
329 `% init — one pluck`,
330 ...init.map((l) => ` ${l}`),
331 ``,
332 `% step — every timestep`,
333 ...step.map((l) => ` ${l}`),
334 ].join('\n');
335};
337/** What the microphone has, and what it would sound like. */
338function showRecording(): void {
339 const info = el('recinfo');
340 if (!session) {
341 info.textContent = '';
342 return;
343 }
344 const n = session.recorder.count;
345 if (n === 0) {
346 info.textContent = 'nothing recorded yet — Run, or Render note';
347 return;
348 }
349 const plan = planPlayback(n, session.dt);
350 info.textContent =
351 `${fmtTime(n * session.dt)} recorded · plays at ` +
352 `${Math.round(plan.rate).toLocaleString()} Hz` +
353 (plan.realTime ? '' : ' (rate clamped)') +
354 (session.recorder.full ? ' · buffer full' : '');
357/** The facts that only change on a rebuild. */
358function showStatics(): void {
359 if (!session) return;
360 const { air, string } = session;
361 const fmax = C_AIR / (POOR_RESOLUTION * air.h);
362 el('domaininfo').textContent =
363 `The air is a ${fmtLength(air.Lx)} × ${fmtLength(air.Ly)} × ${fmtLength(air.Lz)} box ` +
364 `at ${air.nx}×${air.ny}×${air.nz} cells of ${fmtLength(air.h)} — honest to about ` +
365 `${(fmax / 1000).toFixed(1)} kHz. The string has ${string.ns} nodes; ` +
366 `dt = ${fmtTime(session.dt)} (${Math.round(1 / session.dt / 1000)} kHz).`;
369function stats(): void {
370 if (!session) return;
371 const rate =
372 running && msPerFrame > 0 ? `${msPerFrame.toFixed(1)} ms/frame` : 'paused';
373 const f = params.f0 ?? 0;
374 const partials = Math.max(1, Math.floor(C_AIR / (POOR_RESOLUTION * session.air.h) / Math.max(f, 1)));
375 el('stats').innerHTML =
376 `t = <b>${fmtTime(session.t)}</b> · step <b>${session.steps.toLocaleString()}</b> · ` +
377 `${fmtValue(f)} Hz fundamental — the air carries its first ~${partials} partials · ${rate}`;
380/**
381 * Follow the field with the colour scale, and keep the string plot fed.
382 *
383 * The only readbacks in the app, a few times a second rather than every
384 * frame, and strictly one at a time — they share a staging buffer.
385 */
386async function pollFields(): Promise<void> {
387 if (!session || reading) return;
388 reading = true;
389 try {
390 const u = await session.read(session.displacementName);
391 lastString = u;
392 if (autoScale) {
393 const p = await session.read(session.pressureName);
394 let peak = 0;
395 for (const v of p) peak = Math.max(peak, Math.abs(v));
396 peakSeen = Math.max(peakSeen, peak);
397 scale = peak > scale ? peak : 0.97 * scale + 0.03 * peak;
398 scale = Math.max(scale, 0.05 * peakSeen, 1e-12);
399 }
400 dirty = true;
401 } catch {
402 // A rebuild can destroy the buffers mid-read; the next poll recovers.
403 } finally {
404 reading = false;
405 }
408let lastPollAt = 0;
409function frame(): void {
410 if (session && (running || dirty)) {
411 if (running && !renderingNote) {
412 // Fractional speeds accumulate a debt and step when it reaches a whole
413 // timestep, so ¼× is one step every fourth frame rather than nothing.
414 stepDebt += stepsPerFrame;
415 const n = Math.floor(stepDebt);
416 if (n > 0) {
417 session.step(n);
418 stepDebt -= n;
419 }
420 }
421 const cf = cameraFrame(camera, canvas.width / Math.max(canvas.height, 1));
422 view.draw({
423 frame: cf,
424 scale: Math.max(scale, 1e-20),
425 opacity: show.field ? viewState.opacity : 0,
426 contrast: viewState.contrast,
427 steps: viewState.quality,
428 clipY: viewState.clipFrac * session.air.Ly,
429 body: show.body ? 0.6 : 0,
430 mic: show.mic ? mic : null,
431 });
432 overlay.draw({
433 frame: cf,
434 geometry: {
435 Ls: session.string.Ls,
436 stringZ: scene.stringZ(sceneParams),
437 boxl: sceneParams.boxl ?? 0,
438 boxw: sceneParams.boxw ?? 0,
439 boxd: sceneParams.boxd ?? 0,
440 holer: sceneParams.holer ?? 0,
441 holex: sceneParams.holex ?? 0,
442 },
443 string: show.string ? lastString : null,
444 exaggerate: viewState.exaggerate,
445 wireframe: show.wire,
446 });
447 plot.draw(lastString, 1.1 * (params.amp ?? 0.002), darkMedia.matches);
448 el('stringlabel').textContent =
449 `string displacement, full scale ±${fmtValue(1100 * (params.amp ?? 0.002))} mm`;
450 dirty = false;
451 colorbar.setRange(-scale, scale);
452 frames++;
454 const now = performance.now();
455 if ((running || renderingNote) && now - lastPollAt > 250) {
456 lastPollAt = now;
457 void pollFields();
458 }
459 }
460 const now = performance.now();
461 if (now - lastFpsAt > 400) {
462 msPerFrame = frames > 0 ? (now - lastFpsAt) / frames : 0;
463 frames = 0;
464 lastFpsAt = now;
465 stats();
466 showRecording();
467 }
468 requestAnimationFrame(frame);
471/* ------------------------------------------------------- rendering a note -- */
473/**
474 * Pluck, then run the solver as fast as the GPU will go — no display frames
475 * in the way, just batches of steps with a sync between them so the queue
476 * never runs unboundedly ahead — until the requested duration of audio is in
477 * the trace. Then play it.
478 */
479async function renderNote(): Promise<void> {
480 if (!session || building) return;
481 const btn = el<HTMLButtonElement>('rendernote');
482 if (renderingNote) {
483 renderingNote = false; // cancel: the loop below notices and stops
484 return;
485 }
486 renderingNote = true;
487 setRunning(false);
488 btn.textContent = 'Cancel';
489 const prog = el<HTMLProgressElement>('renderprog');
490 prog.hidden = false;
491 prog.value = 0;
492 const seconds = Number(el<HTMLSelectElement>('renderdur').value);
493 try {
494 pluck();
495 const s = session;
496 const total = Math.min(
497 Math.ceil(seconds / s.dt),
498 s.recorder.capacity,
499 );
500 const batch = 512;
501 let done = 0;
502 while (done < total && renderingNote && session === s) {
503 const n = Math.min(batch, total - done);
504 s.step(n);
505 done += n;
506 await s.sync();
507 prog.value = done / total;
508 }
509 if (renderingNote && session === s) {
510 const trace = await s.recorder.read();
511 stopAudio();
512 await playTrace(trace, planPlayback(trace.length, s.dt));
513 clearError();
514 }
515 } catch (e) {
516 showError(e, '');
517 } finally {
518 renderingNote = false;
519 prog.hidden = true;
520 btn.textContent = 'Render note';
521 dirty = true;
522 showRecording();
523 }
526/* -------------------------------------------------------------- wiring --- */
528function showFile(which: 'model' | 'scene'): void {
529 editor.value = sources[which];
530 el('editor-title').textContent =
531 which === 'model'
532 ? 'init and step — string and air together, compiled to WebGPU'
533 : 'medium(x, y, z, …) → walls and coupling, evaluated once on the CPU';
536el('gridsize').addEventListener('change', (e) => {
537 gridNx = Number((e.target as HTMLSelectElement).value);
538 void rebuild();
539});
541el('stringlen').addEventListener('change', (e) => {
542 Ls = Number((e.target as HTMLSelectElement).value);
543 void rebuild();
544});
546const runPause = el<HTMLButtonElement>('runpause');
547const setRunning = (r: boolean): void => {
548 running = r;
549 runPause.textContent = r ? 'Pause' : 'Run';
550 frames = 0;
551 lastFpsAt = performance.now();
552 dirty = true;
553};
554runPause.addEventListener('click', () => setRunning(!running && !renderingNote));
556el('pluck').addEventListener('click', pluck);
558el('spf').addEventListener('change', (e) => {
559 stepsPerFrame = Number((e.target as HTMLSelectElement).value);
560});
562el('rendernote').addEventListener('click', () => void renderNote());
564const listen = el<HTMLButtonElement>('listen');
565listen.addEventListener('click', () => {
566 if (!session) return;
567 const s = session;
568 if (s.recorder.count === 0) {
569 showError(new Error('the microphone has not recorded anything yet — Run, or Render note'), '');
570 return;
571 }
572 listen.disabled = true;
573 stopAudio();
574 void s.recorder
575 .read()
576 .then((trace) => playTrace(trace, planPlayback(trace.length, s.dt)))
577 .then(() => clearError())
578 .catch((e: unknown) => showError(e, ''))
579 .finally(() => {
580 listen.disabled = false;
581 });
582});
584el('download').addEventListener('click', () => {
585 if (!session) return;
586 const s = session;
587 if (s.recorder.count === 0) {
588 showError(new Error('the microphone has not recorded anything yet — Run, or Render note'), '');
589 return;
590 }
591 void s.recorder
592 .read()
593 .then((trace) => {
594 const url = URL.createObjectURL(traceToWav(trace, s.dt));
595 const a = document.createElement('a');
596 a.href = url;
597 a.download = 'dulcimer.wav';
598 a.click();
599 setTimeout(() => URL.revokeObjectURL(url), 10_000);
600 })
601 .catch((e: unknown) => showError(e, ''));
602});
604colormapSelect.addEventListener('change', () => {
605 colormapName = colormapSelect.value;
606 view.setColormap(colormaps[colormapName]);
607 colorbar.setColormap(colormaps[colormapName]);
608 dirty = true;
609});
611el('scalemode').addEventListener('change', (e) => {
612 autoScale = (e.target as HTMLSelectElement).value === 'auto';
613});
615const bindRange = (id: string, apply: (v: number) => void): void => {
616 el<HTMLInputElement>(id).addEventListener('input', (e) => {
617 apply(Number((e.target as HTMLInputElement).value));
618 dirty = true;
619 });
620};
621bindRange('opacity', (v) => (viewState.opacity = v));
622bindRange('contrast', (v) => (viewState.contrast = v));
623bindRange('clipy', (v) => (viewState.clipFrac = v));
624bindRange('exaggerate', (v) => (viewState.exaggerate = v));
625el('quality').addEventListener('change', (e) => {
626 viewState.quality = Number((e.target as HTMLSelectElement).value);
627 dirty = true;
628});
630const bindCheck = (id: string, apply: (v: boolean) => void): void => {
631 el<HTMLInputElement>(id).addEventListener('change', (e) => {
632 apply((e.target as HTMLInputElement).checked);
633 dirty = true;
634 });
635};
636bindCheck('showfield', (v) => (show.field = v));
637bindCheck('showbody', (v) => (show.body = v));
638bindCheck('showstring', (v) => (show.string = v));
639bindCheck('showwire', (v) => (show.wire = v));
640bindCheck('showmic', (v) => (show.mic = v));
642// --- orbit ---------------------------------------------------------------
643let dragging = false;
644let last = [0, 0];
645canvas.addEventListener('pointerdown', (e) => {
646 dragging = true;
647 last = [e.clientX, e.clientY];
648 canvas.setPointerCapture(e.pointerId);
649});
650canvas.addEventListener('pointermove', (e) => {
651 if (!dragging) return;
652 camera.az -= (e.clientX - last[0]) * 0.008;
653 camera.el = Math.min(1.5, Math.max(-1.5, camera.el + (e.clientY - last[1]) * 0.008));
654 last = [e.clientX, e.clientY];
655 dirty = true;
656});
657canvas.addEventListener('pointerup', () => (dragging = false));
658canvas.addEventListener('pointercancel', () => (dragging = false));
659canvas.addEventListener(
660 'wheel',
661 (e) => {
662 e.preventDefault();
663 camera.dist = Math.min(5, Math.max(0.7, camera.dist * Math.exp(e.deltaY * 0.001)));
664 dirty = true;
665 },
666 { passive: false },
667);
669editorFile.addEventListener('change', () => {
670 showFile(editorFile.value as 'model' | 'scene');
671});
673el('recompile').addEventListener('click', () => {
674 void rebuild();
675});
677el('revert').addEventListener('click', () => {
678 const which = editorFile.value as 'model' | 'scene';
679 sources[which] = which === 'model' ? model.source : scene.source;
680 showFile(which);
681 void rebuild();
682});
684/* ---------------------------------------------------------------- start -- */
686buildParamControls();
687buildMicControls();
688showFile('model');
689darkMedia.addEventListener('change', () => (dirty = true));
690await rebuild();
691// The initial pluck shape, so the page opens showing the string drawn back.
692void pollFields();
693requestAnimationFrame(frame);