/** * The app: two MATLAB files, a GPU, and a canvas. * * Everything the page does falls into three motions. Changing a *parameter* * writes a uniform, which is free and does not interrupt the run. Changing the * *scene* re-evaluates its .m on the CPU and re-uploads two arrays, which is * cheap and needs no recompile. Changing the *model*, the grid, or either * file's text recompiles — a fresh session, from source to shaders. */ import { requestAcousticDevice } from './device.ts'; import { ModelSession } from './mgpu/session.ts'; import { EXTERNAL_OPS } from './mgpu/externals.ts'; import { formatFailure, ModelCompileError } from './mgpu/errors.ts'; import { mModels, mModelByKey, defaultParams, type MModel, type Params, type ParamSpec, } from './mgpu/registry.ts'; import { mScenes, mSceneByKey, defaultSceneParams, type MScene, } from './scene/registry.ts'; import { FieldView } from './render/field.ts'; import { Colorbar, fmtValue } from './render/colorbar.ts'; import { colormaps, colormapNames } from './render/colormaps.ts'; import { CodeEditor } from './editor/codeEditor.ts'; import { planPlayback, playTrace, stop as stopAudio } from './audio/play.ts'; import { C_AIR, DOMAIN, POOR_RESOLUTION, fmtLength, fmtTime } from './units.ts'; const el = (id: string): T => { const node = document.getElementById(id); if (!node) throw new Error(`missing element #${id}`); return node as T; }; const errBox = el('err'); const showError = (e: unknown, source: string): void => { errBox.textContent = formatFailure(e, source); }; const clearError = (): void => { errBox.textContent = ''; }; /* ---------------------------------------------------------------- state -- */ let model: MModel = mModels[0]; let scene: MScene = mScenes[0]; // The starting scene may ask for source settings and a microphone position, // the same way it does when picked from the dropdown later. let params: Params = { ...defaultParams(model), ...scene.suggest }; let sceneParams: Params = defaultSceneParams(scene); /** The editor's working copies, which may differ from the presets. */ const sources = { model: model.source, scene: scene.source }; let gridN = 512; let stepsPerFrame = 4; /** Timestep, as a fraction of the largest one the scheme is stable at. */ let cfl = 0.5; /** Where the microphone sits, in metres. */ let mic = { ...(scene.mic ?? { x: DOMAIN * 0.2, y: 0 }) }; /** Paused until asked. The page compiles and draws its silent initial state on * load, so what is on screen is the medium about to be sounded, and nothing * moves until Run. */ let running = false; let session: ModelSession | null = null; /** Pressure the colormap saturates at, and whether it follows the field. */ let scale = 1; let autoScale = true; /** Largest pressure seen since the last restart. The colour scale is not * allowed to fall far below it, so that once a pulse has left the grid what * is drawn is an empty grid rather than roundoff at full contrast. */ let peakSeen = 0; let colormapName = colormapNames[0]; let showMedium = true; /** * Something on screen would change if we drew now. * * A running simulation is dirty every frame by definition. A paused one is * dirty only when told to be — a new colour scale, a new medium, a resize — * and otherwise draws nothing at all. Skipping the draw is safe because a * WebGPU canvas keeps the last frame it presented; without it a paused page * would still run a full-screen fragment pass sixty times a second, which is * real GPU work in aid of an unchanging picture. */ let dirty = true; /* ------------------------------------------------------------- the page -- */ const device = await requestAcousticDevice().catch((e: unknown) => { showError(e, ''); return null; }); if (!device) throw new Error('no GPU'); // A lost device takes everything with it and nothing afterwards will work, so // say so rather than leaving a frozen picture and no explanation. void device.lost.then((info) => { showError( new Error( `the GPU device was lost (${info.reason}): ${info.message}\n` + 'Reload the page to start again.', ), '', ); }); const canvas = el('view'); const view = new FieldView({ device, canvas, nx: gridN, ny: gridN }); view.setColormap(colormaps[colormapName]); const colorbar = new Colorbar(el('colorbar')); colorbar.setColormap(colormaps[colormapName]); // The canvas is sized by CSS; match its backing store when that changes // rather than measuring it every frame. // Resizing reallocates the canvas's backing store, which clears it. new ResizeObserver(() => { view.resize(); dirty = true; }).observe(canvas); const editor = new CodeEditor({ textarea: el('source'), overlay: el('highlight'), external: EXTERNAL_OPS, onInput: (value) => { sources[editorFile.value as 'model' | 'scene'] = value; el('recompile').classList.add('primary'); }, }); const modelSelect = el('model'); const sceneSelect = el('scene'); const editorFile = el('editor-file'); const colormapSelect = el('colormap'); for (const m of mModels) { modelSelect.append(new Option(m.label, m.key)); } for (const s of mScenes) { sceneSelect.append(new Option(s.label, s.key)); } for (const name of colormapNames) { colormapSelect.append(new Option(name, name)); } /* ------------------------------------------------------------- controls -- */ /** * One slider per parameter the registry declares. A parameter that is a seed * gets a button instead: its value picks a draw and means nothing on its own, * so there is nothing to slide along. */ function buildSliders( host: HTMLElement, specs: ParamSpec[], values: Params, onChange: (key: string, value: number) => void, ): Map void> { const setters = new Map void>(); host.textContent = ''; for (const spec of specs) { const row = document.createElement('label'); row.className = 'slider'; if (spec.hint) row.title = spec.hint; const name = document.createElement('span'); name.textContent = spec.label; row.append(name); if (spec.reseed) { const button = document.createElement('button'); button.type = 'button'; button.textContent = 'new draw'; const out = document.createElement('output'); out.textContent = String(values[spec.key]); button.addEventListener('click', () => { const next = 1 + Math.floor(Math.random() * (spec.max - spec.min)); out.textContent = String(next); onChange(spec.key, next); }); row.append(button, out); } else { const input = document.createElement('input'); input.type = 'range'; input.min = String(spec.min); input.max = String(spec.max); input.step = String(spec.step); input.value = String(values[spec.key]); const out = document.createElement('output'); out.textContent = fmtValue(values[spec.key]); input.addEventListener('input', () => { const v = Number(input.value); out.textContent = fmtValue(v); onChange(spec.key, v); }); row.append(input, out); setters.set(spec.key, (value) => { input.value = String(value); out.textContent = fmtValue(value); }); } host.append(row); } return setters; } /** Scene edits go through the MATLAB interpreter, which is fast but not free; * a drag should not queue up one evaluation per pixel. */ let sceneTimer = 0; const scheduleSceneUpdate = (): void => { clearTimeout(sceneTimer); sceneTimer = window.setTimeout(applyScene, 120); }; function applyScene(): void { if (!session) return; try { const before = session.dt; session.setScene(scene, sceneParams, sources.scene); clearError(); // A different timestep leaves the leapfrog's two histories half a step // apart, so that is the one scene change the run cannot survive. if (Math.abs(session.dt - before) > 1e-12 * before) restart(); showDt(); dirty = true; view.setSource( session.gpu.stateBuffer(session.pressureName)!, session.gpu.stateBuffer('c')!, session.grid.nx, session.grid.ny, ); } catch (e) { showError(e, sources.scene); } } /** Sliders for the microphone's position, kept so a drag on the canvas can * move them too. */ let micSliders = new Map void>(); /** The microphone's position. Two sliders, in scene coordinates, since that * is what the scene's own parameters are in — and the canvas itself, which * is the direct way to place it. */ function buildMicControls(): void { const half = DOMAIN / 2; const specs: ParamSpec[] = [ { key: 'x', label: 'mic x (m)', value: mic.x, min: -half, max: half, step: 0.05 }, { key: 'y', label: 'mic y (m)', value: mic.y, min: -half, max: half, step: 0.05 }, ]; micSliders = buildSliders(el('micparams'), specs, { x: mic.x, y: mic.y }, (key, value) => { setMic(key === 'x' ? value : mic.x, key === 'y' ? value : mic.y, false); }); } /** Move the microphone, from wherever the instruction came. */ function setMic(x: number, y: number, syncSliders = true): void { const half = (session?.grid.L ?? DOMAIN) / 2; mic = { x: Math.max(-half, Math.min(half, x)), y: Math.max(-half, Math.min(half, y)), }; session?.setMic(mic.x, mic.y); if (syncSliders) { micSliders.get('x')?.(mic.x); micSliders.get('y')?.(mic.y); } dirty = true; } /** * Drag the microphone around the picture. * * The canvas shows the whole domain, so a pixel maps to a point with nothing * more than a scale: pointer down puts the microphone where it landed and * begins a drag, and pointer capture keeps that drag alive if it wanders off * the canvas. */ function micFromEvent(e: PointerEvent): { x: number; y: number } { const rect = canvas.getBoundingClientRect(); const L = session?.grid.L ?? DOMAIN; const u = (e.clientX - rect.left) / rect.width; // Screen y runs down; the grid's runs up. const v = (e.clientY - rect.top) / rect.height; return { x: -L / 2 + u * L, y: L / 2 - v * L }; } canvas.addEventListener('pointerdown', (e) => { try { canvas.setPointerCapture(e.pointerId); } catch { // Best effort: a drag that leaves the canvas just stops tracking. } const at = micFromEvent(e); setMic(at.x, at.y); e.preventDefault(); }); canvas.addEventListener('pointermove', (e) => { if (!canvas.hasPointerCapture(e.pointerId)) return; const at = micFromEvent(e); setMic(at.x, at.y); }); canvas.addEventListener('pointerup', (e) => { if (canvas.hasPointerCapture(e.pointerId)) canvas.releasePointerCapture(e.pointerId); }); function buildParamControls(): void { buildSliders(el('params'), model.params, params, (key, value) => { params = { ...params, [key]: value }; session?.setParams(params); }); el('scene-title').textContent = `scene — ${scene.blurb}`; buildSliders(el('sceneparams'), scene.params, sceneParams, (key, value) => { sceneParams = { ...sceneParams, [key]: value }; scheduleSceneUpdate(); }); } /* ------------------------------------------------------------- the loop -- */ let frames = 0; let lastFpsAt = performance.now(); let msPerFrame = 0; /** A readback in flight; only one at a time, since they share a buffer. */ let reading = false; function restart(): void { if (!session) return; session.reset(); peakSeen = 0; scale = autoScale ? 1e-6 : scale; dirty = true; } /** * Compile the current sources into a new session and swap it in. * * The old session keeps running until the new one exists, and is only torn * down once the swap has happened. Two reasons: a compile that fails leaves * something on screen and something to edit rather than a dead page, and * destroying GPU resources while the next lot of shaders are still compiling * is exactly the sort of thing a browser is entitled to answer with a lost * device. */ let building = false; async function rebuild(): Promise { if (building) return; building = true; const old = session; try { const next = await ModelSession.create({ device: device!, model, params, source: sources.model, scene, sceneParams, sceneSource: sources.scene, n: gridN, L: DOMAIN, cfl, }); next.reset(); session = next; view.setSource( next.gpu.stateBuffer(next.pressureName)!, next.gpu.stateBuffer('c')!, next.grid.nx, next.grid.ny, ); next.setMic(mic.x, mic.y); old?.destroy(); peakSeen = 0; scale = 1e-6; dirty = true; clearError(); showDt(); showRecording(); el('recompile').classList.remove('primary'); el('compiled').textContent = describe(next); } catch (e) { // Which file the failure belongs to decides which one to show it against. const which = failingFile(e); showError(e, sources[which]); if (e instanceof ModelCompileError && e.start !== undefined) { editorFile.value = which; showFile(which); editor.select(e.start, e.end ?? e.start + 1); } } finally { building = false; } } /** A scene failure is reported against `medium`, everything else against the * model's own functions. */ const failingFile = (e: unknown): 'model' | 'scene' => e instanceof ModelCompileError && e.fn === 'medium' ? 'scene' : 'model'; const describe = (s: ModelSession): string => { const { init, step } = s.describe(); return [ `% init — run once`, ...init.map((l) => ` ${l}`), ``, `% step — run ${stepsPerFrame}x per frame`, ...step.map((l) => ` ${l}`), ].join('\n'); }; /** Where the microphone sits, in grid-index coordinates, for the marker. */ function micIndex(s: ModelSession): { ix: number; iy: number } { const { L, h } = s.grid; return { ix: (mic.x + L / 2) / h - 0.5, iy: (mic.y + L / 2) / h - 0.5 }; } /** * What the microphone has, and what it would sound like. * * The playback rate is just 1/dt — real time, real pitch, no translation — * because dt is already a real duration. The duration of the clip is still * worth watching: a pulse is a few dozen cycles, which at an audible pitch is * a few tens of milliseconds however long the simulation runs. Sustained * sound needs a sustained source (turn `continuous` up) and a long enough * run, which is what the speed control is for. */ function showRecording(): void { const info = el('recinfo'); if (!session) { info.textContent = ''; return; } const n = session.recorder.count; if (n === 0) { info.textContent = 'nothing recorded yet — press Run'; return; } const plan = planPlayback(n, session.dt); info.textContent = `${n.toLocaleString()} samples · ${fmtTime(n * session.dt)} of simulated time · ` + `${fmtTime(plan.duration)} of audio at ${Math.round(plan.rate).toLocaleString()} Hz` + (plan.realTime ? '' : ' (sped up — outside the audio range)') + (session.recorder.full ? ' · buffer full' : ''); } /** The timestep the slider currently asks for, as a real duration. */ function showDt(): void { const out = el('dtout'); out.textContent = session ? fmtTime(session.dt) : '—'; out.classList.toggle('unstable', cfl >= 1); el('cfl-label').title = `The timestep, as a fraction of the largest one this scheme is stable at ` + `on this grid` + (session ? ` (dt < ${fmtTime(session.dtLimit)} here)` : '') + `. At 1 and above it diverges, which is worth seeing once.`; } /** * How well the grid resolves the current source frequency: the wavelength at * the background speed, divided by the cell size. Below `POOR_RESOLUTION` * cells per wavelength, what is on screen is as much grid dispersion as it is * sound — worth surfacing rather than leaving as a silent limitation. */ function resolutionInfo(s: ModelSession): { text: string; poor: boolean } { const f = params.f ?? 1; const wavelength = s.scene.cref / Math.max(f, 1e-9); const cells = wavelength / s.grid.h; const poor = cells < POOR_RESOLUTION; return { text: `λ = ${fmtLength(wavelength)} (${cells.toFixed(1)} cells)`, poor }; } function stats(): void { if (!session) return; const { grid } = session; // The rate is reported only when it means something. Paused, the loop takes // no steps and draws nothing, so any figure here would be measuring the // display's refresh interval and calling it solver throughput. const rate = running && msPerFrame > 0 ? `${msPerFrame.toFixed(1)} ms/frame · ` + `${((stepsPerFrame * grid.npts) / (msPerFrame * 1e3)).toFixed(0)} Mpoint/s` : 'paused'; const res = resolutionInfo(session); el('stats').innerHTML = `t = ${fmtTime(session.t)} · step ${session.steps} · ` + `dt = ${fmtTime(session.dt)} · ${grid.nx}×${grid.ny} over ${fmtLength(grid.L)} ` + `(${fmtLength(grid.h)} cells) · ` + `c ∈ [${fmtValue(session.scene.cmin)}, ${fmtValue(session.scene.cmax)}] m/s · ` + `${res.text} · ${rate}`; } /** * Follow the field with the colour scale. * * The only readback in the app, and it happens a few times a second rather * than every frame. Rising fast and falling slowly, so that a pulse arriving * is not clipped and a pulse leaving does not make the remaining ripples * flare up to full contrast. */ async function autoscaleStep(): Promise { if (!session || reading || !autoScale) return; reading = true; try { const p = await session.read(session.pressureName); let peak = 0; for (const v of p) peak = Math.max(peak, Math.abs(v)); peakSeen = Math.max(peakSeen, peak); scale = peak > scale ? peak : 0.97 * scale + 0.03 * peak; scale = Math.max(scale, 0.02 * peakSeen, 1e-9); dirty = true; } catch { // A rebuild can destroy the buffers mid-read; the next frame recovers. } finally { reading = false; } } let sinceScale = 0; function frame(): void { if (session && (running || dirty)) { if (running) session.step(stepsPerFrame); view.draw({ scale, cref: session.scene.cref, cdev: session.scene.cdev, medium: showMedium ? 0.5 : 0, mic: micIndex(session), }); dirty = false; // Only while running: a paused field cannot have changed since the last // time its peak was measured, so the readback would be pure waste. if (running && ++sinceScale >= 6) { sinceScale = 0; void autoscaleStep(); } colorbar.setRange(-scale, scale); frames++; } const now = performance.now(); if (now - lastFpsAt > 400) { // Frames that drew nothing are not frames; a paused page reports no rate // rather than the display's refresh interval dressed up as one. msPerFrame = frames > 0 ? (now - lastFpsAt) / frames : 0; frames = 0; lastFpsAt = now; stats(); showRecording(); } requestAnimationFrame(frame); } /* -------------------------------------------------------------- wiring --- */ function showFile(which: 'model' | 'scene'): void { editor.value = sources[which]; el('editor-title').textContent = which === 'model' ? 'init and step, compiled to WebGPU' : 'medium(x, y, …) → sound speed and absorption, evaluated once on the CPU'; } modelSelect.addEventListener('change', () => { model = mModelByKey(modelSelect.value) ?? mModels[0]; params = { ...defaultParams(model), ...params }; sources.model = model.source; if (editorFile.value === 'model') showFile('model'); el('blurb').textContent = `${model.blurb} ${scene.blurb}`; buildParamControls(); void rebuild(); }); sceneSelect.addEventListener('change', () => { scene = mSceneByKey(sceneSelect.value) ?? mScenes[0]; sceneParams = defaultSceneParams(scene); // A room is no use with the source outside it, so a scene may ask for // source settings; they land in the sliders like any other value. if (scene.suggest) params = { ...params, ...scene.suggest }; if (scene.mic) mic = { ...scene.mic }; sources.scene = scene.source; if (editorFile.value === 'scene') showFile('scene'); el('blurb').textContent = `${model.blurb} ${scene.blurb}`; buildParamControls(); session?.setParams(params); applyScene(); restart(); }); el('gridsize').addEventListener('change', (e) => { gridN = Number((e.target as HTMLSelectElement).value); void rebuild(); }); const listen = el('listen'); listen.addEventListener('click', () => { if (!session) return; const s = session; const n = s.recorder.count; if (n === 0) { showError( new Error('the microphone has not recorded anything yet — press Run first'), '', ); return; } listen.disabled = true; stopAudio(); void s.recorder .read() .then((trace) => playTrace(trace, planPlayback(trace.length, s.dt))) .then(() => clearError()) .catch((e: unknown) => showError(e, '')) .finally(() => { listen.disabled = false; }); }); el('cfl').addEventListener('input', (e) => { cfl = Number((e.target as HTMLInputElement).value); session?.setCfl(cfl); showDt(); }); el('spf').addEventListener('change', (e) => { stepsPerFrame = Number((e.target as HTMLSelectElement).value); if (session) el('compiled').textContent = describe(session); }); colormapSelect.addEventListener('change', () => { colormapName = colormapSelect.value; view.setColormap(colormaps[colormapName]); colorbar.setColormap(colormaps[colormapName]); dirty = true; }); el('scalemode').addEventListener('change', (e) => { autoScale = (e.target as HTMLSelectElement).value === 'auto'; dirty = true; }); el('showmedium').addEventListener('change', (e) => { showMedium = (e.target as HTMLInputElement).checked; dirty = true; }); const runPause = el('runpause'); runPause.addEventListener('click', () => { running = !running; runPause.textContent = running ? 'Pause' : 'Run'; // Start the frame-rate window fresh, so a resumed run is not averaged // against the paused frames before it. frames = 0; lastFpsAt = performance.now(); dirty = true; }); el('restart').addEventListener('click', restart); editorFile.addEventListener('change', () => { showFile(editorFile.value as 'model' | 'scene'); }); el('recompile').addEventListener('click', () => { void rebuild(); }); el('revert').addEventListener('click', () => { const which = editorFile.value as 'model' | 'scene'; sources[which] = which === 'model' ? model.source : scene.source; showFile(which); void rebuild(); }); /* ---------------------------------------------------------------- start -- */ // The grid dropdown's options are written statically in index.html; annotate // each with the cell size it implies on the actual domain, rather than // hardcoding a number that would drift if DOMAIN ever changed. for (const opt of el('gridsize').options) { const n = Number(opt.value); opt.textContent = `${n}² (${fmtLength(DOMAIN / n)} cells)`; } el('domaininfo').textContent = `Domain: ${fmtLength(DOMAIN)} × ${fmtLength(DOMAIN)}, background speed ` + `${C_AIR} m/s (air).`; modelSelect.value = model.key; sceneSelect.value = scene.key; colormapSelect.value = colormapName; el('blurb').textContent = `${model.blurb} ${scene.blurb}`; buildParamControls(); buildMicControls(); showFile('model'); el('cfl').value = String(cfl); showDt(); showRecording(); await rebuild(); requestAnimationFrame(frame);