/** * The app: a scene you compose, the MATLAB it generates, a GPU, and a * microphone. * * Three altitudes, one simulation. *Play* is presets and the handful of * musical knobs. *Build* is the studio: add strings, bars and membranes, * pick a body, place the microphone. *Lab* is the truth: the generated (and * editable) .m files, what they compiled to, and the recording's spectrum * against where each object's partials should land. * * Everything the page does falls into three motions, and the tabs are * honest about them. Moving a *model parameter* (a pitch, a decay) writes a * GPU uniform, which is free. Moving a *placement or body parameter* * re-evaluates the scene .m on the CPU and re-uploads a few arrays, which is * cheap and needs no recompile. Changing the *structure* — objects added or * removed, a size, the air grid, or either file's text — recompiles: a fresh * session, from source to shaders. */ import { requestAcousticDevice } from './device.ts'; import { StudioSession, specParams } from './studio/session.ts'; import { formatFailure, ModelCompileError } from './mgpu/errors.ts'; import { OBJECT_TEMPLATES, templateOf } from './studio/objects.ts'; import { BODY_TEMPLATES } from './studio/bodies.ts'; import { PRESETS, cloneSpec, presetByKey } from './studio/presets.ts'; import { defaultsOf, suffixed, type ObjectType, type ParamSpec, type Params, type SceneSpec, } from './studio/types.ts'; import { suffixedSpecs } from './studio/codegen.ts'; import { makeSceneFile, parseSceneFile, serializeSceneFile } from './studio/scenefile.ts'; import { fastIneligible, prepareRoom, roomContributions, type FastRoom } from './studio/fast.ts'; import { synthesize } from './studio/modal.ts'; import { RoomCancelled } from './studio/room.ts'; import { drawSpectrum, objectColor, predictedPartials, type PartialSet, } from './studio/spectrum.ts'; import { VolumeView, cameraFrame, type Camera } from './render/volume.ts'; import { Overlay, type OverlayObject } 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 initialPreset = presetByKey(location.hash.replace(/^#preset\//, '')) ?? PRESETS[0]; let presetKey: string | null = initialPreset.key; let spec: SceneSpec = cloneSpec(initialPreset.spec); let { params, sceneParams } = specParams(spec); /** The editor's hand-edited copies; null means "as generated". */ const sources: { model: string | null; scene: string | null } = { model: null, scene: null, }; let gridNx = 96; /** 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; const mic = { ...initialPreset.mic }; /** Paused until asked. The page compiles and draws its silent initial state * on load and nothing moves until Run (or Render clip). */ let running = false; let session: StudioSession | 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, objects: true, wire: true, mic: true }; /** Pressure the colormap saturates at, and whether it follows the field. */ let scale = 1e-6; let autoScale = true; let peakSeen = 0; let colormapName = colormapNames[0]; /** The last displacement read back per object, for the overlay and plot. */ const lastFields = new Map(); /** Per-object plot scale: the largest |u| seen since the last excite. */ const plotPeak = new Map(); let plotId: string | null = null; /** The last recording analyzed, so the spectrum can be redrawn. */ let lastTrace: { trace: Float32Array; dt: number } | null = null; let dirty = true; let renderingClip = false; /** The fast path (src/studio/fast.ts): the cached room recording, keyed on * everything that shapes it — the fourth cost tier. Model parameters reuse * it; geometry, grid or mic edits key differently and re-record. */ let fastRoom: { key: string; fr: FastRoom } | null = null; let fastBusy = false; let fastCancel = false; /** The last fast-synthesized clip. Live recording replaces it: whatever was * recorded or synthesized most recently is what Listen and Save play. */ let fastTrace: { trace: Float32Array; dt: number; note: string } | null = null; /* ------------------------------------------------------------- the page -- */ const device = await requestAcousticDevice().catch((e: unknown) => { showError(e, ''); return null; }); if (!device) throw new Error('no GPU'); 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('objplot')); 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 editorFile = el('editor-file'); /** The host-provided names the editor highlights; refilled per scene, since * the op set (dxx_s1, lap_m1, ...) is the scene's own. */ const externalNames = new Set(['sponge3']); const editor = new CodeEditor({ textarea: el('source'), overlay: el('highlight'), external: externalNames, onInput: (value) => { sources[editorFile.value as 'model' | 'scene'] = value; el('recompile').classList.add('primary'); }, }); const colormapSelect = el('colormap'); for (const name of colormapNames) colormapSelect.append(new Option(name, name)); colormapSelect.value = colormapName; /* ---------------------------------------------------------------- utils -- */ const objectIds = (): string[] => spec.objects.map((o) => o.id); function nextId(type: ObjectType): string { const prefix = type === 'string' ? 's' : type === 'bar' ? 'b' : 'm'; for (let n = 1; ; n++) { const id = `${prefix}${n}`; if (!objectIds().includes(id)) return id; } } /** What the excite button should say for this scene. */ function verb(): string { const verbs = new Set(spec.objects.map((o) => templateOf(o).verb)); return verbs.size === 1 ? [...verbs][0] : 'Excite'; } /** Reconcile the parameter dicts with the current spec: defaults for new * keys, current values kept where they still apply. */ function syncParams(): void { const d = specParams(spec); params = { ...d.params, ...pick(params, Object.keys(d.params)) }; sceneParams = { ...d.sceneParams, ...pick(sceneParams, Object.keys(d.sceneParams)) }; } const pick = (o: Params, keys: string[]): Params => Object.fromEntries(keys.filter((k) => k in o).map((k) => [k, o[k]])); /** Structure changed: keep the builder's values in the spec so presets and * future codegen see them. */ function writeBack(): void { for (const o of spec.objects) { const t = templateOf(o); o.params = Object.fromEntries( t.paramSpecs.map((s) => [s.key, params[suffixed(s.key, o.id)] ?? s.value]), ); o.place = Object.fromEntries( t.placeSpecs.map((s) => [s.key, sceneParams[suffixed(s.key, o.id)] ?? s.value]), ); } spec.body.params = pick( sceneParams, BODY_TEMPLATES[spec.body.type].paramSpecs.map((s) => s.key), ); } /* ------------------------------------------------------------- controls -- */ /** One slider per parameter spec. */ function buildSliders( host: HTMLElement, specs: ParamSpec[], values: Params, onChange: (key: string, value: number) => void, ): void { host.textContent = ''; addSliders(host, specs, values, onChange); } function addSliders( host: HTMLElement, specs: ParamSpec[], values: Params, onChange: (key: string, value: number) => void, ): void { 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] ?? spec.value); const out = document.createElement('output'); out.textContent = fmtValue(values[spec.key] ?? spec.value); input.addEventListener('input', () => { const v = Number(input.value); out.textContent = fmtValue(v); onChange(spec.key, v); }); row.append(name, input, out); host.append(row); } } /** 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(sceneParams, sources.scene ?? undefined); clearError(); // A scene edit that raises the fastest speed invalidates the timestep // (and with it every object 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 ?? session.sceneSource); } } const onModelParam = (key: string, value: number): void => { params = { ...params, [key]: value }; session?.setParams(params); }; const onSceneParam = (key: string, value: number): void => { sceneParams = { ...sceneParams, [key]: value }; scheduleSceneUpdate(); }; function buildPresetCards(): void { const host = el('presets'); host.textContent = ''; for (const p of PRESETS) { const card = document.createElement('button'); card.className = `preset${p.key === presetKey ? ' active' : ''}`; card.innerHTML = `${p.label}${p.blurb}`; card.addEventListener('click', () => { presetKey = p.key; location.hash = `preset/${p.key}`; spec = cloneSpec(p.spec); Object.assign(mic, p.mic); ({ params, sceneParams } = specParams(spec)); sources.model = null; sources.scene = null; refreshControls(); void rebuild(); }); host.append(card); } } function buildMusicalSliders(): void { const host = el('musicalparams'); host.textContent = ''; for (const o of spec.objects) { const t = templateOf(o); const specs = suffixedSpecs(t.paramSpecs, o.id) .filter((s) => s.musical) .map((s) => ({ ...s, label: `${o.id} · ${s.label}` })); addSliders(host, specs, params, onModelParam); } } /** The Play tab's geometry knobs — deliberately a separate group from the * tuning sliders, because they cost what tuning never does: an object size * recompiles, a body shape re-evaluates the scene, and either way the next * Fast render re-records its room. Only the specs marked musical appear; * the full set lives in the Build tab. */ function buildShapeSliders(): void { const host = el('shapeparams'); host.textContent = ''; for (const o of spec.objects) { const t = templateOf(o); const sizeSpecs = t.sizeSpecs .filter((s) => s.musical) .map((s) => ({ ...s, label: `${o.id} · ${s.label}` })); addSliders( host, sizeSpecs, Object.fromEntries(t.sizeSpecs.map((s) => [s.key, (o.size as Params)[s.key] ?? s.value])), (key, value) => { writeBack(); (o.size as Params)[key] = value; presetKey = null; scheduleRebuild(); }, ); } const body = BODY_TEMPLATES[spec.body.type]; const bodySpecs = body.paramSpecs .filter((s) => s.musical) .map((s) => ({ ...s, label: `body · ${s.label}` })); addSliders(host, bodySpecs, sceneParams, onSceneParam); } function buildObjectCards(): void { const host = el('objects'); host.textContent = ''; spec.objects.forEach((o, index) => { const t = templateOf(o); const card = document.createElement('div'); card.className = 'objcard'; const head = document.createElement('div'); head.className = 'objcard-head'; const swatch = document.createElement('i'); swatch.className = 'swatch'; swatch.style.background = objectColor(index); const title = document.createElement('b'); title.textContent = `${t.label} ${o.id}`; const blurb = document.createElement('span'); blurb.className = 'sub'; blurb.style.margin = '0'; blurb.textContent = t.blurb; const remove = document.createElement('button'); remove.textContent = 'remove'; remove.disabled = spec.objects.length === 1; remove.title = remove.disabled ? 'A scene needs at least one object' : 'Remove this object (recompiles)'; remove.addEventListener('click', () => { writeBack(); spec.objects = spec.objects.filter((x) => x.id !== o.id); presetKey = null; syncParams(); refreshControls(); void rebuild(); }); head.append(swatch, title, blurb, remove); card.append(head); const sliders = document.createElement('div'); sliders.className = 'sliders'; // Sizes first: they rebuild. Then placement (scene) and parameters. addSliders( sliders, t.sizeSpecs, { ...Object.fromEntries(t.sizeSpecs.map((s) => [s.key, (o.size as Params)[s.key] ?? s.value])) }, (key, value) => { writeBack(); (o.size as Params)[key] = value; presetKey = null; scheduleRebuild(); }, ); addSliders(sliders, suffixedSpecs(t.placeSpecs, o.id), sceneParams, onSceneParam); addSliders(sliders, suffixedSpecs(t.paramSpecs, o.id), params, onModelParam); card.append(sliders); host.append(card); }); } function buildBodyControls(): void { const select = el('bodytype'); select.value = spec.body.type; el('bodyblurb').textContent = BODY_TEMPLATES[spec.body.type].blurb; buildSliders( el('bodyparams'), BODY_TEMPLATES[spec.body.type].paramSpecs, sceneParams, onSceneParam, ); } 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 }, ]; 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 buildObjPick(): void { const pick = el('objpick'); pick.textContent = ''; for (const o of spec.objects) { pick.append(new Option(`${templateOf(o).label} ${o.id}`, o.id)); } if (!plotId || !objectIds().includes(plotId)) plotId = spec.objects[0]?.id ?? null; if (plotId) pick.value = plotId; } function refreshControls(): void { el('excite').textContent = verb(); buildPresetCards(); buildMusicalSliders(); buildShapeSliders(); buildObjectCards(); buildBodyControls(); buildMicControls(); buildObjPick(); } /* ------------------------------------------------------------- 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 excite(): void { if (!session) return; session.excite(); fastTrace = null; peakSeen = 0; if (autoScale) scale = 1e-6; plotPeak.clear(); dirty = true; showRecording(); } /** * Compile the current scene 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. */ let building = false; let rebuildAgain = false; async function rebuild(): Promise { if (building) { rebuildAgain = true; return; } building = true; renderingClip = false; const old = session; try { syncParams(); const next = await StudioSession.create({ device: device!, spec, params, sceneParams, modelSource: sources.model ?? undefined, sceneSource: sources.scene ?? undefined, nx: gridNx, }); next.excite(); session = next; view.setSource( next.gpu.stateBuffer('p')!, 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(); lastFields.clear(); plotPeak.clear(); peakSeen = 0; scale = 1e-6; lastTrace = null; fastTrace = null; dirty = true; clearError(); showRecording(); showStatics(); el('recompile').classList.remove('primary'); el('compiled').textContent = describe(next); externalNames.clear(); externalNames.add('sponge3'); for (const op of next.generated.ops) externalNames.add(op.name); showFile(editorFile.value as 'model' | 'scene'); drawSpec(); } catch (e) { const which = failingFile(e); showError(e, sources[which] ?? ''); if (e instanceof ModelCompileError && e.start !== undefined && sources[which]) { editorFile.value = which; showFile(which); editor.select(e.start, e.end ?? e.start + 1); } } finally { building = false; if (rebuildAgain) { rebuildAgain = false; void rebuild(); } } } /** Size-slider drags would rebuild per pixel; give them a moment to settle. */ let rebuildTimer = 0; const scheduleRebuild = (): void => { clearTimeout(rebuildTimer); rebuildTimer = window.setTimeout(() => void rebuild(), 250); }; /** 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: StudioSession): string => { const { init, step } = s.describe(); return [ `% init — one ${verb().toLowerCase()}`, ...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 (fastTrace) { const { trace, dt, note } = fastTrace; const plan = planPlayback(trace.length, dt); info.textContent = `${fmtTime(trace.length * dt)} synthesized ${note} · plays at ` + `${Math.round(plan.rate).toLocaleString()} Hz` + (plan.realTime ? '' : ' (rate clamped)'); return; } if (!session) { info.textContent = ''; return; } const n = session.recorder.count; if (n === 0) { info.textContent = 'nothing recorded yet — Run, or Render clip'; 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 } = session; const fmax = C_AIR / (POOR_RESOLUTION * air.h); const objects = spec.objects .map((o) => { const g = session!.grids[o.id]; return `${o.id}: ${g.kind === 'line' ? `${g.n1} nodes` : `${g.n1}×${g.n2} nodes`}`; }) .join(', '); 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. Objects: ${objects}. ` + `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'; el('stats').innerHTML = `t = ${fmtTime(session.t)} · step ${session.steps.toLocaleString()} · ` + `${spec.objects.length} object${spec.objects.length === 1 ? '' : 's'} · ${rate}`; } /** * Follow the field with the colour scale, and keep the overlay and 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 { for (const o of spec.objects) { const u = await session.read(`u_${o.id}`); lastFields.set(o.id, u); let peak = plotPeak.get(o.id) ?? 0; for (const v of u) peak = Math.max(peak, Math.abs(v)); plotPeak.set(o.id, peak); } if (autoScale) { const p = await session.read('p'); 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; } } /** The overlay's view of the scene, from the builder state. */ function overlayObjects(): OverlayObject[] { return spec.objects.map((o, index) => { const g = session?.grids[o.id]; const color = objectColor(index); if (o.type === 'membrane') { return { kind: 'rect' as const, color, L1: o.size.L1, L2: o.size.L2 ?? o.size.L1, n1: g?.n1 ?? 2, n2: g?.n2 ?? 2, oz: sceneParams[`oz_${o.id}`] ?? 0, u: lastFields.get(o.id) ?? null, }; } return { kind: 'line' as const, color, L1: o.size.L1, oy: sceneParams[`oy_${o.id}`] ?? 0, oz: sceneParams[`oz_${o.id}`] ?? 0, u: lastFields.get(o.id) ?? null, posts: o.type === 'string', }; }); } /** The selected object's displacement, as a 1D slice for the plot. */ function plotSlice(): { u: Float32Array | null; label: string } { if (!plotId || !session) return { u: null, label: '' }; const o = spec.objects.find((x) => x.id === plotId); const u = lastFields.get(plotId) ?? null; const g = session.grids[plotId]; if (!o || !g) return { u: null, label: '' }; let slice = u; let where = ''; if (o.type === 'membrane' && u) { const midJ = Math.floor(g.n2 / 2); slice = u.subarray(g.n1 * midJ, g.n1 * (midJ + 1)); where = ' (centre row)'; } return { u: slice, label: `${plotId} displacement${where}, full scale ±${fmtValue(1100 * plotScale())} mm`, }; } const plotScale = (): number => Math.max(plotPeak.get(plotId ?? '') ?? 0, 1e-6) * 1.05; let lastPollAt = 0; function frame(): void { if (session && (running || dirty)) { if (running && !renderingClip) { // 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, objects: overlayObjects(), body: { type: spec.body.type, params: sceneParams }, exaggerate: viewState.exaggerate, wireframe: show.wire, showObjects: show.objects, }); const slice = plotSlice(); plot.draw(slice.u, plotScale(), darkMedia.matches); el('objlabel').textContent = slice.label; dirty = false; colorbar.setRange(-scale, scale); frames++; const now = performance.now(); if ((running || renderingClip) && 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); } /* ---------------------------------------------------------- the spectrum -- */ function drawSpec(): void { const canvasEl = el('spectrum'); const legend = el('speclegend'); legend.textContent = ''; if (!session || !lastTrace) { const ctx = canvasEl.getContext('2d'); ctx?.clearRect(0, 0, canvasEl.width, canvasEl.height); return; } const fair = C_AIR / (POOR_RESOLUTION * session.air.h); const fmax = Math.min(6000, Math.max(1000, 1.3 * fair)); const partials: PartialSet[] = spec.objects.map((o) => predictedPartials(o, session!.grids[o.id], params, fmax), ); drawSpectrum(canvasEl, { trace: lastTrace.trace, dt: lastTrace.dt, fmax, partials, dark: darkMedia.matches, }); partials.forEach((set, i) => { const item = document.createElement('i'); item.style.color = objectColor(i); item.textContent = `■ ${set.label}`; legend.append(item); }); } async function analyzeTrace(): Promise { if (!session || session.recorder.count === 0) return null; const s = session; const trace = await s.recorder.read(); lastTrace = { trace, dt: s.dt }; drawSpec(); return trace; } /* ------------------------------------------------------- rendering a clip -- */ /** * Excite, 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 renderClip(): Promise { if (!session || building || fastBusy) return; const btn = el('renderclip'); if (renderingClip) { renderingClip = false; // cancel: the loop below notices and stops return; } renderingClip = true; setRunning(false); btn.textContent = 'Cancel'; const prog = el('renderprog'); prog.hidden = false; prog.value = 0; const seconds = Number(el('renderdur').value); try { excite(); const s = session; const total = Math.min(Math.ceil(seconds / s.dt), s.recorder.capacity); const batch = 512; let done = 0; while (done < total && renderingClip && session === s) { const n = Math.min(batch, total - done); s.step(n); done += n; await s.sync(); prog.value = done / total; } if (renderingClip && session === s) { const trace = await analyzeTrace(); if (trace) { stopAudio(); await playTrace(trace, planPlayback(trace.length, s.dt)); } clearError(); } } catch (e) { showError(e, ''); } finally { renderingClip = false; prog.hidden = true; btn.textContent = 'Render clip'; dirty = true; showRecording(); } } /* ------------------------------------------------- the fast render path -- */ /** Everything the room recording depends on. A matching key means the cached * kernels are still the truth; model parameters are deliberately absent. */ const fastKey = (): string => JSON.stringify({ objects: spec.objects.map((o) => ({ id: o.id, type: o.type, size: o.size })), body: spec.body.type, sceneParams, nx: gridNx, mic: { x: mic.x, y: mic.y, z: mic.z }, scene: sources.scene, }); /** * Render the clip from precomputation: record the room once (the only slow * step — about half a second of ordinary rendering), then synthesize the mic * trace from the membrane's closed-form modes against the cached kernels. * Any later change to pitch, decay, brightness, the strike or the gains * reuses the room and lands here in about a second; geometry, grid and mic * edits change the key and re-record. The volume view is untouched: the fast * path produces the trace and nothing else, so the field on screen stays * whatever the live solver last drew. */ async function fastRenderClip(): Promise { const btn = el('fastrender'); if (fastBusy) { fastCancel = true; // cancel: the recording and synthesis loops notice return; } if (building || renderingClip) return; const bad = fastIneligible(spec, sources.model ?? undefined); if (bad) { showError(new Error(`Fast render: ${bad}.`), ''); return; } fastBusy = true; fastCancel = false; setRunning(false); btn.textContent = 'Cancel'; const prog = el('renderprog'); prog.hidden = false; prog.value = 0; const seconds = Number(el('renderdur').value); try { syncParams(); const P = { ...params }; const key = fastKey(); // Two attempts at most: the second only if a new strike reaches well // outside the cached recording's membrane mode set, which discards it. for (let attempt = 0; attempt < 2; attempt++) { let synthBase = 0; if (!fastRoom || fastRoom.key !== key) { fastRoom = null; synthBase = 0.9; const fr = await prepareRoom({ device: device!, spec, params: P, sceneParams: { ...sceneParams }, sceneSource: sources.scene ?? undefined, nx: gridNx, mic: { ...mic }, irSeconds: 0.5, // Membranes recorded a decade deeper than synthesis keeps, so // later strikes rarely want modes the recording lacks (strings // and bars record every mode and never can). modeOpts: { eps: 1e-7 }, onProgress: (done, total) => { prog.value = 0.9 * (done / total); }, cancelled: () => fastCancel, }); fastRoom = { key, fr }; } const { fr } = fastRoom; const total = Math.ceil(seconds / fr.dt); const { contribs, used, wanted, missing } = roomContributions(fr, spec, P, { eps: 1e-6 }); if (missing > 0.02 * wanted && attempt === 0) { fastRoom = null; // the strike moved too far; record afresh continue; } const master = new Float64Array(total); const batch = 64; for (let q0 = 0; q0 < contribs.length; q0 += batch) { if (fastCancel) throw new RoomCancelled(); const part = synthesize(contribs.slice(q0, q0 + batch), fr.room.steps, total); for (let t = 0; t < total; t++) master[t] += part[t]; prog.value = synthBase + (1 - synthBase) * Math.min(1, (q0 + batch) / contribs.length); await new Promise((r) => setTimeout(r, 0)); } const note = `(fast, ${used} modes)` + (fr.kernelDecay > 1e-3 ? ' · the room was still ringing where its recording ends; the clip truncates it' : ''); fastTrace = { trace: Float32Array.from(master), dt: fr.dt, note }; lastTrace = { trace: fastTrace.trace, dt: fr.dt }; drawSpec(); clearError(); showRecording(); stopAudio(); await playTrace(fastTrace.trace, planPlayback(fastTrace.trace.length, fr.dt)); break; } } catch (e) { if (!(e instanceof RoomCancelled)) showError(e, ''); } finally { fastBusy = false; prog.hidden = true; btn.textContent = 'Fast render'; showRecording(); } } /* -------------------------------------------------------------- wiring --- */ const TABS = ['play', 'build', 'lab'] as const; for (const tab of TABS) { el(`tab-${tab}`).addEventListener('click', () => { // The Play shape knobs and the Build cards are two views of the same // values; rebuilding the controls on a tab switch keeps them in step. refreshControls(); for (const t of TABS) { el(`tab-${t}`).classList.toggle('active', t === tab); el(`panel-${t}`).classList.toggle('active', t === tab); } if (tab === 'lab') drawSpec(); }); } function showFile(which: 'model' | 'scene'): void { if (!session) return; editor.value = sources[which] ?? (which === 'model' ? session.generated.modelSource : session.generated.sceneSource); const edited = sources[which] !== null ? ' — edited by hand' : ' — generated from the scene'; el('editor-title').textContent = (which === 'model' ? 'init and step, all objects together, compiled to WebGPU' : 'medium(x, y, z, …) → walls and coupling, evaluated once on the CPU') + edited; } el('gridsize').addEventListener('change', (e) => { gridNx = Number((e.target as HTMLSelectElement).value); void rebuild(); }); el('savescene').addEventListener('click', () => { writeBack(); const file = makeSceneFile({ spec, params, sceneParams, mic, nx: gridNx, modelSource: sources.model, sceneSource: sources.scene, }); const url = URL.createObjectURL( new Blob([serializeSceneFile(file)], { type: 'application/json' }), ); const a = document.createElement('a'); a.href = url; a.download = 'soundbox-scene.json'; a.click(); setTimeout(() => URL.revokeObjectURL(url), 10_000); // Show what to do with the file: the exact command that renders it // outside the browser, ready to copy. const cmd = 'npm run render -- soundbox-scene.json --seconds 10 --out clip.wav'; const hint = el('scenehint'); hint.hidden = false; hint.textContent = 'Saved soundbox-scene.json. To render it on another machine ' + '(a checkout of this repo, numbl beside it, any GPU): '; const code = document.createElement('code'); code.textContent = cmd; const copy = document.createElement('button'); copy.textContent = 'copy'; copy.addEventListener('click', () => { void navigator.clipboard.writeText(cmd).then(() => { copy.textContent = 'copied'; setTimeout(() => (copy.textContent = 'copy'), 1500); }); }); const tail = document.createElement('span'); tail.textContent = ' — add --nx 192 or higher for more bandwidth than the browser grid; see the README.'; hint.append(code, copy, tail); }); const loadFile = el('loadfile'); el('loadscene').addEventListener('click', () => loadFile.click()); loadFile.addEventListener('change', () => { const f = loadFile.files?.[0]; loadFile.value = ''; if (!f) return; void f.text().then((text) => { try { const file = parseSceneFile(text); spec = file.spec; presetKey = null; ({ params, sceneParams } = specParams(spec)); Object.assign(params, file.params); Object.assign(sceneParams, file.sceneParams); Object.assign(mic, file.mic); gridNx = file.nx; const select = el('gridsize'); if (![...select.options].some((o) => o.value === String(gridNx))) { select.append(new Option(String(gridNx), String(gridNx))); } select.value = String(gridNx); sources.model = file.sources?.model ?? null; sources.scene = file.sources?.scene ?? null; refreshControls(); clearError(); void rebuild(); } catch (e) { showError(e, ''); } }); }); el('bodytype').addEventListener('change', (e) => { writeBack(); spec.body = { type: (e.target as HTMLSelectElement).value as SceneSpec['body']['type'], params: {} }; presetKey = null; syncParams(); Object.assign(sceneParams, defaultsOf(BODY_TEMPLATES[spec.body.type].paramSpecs)); refreshControls(); void rebuild(); }); const addObject = (type: ObjectType): void => { writeBack(); const t = OBJECT_TEMPLATES[type]; const size: { L1: number; L2?: number } = { L1: t.sizeSpecs[0].value }; if (type === 'membrane') size.L2 = t.sizeSpecs[1].value; spec.objects.push({ id: nextId(type), type, size, params: {}, place: {} }); presetKey = null; syncParams(); refreshControls(); void rebuild(); }; el('add-string').addEventListener('click', () => addObject('string')); el('add-bar').addEventListener('click', () => addObject('bar')); el('add-membrane').addEventListener('click', () => addObject('membrane')); const runPause = el('runpause'); const setRunning = (r: boolean): void => { if (r && fastTrace) { // Live stepping records over the microphone again; the synthesized clip // stops being "the latest recording" the moment the solver moves. fastTrace = null; showRecording(); } running = r; runPause.textContent = r ? 'Pause' : 'Run'; frames = 0; lastFpsAt = performance.now(); dirty = true; }; runPause.addEventListener('click', () => setRunning(!running && !renderingClip && !fastBusy)); el('excite').addEventListener('click', excite); el('spf').addEventListener('change', (e) => { stepsPerFrame = Number((e.target as HTMLSelectElement).value); }); el('renderclip').addEventListener('click', () => void renderClip()); el('fastrender').addEventListener('click', () => void fastRenderClip()); const listen = el('listen'); listen.addEventListener('click', () => { if (fastTrace) { const { trace, dt } = fastTrace; listen.disabled = true; stopAudio(); void playTrace(trace, planPlayback(trace.length, dt)) .then(() => clearError()) .catch((e: unknown) => showError(e, '')) .finally(() => { listen.disabled = false; }); return; } if (!session) return; const s = session; if (s.recorder.count === 0) { showError(new Error('the microphone has not recorded anything yet — Run, or Render clip'), ''); return; } listen.disabled = true; stopAudio(); void analyzeTrace() .then((trace) => (trace ? playTrace(trace, planPlayback(trace.length, s.dt)) : null)) .then(() => clearError()) .catch((e: unknown) => showError(e, '')) .finally(() => { listen.disabled = false; }); }); el('download').addEventListener('click', () => { if (fastTrace) { const url = URL.createObjectURL(traceToWav(fastTrace.trace, fastTrace.dt)); const a = document.createElement('a'); a.href = url; a.download = 'soundbox.wav'; a.click(); setTimeout(() => URL.revokeObjectURL(url), 10_000); return; } if (!session) return; const s = session; if (s.recorder.count === 0) { showError(new Error('the microphone has not recorded anything yet — Run, or Render clip'), ''); 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 = 'soundbox.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('showobjects', (v) => (show.objects = v)); bindCheck('showwire', (v) => (show.wire = v)); bindCheck('showmic', (v) => (show.mic = v)); el('objpick').addEventListener('change', (e) => { plotId = (e.target as HTMLSelectElement).value; dirty = true; }); // --- 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', () => { sources[editorFile.value as 'model' | 'scene'] = null; void rebuild(); }); darkMedia.addEventListener('change', () => { dirty = true; drawSpec(); }); /* ---------------------------------------------------------------- start -- */ refreshControls(); await rebuild(); // The initial excitation shapes, so the page opens showing something. void pollFields(); requestAnimationFrame(frame);