Implements the simulator described in README.md: a 3D room in which a
loudspeaker can be moved and aimed, heard binaurally from the camera's
position, with every acoustic term shown live so the user can read why it
sounds the way it does.
Only README.md was previously committed. An earlier AI-generated
implementation existed in the working tree but was never committed; it is
superseded rather than modified. That implementation did not work:
renderer.setSize() was never called and the stylesheet had no rule for
html, body or the canvas container, so the WebGL view rendered into a
300x150 box in the corner of an unstyled page. Its acoustics were also
wrong in ways that voided the stated goal, which is what motivated a
rewrite rather than a repair:
- AudioContext.listener orientation (forwardX/Y/Z, upX/Y/Z) was never
written, only position. Orbiting the camera therefore produced no
change in the sound field at all.
- The listener was a separate avatar mesh, not the camera, contradicting
the README. Its syncFromCamera() had zero callers.
- The reverb send was tapped after the distance, cone and occlusion
gains, making the direct-to-reverberant ratio mathematically constant.
Walking away sounded like a fader move rather than like distance.
- Doppler was derived from a single-frame velocity finite difference
smoothed with a per-frame (not per-second) lerp, so any drag produced
an octave-wide pitch chirp and the response varied with refresh rate.
Design decisions worth recording, since each has a cheaper alternative
that was rejected:
The camera IS the Web Audio listener. Position and orientation are both
published every frame. OrbitControls panning is disabled on purpose: it
moves the orbit target without moving the camera, which would silently
desync the ear from the view and break that invariant.
Doppler is emergent, not computed. A DelayNode holds distance /
speedOfSound; chasing it resamples the signal exactly as air does. This
removes velocity estimation from the codebase entirely, which is what
eliminated the drag-chirp class of bug. The reported ratio is 1 - dD/dt,
the shift a delay line actually produces. The textbook moving-source form
1/(1 - v/c) agrees only to first order and has a pole that inverts the
sign of the shift when a source closes fast.
The reverb send is tapped BEFORE distance, directivity and occlusion,
because a room's reverberant field is roughly uniform: it depends on how
much power the source radiates, not on where the listener stands. This is
what makes the critical distance audible, and it is the one thing the
simulator teaches that a fader cannot fake.
Room acoustics are derived from geometry via Sabine (T60 = 0.161V/Sa)
rather than from named presets, so the room-size sliders genuinely change
the sound. The impulse response is normalised to unit energy per channel,
not unit peak, because a convolution's output level follows total energy;
peak normalisation would make a long tail far louder than a short one and
the send gain would stop meaning anything.
Source presets are built additively from a bounded harmonic series instead
of being sampled from ideal waveforms, leaving an octave of clean headroom
so Doppler can pitch them up without folding harmonics back down as
aliasing.
User-supplied audio files are summed to mono. A stereo mix fed into the
panner would leave part of the image fixed to the listener's ears no
matter where the source moved, which is precisely the illusion this app
exists to break.
Dark theme only. The viewport is a lit 3D room; a light chrome around it
reads as a white frame on a dark photograph, and re-lighting the scene for
a light theme would need a second set of materials for no real gain.
User-visible behaviour, relative to the uncommitted prior version:
- The 3D view fills its pane and is sized on construction plus via a
ResizeObserver.
- Orbiting the view swings the sound between the ears (measured at
roughly 14 dB of L-R swing across a full orbit in an anechoic room).
- Walking away attenuates the direct path while the reverberant field
holds steady, and a HUD chip reports which of the two dominates.
- A signal-path table lists every stage between source and ear in dB on
one shared -60..0 dB scale. The stage losses sum exactly to the total;
air absorption deliberately reports no dB because it has no broadband
loss, only a cutoff.
- Audio files can be loaded by drop or picker and behave as the source.
- Three guided demos, a first-run explainer, and a keyboard model.
- Leaked agent artifacts are gone: the panel header no longer reads
"M4 OVERLAY", sections are no longer lettered "A.", "B.", and the
milestone scratch directories and docs are deleted.
Known limitations, also listed in README.md:
- Reverb is a single static impulse response per room; it does not vary
with listener position, so walking into a corner is not audible.
- No precedence effect. Real ears localise well beyond the critical
distance because the first wavefront wins; here localisation degrades
with the direct-to-reverberant ratio, so a very live room smears the
image more than it would in life.
- Occlusion tests one obstacle and models diffraction as a fixed
broadband loss rather than as a function of the obstacle's size.
- HRTF quality is whatever the browser's PannerNode provides.
- Loaded files loop with an audible seam; the crossfade used on the
generated presets would be wrong for music.
Test Plan
npm test 116 unit tests. Physics and room acoustics are pure
functions. The audio graph is tested against a mock
AudioContext that records connections, so the suite
can assert topology directly: that the reverb send
hangs off the delay rather than the direct gain, and
that listener forward/up are published and not just
position. Both were silently wrong before.
npm run test:e2e 47 checks driving real Chrome; starts its own dev
server. Covers what a mock cannot: that the view is
actually sized, that orbiting changes the ear balance,
that walking away attenuates the direct path while the
room holds steady, that each demo does what its
caption claims, that the panel follows the demos
rather than silently reverting them, and that a real
WAV decoded by the browser spatialises like any other
source while a non-audio file fails visibly.
npm run build tsc plus a production bundle, split so three.js gets
its own long-lived chunk (470 kB three, 99 kB app).
Manual check: load the page in a browser with headphones, press
"Enable audio & start", then press 3 and walk away with W/S. The Direct
row should fall while Room (wet) stays flat, and the HUD chip should flip
to room-dominant as you cross the ring drawn on the floor.
Refs: ORIGINAL_REQUEST.md
Docs: README.md
416 lines
15 KiB
JavaScript
416 lines
15 KiB
JavaScript
/**
|
||
* Browser smoke test.
|
||
*
|
||
* Drives the real app in real Chrome, where a real WebGL context and a real
|
||
* AudioContext exist, and asserts on the acoustic behaviour the unit suite
|
||
* cannot reach: that the view is actually sized, that turning your head
|
||
* changes the binaural image, that walking away is audible, and that the
|
||
* demos do what they claim.
|
||
*
|
||
* npm run test:e2e (against an already-running dev server)
|
||
* npm run test:e2e -- <url>
|
||
*/
|
||
import { spawn } from 'node:child_process';
|
||
import { writeFileSync } from 'node:fs';
|
||
import { tmpdir } from 'node:os';
|
||
import { chromium } from 'playwright';
|
||
|
||
/** A real 16-bit PCM WAV, so the browser's own decoder is exercised. */
|
||
function makeWavFile(seconds, frequency, sampleRate = 44100) {
|
||
const frames = Math.floor(seconds * sampleRate);
|
||
const dataBytes = frames * 4; // stereo, 16-bit
|
||
const buffer = Buffer.alloc(44 + dataBytes);
|
||
|
||
buffer.write('RIFF', 0);
|
||
buffer.writeUInt32LE(36 + dataBytes, 4);
|
||
buffer.write('WAVE', 8);
|
||
buffer.write('fmt ', 12);
|
||
buffer.writeUInt32LE(16, 16); // PCM chunk size
|
||
buffer.writeUInt16LE(1, 20); // PCM
|
||
buffer.writeUInt16LE(2, 22); // channels
|
||
buffer.writeUInt32LE(sampleRate, 24);
|
||
buffer.writeUInt32LE(sampleRate * 4, 28); // byte rate
|
||
buffer.writeUInt16LE(4, 32); // block align
|
||
buffer.writeUInt16LE(16, 34); // bits per sample
|
||
buffer.write('data', 36);
|
||
buffer.writeUInt32LE(dataBytes, 40);
|
||
|
||
for (let i = 0; i < frames; i++) {
|
||
const sample = Math.round(Math.sin((2 * Math.PI * frequency * i) / sampleRate) * 20000);
|
||
buffer.writeInt16LE(sample, 44 + i * 4);
|
||
buffer.writeInt16LE(sample, 44 + i * 4 + 2);
|
||
}
|
||
return buffer;
|
||
}
|
||
|
||
let failures = 0;
|
||
let checks = 0;
|
||
let server = null;
|
||
|
||
/** Boots a dev server unless the caller pointed us at one already. */
|
||
async function resolveUrl() {
|
||
const explicit = process.argv[2];
|
||
if (explicit) return explicit;
|
||
|
||
const port = 5179;
|
||
const url = `http://localhost:${port}/`;
|
||
server = spawn('npx', ['vite', '--port', String(port), '--strictPort'], {
|
||
stdio: 'ignore',
|
||
detached: false,
|
||
});
|
||
|
||
for (let attempt = 0; attempt < 40; attempt++) {
|
||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||
try {
|
||
const response = await fetch(url);
|
||
if (response.ok) return url;
|
||
} catch {
|
||
/* not up yet */
|
||
}
|
||
}
|
||
throw new Error('dev server did not start');
|
||
}
|
||
|
||
const url = await resolveUrl();
|
||
|
||
function check(name, condition, detail = '') {
|
||
checks++;
|
||
if (condition) {
|
||
console.log(` ✓ ${name}`);
|
||
} else {
|
||
failures++;
|
||
console.log(` ✗ ${name}${detail ? ` — ${detail}` : ''}`);
|
||
}
|
||
}
|
||
|
||
const browser = await chromium.launch({
|
||
channel: 'chrome',
|
||
args: [
|
||
'--use-gl=swiftshader',
|
||
'--enable-unsafe-swiftshader',
|
||
'--autoplay-policy=no-user-gesture-required',
|
||
],
|
||
});
|
||
const page = await browser.newPage({ viewport: { width: 1440, height: 900 }, colorScheme: 'dark' });
|
||
|
||
const pageErrors = [];
|
||
page.on('pageerror', (e) => pageErrors.push(e.message));
|
||
page.on('console', (m) => {
|
||
if (m.type() === 'error' && !m.text().includes('favicon')) pageErrors.push(m.text());
|
||
});
|
||
|
||
const telemetry = () => page.evaluate(() => window.__resonance.telemetry());
|
||
const settings = () => page.evaluate(() => window.__resonance.settings());
|
||
const settle = (ms = 900) => page.waitForTimeout(ms);
|
||
|
||
console.log(`\nResonance smoke test — ${url}\n`);
|
||
|
||
await page.goto(url, { waitUntil: 'networkidle' });
|
||
await settle(1200);
|
||
|
||
console.log('boot');
|
||
{
|
||
const box = await page.evaluate(() => {
|
||
const c = document.querySelector('#viewport canvas');
|
||
return { w: c.width, h: c.height, cssW: c.clientWidth, cssH: c.clientHeight };
|
||
});
|
||
check('the 3D view fills its pane', box.cssW > 800 && box.cssH > 400, JSON.stringify(box));
|
||
check('the drawing buffer matches the CSS box', box.w >= box.cssW && box.h >= box.cssH);
|
||
check('the first-run dialog is shown', await page.locator('#welcome[open]').count() === 1);
|
||
|
||
const t = await telemetry();
|
||
check('telemetry is live before audio is unlocked', t.distance > 0 && Number.isFinite(t.distanceGainDb));
|
||
check('audio is not yet running', t.running === false);
|
||
}
|
||
|
||
console.log('\nunlocking audio');
|
||
await page.locator('#welcome button', { hasText: 'Enable audio' }).click();
|
||
await settle(1500);
|
||
{
|
||
const t = await telemetry();
|
||
check('the context reports running', t.running === true);
|
||
check('the status chip agrees', (await page.locator('#status').getAttribute('data-state')) === 'running');
|
||
check('the dialog closed', await page.locator('#welcome[open]').count() === 0);
|
||
check('output has level', t.outputLevelDb > -80, `${t.outputLevelDb} dB`);
|
||
}
|
||
|
||
console.log('\nbinaural image (the camera is the microphone)');
|
||
{
|
||
// Dry the room right out and use broadband noise. Reverb is diffuse by
|
||
// construction, so a live room legitimately masks the binaural image — this
|
||
// check is about HRTF, not about the direct-to-reverberant balance.
|
||
await page.selectOption('#surface', 'anechoic');
|
||
await page.selectOption('#preset', 'pink');
|
||
await settle(900);
|
||
|
||
const cameraBefore = await page.evaluate(() => window.__resonance.camera());
|
||
|
||
// Drag from low on the left, well clear of the transform gizmo — a drag that
|
||
// starts on the gizmo moves the source instead of orbiting the camera.
|
||
// Sample the ear balance right through the sweep rather than only at the
|
||
// ends: a sweep that happens to land back on its starting bearing would
|
||
// otherwise look like nothing moved.
|
||
const box = await page.locator('#viewport canvas').boundingBox();
|
||
const startX = box.x + box.width * 0.18;
|
||
const startY = box.y + box.height * 0.78;
|
||
let min = Infinity;
|
||
let max = -Infinity;
|
||
|
||
await page.mouse.move(startX, startY);
|
||
await page.mouse.down();
|
||
for (let i = 1; i <= 18; i++) {
|
||
await page.mouse.move(startX + i * 22, startY);
|
||
await page.waitForTimeout(90);
|
||
const t = await telemetry();
|
||
const balance = t.leftLevelDb - t.rightLevelDb;
|
||
if (Number.isFinite(balance)) {
|
||
min = Math.min(min, balance);
|
||
max = Math.max(max, balance);
|
||
}
|
||
}
|
||
await page.mouse.up();
|
||
await settle(800);
|
||
|
||
const cameraAfter = await page.evaluate(() => window.__resonance.camera());
|
||
const moved = Math.abs(cameraAfter.x - cameraBefore.x) + Math.abs(cameraAfter.z - cameraBefore.z);
|
||
check('dragging orbits the camera', moved > 1, `moved ${moved.toFixed(2)} m`);
|
||
check(
|
||
'orbiting swings the sound between the ears',
|
||
max - min > 4,
|
||
`L−R spanned ${min.toFixed(1)} … ${max.toFixed(1)} dB`
|
||
);
|
||
|
||
await page.selectOption('#surface', 'studio');
|
||
await page.selectOption('#preset', 'sawtooth');
|
||
await settle(600);
|
||
}
|
||
|
||
console.log('\nwalking away');
|
||
{
|
||
const before = await telemetry();
|
||
await page.locator('#viewport canvas').click({ position: { x: 500, y: 300 } });
|
||
await page.keyboard.down('KeyS');
|
||
await settle(1100);
|
||
await page.keyboard.up('KeyS');
|
||
await settle(500);
|
||
const after = await telemetry();
|
||
|
||
check(
|
||
'holding S increases the distance',
|
||
after.distance > before.distance + 0.5,
|
||
`${before.distance.toFixed(2)} m → ${after.distance.toFixed(2)} m`
|
||
);
|
||
// Check the distance term specifically: walking also changes the off-axis
|
||
// angle, so the *total* gain can legitimately rise as you back away.
|
||
check(
|
||
'distance attenuation increases',
|
||
after.distanceGainDb < before.distanceGainDb - 0.5,
|
||
`${before.distanceGainDb.toFixed(1)} dB → ${after.distanceGainDb.toFixed(1)} dB`
|
||
);
|
||
check(
|
||
'the reverberant field does not follow it down',
|
||
Math.abs(after.reverbGainDb - before.reverbGainDb) < 0.5,
|
||
`room ${before.reverbGainDb.toFixed(1)} dB → ${after.reverbGainDb.toFixed(1)} dB`
|
||
);
|
||
}
|
||
|
||
console.log('\ndemo 1 — siren fly-by');
|
||
{
|
||
await page.keyboard.press('Digit1');
|
||
await settle(600);
|
||
let sawShift = false;
|
||
let extreme = 1;
|
||
for (let i = 0; i < 40; i++) {
|
||
const t = await telemetry();
|
||
if (Math.abs(t.dopplerCents) > 12) sawShift = true;
|
||
if (Math.abs(t.dopplerCents) > Math.abs(extreme)) extreme = t.dopplerCents;
|
||
await page.waitForTimeout(120);
|
||
}
|
||
check('a moving source produces a Doppler shift', sawShift, `peak ${extreme.toFixed(1)} cents`);
|
||
check('the preset switched to the engine', (await settings()).preset === 'engine');
|
||
|
||
// A demo that changes the engine without updating the panel leaves every
|
||
// control showing a stale value, and the next touch writes it back — silently
|
||
// undoing the demo the user just asked for.
|
||
check(
|
||
'the panel followed the demo',
|
||
(await page.locator('#preset').inputValue()) === 'engine',
|
||
`preset select shows "${await page.locator('#preset').inputValue()}"`
|
||
);
|
||
check(
|
||
'the motion control followed the demo',
|
||
(await page.locator('.segmented button[aria-checked="true"]').first().textContent())?.trim() ===
|
||
'Fly-by'
|
||
);
|
||
}
|
||
|
||
console.log('\ndemo 2 — behind the pillar');
|
||
{
|
||
await page.keyboard.press('Digit2');
|
||
await settle(1200);
|
||
const t = await telemetry();
|
||
check('the line of sight is blocked', t.occlusion > 0.5, `occlusion ${t.occlusion.toFixed(2)}`);
|
||
check('the direct path is lowpassed', t.occlusionCutoff < 4000, `${Math.round(t.occlusionCutoff)} Hz`);
|
||
check('the signal path row flags it', await page.locator('.path tr[data-flag="warn"]').count() > 0);
|
||
}
|
||
|
||
console.log('\ndemo 3 — past the critical distance');
|
||
{
|
||
await page.keyboard.press('Digit3');
|
||
await settle(1200);
|
||
const t = await telemetry();
|
||
check('the room got livelier', t.t60 > 1.5, `t60 ${t.t60.toFixed(2)} s`);
|
||
check('a critical distance is reported', t.criticalDistance > 0 && t.criticalDistance < 20);
|
||
check('the room controls followed the demo', (await page.locator('#surface').inputValue()) === 'hall');
|
||
check('the width slider followed the demo', (await page.locator('#room-w').inputValue()) === '34');
|
||
check(
|
||
'direct-to-reverb is derived from the two levels shown',
|
||
Math.abs(t.directToReverbDb - (t.directGainDb - t.reverbGainDb)) < 1e-6
|
||
);
|
||
}
|
||
|
||
console.log('\nUI wiring');
|
||
{
|
||
await page.selectOption('#surface', 'cathedral');
|
||
await settle(900);
|
||
const stone = await telemetry();
|
||
await page.selectOption('#surface', 'anechoic');
|
||
await settle(900);
|
||
const foam = await telemetry();
|
||
check('harder surfaces ring longer', stone.t60 > foam.t60, `${stone.t60.toFixed(2)} vs ${foam.t60.toFixed(2)} s`);
|
||
check('deader surfaces push the critical distance out', foam.criticalDistance > stone.criticalDistance);
|
||
|
||
await page.locator('#play').click();
|
||
await settle(500);
|
||
check('pause stops playback', (await page.evaluate(() => window.__resonance.playing())) === false);
|
||
await page.locator('#play').click();
|
||
await settle(500);
|
||
check('play resumes it', (await page.evaluate(() => window.__resonance.playing())) === true);
|
||
|
||
await page.locator('#hide-panels').click();
|
||
await settle(400);
|
||
check('panels can be hidden', await page.evaluate(() => document.body.classList.contains('panels-hidden')));
|
||
await page.locator('#hide-panels').click();
|
||
await settle(400);
|
||
|
||
const shown = await page.evaluate(() => {
|
||
const rows = [...document.querySelectorAll('.path tr')];
|
||
const row = rows.find((r) => r.querySelector('th')?.textContent === 'Distance');
|
||
return row?.querySelector('td.detail')?.textContent ?? '';
|
||
});
|
||
const t = await telemetry();
|
||
check(
|
||
'the signal path prints the distance it measured',
|
||
Math.abs(parseFloat(shown) - t.distance) < 0.2,
|
||
`panel "${shown}" vs telemetry ${t.distance.toFixed(2)}`
|
||
);
|
||
}
|
||
|
||
console.log('\nloading a file from disk');
|
||
{
|
||
const wav = makeWavFile(3, 220);
|
||
const path = `${tmpdir()}/resonance-e2e-tone.wav`;
|
||
writeFileSync(path, wav);
|
||
|
||
await page.setInputFiles('#audio-file', path);
|
||
await settle(2000);
|
||
|
||
const settingsAfter = await settings();
|
||
check('the loaded file becomes the source', settingsAfter.preset === 'file');
|
||
check(
|
||
'the dropdown names the track',
|
||
(await page.locator('#preset option[value="file"]').textContent())?.includes('resonance-e2e-tone.wav'),
|
||
await page.locator('#preset option[value="file"]').textContent()
|
||
);
|
||
check('the dropdown selects it', (await page.locator('#preset').inputValue()) === 'file');
|
||
check(
|
||
'the duration is reported',
|
||
/0:03/.test((await page.locator('#audio-file-status').textContent()) ?? ''),
|
||
await page.locator('#audio-file-status').textContent()
|
||
);
|
||
check('playback started', (await page.evaluate(() => window.__resonance.playing())) === true);
|
||
|
||
const t = await telemetry();
|
||
check('the file is producing output', t.outputLevelDb > -80, `${t.outputLevelDb.toFixed(1)} dBFS`);
|
||
|
||
const sourceRow = await page.evaluate(() => {
|
||
const rows = [...document.querySelectorAll('.path tr')];
|
||
const row = rows.find((r) => r.querySelector('th')?.textContent === 'Source');
|
||
return row?.querySelector('td.detail')?.textContent ?? '';
|
||
});
|
||
check(
|
||
'the signal path names the track, not "file"',
|
||
sourceRow.includes('resonance-e2e-tone'),
|
||
`shows "${sourceRow}"`
|
||
);
|
||
|
||
// The whole point: a file behaves like every other source in the simulation.
|
||
await page.locator('.segmented button', { hasText: 'Orbit' }).click();
|
||
await settle(1500);
|
||
let min = Infinity;
|
||
let max = -Infinity;
|
||
for (let i = 0; i < 25; i++) {
|
||
const s = await telemetry();
|
||
const balance = s.leftLevelDb - s.rightLevelDb;
|
||
if (Number.isFinite(balance)) {
|
||
min = Math.min(min, balance);
|
||
max = Math.max(max, balance);
|
||
}
|
||
await page.waitForTimeout(150);
|
||
}
|
||
check(
|
||
'the file is spatialised like any other source',
|
||
max - min > 3,
|
||
`L−R spanned ${min.toFixed(1)} … ${max.toFixed(1)} dB`
|
||
);
|
||
|
||
// A non-audio file must fail visibly, not silently or in the console.
|
||
const junk = `${tmpdir()}/resonance-e2e-junk.txt`;
|
||
writeFileSync(junk, 'this is not audio');
|
||
await page.setInputFiles('#audio-file', junk);
|
||
await settle(1500);
|
||
const status = (await page.locator('#audio-file-status').textContent()) ?? '';
|
||
check('a non-audio file reports a readable error', /Could not decode/.test(status), status);
|
||
check(
|
||
'a failed load leaves the previous track playing',
|
||
(await settings()).preset === 'file' &&
|
||
(await page.evaluate(() => window.__resonance.playing())) === true
|
||
);
|
||
|
||
await page.locator('.segmented button', { hasText: 'Parked' }).click();
|
||
await page.selectOption('#preset', 'sawtooth');
|
||
await settle(600);
|
||
}
|
||
|
||
console.log('\naccessibility basics');
|
||
{
|
||
const audit = await page.evaluate(() => {
|
||
const controls = [...document.querySelectorAll('#panel input, #panel select')];
|
||
const unlabelled = controls.filter(
|
||
(c) => !c.id || !document.querySelector(`label[for="${c.id}"]`)
|
||
).length;
|
||
return {
|
||
total: controls.length,
|
||
unlabelled,
|
||
headings: document.querySelectorAll('h1, h2').length,
|
||
liveRegions: document.querySelectorAll('[aria-live]').length,
|
||
canvasLabels: [...document.querySelectorAll('#strip canvas')].every((c) =>
|
||
c.getAttribute('aria-label')
|
||
),
|
||
};
|
||
});
|
||
check('every panel control has a real label', audit.unlabelled === 0, `${audit.unlabelled} of ${audit.total}`);
|
||
check('the page is structured with headings', audit.headings >= 4);
|
||
check('there is a polite live region', audit.liveRegions >= 1);
|
||
check('the meter canvases are described', audit.canvasLabels);
|
||
}
|
||
|
||
console.log('\nruntime health');
|
||
check('no uncaught page errors', pageErrors.length === 0, pageErrors.slice(0, 3).join(' | '));
|
||
|
||
await browser.close();
|
||
server?.kill();
|
||
|
||
console.log(`\n${checks - failures}/${checks} checks passed\n`);
|
||
process.exit(failures === 0 ? 0 : 1);
|