/ proof-of-concept / instrawave
Sign in
proof-of-concept / instrawave
instrawave / src / main.ts
420 lines · 14.3 KBCodeBlameHistory
2 * The page: load a bundled instrument, move its parameters, bake, render,
3 * listen. The cost model is on screen: tuning renders in about a second
4 * from a kept bake; a geometry or accuracy edit updates the 3D view at once
5 * (the medium pass is cheap) but marks the bake stale, and Precompute with
6 * its progress bar is the price of the change. After a render, the trace
7 * and its spectrum are drawn, with each rendered mode's own discrete
8 * frequency marked over the measured peaks.
9 */
10import { requestDevice } from './host/device.ts';
11import { InstrumentSession, kernelDecay, PhaseCancelled } from './host/session.ts';
12import { Viewer, slotPartials, type Camera } from './host/view.ts';
13import { spectrumOf } from './fft.ts';
14import { parseInstrument, paramValues, type InstrumentFile, type ParamSpec, type StimulusEvent } from './format.ts';
15import { traceToWavBytes } from './wav.ts';
16import type { Bake } from './host/bake.ts';
17import drumJson from '../instruments/drum.instrument.json';
18import dulcimerJson from '../instruments/dulcimer.instrument.json';
19import chimeJson from '../instruments/chime.instrument.json';
21const el = <T extends HTMLElement>(id: string): T => document.getElementById(id) as T;
22const instrumentSel = el<HTMLSelectElement>('instrument');
23const tuningDiv = el<HTMLDivElement>('tuning');
24const geometryDiv = el<HTMLDivElement>('geometry');
25const eventsBox = el<HTMLTextAreaElement>('events');
26const bakeState = el<HTMLSpanElement>('bakestate');
27const gridInfo = el<HTMLSpanElement>('gridinfo');
28const progress = el<HTMLProgressElement>('progress');
29const status = el<HTMLParagraphElement>('status');
30const volumeCanvas = el<HTMLCanvasElement>('volume');
31const overlayCanvas = el<HTMLCanvasElement>('overlay');
32const traceCanvas = el<HTMLCanvasElement>('trace');
33const spectrumCanvas = el<HTMLCanvasElement>('spectrum');
34const buttons = {
35 precompute: el<HTMLButtonElement>('precompute'),
36 cancel: el<HTMLButtonElement>('cancel'),
37 render: el<HTMLButtonElement>('render'),
38 play: el<HTMLButtonElement>('play'),
39 save: el<HTMLButtonElement>('save'),
40};
42const instruments: InstrumentFile[] = [drumJson, dulcimerJson, chimeJson].map((j) =>
43 parseInstrument(JSON.stringify(j)),
44);
45for (const inst of instruments) {
46 const o = document.createElement('option');
47 o.value = inst.name;
48 o.textContent = inst.name;
49 instrumentSel.append(o);
52let device: GPUDevice;
53let session: InstrumentSession;
54let bake: Bake | null = null;
55let viewer: Viewer | null = null;
56let cancelFlag = false;
57let last: { trace: Float32Array; dt: number; partials: { freq: number; weight: number }[] } | null = null;
58let audioCtx: AudioContext | null = null;
59const cam: Camera = { yaw: -2.2, pitch: 0.45, dist: 1.6 };
61const fmt = (v: number): string => {
62 const a = Math.abs(v);
63 if (v === 0) return '0';
64 if (a >= 1000 || a < 0.01) return v.toExponential(2);
65 return `${Math.round(v * 10000) / 10000}`;
66};
68/* ------------------------------------------------------- scene view --- */
70function sizeCanvas(c: HTMLCanvasElement): void {
71 const r = c.getBoundingClientRect();
72 const dpr = Math.min(devicePixelRatio || 1, 2);
73 const w = Math.max(1, Math.round(r.width * dpr));
74 const h = Math.max(1, Math.round(r.height * dpr));
75 if (c.width !== w || c.height !== h) {
76 c.width = w;
77 c.height = h;
78 }
81let viewTimer: number | null = null;
82function scheduleViewUpdate(): void {
83 if (viewTimer !== null) clearTimeout(viewTimer);
84 viewTimer = window.setTimeout(() => {
85 viewTimer = null;
86 void (async () => {
87 try {
88 await viewer?.update(session.scope());
89 viewer?.draw(cam);
90 } catch (e) {
91 status.textContent = `scene view: ${e instanceof Error ? e.message : e}`;
92 }
93 })();
94 }, 180);
98 let dragging = false;
99 let px = 0;
100 let py = 0;
101 overlayCanvas.addEventListener('pointerdown', (e) => {
102 dragging = true;
103 px = e.clientX;
104 py = e.clientY;
105 overlayCanvas.setPointerCapture(e.pointerId);
106 });
107 overlayCanvas.addEventListener('pointermove', (e) => {
108 if (!dragging) return;
109 cam.yaw -= (e.clientX - px) * 0.008;
110 cam.pitch = Math.min(1.45, Math.max(-1.45, cam.pitch + (e.clientY - py) * 0.008));
111 px = e.clientX;
112 py = e.clientY;
113 viewer?.draw(cam);
114 });
115 overlayCanvas.addEventListener('pointerup', () => {
116 dragging = false;
117 });
118 overlayCanvas.addEventListener(
119 'wheel',
120 (e) => {
121 e.preventDefault();
122 cam.dist = Math.min(6, Math.max(0.3, cam.dist * Math.exp(e.deltaY * 0.001)));
123 viewer?.draw(cam);
124 },
125 { passive: false },
126 );
129/* ---------------------------------------------------------- plots ----- */
131function drawTrace(trace: Float32Array, dt: number): void {
132 sizeCanvas(traceCanvas);
133 traceCanvas.hidden = false;
134 const g = traceCanvas.getContext('2d')!;
135 const w = traceCanvas.width;
136 const h = traceCanvas.height;
137 g.fillStyle = '#14161a';
138 g.fillRect(0, 0, w, h);
139 let peak = 0;
140 for (const v of trace) peak = Math.max(peak, Math.abs(v));
141 const scale = peak > 0 ? (0.46 * h) / peak : 0;
142 g.strokeStyle = '#7fb4e6';
143 g.lineWidth = 1;
144 g.beginPath();
145 for (let x = 0; x < w; x++) {
146 const i0 = Math.floor((x / w) * trace.length);
147 const i1 = Math.min(trace.length, Math.floor(((x + 1) / w) * trace.length) + 1);
148 let lo = Infinity;
149 let hi = -Infinity;
150 for (let i = i0; i < i1; i++) {
151 if (trace[i] < lo) lo = trace[i];
152 if (trace[i] > hi) hi = trace[i];
153 }
154 g.moveTo(x + 0.5, h / 2 - hi * scale);
155 g.lineTo(x + 0.5, h / 2 - lo * scale + 1);
156 }
157 g.stroke();
158 // Second ticks.
159 g.fillStyle = 'rgba(255,255,255,0.55)';
160 g.font = `${11 * (traceCanvas.width / traceCanvas.getBoundingClientRect().width)}px system-ui`;
161 const seconds = trace.length * dt;
162 const tick = seconds > 12 ? 2 : seconds > 5 ? 1 : 0.5;
163 for (let t = 0; t <= seconds; t += tick) {
164 const x = (t / seconds) * w;
165 g.fillRect(x, h - 6, 1, 6);
166 if (t > 0) g.fillText(`${t}s`, x + 3, h - 4);
167 }
170function drawSpectrum(
171 trace: Float32Array,
172 dt: number,
173 partials: { freq: number; weight: number }[],
174 honestHz: number,
175): void {
176 sizeCanvas(spectrumCanvas);
177 spectrumCanvas.hidden = false;
178 const g = spectrumCanvas.getContext('2d')!;
179 const w = spectrumCanvas.width;
180 const h = spectrumCanvas.height;
181 const dpr = spectrumCanvas.width / spectrumCanvas.getBoundingClientRect().width;
182 g.fillStyle = '#14161a';
183 g.fillRect(0, 0, w, h);
184 const { db, binHz } = spectrumOf(trace, dt);
185 const fLo = 30;
186 const fHi = Math.min(1 / (2 * dt), 12000);
187 const xOf = (f: number): number => (Math.log(f / fLo) / Math.log(fHi / fLo)) * w;
188 const dbFloor = -100;
189 const yOf = (d: number): number => ((-d / -dbFloor) * (h - 18));
191 // The band the grid does not resolve, shaded.
192 if (honestHz < fHi) {
193 g.fillStyle = 'rgba(255,255,255,0.06)';
194 g.fillRect(xOf(honestHz), 0, w - xOf(honestHz), h);
195 g.fillStyle = 'rgba(255,255,255,0.4)';
196 g.font = `${10 * dpr}px system-ui`;
197 g.fillText('above the grid’s resolution', xOf(honestHz) + 4 * dpr, 12 * dpr);
198 }
200 // Decade grid and labels.
201 g.strokeStyle = 'rgba(255,255,255,0.12)';
202 g.fillStyle = 'rgba(255,255,255,0.55)';
203 g.font = `${11 * dpr}px system-ui`;
204 g.lineWidth = 1;
205 for (const f of [50, 100, 200, 500, 1000, 2000, 5000, 10000]) {
206 if (f < fLo || f > fHi) continue;
207 const x = xOf(f);
208 g.beginPath();
209 g.moveTo(x, 0);
210 g.lineTo(x, h);
211 g.stroke();
212 g.fillText(f >= 1000 ? `${f / 1000}k` : `${f}`, x + 2 * dpr, h - 4 * dpr);
213 }
215 // Predicted partials: the discrete scheme's own frequencies, from the
216 // rendered slots. Ticks from the top, opacity by source strength.
217 for (const p of partials) {
218 if (p.freq < fLo || p.freq > fHi) continue;
219 const x = xOf(p.freq);
220 g.strokeStyle = `rgba(255, 200, 80, ${0.25 + 0.75 * Math.min(1, Math.sqrt(p.weight))})`;
221 g.lineWidth = 1.5;
222 g.beginPath();
223 g.moveTo(x, 14 * dpr);
224 g.lineTo(x, 34 * dpr);
225 g.stroke();
226 }
228 // The measured spectrum.
229 g.strokeStyle = '#7fb4e6';
230 g.lineWidth = 1.2;
231 g.beginPath();
232 let started = false;
233 for (let x = 0; x < w; x++) {
234 const f0 = fLo * Math.pow(fHi / fLo, x / w);
235 const f1 = fLo * Math.pow(fHi / fLo, (x + 1) / w);
236 const i0 = Math.max(1, Math.floor(f0 / binHz));
237 const i1 = Math.max(i0 + 1, Math.ceil(f1 / binHz));
238 let best = dbFloor;
239 for (let i = i0; i < Math.min(i1, db.length); i++) best = Math.max(best, db[i]);
240 const y = yOf(Math.max(best, dbFloor));
241 if (started) g.lineTo(x, y);
242 else g.moveTo(x, y);
243 started = true;
244 }
245 g.stroke();
248/* ------------------------------------------------------- lifecycle ---- */
250async function refreshBakeState(): Promise<void> {
251 const s = session.scope();
252 gridInfo.textContent =
253 `air ${s.nx}×${s.ny}×${s.nz}, dt ${(1e6 * s.dt).toFixed(2)} µs, ` +
254 `${s.n_rows} kernel rows × ${s.ir_steps} steps ` +
255 `(${((s.n_rows * s.ir_steps * 4) / 1e6).toFixed(0)} MB bake)`;
256 const key = await session.bakeKey();
257 const ok = bake !== null && bake.key === key;
258 bakeState.textContent = ok ? 'bake ready' : 'bake needed';
259 bakeState.className = ok ? 'ready' : 'stale';
260 buttons.render.disabled = !ok;
263function slider(p: ParamSpec, into: HTMLElement): void {
264 const div = document.createElement('div');
265 div.className = 'param';
266 const label = document.createElement('label');
267 const name = document.createElement('span');
268 // Tuning labels carry the key, which is what a stimulus "set" uses.
269 name.textContent =
270 `${p.label}${p.unit !== '1' ? ` (${p.unit})` : ''}` +
271 (p.class === 'tuning' && p.key !== p.label ? ` · ${p.key}` : '');
272 if (p.hint) label.title = p.hint;
273 const value = document.createElement('span');
274 const input = document.createElement('input');
275 input.type = 'range';
276 input.min = `${p.min}`;
277 input.max = `${p.max}`;
278 input.step = `${p.step ?? (p.max - p.min) / 200}`;
279 input.value = `${session.values[p.key]}`;
280 value.textContent = fmt(session.values[p.key]);
281 input.oninput = () => {
282 session.values[p.key] = Number(input.value);
283 value.textContent = fmt(Number(input.value));
284 void refreshBakeState();
285 if (p.class !== 'tuning') scheduleViewUpdate();
286 };
287 label.append(name, value);
288 div.append(label, input);
289 into.append(div);
292function loadInstrument(name: string): void {
293 const inst = instruments.find((i) => i.name === name)!;
294 session = new InstrumentSession(device, inst, paramValues(inst));
295 bake = null;
296 last = null;
297 buttons.play.disabled = true;
298 buttons.save.disabled = true;
299 traceCanvas.hidden = true;
300 spectrumCanvas.hidden = true;
301 tuningDiv.replaceChildren();
302 geometryDiv.replaceChildren();
303 for (const p of inst.params) slider(p, p.class === 'tuning' ? tuningDiv : geometryDiv);
304 status.textContent = inst.description;
305 el<HTMLSpanElement>('evhint').textContent =
306 `JSON list; "set" may override: ${inst.render.eventParams.join(', ')}`;
307 viewer?.destroy();
308 viewer = null;
309 if (inst.visual) {
310 sizeCanvas(volumeCanvas);
311 sizeCanvas(overlayCanvas);
312 viewer = new Viewer(device, inst, volumeCanvas, overlayCanvas);
313 scheduleViewUpdate();
314 }
315 void refreshBakeState();
318buttons.precompute.onclick = async () => {
319 cancelFlag = false;
320 progress.hidden = false;
321 buttons.cancel.hidden = false;
322 buttons.precompute.disabled = true;
323 const t0 = performance.now();
324 try {
325 bake = await session.precompute(
326 (done, total) => {
327 progress.value = done / total;
328 },
329 () => cancelFlag,
330 );
331 const decay = kernelDecay(bake);
332 status.textContent =
333 `precomputed in ${((performance.now() - t0) / 1000).toFixed(1)} s` +
334 (decay !== null
335 ? `; room response at truncation ${decay > 0 ? `${(20 * Math.log10(decay)).toFixed(0)} dB` : 'silent'}` +
336 (decay > 1e-3 ? ' (still ringing; a longer room response length would carry it)' : '')
337 : '');
338 } catch (e) {
339 status.textContent = e instanceof PhaseCancelled ? 'precompute cancelled' : `precompute failed: ${e}`;
340 } finally {
341 progress.hidden = true;
342 buttons.cancel.hidden = true;
343 buttons.precompute.disabled = false;
344 void refreshBakeState();
345 }
346};
347buttons.cancel.onclick = () => {
348 cancelFlag = true;
349};
351buttons.render.onclick = async () => {
352 if (!bake) return;
353 let events: StimulusEvent[];
354 try {
355 events = JSON.parse(eventsBox.value) as StimulusEvent[];
356 if (!Array.isArray(events) || events.some((e) => typeof e.t !== 'number')) {
357 throw new Error('want a list of {"t": seconds, "set": {...}}');
358 }
359 } catch (e) {
360 status.textContent = `stimuli: ${e instanceof Error ? e.message : e}`;
361 return;
362 }
363 const seconds = Number(el<HTMLInputElement>('seconds').value);
364 buttons.render.disabled = true;
365 const t0 = performance.now();
366 try {
367 const vp = session.inst.visual?.partials;
368 const r = await session.render(bake, seconds, events, vp ? [vp.buffer] : []);
369 const partials = vp ? slotPartials(r.probes[vp.buffer], vp, r.dt) : [];
370 last = { trace: r.trace, dt: r.dt, partials };
371 status.textContent =
372 `rendered ${events.length} event${events.length === 1 ? '' : 's'}, ` +
373 `${r.trace.length.toLocaleString()} samples in ${((performance.now() - t0) / 1000).toFixed(1)} s`;
374 buttons.play.disabled = false;
375 buttons.save.disabled = false;
376 const s = session.scope();
377 drawTrace(r.trace, r.dt);
378 drawSpectrum(r.trace, r.dt, partials, s.c0 / (8 * s.h));
379 } catch (e) {
380 status.textContent = `render failed: ${e}`;
381 } finally {
382 buttons.render.disabled = false;
383 }
384};
386buttons.play.onclick = async () => {
387 if (!last) return;
388 audioCtx ??= new AudioContext();
389 const bytes = traceToWavBytes(last.trace, last.dt);
390 const buf = await audioCtx.decodeAudioData(bytes.buffer.slice(0) as ArrayBuffer);
391 const src = audioCtx.createBufferSource();
392 src.buffer = buf;
393 src.connect(audioCtx.destination);
394 src.start();
395};
397buttons.save.onclick = () => {
398 if (!last) return;
399 const bytes = traceToWavBytes(last.trace, last.dt);
400 const url = URL.createObjectURL(new Blob([bytes as Uint8Array<ArrayBuffer>], { type: 'audio/wav' }));
401 const a = document.createElement('a');
402 a.href = url;
403 a.download = `${session.inst.name}.wav`;
404 a.click();
405 URL.revokeObjectURL(url);
406};
408instrumentSel.onchange = () => loadInstrument(instrumentSel.value);
409window.addEventListener('resize', () => {
410 sizeCanvas(volumeCanvas);
411 sizeCanvas(overlayCanvas);
412 scheduleViewUpdate();
413});
415try {
416 device = await requestDevice();
417 loadInstrument(instruments[0].name);
418} catch (e) {
419 status.textContent = `${e instanceof Error ? e.message : e}`;
↑↓ move↵openescclose