/** * The app: two MATLAB files, a GPU, a string, a box, and a microphone. * * 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 *body* re-evaluates the scene .m on the CPU and re-uploads five arrays, * which is cheap and needs no recompile. Changing the grid, the string * length, or either file's text recompiles — a fresh session, from source to * shaders. * * There are two ways to run. *Watching*: a few timesteps per frame, the wave * crawling in slow motion. *Rendering a note*: the solver flat out with no * display until a chosen duration of audio exists, then playback. Both feed * the same GPU-side microphone. */ 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 { dulcimerModel, defaultParams, type Params, type ParamSpec, } from './mgpu/registry.ts'; import { boxScene, defaultSceneParams, type MScene } from './scene/registry.ts'; import { VolumeView, cameraFrame, type Camera } from './render/volume.ts'; import { Overlay } from './render/overlay.ts'; import { StringPlot } from './render/stringplot.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 { traceToWav } from './audio/wav.ts'; import { C_AIR, 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 -- */ const model = dulcimerModel; const scene: MScene = boxScene; let params: Params = defaultParams(model); 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 gridNx = 128; let Ls = 0.6; /** Timesteps per display frame. May be fractional — ¼× means one step every * fourth frame — which is what `stepDebt` accumulates toward. */ let stepsPerFrame = 16; let stepDebt = 0; /** Where the microphone sits, in metres. Off to the side and above, out in * the air the instrument radiates into. */ const mic = { x: 0.12, y: 0.08, z: 0.1 }; /** Paused until asked. The page compiles and draws its silent initial state * on load — the string drawn back into its pluck — and nothing moves until * Run (or Render note). */ let running = false; let session: ModelSession | null = null; const camera: Camera = { az: -2.2, el: 0.45, dist: 1.35 }; const viewState = { opacity: 2, contrast: 1.6, quality: 192, clipFrac: 0.5, exaggerate: 25 }; const show = { field: true, body: true, string: true, wire: true, mic: true }; /** Pressure the colormap saturates at, and whether it follows the field. */ let scale = 1e-6; let autoScale = true; /** Largest pressure seen since the last pluck. The colour scale is not * allowed to fall far below it, so that once the note has faded what is * drawn is quiet air rather than roundoff at full contrast. */ let peakSeen = 0; let colormapName = colormapNames[0]; /** The last string displacement read back, for the plot and the overlay. */ let lastString: Float32Array | null = null; /** Something on screen would change if we drew now. A running simulation is * dirty every frame by definition; a paused one only when told. */ let dirty = true; /** A note render in progress (the flag doubles as its cancel switch). */ let renderingNote = false; /* ------------------------------------------------------------- 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 VolumeView(device, canvas); view.setColormap(colormaps[colormapName]); const overlay = new Overlay(el('overlay')); const plot = new StringPlot(el('stringplot')); const colorbar = new Colorbar(el('colorbar')); colorbar.setColormap(colormaps[colormapName]); const darkMedia = matchMedia('(prefers-color-scheme: dark)'); new ResizeObserver(() => { view.resize(); overlay.resize(); plot.resize(); dirty = true; }).observe(canvas); const editor = new CodeEditor({ textarea: el('source'), overlay: el('highlight'), external: new Set([...EXTERNAL_OPS.keys(), 'sponge3']), onInput: (value) => { sources[editorFile.value as 'model' | 'scene'] = value; el('recompile').classList.add('primary'); }, }); const editorFile = el('editor-file'); const colormapSelect = el('colormap'); for (const name of colormapNames) colormapSelect.append(new Option(name, name)); colormapSelect.value = colormapName; /* ------------------------------------------------------------- controls -- */ /** One slider per parameter the registry declares. */ 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; 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(name, input, out); host.append(row); setters.set(spec.key, (value) => { input.value = String(value); out.textContent = fmtValue(value); }); } 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, 150); }; function applyScene(): void { if (!session) return; try { session.setScene(scene, sceneParams, sources.scene); clearError(); // A scene edit that raises the fastest speed invalidates the timestep // (and with it the string grid), which only a rebuild can fix. if (Math.abs(session.dtWanted - session.dt) > 1e-12 * session.dt) { void rebuild(); return; } dirty = true; } catch (e) { showError(e, sources.scene); } } let micSliders = new Map void>(); function buildMicControls(): void { const specs: ParamSpec[] = [ { key: 'x', label: 'mic x (m)', value: mic.x, min: -0.45, max: 0.45, step: 0.01 }, { key: 'y', label: 'mic y (m)', value: mic.y, min: -0.22, max: 0.22, step: 0.01 }, { key: 'z', label: 'mic z (m)', value: mic.z, min: -0.22, max: 0.22, step: 0.01 }, ]; micSliders = buildSliders( el('micparams'), specs, { x: mic.x, y: mic.y, z: mic.z }, (key, value) => { mic[key as 'x' | 'y' | 'z'] = value; session?.setMic(mic.x, mic.y, mic.z); dirty = true; }, ); } function buildParamControls(): void { buildSliders(el('params'), model.params, params, (key, value) => { params = { ...params, [key]: value }; session?.setParams(params); }); el('scene-title').textContent = `body — ${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 pluck(): void { if (!session) return; session.pluck(); peakSeen = 0; if (autoScale) scale = 1e-6; dirty = true; showRecording(); } /** * 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: 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; renderingNote = false; const old = session; try { const next = await ModelSession.create({ device: device!, model, params, source: sources.model, scene, sceneParams, sceneSource: sources.scene, nx: gridNx, Ls, }); next.pluck(); session = next; view.setSource( next.gpu.stateBuffer(next.pressureName)!, next.gpu.stateBuffer('wall')!, { nx: next.air.nx, ny: next.air.ny, nz: next.air.nz, Lx: next.air.Lx, Ly: next.air.Ly, Lz: next.air.Lz, h: next.air.h, }, ); next.setMic(mic.x, mic.y, mic.z); old?.destroy(); lastString = null; peakSeen = 0; scale = 1e-6; dirty = true; clearError(); showRecording(); showStatics(); 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 — one pluck`, ...init.map((l) => ` ${l}`), ``, `% step — every timestep`, ...step.map((l) => ` ${l}`), ].join('\n'); }; /** What the microphone has, and what it would sound like. */ 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 — Run, or Render note'; return; } const plan = planPlayback(n, session.dt); info.textContent = `${fmtTime(n * session.dt)} recorded · plays at ` + `${Math.round(plan.rate).toLocaleString()} Hz` + (plan.realTime ? '' : ' (rate clamped)') + (session.recorder.full ? ' · buffer full' : ''); } /** The facts that only change on a rebuild. */ function showStatics(): void { if (!session) return; const { air, string } = session; const fmax = C_AIR / (POOR_RESOLUTION * air.h); el('domaininfo').textContent = `The air is a ${fmtLength(air.Lx)} × ${fmtLength(air.Ly)} × ${fmtLength(air.Lz)} box ` + `at ${air.nx}×${air.ny}×${air.nz} cells of ${fmtLength(air.h)} — honest to about ` + `${(fmax / 1000).toFixed(1)} kHz. The string has ${string.ns} nodes; ` + `dt = ${fmtTime(session.dt)} (${Math.round(1 / session.dt / 1000)} kHz).`; } function stats(): void { if (!session) return; const rate = running && msPerFrame > 0 ? `${msPerFrame.toFixed(1)} ms/frame` : 'paused'; const f = params.f0 ?? 0; const partials = Math.max(1, Math.floor(C_AIR / (POOR_RESOLUTION * session.air.h) / Math.max(f, 1))); el('stats').innerHTML = `t = ${fmtTime(session.t)} · step ${session.steps.toLocaleString()} · ` + `${fmtValue(f)} Hz fundamental — the air carries its first ~${partials} partials · ${rate}`; } /** * Follow the field with the colour scale, and keep the string plot fed. * * The only readbacks in the app, a few times a second rather than every * frame, and strictly one at a time — they share a staging buffer. */ async function pollFields(): Promise { if (!session || reading) return; reading = true; try { const u = await session.read(session.displacementName); lastString = u; if (autoScale) { 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.05 * peakSeen, 1e-12); } dirty = true; } catch { // A rebuild can destroy the buffers mid-read; the next poll recovers. } finally { reading = false; } } let lastPollAt = 0; function frame(): void { if (session && (running || dirty)) { if (running && !renderingNote) { // Fractional speeds accumulate a debt and step when it reaches a whole // timestep, so ¼× is one step every fourth frame rather than nothing. stepDebt += stepsPerFrame; const n = Math.floor(stepDebt); if (n > 0) { session.step(n); stepDebt -= n; } } const cf = cameraFrame(camera, canvas.width / Math.max(canvas.height, 1)); view.draw({ frame: cf, scale: Math.max(scale, 1e-20), opacity: show.field ? viewState.opacity : 0, contrast: viewState.contrast, steps: viewState.quality, clipY: viewState.clipFrac * session.air.Ly, body: show.body ? 0.6 : 0, mic: show.mic ? mic : null, }); overlay.draw({ frame: cf, geometry: { Ls: session.string.Ls, stringZ: scene.stringZ(sceneParams), boxl: sceneParams.boxl ?? 0, boxw: sceneParams.boxw ?? 0, boxd: sceneParams.boxd ?? 0, holer: sceneParams.holer ?? 0, holex: sceneParams.holex ?? 0, }, string: show.string ? lastString : null, exaggerate: viewState.exaggerate, wireframe: show.wire, }); plot.draw(lastString, 1.1 * (params.amp ?? 0.002), darkMedia.matches); el('stringlabel').textContent = `string displacement, full scale ±${fmtValue(1100 * (params.amp ?? 0.002))} mm`; dirty = false; colorbar.setRange(-scale, scale); frames++; const now = performance.now(); if ((running || renderingNote) && now - lastPollAt > 250) { lastPollAt = now; void pollFields(); } } const now = performance.now(); if (now - lastFpsAt > 400) { msPerFrame = frames > 0 ? (now - lastFpsAt) / frames : 0; frames = 0; lastFpsAt = now; stats(); showRecording(); } requestAnimationFrame(frame); } /* ------------------------------------------------------- rendering a note -- */ /** * Pluck, then run the solver as fast as the GPU will go — no display frames * in the way, just batches of steps with a sync between them so the queue * never runs unboundedly ahead — until the requested duration of audio is in * the trace. Then play it. */ async function renderNote(): Promise { if (!session || building) return; const btn = el('rendernote'); if (renderingNote) { renderingNote = false; // cancel: the loop below notices and stops return; } renderingNote = true; setRunning(false); btn.textContent = 'Cancel'; const prog = el('renderprog'); prog.hidden = false; prog.value = 0; const seconds = Number(el('renderdur').value); try { pluck(); const s = session; const total = Math.min( Math.ceil(seconds / s.dt), s.recorder.capacity, ); const batch = 512; let done = 0; while (done < total && renderingNote && session === s) { const n = Math.min(batch, total - done); s.step(n); done += n; await s.sync(); prog.value = done / total; } if (renderingNote && session === s) { const trace = await s.recorder.read(); stopAudio(); await playTrace(trace, planPlayback(trace.length, s.dt)); clearError(); } } catch (e) { showError(e, ''); } finally { renderingNote = false; prog.hidden = true; btn.textContent = 'Render note'; dirty = true; showRecording(); } } /* -------------------------------------------------------------- wiring --- */ function showFile(which: 'model' | 'scene'): void { editor.value = sources[which]; el('editor-title').textContent = which === 'model' ? 'init and step — string and air together, compiled to WebGPU' : 'medium(x, y, z, …) → walls and coupling, evaluated once on the CPU'; } el('gridsize').addEventListener('change', (e) => { gridNx = Number((e.target as HTMLSelectElement).value); void rebuild(); }); el('stringlen').addEventListener('change', (e) => { Ls = Number((e.target as HTMLSelectElement).value); void rebuild(); }); const runPause = el('runpause'); const setRunning = (r: boolean): void => { running = r; runPause.textContent = r ? 'Pause' : 'Run'; frames = 0; lastFpsAt = performance.now(); dirty = true; }; runPause.addEventListener('click', () => setRunning(!running && !renderingNote)); el('pluck').addEventListener('click', pluck); el('spf').addEventListener('change', (e) => { stepsPerFrame = Number((e.target as HTMLSelectElement).value); }); el('rendernote').addEventListener('click', () => void renderNote()); const listen = el('listen'); listen.addEventListener('click', () => { if (!session) return; const s = session; if (s.recorder.count === 0) { showError(new Error('the microphone has not recorded anything yet — Run, or Render note'), ''); 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('download').addEventListener('click', () => { if (!session) return; const s = session; if (s.recorder.count === 0) { showError(new Error('the microphone has not recorded anything yet — Run, or Render note'), ''); return; } void s.recorder .read() .then((trace) => { const url = URL.createObjectURL(traceToWav(trace, s.dt)); const a = document.createElement('a'); a.href = url; a.download = 'dulcimer.wav'; a.click(); setTimeout(() => URL.revokeObjectURL(url), 10_000); }) .catch((e: unknown) => showError(e, '')); }); 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'; }); const bindRange = (id: string, apply: (v: number) => void): void => { el(id).addEventListener('input', (e) => { apply(Number((e.target as HTMLInputElement).value)); dirty = true; }); }; bindRange('opacity', (v) => (viewState.opacity = v)); bindRange('contrast', (v) => (viewState.contrast = v)); bindRange('clipy', (v) => (viewState.clipFrac = v)); bindRange('exaggerate', (v) => (viewState.exaggerate = v)); el('quality').addEventListener('change', (e) => { viewState.quality = Number((e.target as HTMLSelectElement).value); dirty = true; }); const bindCheck = (id: string, apply: (v: boolean) => void): void => { el(id).addEventListener('change', (e) => { apply((e.target as HTMLInputElement).checked); dirty = true; }); }; bindCheck('showfield', (v) => (show.field = v)); bindCheck('showbody', (v) => (show.body = v)); bindCheck('showstring', (v) => (show.string = v)); bindCheck('showwire', (v) => (show.wire = v)); bindCheck('showmic', (v) => (show.mic = v)); // --- orbit --------------------------------------------------------------- let dragging = false; let last = [0, 0]; canvas.addEventListener('pointerdown', (e) => { dragging = true; last = [e.clientX, e.clientY]; canvas.setPointerCapture(e.pointerId); }); canvas.addEventListener('pointermove', (e) => { if (!dragging) return; camera.az -= (e.clientX - last[0]) * 0.008; camera.el = Math.min(1.5, Math.max(-1.5, camera.el + (e.clientY - last[1]) * 0.008)); last = [e.clientX, e.clientY]; dirty = true; }); canvas.addEventListener('pointerup', () => (dragging = false)); canvas.addEventListener('pointercancel', () => (dragging = false)); canvas.addEventListener( 'wheel', (e) => { e.preventDefault(); camera.dist = Math.min(5, Math.max(0.7, camera.dist * Math.exp(e.deltaY * 0.001))); dirty = true; }, { passive: false }, ); 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 -- */ buildParamControls(); buildMicControls(); showFile('model'); darkMedia.addEventListener('change', () => (dirty = true)); await rebuild(); // The initial pluck shape, so the page opens showing the string drawn back. void pollFields(); requestAnimationFrame(frame);