// WAI spatial-audio sink — load a wai.audio.scene, mix it with the same // deterministic Rust engine that made the corpus, verify the mixdown // hash in-browser (mixdown-equivalence), play the exact PCM, and show a // top-down radar of the moving objects. import init, { AudioSink } from './pkg/wai_web.js'; const $ = (id) => document.getElementById(id); const ui = { scene: $('scene'), play: $('play'), badge: $('badge'), objs: $('objs'), rate: $('rate'), dur: $('dur'), scenehash: $('scenehash'), mixhash: $('mixhash'), }; const canvas = $('c'), g = canvas.getContext('2d'); await init(); let expected = await (await fetch('audio/expected.json')).json(); let sink = null, audioCtx = null, srcNode = null, startedAt = 0, playing = false; async function load(name) { stop(); const wscn = new Uint8Array(await (await fetch(`audio/${name}.wscn`)).arrayBuffer()); const bin = new Uint8Array(await (await fetch(`audio/${name}.sources.bin`)).arrayBuffer()); sink = new AudioSink(wscn); sink.load_sources_bin(bin); const layout = sink.floor_layout(); const mh = sink.mixdown_hash_hex(layout); const exp = expected[`${name}.wscn`]; const ok = exp && mh === exp.mixdown_hash && sink.scene_hash_hex() === exp.scene_hash; ui.badge.className = ok ? 'ok' : 'bad'; ui.badge.textContent = ok ? '✓ mixdown verified — hash matches the conformance corpus' : '✗ mixdown hash mismatch'; ui.objs.textContent = sink.num_objects(); ui.rate.textContent = sink.sample_rate(); ui.dur.textContent = `${sink.duration()} samp`; ui.scenehash.textContent = sink.scene_hash_hex().slice(0, 16) + '…'; ui.mixhash.textContent = mh.slice(0, 16) + '…'; } function stop() { if (srcNode) { try { srcNode.stop(); } catch {} srcNode = null; } playing = false; ui.play.textContent = '▶ play'; } function play() { if (!sink) return; audioCtx = audioCtx || new (window.AudioContext || window.webkitAudioContext)(); const layout = sink.floor_layout(); // "stereo" const pcm = sink.mix(layout); // exact i16 interleaved const n = Number(sink.duration()); const buf = audioCtx.createBuffer(2, n, sink.sample_rate()); const L = buf.getChannelData(0), R = buf.getChannelData(1); for (let t = 0; t < n; t++) { L[t] = pcm[t * 2] / 32768; R[t] = pcm[t * 2 + 1] / 32768; } srcNode = audioCtx.createBufferSource(); srcNode.buffer = buf; srcNode.loop = true; srcNode.connect(audioCtx.destination); srcNode.start(); startedAt = audioCtx.currentTime; playing = true; ui.play.textContent = '⏸ stop'; } ui.scene.addEventListener('change', () => load(ui.scene.value)); ui.play.addEventListener('click', () => (playing ? stop() : play())); // ---- radar: listener at center, objects at their (x,z), looping with playback const W = canvas.width, H = canvas.height, CX = W / 2, CY = H / 2, SCALE = 26; function draw() { g.fillStyle = '#04060a'; g.fillRect(0, 0, W, H); // range rings + axes g.strokeStyle = 'rgba(74,85,104,0.5)'; for (let r = SCALE; r < W; r += SCALE * 2) { g.beginPath(); g.arc(CX, CY, r, 0, 7); g.stroke(); } g.beginPath(); g.moveTo(0, CY); g.lineTo(W, CY); g.moveTo(CX, 0); g.lineTo(CX, H); g.stroke(); // listener g.fillStyle = '#f0b429'; g.beginPath(); g.arc(CX, CY, 5, 0, 7); g.fill(); g.fillStyle = '#4a5568'; g.font = '10px monospace'; g.fillText('listener', CX + 8, CY - 8); if (sink) { const dur = Number(sink.duration()); const t = (playing && audioCtx) ? (Math.floor((audioCtx.currentTime - startedAt) * sink.sample_rate()) % dur) : 0; const xz = sink.object_xz(BigInt(t)); g.fillStyle = '#00e5a0'; for (let i = 0; i * 2 < xz.length; i++) { const x = CX + xz[i * 2] * SCALE; // +x → right (matches pan) const z = CY - xz[i * 2 + 1] * SCALE; // +z (front) → up g.beginPath(); g.arc(x, z, 7, 0, 7); g.fill(); } } requestAnimationFrame(draw); } await load(ui.scene.value); requestAnimationFrame(draw);