/** * The page: load a bundled instrument, move its parameters, bake, render, * listen. The cost model is on screen: tuning renders in about a second * from a kept bake; a geometry or accuracy edit updates the 3D view at once * (the medium pass is cheap) but marks the bake stale, and Precompute with * its progress bar is the price of the change. After a render, the trace * and its spectrum are drawn, with each rendered mode's own discrete * frequency marked over the measured peaks. */ import { requestDevice } from './host/device.ts'; import { InstrumentSession, kernelDecay, PhaseCancelled } from './host/session.ts'; import { Viewer, slotPartials, type Camera } from './host/view.ts'; import { spectrumOf } from './fft.ts'; import { parseInstrument, paramValues, type InstrumentFile, type ParamSpec, type StimulusEvent } from './format.ts'; import { traceToWavBytes } from './wav.ts'; import type { Bake } from './host/bake.ts'; import drumJson from '../instruments/drum.instrument.json'; import dulcimerJson from '../instruments/dulcimer.instrument.json'; import chimeJson from '../instruments/chime.instrument.json'; const el = (id: string): T => document.getElementById(id) as T; const instrumentSel = el('instrument'); const tuningDiv = el('tuning'); const geometryDiv = el('geometry'); const eventsBox = el('events'); const bakeState = el('bakestate'); const gridInfo = el('gridinfo'); const progress = el('progress'); const status = el('status'); const volumeCanvas = el('volume'); const overlayCanvas = el('overlay'); const traceCanvas = el('trace'); const spectrumCanvas = el('spectrum'); const buttons = { precompute: el('precompute'), cancel: el('cancel'), render: el('render'), play: el('play'), save: el('save'), }; const instruments: InstrumentFile[] = [drumJson, dulcimerJson, chimeJson].map((j) => parseInstrument(JSON.stringify(j)), ); for (const inst of instruments) { const o = document.createElement('option'); o.value = inst.name; o.textContent = inst.name; instrumentSel.append(o); } let device: GPUDevice; let session: InstrumentSession; let bake: Bake | null = null; let viewer: Viewer | null = null; let cancelFlag = false; let last: { trace: Float32Array; dt: number; partials: { freq: number; weight: number }[] } | null = null; let audioCtx: AudioContext | null = null; const cam: Camera = { yaw: -2.2, pitch: 0.45, dist: 1.6 }; const fmt = (v: number): string => { const a = Math.abs(v); if (v === 0) return '0'; if (a >= 1000 || a < 0.01) return v.toExponential(2); return `${Math.round(v * 10000) / 10000}`; }; /* ------------------------------------------------------- scene view --- */ function sizeCanvas(c: HTMLCanvasElement): void { const r = c.getBoundingClientRect(); const dpr = Math.min(devicePixelRatio || 1, 2); const w = Math.max(1, Math.round(r.width * dpr)); const h = Math.max(1, Math.round(r.height * dpr)); if (c.width !== w || c.height !== h) { c.width = w; c.height = h; } } let viewTimer: number | null = null; function scheduleViewUpdate(): void { if (viewTimer !== null) clearTimeout(viewTimer); viewTimer = window.setTimeout(() => { viewTimer = null; void (async () => { try { await viewer?.update(session.scope()); viewer?.draw(cam); } catch (e) { status.textContent = `scene view: ${e instanceof Error ? e.message : e}`; } })(); }, 180); } { let dragging = false; let px = 0; let py = 0; overlayCanvas.addEventListener('pointerdown', (e) => { dragging = true; px = e.clientX; py = e.clientY; overlayCanvas.setPointerCapture(e.pointerId); }); overlayCanvas.addEventListener('pointermove', (e) => { if (!dragging) return; cam.yaw -= (e.clientX - px) * 0.008; cam.pitch = Math.min(1.45, Math.max(-1.45, cam.pitch + (e.clientY - py) * 0.008)); px = e.clientX; py = e.clientY; viewer?.draw(cam); }); overlayCanvas.addEventListener('pointerup', () => { dragging = false; }); overlayCanvas.addEventListener( 'wheel', (e) => { e.preventDefault(); cam.dist = Math.min(6, Math.max(0.3, cam.dist * Math.exp(e.deltaY * 0.001))); viewer?.draw(cam); }, { passive: false }, ); } /* ---------------------------------------------------------- plots ----- */ function drawTrace(trace: Float32Array, dt: number): void { sizeCanvas(traceCanvas); traceCanvas.hidden = false; const g = traceCanvas.getContext('2d')!; const w = traceCanvas.width; const h = traceCanvas.height; g.fillStyle = '#14161a'; g.fillRect(0, 0, w, h); let peak = 0; for (const v of trace) peak = Math.max(peak, Math.abs(v)); const scale = peak > 0 ? (0.46 * h) / peak : 0; g.strokeStyle = '#7fb4e6'; g.lineWidth = 1; g.beginPath(); for (let x = 0; x < w; x++) { const i0 = Math.floor((x / w) * trace.length); const i1 = Math.min(trace.length, Math.floor(((x + 1) / w) * trace.length) + 1); let lo = Infinity; let hi = -Infinity; for (let i = i0; i < i1; i++) { if (trace[i] < lo) lo = trace[i]; if (trace[i] > hi) hi = trace[i]; } g.moveTo(x + 0.5, h / 2 - hi * scale); g.lineTo(x + 0.5, h / 2 - lo * scale + 1); } g.stroke(); // Second ticks. g.fillStyle = 'rgba(255,255,255,0.55)'; g.font = `${11 * (traceCanvas.width / traceCanvas.getBoundingClientRect().width)}px system-ui`; const seconds = trace.length * dt; const tick = seconds > 12 ? 2 : seconds > 5 ? 1 : 0.5; for (let t = 0; t <= seconds; t += tick) { const x = (t / seconds) * w; g.fillRect(x, h - 6, 1, 6); if (t > 0) g.fillText(`${t}s`, x + 3, h - 4); } } function drawSpectrum( trace: Float32Array, dt: number, partials: { freq: number; weight: number }[], honestHz: number, ): void { sizeCanvas(spectrumCanvas); spectrumCanvas.hidden = false; const g = spectrumCanvas.getContext('2d')!; const w = spectrumCanvas.width; const h = spectrumCanvas.height; const dpr = spectrumCanvas.width / spectrumCanvas.getBoundingClientRect().width; g.fillStyle = '#14161a'; g.fillRect(0, 0, w, h); const { db, binHz } = spectrumOf(trace, dt); const fLo = 30; const fHi = Math.min(1 / (2 * dt), 12000); const xOf = (f: number): number => (Math.log(f / fLo) / Math.log(fHi / fLo)) * w; const dbFloor = -100; const yOf = (d: number): number => ((-d / -dbFloor) * (h - 18)); // The band the grid does not resolve, shaded. if (honestHz < fHi) { g.fillStyle = 'rgba(255,255,255,0.06)'; g.fillRect(xOf(honestHz), 0, w - xOf(honestHz), h); g.fillStyle = 'rgba(255,255,255,0.4)'; g.font = `${10 * dpr}px system-ui`; g.fillText('above the grid’s resolution', xOf(honestHz) + 4 * dpr, 12 * dpr); } // Decade grid and labels. g.strokeStyle = 'rgba(255,255,255,0.12)'; g.fillStyle = 'rgba(255,255,255,0.55)'; g.font = `${11 * dpr}px system-ui`; g.lineWidth = 1; for (const f of [50, 100, 200, 500, 1000, 2000, 5000, 10000]) { if (f < fLo || f > fHi) continue; const x = xOf(f); g.beginPath(); g.moveTo(x, 0); g.lineTo(x, h); g.stroke(); g.fillText(f >= 1000 ? `${f / 1000}k` : `${f}`, x + 2 * dpr, h - 4 * dpr); } // Predicted partials: the discrete scheme's own frequencies, from the // rendered slots. Ticks from the top, opacity by source strength. for (const p of partials) { if (p.freq < fLo || p.freq > fHi) continue; const x = xOf(p.freq); g.strokeStyle = `rgba(255, 200, 80, ${0.25 + 0.75 * Math.min(1, Math.sqrt(p.weight))})`; g.lineWidth = 1.5; g.beginPath(); g.moveTo(x, 14 * dpr); g.lineTo(x, 34 * dpr); g.stroke(); } // The measured spectrum. g.strokeStyle = '#7fb4e6'; g.lineWidth = 1.2; g.beginPath(); let started = false; for (let x = 0; x < w; x++) { const f0 = fLo * Math.pow(fHi / fLo, x / w); const f1 = fLo * Math.pow(fHi / fLo, (x + 1) / w); const i0 = Math.max(1, Math.floor(f0 / binHz)); const i1 = Math.max(i0 + 1, Math.ceil(f1 / binHz)); let best = dbFloor; for (let i = i0; i < Math.min(i1, db.length); i++) best = Math.max(best, db[i]); const y = yOf(Math.max(best, dbFloor)); if (started) g.lineTo(x, y); else g.moveTo(x, y); started = true; } g.stroke(); } /* ------------------------------------------------------- lifecycle ---- */ async function refreshBakeState(): Promise { const s = session.scope(); gridInfo.textContent = `air ${s.nx}×${s.ny}×${s.nz}, dt ${(1e6 * s.dt).toFixed(2)} µs, ` + `${s.n_rows} kernel rows × ${s.ir_steps} steps ` + `(${((s.n_rows * s.ir_steps * 4) / 1e6).toFixed(0)} MB bake)`; const key = await session.bakeKey(); const ok = bake !== null && bake.key === key; bakeState.textContent = ok ? 'bake ready' : 'bake needed'; bakeState.className = ok ? 'ready' : 'stale'; buttons.render.disabled = !ok; } function slider(p: ParamSpec, into: HTMLElement): void { const div = document.createElement('div'); div.className = 'param'; const label = document.createElement('label'); const name = document.createElement('span'); // Tuning labels carry the key, which is what a stimulus "set" uses. name.textContent = `${p.label}${p.unit !== '1' ? ` (${p.unit})` : ''}` + (p.class === 'tuning' && p.key !== p.label ? ` · ${p.key}` : ''); if (p.hint) label.title = p.hint; const value = document.createElement('span'); const input = document.createElement('input'); input.type = 'range'; input.min = `${p.min}`; input.max = `${p.max}`; input.step = `${p.step ?? (p.max - p.min) / 200}`; input.value = `${session.values[p.key]}`; value.textContent = fmt(session.values[p.key]); input.oninput = () => { session.values[p.key] = Number(input.value); value.textContent = fmt(Number(input.value)); void refreshBakeState(); if (p.class !== 'tuning') scheduleViewUpdate(); }; label.append(name, value); div.append(label, input); into.append(div); } function loadInstrument(name: string): void { const inst = instruments.find((i) => i.name === name)!; session = new InstrumentSession(device, inst, paramValues(inst)); bake = null; last = null; buttons.play.disabled = true; buttons.save.disabled = true; traceCanvas.hidden = true; spectrumCanvas.hidden = true; tuningDiv.replaceChildren(); geometryDiv.replaceChildren(); for (const p of inst.params) slider(p, p.class === 'tuning' ? tuningDiv : geometryDiv); status.textContent = inst.description; el('evhint').textContent = `JSON list; "set" may override: ${inst.render.eventParams.join(', ')}`; viewer?.destroy(); viewer = null; if (inst.visual) { sizeCanvas(volumeCanvas); sizeCanvas(overlayCanvas); viewer = new Viewer(device, inst, volumeCanvas, overlayCanvas); scheduleViewUpdate(); } void refreshBakeState(); } buttons.precompute.onclick = async () => { cancelFlag = false; progress.hidden = false; buttons.cancel.hidden = false; buttons.precompute.disabled = true; const t0 = performance.now(); try { bake = await session.precompute( (done, total) => { progress.value = done / total; }, () => cancelFlag, ); const decay = kernelDecay(bake); status.textContent = `precomputed in ${((performance.now() - t0) / 1000).toFixed(1)} s` + (decay !== null ? `; room response at truncation ${decay > 0 ? `${(20 * Math.log10(decay)).toFixed(0)} dB` : 'silent'}` + (decay > 1e-3 ? ' (still ringing; a longer room response length would carry it)' : '') : ''); } catch (e) { status.textContent = e instanceof PhaseCancelled ? 'precompute cancelled' : `precompute failed: ${e}`; } finally { progress.hidden = true; buttons.cancel.hidden = true; buttons.precompute.disabled = false; void refreshBakeState(); } }; buttons.cancel.onclick = () => { cancelFlag = true; }; buttons.render.onclick = async () => { if (!bake) return; let events: StimulusEvent[]; try { events = JSON.parse(eventsBox.value) as StimulusEvent[]; if (!Array.isArray(events) || events.some((e) => typeof e.t !== 'number')) { throw new Error('want a list of {"t": seconds, "set": {...}}'); } } catch (e) { status.textContent = `stimuli: ${e instanceof Error ? e.message : e}`; return; } const seconds = Number(el('seconds').value); buttons.render.disabled = true; const t0 = performance.now(); try { const vp = session.inst.visual?.partials; const r = await session.render(bake, seconds, events, vp ? [vp.buffer] : []); const partials = vp ? slotPartials(r.probes[vp.buffer], vp, r.dt) : []; last = { trace: r.trace, dt: r.dt, partials }; status.textContent = `rendered ${events.length} event${events.length === 1 ? '' : 's'}, ` + `${r.trace.length.toLocaleString()} samples in ${((performance.now() - t0) / 1000).toFixed(1)} s`; buttons.play.disabled = false; buttons.save.disabled = false; const s = session.scope(); drawTrace(r.trace, r.dt); drawSpectrum(r.trace, r.dt, partials, s.c0 / (8 * s.h)); } catch (e) { status.textContent = `render failed: ${e}`; } finally { buttons.render.disabled = false; } }; buttons.play.onclick = async () => { if (!last) return; audioCtx ??= new AudioContext(); const bytes = traceToWavBytes(last.trace, last.dt); const buf = await audioCtx.decodeAudioData(bytes.buffer.slice(0) as ArrayBuffer); const src = audioCtx.createBufferSource(); src.buffer = buf; src.connect(audioCtx.destination); src.start(); }; buttons.save.onclick = () => { if (!last) return; const bytes = traceToWavBytes(last.trace, last.dt); const url = URL.createObjectURL(new Blob([bytes as Uint8Array], { type: 'audio/wav' })); const a = document.createElement('a'); a.href = url; a.download = `${session.inst.name}.wav`; a.click(); URL.revokeObjectURL(url); }; instrumentSel.onchange = () => loadInstrument(instrumentSel.value); window.addEventListener('resize', () => { sizeCanvas(volumeCanvas); sizeCanvas(overlayCanvas); scheduleViewUpdate(); }); try { device = await requestDevice(); loadInstrument(instruments[0].name); } catch (e) { status.textContent = `${e instanceof Error ? e.message : e}`; }