/** * The app: wiring between the solver (sim.ts), the scenes (scenes.ts), the * volume renderer (render/volume.ts) and the page. */ import { requestAcousticDevice, maxGridSide } from './device.ts'; import { makeGrid, stableDt, type Grid } from './grid.ts'; import { Sim, MAX_STEPS_PER_FRAME, MIC_CAPACITY, type SourceParams } from './sim.ts'; import { playTrace } from './audio.ts'; import { scenes, sceneKeys, buildMedium, type Medium, type SceneParam } from './scenes.ts'; import { VolumeView, type Camera } from './render/volume.ts'; import { colormaps, colormapNames } from './render/colormaps.ts'; import { C_AIR, DOMAIN, POOR_RESOLUTION, fmtLength, fmtTime } from './units.ts'; const $ = (id: string): T => { const el = document.getElementById(id); if (!el) throw new Error(`no #${id}`); return el as T; }; const err = $('err'); const report = (e: unknown) => { err.textContent = e instanceof Error ? e.message : String(e); }; /** A labelled slider with a live readout, in one of the .sliders grids. */ function slider( parent: HTMLElement, p: SceneParam, onInput: (v: number) => void, ): { set: (v: number) => void } { const row = document.createElement('label'); row.className = 'slider'; const name = document.createElement('span'); name.textContent = p.label; const input = document.createElement('input'); input.type = 'range'; input.min = String(p.min); input.max = String(p.max); input.step = String(p.step); input.value = String(p.value); const out = document.createElement('output'); const fmt = (v: number) => `${+v.toPrecision(3)}${p.unit ? ' ' + p.unit : ''}`; out.textContent = fmt(p.value); input.addEventListener('input', () => { const v = Number(input.value); out.textContent = fmt(v); onInput(v); }); row.append(name, input, out); parent.append(row); return { set: (v) => { input.value = String(v); out.textContent = fmt(v); }, }; } async function start() { const device = await requestAcousticDevice(); device.addEventListener('uncapturederror', (e) => report((e as GPUUncapturedErrorEvent).error.message)); // --- state --------------------------------------------------------------- const gridSel = $('gridsize'); for (const n of [64, 96, 128, 160, 192]) { if (n > maxGridSide(device)) break; const opt = document.createElement('option'); opt.value = String(n); opt.textContent = `${n}³`; if (n === 128) opt.selected = true; gridSel.append(opt); } let sceneKey = 'sphere'; let grid: Grid = makeGrid(Number(gridSel.value), DOMAIN); let cfl = 0.5; let running = false; let stepsPerFrame = 4; const source: SourceParams = { f: 1200, cycles: 2, cw: 0, point: 0, x0: -0.32 * DOMAIN, y0: 0, z0: 0, w: 0.02 * DOMAIN, }; const sceneValues: Record> = {}; for (const k of sceneKeys) { sceneValues[k] = Object.fromEntries(scenes[k].params.map((p) => [p.key, p.value])); } const camera: Camera = { az: -2.35, el: 0.5, dist: 2.6 * DOMAIN }; const viewState = { opacity: 1.6, contrast: 1.6, clip: 0.5 }; let quality = 192; let colormap = 'coolwarm'; let scaleMode: 'auto' | 'fixed' = 'fixed'; let scale = 1; let showMedium = true; /** Microphone position, metres. In the forward-scattering shadow of the * default sphere, which is where there is something to hear. */ const mic = { x: 0.25 * DOMAIN, y: 0, z: 0 }; let medium: Medium = buildMedium(scenes[sceneKey], grid, sceneValues[sceneKey]); let sim = new Sim(device, grid, medium, source, stableDt(grid.h, medium.cmax, cfl)); const view = new VolumeView(device, $('view')); view.setSource(sim.pressures, sim.speed, grid.n, grid.L); view.setColormap(colormaps[colormap]); const applyProbe = () => { const toIdx = (v: number) => Math.floor((v + grid.L / 2) / grid.h); sim.setProbe(toIdx(mic.x), toIdx(mic.y), toIdx(mic.z)); }; applyProbe(); // --- rebuilds ------------------------------------------------------------ const applyDt = () => { const dt = stableDt(grid.h, medium.cmax, cfl); if (dt !== sim.dt) { sim.dt = dt; // One sample per step: two timesteps would be two sample rates in one // trace. sim.resetTrace(); } }; /** New medium on the existing grid (a scene parameter moved): rebuild the * sim but keep the run going conceptually — the field restarts, which is * honest, since the old field belongs to the old medium. */ const rebuildSim = () => { sim.destroy(); medium = buildMedium(scenes[sceneKey], grid, sceneValues[sceneKey]); sim = new Sim(device, grid, medium, source, stableDt(grid.h, medium.cmax, cfl)); view.setSource(sim.pressures, sim.speed, grid.n, grid.L); applyProbe(); updateStatics(); }; // --- controls ------------------------------------------------------------ const sceneSel = $('scene'); for (const k of sceneKeys) { const opt = document.createElement('option'); opt.value = k; opt.textContent = scenes[k].label; sceneSel.append(opt); } const sceneParamsBox = $('sceneparams'); const buildSceneSliders = () => { sceneParamsBox.replaceChildren(); $('scene-title').textContent = `scene — ${scenes[sceneKey].label.toLowerCase()}`; $('blurb').textContent = scenes[sceneKey].blurb; for (const p of scenes[sceneKey].params) { slider(sceneParamsBox, { ...p, value: sceneValues[sceneKey][p.key] }, (v) => { sceneValues[sceneKey][p.key] = v; rebuildSim(); }); } }; sceneSel.addEventListener('change', () => { sceneKey = sceneSel.value; const want = scenes[sceneKey].source; if (want) { Object.assign(source, want); const s = source as unknown as Record; for (const [k, set] of Object.entries(sourceSliders)) set.set(s[k]); } buildSceneSliders(); rebuildSim(); }); gridSel.addEventListener('change', () => { grid = makeGrid(Number(gridSel.value), DOMAIN); rebuildSim(); }); const runBtn = $('runpause'); const setRunning = (r: boolean) => { running = r; runBtn.textContent = r ? 'Pause' : 'Run'; }; runBtn.addEventListener('click', () => setRunning(!running)); $('restart').addEventListener('click', () => { sim.restart(); }); // Source sliders. Moving one does not restart: the source term reads these // every step, so the change simply takes effect. const sourceSliders: Record void }> = {}; const params = $('params'); const sourceDefs: (SceneParam & { key: keyof SourceParams })[] = [ { key: 'f', label: 'frequency', min: 200, max: 4000, step: 20, value: source.f, unit: 'Hz' }, { key: 'cycles', label: 'pulse length', min: 0.5, max: 12, step: 0.5, value: source.cycles }, { key: 'cw', label: 'continuous', min: 0, max: 1, step: 0.05, value: source.cw }, { key: 'point', label: 'plane ↔ point', min: 0, max: 1, step: 0.05, value: source.point }, { key: 'x0', label: 'x position', min: -0.4 * DOMAIN, max: 0.4 * DOMAIN, step: 0.01, value: source.x0, unit: 'm' }, { key: 'y0', label: 'y position', min: -0.4 * DOMAIN, max: 0.4 * DOMAIN, step: 0.01, value: source.y0, unit: 'm' }, { key: 'z0', label: 'z position', min: -0.4 * DOMAIN, max: 0.4 * DOMAIN, step: 0.01, value: source.z0, unit: 'm' }, ]; for (const p of sourceDefs) { sourceSliders[p.key] = slider(params, p, (v) => { source[p.key] = v; }); } // The microphone: three position sliders, a Listen button, a readout. const micparams = $('micparams'); for (const axis of ['x', 'y', 'z'] as const) { slider( micparams, { key: axis, label: `mic ${axis}`, min: -0.45 * DOMAIN, max: 0.45 * DOMAIN, step: 0.01, value: mic[axis], unit: 'm' }, (v) => { mic[axis] = v; applyProbe(); }, ); } let playNote = ''; $('listen').addEventListener('click', () => { sim .readTrace() .then(async (tr) => { if (!tr || tr.length < 2) return; const played = await playTrace(tr, sim.dt); playNote = played.peak > 0 ? ` — played ${fmtTime(played.duration)}` : ' — the microphone heard only silence'; }) .catch(report); }); const viewparams = $('viewparams'); slider(viewparams, { key: 'op', label: 'opacity', min: 0.2, max: 6, step: 0.1, value: viewState.opacity }, (v) => { viewState.opacity = v; }); slider(viewparams, { key: 'ct', label: 'contrast', min: 0.5, max: 3, step: 0.1, value: viewState.contrast }, (v) => { viewState.contrast = v; }); slider(viewparams, { key: 'clip', label: 'clip x', min: -0.5, max: 0.5, step: 0.01, value: viewState.clip }, (v) => { viewState.clip = v; }); const cmapSel = $('colormap'); for (const name of colormapNames) { const opt = document.createElement('option'); opt.value = name; opt.textContent = name; cmapSel.append(opt); } cmapSel.value = colormap; cmapSel.addEventListener('change', () => { colormap = cmapSel.value; view.setColormap(colormaps[colormap]); drawColorbar(); }); $('scalemode').addEventListener('change', (e) => { scaleMode = (e.target as HTMLSelectElement).value as typeof scaleMode; }); $('showmedium').addEventListener('change', (e) => { showMedium = (e.target as HTMLInputElement).checked; }); $('quality').addEventListener('change', (e) => { quality = Number((e.target as HTMLSelectElement).value); }); $('spf').addEventListener('change', (e) => { stepsPerFrame = Math.min(Number((e.target as HTMLSelectElement).value), MAX_STEPS_PER_FRAME); }); const cflSlider = $('cfl'); const dtout = $('dtout'); cflSlider.addEventListener('input', () => { cfl = Number(cflSlider.value); applyDt(); updateStatics(); }); // --- orbit --------------------------------------------------------------- const canvas = $('view'); 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]; }); canvas.addEventListener('pointerup', () => (dragging = false)); canvas.addEventListener('pointercancel', () => (dragging = false)); canvas.addEventListener( 'wheel', (e) => { e.preventDefault(); camera.dist = Math.min(8 * DOMAIN, Math.max(1.1 * DOMAIN, camera.dist * Math.exp(e.deltaY * 0.001))); }, { passive: false }, ); // --- readouts ------------------------------------------------------------ const stats = $('stats'); const domaininfo = $('domaininfo'); const drawColorbar = () => { const cb = $('cbar'); const ctx = cb.getContext('2d')!; const f = colormaps[colormap]; for (let i = 0; i < cb.height; i++) { const [r, g, b] = f(1 - i / (cb.height - 1)); ctx.fillStyle = `rgb(${r},${g},${b})`; ctx.fillRect(0, i, cb.width, 1); } }; drawColorbar(); const updateStatics = () => { applyDt(); const lam = C_AIR / source.f; const cells = lam / grid.h; domaininfo.textContent = `A ${fmtLength(grid.L)} cube of air at ${grid.n}³ points (${fmtLength(grid.h)} cells); ` + `at ${source.f} Hz a wavelength is ${fmtLength(lam)}, ${cells.toFixed(1)} cells.`; dtout.textContent = fmtTime(sim.dt); dtout.classList.toggle('unstable', cfl >= 1); }; updateStatics(); // --- frame loop ---------------------------------------------------------- let frames = 0; let lastFps = performance.now(); let msPerFrame = 0; const frame = () => { view.resize(); if (running) { sim.run(stepsPerFrame); if (scaleMode === 'auto') { // A rebuild can destroy the sim while a readback is in flight; a // rejection then is about the old sim and not worth reporting. sim .peak() .then((m) => { if (m !== null && m > 0) scale = Math.max(scale * 0.98, m * 0.85); }) .catch(() => {}); } } view.draw(sim.pressureIndex, { camera, scale: Math.max(scale, 1e-20), opacity: viewState.opacity, contrast: viewState.contrast, steps: quality, clipX: viewState.clip * grid.L, medium: showMedium ? 0.35 : 0, cref: C_AIR, cdev: Math.max(medium.cmax - C_AIR, C_AIR - medium.cmin, 1e-6), mic, }); frames++; const now = performance.now(); if (now - lastFps > 500) { msPerFrame = (now - lastFps) / frames; frames = 0; lastFps = now; const cellsPerLam = C_AIR / source.f / grid.h; const resolution = cellsPerLam < POOR_RESOLUTION ? ` — ${cellsPerLam.toFixed(1)} cells per wavelength: mostly grid dispersion` : ''; const rate = running ? ` — ${msPerFrame.toFixed(1)} ms/frame` : ' — paused'; stats.innerHTML = `step ${sim.steps} — t = ${fmtTime(sim.t)} — dt = ${sim.dt.toExponential(3)} s` + ` — c ∈ [${(medium.cmin / C_AIR).toPrecision(3)}, ${(medium.cmax / C_AIR).toPrecision(3)}]·c₀` + rate + resolution; $('recinfo').textContent = sim.recorded === 0 ? 'nothing recorded yet' : `${sim.recorded.toLocaleString('en-US')} samples — ${fmtTime(sim.recorded * sim.dt)}` + (sim.recorded >= MIC_CAPACITY ? ' (full)' : '') + playNote; } $('cbhi').textContent = `+${scale.toPrecision(2)}`; $('cblo').textContent = `−${scale.toPrecision(2)}`; requestAnimationFrame(frame); }; buildSceneSliders(); requestAnimationFrame(frame); } start().catch(report);