diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..396138b
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,9 @@
+node_modules/
+dist/
+*.local
+.DS_Store
+.vite/
+coverage/
+test-results/
+playwright-report/
+*.log
diff --git a/ORIGINAL_REQUEST.md b/ORIGINAL_REQUEST.md
new file mode 100644
index 0000000..f9076d3
--- /dev/null
+++ b/ORIGINAL_REQUEST.md
@@ -0,0 +1,52 @@
+# Original User Request
+
+## Initial Request — 2026-07-26T09:38:00Z
+
+An interactive 3D web application using Three.js and Web Audio API that simulates realistic real-time spatial audio propagation (distance attenuation, directional sound cone, doppler effect, room acoustic reflections) with interactive 3D sound source controls and listener perspective.
+
+Working directory: /pantheon/pfs/git/threejs_sound_sim
+Integrity mode: demo
+
+## Requirements
+
+### R1. Interactive 3D Audio Environment & Navigation
+Build a responsive 3D web interface using Vite, Three.js, and HTML5/CSS. Render a textured 3D room environment containing a movable 3D sound source object (with directional orientation indicators) and a listener/microphone object mapped to camera controls. Provide smooth orbit and keyboard/mouse controls to manipulate both listener and source positions in real time.
+
+### R2. Real-Time Spatial Audio Physics Engine
+Implement realistic spatial audio rendering using Web Audio API:
+1. Distance Attenuation: Inverse-square / exponential distance attenuation models with configurable rolloff factors.
+2. Directional Emission: Cone-based directional audio patterning (inner angle, outer angle, outer gain attenuation).
+3. Doppler Pitch Shift: Dynamic frequency pitch calculation based on relative velocity vector between moving sound source and listener.
+4. Room Acoustics & Occlusion: Environment reverberation simulation using ConvolverNode / impulse responses or synthetic feedback delay networks, plus acoustic attenuation when obstacles block direct line-of-sight.
+
+### R3. Audio Dashboard & Visualizers
+Provide a modern, glassmorphic UI overlay with:
+1. Audio Source Controls: Preset selection (instrument sample, engine sound, synthesized tone generator), play/pause, volume master.
+2. Physics & Room Controls: Sliders for source velocity, room dimensions/reverb preset, rolloff factor, and directional cone angles.
+3. Spectrum & Waveform Analysis: Live canvas visualizers showing real-time frequency spectrum and oscilloscope waveform data from Web Audio AnalyserNode.
+
+### R4. Verification & Build Suite
+Provide programmatic test coverage:
+1. Unit tests for spatial audio physics calculations (distance gain formulas, doppler shift equation output validation).
+2. Automated build check ensuring npm run build generates an error-free production bundle.
+
+## Acceptance Criteria
+
+### 3D Visualization & Interaction
+- [ ] 3D scene renders smoothly (60 FPS target) with room boundaries, sound source mesh, directional vector cone, and listener mesh.
+- [ ] User can interactively position, drag, or rotate the sound source in 3D space.
+- [ ] Camera controls allow orbiting, panning, and inspecting the acoustic environment from the listener's perspective.
+
+### Realistic Spatial Audio Simulation
+- [ ] Moving listener further from sound source reduces volume according to configured distance attenuation model.
+- [ ] Dynamic relative motion between source and listener produces audible and measurable Doppler pitch shift.
+- [ ] Rotating the directional sound source attenuates volume when the listener is outside the inner emission cone.
+- [ ] Toggling room reverb presets dynamically changes spatial acoustic reflection parameters.
+
+### User Interface & Analysis
+- [ ] Control dashboard updates physics and audio parameters seamlessly without audio glitching.
+- [ ] Frequency spectrum and oscilloscope canvases render live audio waveforms accurately.
+
+### Programmatic Verification
+- [ ] npm test passes all unit tests for spatial audio math and calculation modules.
+- [ ] npm run build succeeds without build or bundling errors.
diff --git a/README.md b/README.md
index 2409ff7..affc62a 100644
--- a/README.md
+++ b/README.md
@@ -1,9 +1,151 @@
-# A sound simulator in threejs
+# Resonance — a spatial audio simulator in Three.js
-## Purpose and general information
+Move a loudspeaker through a 3D room and hear exactly what distance, direction,
+obstacles and the room itself do to the sound. **Your camera is the microphone**:
+orbiting the view swings the sound around your head, walking away makes the room
+take over, and stepping behind a pillar muffles the source.
-This is a realistic sound simulator where a 3D-scene is rendered and a sound
-source within that scene. The user can move that sound-source around, turn it
-around, in all directions. The camera position is also the "microphone" from
-which point the sound can be heard. The goal is to make an accurate simulation
-of how it would sound in real time.
+Every number on screen is the actual value driving the audio graph that frame.
+
+```bash
+npm install
+npm run dev # http://localhost:5173
+```
+
+Headphones strongly recommended — the binaural simulation collapses on speakers.
+
+**Bring your own audio.** Drop a file onto the 3D view, or use *Load a track from
+your computer* in the Source panel — MP3, WAV, FLAC, OGG, M4A, whatever your
+browser decodes. It becomes the loudspeaker: set Motion to Orbit and your song
+circles your head, Dopplering as it goes, muffling when it passes behind the
+pillar. Files are summed to mono, because a loudspeaker standing at one point in
+a room radiates one signal; a stereo mix would leave half the image glued to your
+ears no matter where the source went. Nothing is uploaded — decoding happens
+locally in the page.
+
+## What it models
+
+| Effect | How |
+| --- | --- |
+| **Distance attenuation** | Inverse / exponential / linear laws. At rolloff 1.0 the inverse law is exact physics: −6 dB per doubling. |
+| **Directivity** | Web Audio's inner/outer cone model. The lobe in the scene dims with the gain you are actually receiving. |
+| **Propagation delay** | A delay line holding `distance / speedOfSound`. Sound genuinely arrives late. |
+| **Doppler** | *Emergent.* Nothing computes a Doppler ratio to feed the audio — the delay line chasing a moving source resamples the signal, exactly as air does. The reported ratio is `1 − dD/dt`, which is what a delay line actually produces. |
+| **Room reverberation** | A synthetic impulse response built from the room's real geometry via Sabine's equation, `T60 = 0.161 V / Sα`. Early reflections land at the actual wall arrival times. |
+| **Direct-to-reverberant ratio** | The reverb send is tapped *before* distance and directivity, because a room's reverberant field is roughly uniform. This is the effect that makes distance audible. |
+| **Occlusion** | A bundle of rays across the head gives fractional occlusion, which sweeps a lowpass on the direct path only — reflected sound still gets through. |
+| **Air absorption** | Distance-dependent treble loss. The default is realistic, which means subtle; the slider goes to 8× if you want to hear it. |
+
+### The critical distance
+
+The one idea the app is built around. The direct sound falls as `1/r`; the
+reverberant field does not fall at all. They cross at the **critical distance**,
+`rc = √(R / 16π)`, drawn as a ring on the floor:
+
+- **Inside the ring** the source dominates. You can point at it with your eyes shut.
+- **Outside it** the room dominates. It does not get quieter as you back away — and that constancy is a large part of how your ears judge distance.
+
+Press 3 for a guided walk through it.
+
+## Controls
+
+| | | | |
+| --- | --- | --- | --- |
+| W A S D | walk (you are the mic) | Q E | down / up |
+| ↑ ↓ ← → | move the source | PgUp PgDn | source height |
+| drag | orbit | Shift | move 3× faster |
+| G / R | gizmo: move / turn | double-click | place the source |
+| K | play / pause | H | hide panels |
+| 1 2 3 | guided demos | ? | all shortcuts |
+
+Camera panning is deliberately disabled: it would move the orbit target without
+moving the camera, quietly breaking the invariant that the camera *is* the ear.
+
+## The signal path
+
+The bottom-left panel is the whole point. It lists every stage between the
+source and your ear with its loss in dB on one shared −60…0 dB scale, so the
+bars are directly comparable and the stage losses sum exactly to the total:
+
+```
+Source Sawtooth −3.1 dB ██████████
+Distance 12.24 m −21.8 dB ███████
+Directivity 0.0° off-axis 0.0 dB ██████████
+Occlusion clear 0.0 dB ██████████
+Air tone only · LP 20.5 kHz —
+──────────────────────────────────────────────
+At your ear 35.7 ms flight −21.8 dB ████
+Room (wet) t60 1.63 s −9.1 dB ████████
+Direct / room room-dominant −12.7 dB
+```
+
+Air absorption has no broadband dB — it only shapes tone — so it deliberately
+reports no number rather than an invented one.
+
+## Architecture
+
+```
+src/
+ physics/ Pure functions, no Web Audio, no Three.js. Fully unit-tested.
+ vector · attenuation · cone · doppler · occlusion
+ audio/
+ AudioEngine Node graph + per-frame spatial update + telemetry
+ presets Band-limited, seam-free looping source material
+ reverb Sabine acoustics and impulse-response synthesis
+ userAudio Decoding and mono-summing files from disk
+ scene/
+ Stage Renderer, camera-as-listener, orbit + gizmo, occlusion rays
+ Room · SoundSource · Annotations · motion
+ ui/
+ panel · signalPath · readouts · meters · onboarding · controls · format
+ main.ts One rAF loop: stage → engine → readouts
+```
+
+The audio graph:
+
+```
+source ▶ sourceGain ▶ delay ─┬─▶ air ▶ occlusion ▶ direct ▶ panner(HRTF) ─┐
+ │ ├▶ master ▶ limiter ▶ analyser ▶ out
+ └─▶ reverbSend ▶ convolver ──────────────────┘
+```
+
+`AudioEngine.updateSpatial()` computes the full acoustic state whether or not an
+`AudioContext` exists, so every readout is live and correct before the user has
+unlocked audio.
+
+Source presets are built additively from a bounded harmonic series rather than
+sampled from ideal waveforms. That leaves an octave of clean headroom, so
+Doppler can pitch them up without folding harmonics back down as aliasing.
+
+## Tests
+
+```bash
+npm test # 116 unit tests: physics, room acoustics, audio graph, presets, file loading
+npm run test:e2e # 47 checks driving real Chrome (starts its own dev server)
+npm run build # tsc + production bundle
+```
+
+The unit suite runs in Node against a mock `AudioContext` that records the graph
+topology, so it can assert things like "the reverb send is tapped before the
+distance gain" and "the listener's orientation is published, not just its
+position" — both of which were silently wrong before.
+
+The browser suite covers what a mock cannot: that the view is actually sized,
+that orbiting swings the sound between the ears, that walking away attenuates
+the direct path while the reverberant field holds steady, that each demo does
+what it claims, and that the panel controls follow the demos rather than
+silently reverting them.
+
+## Known limitations
+
+- Reverb is a single static impulse response per room. It does not update with
+ listener position, so you cannot hear yourself walk into a corner.
+- No precedence effect. Beyond the critical distance real ears still localise
+ well 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 frequency-dependent function of the obstacle's size.
+- HRTF quality is whatever the browser's `PannerNode` provides.
+- Dark theme only: the viewport is a lit 3D room, and a light chrome around it
+ would need a second set of scene materials to not look like a picture frame.
diff --git a/index.html b/index.html
new file mode 100644
index 0000000..5711613
--- /dev/null
+++ b/index.html
@@ -0,0 +1,66 @@
+
+
+
+
+
+
+
+
+ Resonance · spatial audio studio
+
+
+
+
+
+
+
+ Reset view
+ Hide panels
+
+
+ W A S D walk
+ ↑↓←→ move source
+ drag orbit
+ G /R move / turn
+ ? all controls
+
+
+
+
+
+
+
+ Signal path why it sounds like this
+
+
+
+
+
+
+
+
+ This simulator needs JavaScript and the Web Audio API.
+
+
+
+
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000..9e55954
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,1965 @@
+{
+ "name": "threejs-sound-sim",
+ "version": "2.0.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "threejs-sound-sim",
+ "version": "2.0.0",
+ "dependencies": {
+ "three": "^0.160.0"
+ },
+ "devDependencies": {
+ "@types/three": "^0.160.0",
+ "playwright": "^1.62.0",
+ "typescript": "^5.3.3",
+ "vite": "^5.0.12",
+ "vitest": "^1.2.1"
+ }
+ },
+ "node_modules/@esbuild/aix-ppc64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
+ "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/android-arm": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz",
+ "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/android-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz",
+ "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/android-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz",
+ "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/darwin-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz",
+ "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/darwin-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz",
+ "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz",
+ "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/freebsd-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz",
+ "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-arm": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz",
+ "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz",
+ "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-ia32": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz",
+ "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-loong64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz",
+ "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-mips64el": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz",
+ "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-ppc64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz",
+ "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-riscv64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz",
+ "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-s390x": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz",
+ "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz",
+ "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/netbsd-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz",
+ "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/openbsd-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz",
+ "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/sunos-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz",
+ "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/win32-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz",
+ "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/win32-ia32": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz",
+ "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/win32-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz",
+ "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@jest/schemas": {
+ "version": "29.6.3",
+ "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz",
+ "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@sinclair/typebox": "^0.27.8"
+ },
+ "engines": {
+ "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+ }
+ },
+ "node_modules/@jridgewell/sourcemap-codec": {
+ "version": "1.5.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
+ "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@rollup/rollup-android-arm-eabi": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz",
+ "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-android-arm64": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz",
+ "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-arm64": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz",
+ "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-x64": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz",
+ "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-arm64": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz",
+ "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-x64": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz",
+ "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz",
+ "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-musleabihf": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz",
+ "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-gnu": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz",
+ "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-musl": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz",
+ "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-gnu": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz",
+ "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-musl": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz",
+ "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-gnu": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz",
+ "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-musl": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz",
+ "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-gnu": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz",
+ "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-musl": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz",
+ "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-s390x-gnu": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz",
+ "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-gnu": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz",
+ "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-musl": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz",
+ "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-openbsd-x64": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz",
+ "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-openharmony-arm64": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz",
+ "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-arm64-msvc": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz",
+ "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-ia32-msvc": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz",
+ "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-gnu": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz",
+ "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-msvc": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz",
+ "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@sinclair/typebox": {
+ "version": "0.27.12",
+ "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.12.tgz",
+ "integrity": "sha512-hhyNJ+nbR6ZR7pToHvllEFun9TL0sbL+tk/ON75lo+Xas054uez98qRbsuNt7MBCyZKK4+8Yli/OAGZhmfBZ/g==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/estree": {
+ "version": "1.0.9",
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
+ "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/stats.js": {
+ "version": "0.17.4",
+ "resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.4.tgz",
+ "integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/three": {
+ "version": "0.160.0",
+ "resolved": "https://registry.npmjs.org/@types/three/-/three-0.160.0.tgz",
+ "integrity": "sha512-jWlbUBovicUKaOYxzgkLlhkiEQJkhCVvg4W2IYD2trqD2om3VK4DGLpHH5zQHNr7RweZK/5re/4IVhbhvxbV9w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/stats.js": "*",
+ "@types/webxr": "*",
+ "fflate": "~0.6.10",
+ "meshoptimizer": "~0.18.1"
+ }
+ },
+ "node_modules/@types/webxr": {
+ "version": "0.5.24",
+ "resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz",
+ "integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@vitest/expect": {
+ "version": "1.6.1",
+ "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-1.6.1.tgz",
+ "integrity": "sha512-jXL+9+ZNIJKruofqXuuTClf44eSpcHlgj3CiuNihUF3Ioujtmc0zIa3UJOW5RjDK1YLBJZnWBlPuqhYycLioog==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/spy": "1.6.1",
+ "@vitest/utils": "1.6.1",
+ "chai": "^4.3.10"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/runner": {
+ "version": "1.6.1",
+ "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-1.6.1.tgz",
+ "integrity": "sha512-3nSnYXkVkf3mXFfE7vVyPmi3Sazhb/2cfZGGs0JRzFsPFvAMBEcrweV1V1GsrstdXeKCTXlJbvnQwGWgEIHmOA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/utils": "1.6.1",
+ "p-limit": "^5.0.0",
+ "pathe": "^1.1.1"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/snapshot": {
+ "version": "1.6.1",
+ "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-1.6.1.tgz",
+ "integrity": "sha512-WvidQuWAzU2p95u8GAKlRMqMyN1yOJkGHnx3M1PL9Raf7AQ1kwLKg04ADlCa3+OXUZE7BceOhVZiuWAbzCKcUQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "magic-string": "^0.30.5",
+ "pathe": "^1.1.1",
+ "pretty-format": "^29.7.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/spy": {
+ "version": "1.6.1",
+ "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-1.6.1.tgz",
+ "integrity": "sha512-MGcMmpGkZebsMZhbQKkAf9CX5zGvjkBTqf8Zx3ApYWXr3wG+QvEu2eXWfnIIWYSJExIp4V9FCKDEeygzkYrXMw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tinyspy": "^2.2.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/utils": {
+ "version": "1.6.1",
+ "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-1.6.1.tgz",
+ "integrity": "sha512-jOrrUvXM4Av9ZWiG1EajNto0u96kWAhJ1LmPmJhXXQx/32MecEKd10pOLYgS2BQx1TgkGhloPU1ArDW2vvaY6g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "diff-sequences": "^29.6.3",
+ "estree-walker": "^3.0.3",
+ "loupe": "^2.3.7",
+ "pretty-format": "^29.7.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/acorn": {
+ "version": "8.17.0",
+ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz",
+ "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "acorn": "bin/acorn"
+ },
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
+ "node_modules/acorn-walk": {
+ "version": "8.3.5",
+ "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz",
+ "integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "acorn": "^8.11.0"
+ },
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
+ "node_modules/ansi-styles": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
+ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
+ "node_modules/assertion-error": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.1.0.tgz",
+ "integrity": "sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "*"
+ }
+ },
+ "node_modules/cac": {
+ "version": "6.7.14",
+ "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz",
+ "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/chai": {
+ "version": "4.5.0",
+ "resolved": "https://registry.npmjs.org/chai/-/chai-4.5.0.tgz",
+ "integrity": "sha512-RITGBfijLkBddZvnn8jdqoTypxvqbOLYQkGGxXzeFjVHvudaPw0HNFD9x928/eUwYWd2dPCugVqspGALTZZQKw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "assertion-error": "^1.1.0",
+ "check-error": "^1.0.3",
+ "deep-eql": "^4.1.3",
+ "get-func-name": "^2.0.2",
+ "loupe": "^2.3.6",
+ "pathval": "^1.1.1",
+ "type-detect": "^4.1.0"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/check-error": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/check-error/-/check-error-1.0.3.tgz",
+ "integrity": "sha512-iKEoDYaRmd1mxM90a2OEfWhjsjPpYPuQ+lMYsoxB126+t8fw7ySEO48nmDg5COTjxDI65/Y2OWpeEHk3ZOe8zg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "get-func-name": "^2.0.2"
+ },
+ "engines": {
+ "node": "*"
+ }
+ },
+ "node_modules/confbox": {
+ "version": "0.1.8",
+ "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz",
+ "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/cross-spawn": {
+ "version": "7.0.6",
+ "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
+ "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "path-key": "^3.1.0",
+ "shebang-command": "^2.0.0",
+ "which": "^2.0.1"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/deep-eql": {
+ "version": "4.1.4",
+ "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-4.1.4.tgz",
+ "integrity": "sha512-SUwdGfqdKOwxCPeVYjwSyRpJ7Z+fhpwIAtmCUdZIWZ/YP5R9WAsyuSgpLVDi9bjWoN2LXHNss/dk3urXtdQxGg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "type-detect": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/diff-sequences": {
+ "version": "29.6.3",
+ "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz",
+ "integrity": "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+ }
+ },
+ "node_modules/esbuild": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
+ "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.21.5",
+ "@esbuild/android-arm": "0.21.5",
+ "@esbuild/android-arm64": "0.21.5",
+ "@esbuild/android-x64": "0.21.5",
+ "@esbuild/darwin-arm64": "0.21.5",
+ "@esbuild/darwin-x64": "0.21.5",
+ "@esbuild/freebsd-arm64": "0.21.5",
+ "@esbuild/freebsd-x64": "0.21.5",
+ "@esbuild/linux-arm": "0.21.5",
+ "@esbuild/linux-arm64": "0.21.5",
+ "@esbuild/linux-ia32": "0.21.5",
+ "@esbuild/linux-loong64": "0.21.5",
+ "@esbuild/linux-mips64el": "0.21.5",
+ "@esbuild/linux-ppc64": "0.21.5",
+ "@esbuild/linux-riscv64": "0.21.5",
+ "@esbuild/linux-s390x": "0.21.5",
+ "@esbuild/linux-x64": "0.21.5",
+ "@esbuild/netbsd-x64": "0.21.5",
+ "@esbuild/openbsd-x64": "0.21.5",
+ "@esbuild/sunos-x64": "0.21.5",
+ "@esbuild/win32-arm64": "0.21.5",
+ "@esbuild/win32-ia32": "0.21.5",
+ "@esbuild/win32-x64": "0.21.5"
+ }
+ },
+ "node_modules/estree-walker": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
+ "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "^1.0.0"
+ }
+ },
+ "node_modules/execa": {
+ "version": "8.0.1",
+ "resolved": "https://registry.npmjs.org/execa/-/execa-8.0.1.tgz",
+ "integrity": "sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "cross-spawn": "^7.0.3",
+ "get-stream": "^8.0.1",
+ "human-signals": "^5.0.0",
+ "is-stream": "^3.0.0",
+ "merge-stream": "^2.0.0",
+ "npm-run-path": "^5.1.0",
+ "onetime": "^6.0.0",
+ "signal-exit": "^4.1.0",
+ "strip-final-newline": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=16.17"
+ },
+ "funding": {
+ "url": "https://github.com/sindresorhus/execa?sponsor=1"
+ }
+ },
+ "node_modules/fflate": {
+ "version": "0.6.11",
+ "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.6.11.tgz",
+ "integrity": "sha512-3JyEFWGjFn7zHmoa9+zG1BmW7X2okcmAB+0Cnu9UFbVs/jCBnl2A8o065ZlXiw145K3eBM3uLuzrYXC0RK7eDg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/get-func-name": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.2.tgz",
+ "integrity": "sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "*"
+ }
+ },
+ "node_modules/get-stream": {
+ "version": "8.0.1",
+ "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-8.0.1.tgz",
+ "integrity": "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=16"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/human-signals": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-5.0.0.tgz",
+ "integrity": "sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=16.17.0"
+ }
+ },
+ "node_modules/is-stream": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-3.0.0.tgz",
+ "integrity": "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^12.20.0 || ^14.13.1 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/isexe": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
+ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/js-tokens": {
+ "version": "9.0.1",
+ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz",
+ "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/local-pkg": {
+ "version": "0.5.1",
+ "resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-0.5.1.tgz",
+ "integrity": "sha512-9rrA30MRRP3gBD3HTGnC6cDFpaE1kVDWxWgqWJUN0RvDNAo+Nz/9GxB+nHOH0ifbVFy0hSA1V6vFDvnx54lTEQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "mlly": "^1.7.3",
+ "pkg-types": "^1.2.1"
+ },
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/antfu"
+ }
+ },
+ "node_modules/loupe": {
+ "version": "2.3.7",
+ "resolved": "https://registry.npmjs.org/loupe/-/loupe-2.3.7.tgz",
+ "integrity": "sha512-zSMINGVYkdpYSOBmLi0D1Uo7JU9nVdQKrHxC8eYlV+9YKK9WePqAlL7lSlorG/U2Fw1w0hTBmaa/jrQ3UbPHtA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "get-func-name": "^2.0.1"
+ }
+ },
+ "node_modules/magic-string": {
+ "version": "0.30.21",
+ "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
+ "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.5"
+ }
+ },
+ "node_modules/merge-stream": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz",
+ "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/meshoptimizer": {
+ "version": "0.18.1",
+ "resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-0.18.1.tgz",
+ "integrity": "sha512-ZhoIoL7TNV4s5B6+rx5mC//fw8/POGyNxS/DZyCJeiZ12ScLfVwRE/GfsxwiTkMYYD5DmK2/JXnEVXqL4rF+Sw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/mimic-fn": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-4.0.0.tgz",
+ "integrity": "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/mlly": {
+ "version": "1.8.2",
+ "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.2.tgz",
+ "integrity": "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "acorn": "^8.16.0",
+ "pathe": "^2.0.3",
+ "pkg-types": "^1.3.1",
+ "ufo": "^1.6.3"
+ }
+ },
+ "node_modules/mlly/node_modules/pathe": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
+ "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/nanoid": {
+ "version": "3.3.16",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz",
+ "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
+ "node_modules/npm-run-path": {
+ "version": "5.3.0",
+ "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.3.0.tgz",
+ "integrity": "sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "path-key": "^4.0.0"
+ },
+ "engines": {
+ "node": "^12.20.0 || ^14.13.1 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/npm-run-path/node_modules/path-key": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz",
+ "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/onetime": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/onetime/-/onetime-6.0.0.tgz",
+ "integrity": "sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "mimic-fn": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/p-limit": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-5.0.0.tgz",
+ "integrity": "sha512-/Eaoq+QyLSiXQ4lyYV23f14mZRQcXnxfHrN0vCai+ak9G0pp9iEQukIIZq5NccEvwRB8PUnZT0KsOoDCINS1qQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "yocto-queue": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/path-key": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
+ "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/pathe": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz",
+ "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/pathval": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/pathval/-/pathval-1.1.1.tgz",
+ "integrity": "sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "*"
+ }
+ },
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/pkg-types": {
+ "version": "1.3.1",
+ "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz",
+ "integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "confbox": "^0.1.8",
+ "mlly": "^1.7.4",
+ "pathe": "^2.0.1"
+ }
+ },
+ "node_modules/pkg-types/node_modules/pathe": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
+ "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/playwright": {
+ "version": "1.62.0",
+ "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.0.tgz",
+ "integrity": "sha512-Z14dG305dgaLu6foB1TXQagFiW8JfSUIUaUuPaKQ6NtBPKF1P/qXcqfh6c6K/icPqdy37JmjbiBXf6JNg6Sylw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "playwright-core": "1.62.0"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=20"
+ },
+ "optionalDependencies": {
+ "fsevents": "2.3.2"
+ }
+ },
+ "node_modules/playwright-core": {
+ "version": "1.62.0",
+ "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.0.tgz",
+ "integrity": "sha512-nsNRyq0r2zsG8AcRHWknc9QRA5XCueC7gWMrs+Gx2tlZn9hcl8zudfh00lhJPY1DE7NmZ6bDsT9g2yey8mXljA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "playwright-core": "cli.js"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/playwright/node_modules/fsevents": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
+ "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/postcss": {
+ "version": "8.5.23",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz",
+ "integrity": "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "nanoid": "^3.3.16",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/pretty-format": {
+ "version": "29.7.0",
+ "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz",
+ "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jest/schemas": "^29.6.3",
+ "ansi-styles": "^5.0.0",
+ "react-is": "^18.0.0"
+ },
+ "engines": {
+ "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+ }
+ },
+ "node_modules/react-is": {
+ "version": "18.3.1",
+ "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
+ "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/rollup": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz",
+ "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "1.0.9"
+ },
+ "bin": {
+ "rollup": "dist/bin/rollup"
+ },
+ "engines": {
+ "node": ">=18.0.0",
+ "npm": ">=8.0.0"
+ },
+ "optionalDependencies": {
+ "@rollup/rollup-android-arm-eabi": "4.62.2",
+ "@rollup/rollup-android-arm64": "4.62.2",
+ "@rollup/rollup-darwin-arm64": "4.62.2",
+ "@rollup/rollup-darwin-x64": "4.62.2",
+ "@rollup/rollup-freebsd-arm64": "4.62.2",
+ "@rollup/rollup-freebsd-x64": "4.62.2",
+ "@rollup/rollup-linux-arm-gnueabihf": "4.62.2",
+ "@rollup/rollup-linux-arm-musleabihf": "4.62.2",
+ "@rollup/rollup-linux-arm64-gnu": "4.62.2",
+ "@rollup/rollup-linux-arm64-musl": "4.62.2",
+ "@rollup/rollup-linux-loong64-gnu": "4.62.2",
+ "@rollup/rollup-linux-loong64-musl": "4.62.2",
+ "@rollup/rollup-linux-ppc64-gnu": "4.62.2",
+ "@rollup/rollup-linux-ppc64-musl": "4.62.2",
+ "@rollup/rollup-linux-riscv64-gnu": "4.62.2",
+ "@rollup/rollup-linux-riscv64-musl": "4.62.2",
+ "@rollup/rollup-linux-s390x-gnu": "4.62.2",
+ "@rollup/rollup-linux-x64-gnu": "4.62.2",
+ "@rollup/rollup-linux-x64-musl": "4.62.2",
+ "@rollup/rollup-openbsd-x64": "4.62.2",
+ "@rollup/rollup-openharmony-arm64": "4.62.2",
+ "@rollup/rollup-win32-arm64-msvc": "4.62.2",
+ "@rollup/rollup-win32-ia32-msvc": "4.62.2",
+ "@rollup/rollup-win32-x64-gnu": "4.62.2",
+ "@rollup/rollup-win32-x64-msvc": "4.62.2",
+ "fsevents": "~2.3.2"
+ }
+ },
+ "node_modules/shebang-command": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
+ "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "shebang-regex": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/shebang-regex": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
+ "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/siginfo": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
+ "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/signal-exit": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
+ "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==",
+ "dev": true,
+ "license": "ISC",
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/source-map-js": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/stackback": {
+ "version": "0.0.2",
+ "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
+ "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/std-env": {
+ "version": "3.10.0",
+ "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz",
+ "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/strip-final-newline": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-3.0.0.tgz",
+ "integrity": "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/strip-literal": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-2.1.1.tgz",
+ "integrity": "sha512-631UJ6O00eNGfMiWG78ck80dfBab8X6IVFB51jZK5Icd7XAs60Z5y7QdSd/wGIklnWvRbUNloVzhOKKmutxQ6Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "js-tokens": "^9.0.1"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/antfu"
+ }
+ },
+ "node_modules/three": {
+ "version": "0.160.1",
+ "resolved": "https://registry.npmjs.org/three/-/three-0.160.1.tgz",
+ "integrity": "sha512-Bgl2wPJypDOZ1stAxwfWAcJ0WQf7QzlptsxkjYiURPz+n5k4RBDLsq+6f9Y75TYxn6aHLcWz+JNmwTOXWrQTBQ==",
+ "license": "MIT"
+ },
+ "node_modules/tinybench": {
+ "version": "2.9.0",
+ "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
+ "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/tinypool": {
+ "version": "0.8.4",
+ "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-0.8.4.tgz",
+ "integrity": "sha512-i11VH5gS6IFeLY3gMBQ00/MmLncVP7JLXOw1vlgkytLmJK7QnEr7NXf0LBdxfmNPAeyetukOk0bOYrJrFGjYJQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/tinyspy": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-2.2.1.tgz",
+ "integrity": "sha512-KYad6Vy5VDWV4GH3fjpseMQ/XU2BhIYP7Vzd0LG44qRWm/Yt2WCOTicFdvmgo6gWaqooMQCawTtILVQJupKu7A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/type-detect": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.1.0.tgz",
+ "integrity": "sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/typescript": {
+ "version": "5.9.3",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
+ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ },
+ "node_modules/ufo": {
+ "version": "1.6.4",
+ "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.4.tgz",
+ "integrity": "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/vite": {
+ "version": "5.4.21",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
+ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "esbuild": "^0.21.3",
+ "postcss": "^8.4.43",
+ "rollup": "^4.20.0"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
+ "engines": {
+ "node": "^18.0.0 || >=20.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/vitejs/vite?sponsor=1"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ },
+ "peerDependencies": {
+ "@types/node": "^18.0.0 || >=20.0.0",
+ "less": "*",
+ "lightningcss": "^1.21.0",
+ "sass": "*",
+ "sass-embedded": "*",
+ "stylus": "*",
+ "sugarss": "*",
+ "terser": "^5.4.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ },
+ "less": {
+ "optional": true
+ },
+ "lightningcss": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ },
+ "sass-embedded": {
+ "optional": true
+ },
+ "stylus": {
+ "optional": true
+ },
+ "sugarss": {
+ "optional": true
+ },
+ "terser": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/vite-node": {
+ "version": "1.6.1",
+ "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-1.6.1.tgz",
+ "integrity": "sha512-YAXkfvGtuTzwWbDSACdJSg4A4DZiAqckWe90Zapc/sEX3XvHcw1NdurM/6od8J207tSDqNbSsgdCacBgvJKFuA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "cac": "^6.7.14",
+ "debug": "^4.3.4",
+ "pathe": "^1.1.1",
+ "picocolors": "^1.0.0",
+ "vite": "^5.0.0"
+ },
+ "bin": {
+ "vite-node": "vite-node.mjs"
+ },
+ "engines": {
+ "node": "^18.0.0 || >=20.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/vitest": {
+ "version": "1.6.1",
+ "resolved": "https://registry.npmjs.org/vitest/-/vitest-1.6.1.tgz",
+ "integrity": "sha512-Ljb1cnSJSivGN0LqXd/zmDbWEM0RNNg2t1QW/XUhYl/qPqyu7CsqeWtqQXHVaJsecLPuDoak2oJcZN2QoRIOag==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/expect": "1.6.1",
+ "@vitest/runner": "1.6.1",
+ "@vitest/snapshot": "1.6.1",
+ "@vitest/spy": "1.6.1",
+ "@vitest/utils": "1.6.1",
+ "acorn-walk": "^8.3.2",
+ "chai": "^4.3.10",
+ "debug": "^4.3.4",
+ "execa": "^8.0.1",
+ "local-pkg": "^0.5.0",
+ "magic-string": "^0.30.5",
+ "pathe": "^1.1.1",
+ "picocolors": "^1.0.0",
+ "std-env": "^3.5.0",
+ "strip-literal": "^2.0.0",
+ "tinybench": "^2.5.1",
+ "tinypool": "^0.8.3",
+ "vite": "^5.0.0",
+ "vite-node": "1.6.1",
+ "why-is-node-running": "^2.2.2"
+ },
+ "bin": {
+ "vitest": "vitest.mjs"
+ },
+ "engines": {
+ "node": "^18.0.0 || >=20.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "@edge-runtime/vm": "*",
+ "@types/node": "^18.0.0 || >=20.0.0",
+ "@vitest/browser": "1.6.1",
+ "@vitest/ui": "1.6.1",
+ "happy-dom": "*",
+ "jsdom": "*"
+ },
+ "peerDependenciesMeta": {
+ "@edge-runtime/vm": {
+ "optional": true
+ },
+ "@types/node": {
+ "optional": true
+ },
+ "@vitest/browser": {
+ "optional": true
+ },
+ "@vitest/ui": {
+ "optional": true
+ },
+ "happy-dom": {
+ "optional": true
+ },
+ "jsdom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/which": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
+ "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "isexe": "^2.0.0"
+ },
+ "bin": {
+ "node-which": "bin/node-which"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/why-is-node-running": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
+ "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "siginfo": "^2.0.0",
+ "stackback": "0.0.2"
+ },
+ "bin": {
+ "why-is-node-running": "cli.js"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/yocto-queue": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.2.tgz",
+ "integrity": "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.20"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ }
+ }
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..9707f1f
--- /dev/null
+++ b/package.json
@@ -0,0 +1,26 @@
+{
+ "name": "threejs-sound-sim",
+ "private": true,
+ "version": "2.0.0",
+ "description": "An interactive 3D spatial audio simulator built with Three.js and the Web Audio API.",
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "tsc && vite build",
+ "preview": "vite preview",
+ "typecheck": "tsc --noEmit",
+ "test": "vitest run",
+ "test:e2e": "node scripts/e2e.mjs",
+ "shot": "node scripts/shot.mjs"
+ },
+ "dependencies": {
+ "three": "^0.160.0"
+ },
+ "devDependencies": {
+ "@types/three": "^0.160.0",
+ "playwright": "^1.62.0",
+ "typescript": "^5.3.3",
+ "vite": "^5.0.12",
+ "vitest": "^1.2.1"
+ }
+}
diff --git a/public/favicon.svg b/public/favicon.svg
new file mode 100644
index 0000000..d968ea6
--- /dev/null
+++ b/public/favicon.svg
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
diff --git a/scripts/e2e.mjs b/scripts/e2e.mjs
new file mode 100644
index 0000000..8b378a6
--- /dev/null
+++ b/scripts/e2e.mjs
@@ -0,0 +1,415 @@
+/**
+ * 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 --
+ */
+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);
diff --git a/scripts/shot.mjs b/scripts/shot.mjs
new file mode 100644
index 0000000..f5d0a09
--- /dev/null
+++ b/scripts/shot.mjs
@@ -0,0 +1,51 @@
+// Screenshot helper: node scripts/shot.mjs [width] [height] [action]
+// action: "welcome" keeps the first-run dialog open, anything else starts audio first.
+import { chromium } from 'playwright';
+
+const [, , url = 'http://localhost:5178/', out = '/tmp/shot.png', w = '1440', h = '900', action = 'start'] =
+ process.argv;
+
+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: +w, height: +h },
+ deviceScaleFactor: 1,
+ colorScheme: 'dark',
+});
+
+const logs = [];
+page.on('console', (m) => logs.push(`[${m.type()}] ${m.text()}`));
+page.on('pageerror', (e) => logs.push(`[pageerror] ${e.message}\n${e.stack ?? ''}`));
+page.on('requestfailed', (r) => logs.push(`[404?] ${r.url()} ${r.failure()?.errorText ?? ''}`));
+
+await page.goto(url, { waitUntil: 'networkidle' });
+await page.waitForTimeout(1200);
+
+if (action !== 'welcome') {
+ const start = page.locator('#welcome button', { hasText: 'Enable audio' });
+ if (await start.count()) await start.click();
+ await page.waitForTimeout(2600);
+}
+
+const probe = await page.evaluate(() => {
+ const c = document.querySelector('canvas');
+ const strip = document.getElementById('strip');
+ const path = document.querySelector('.path');
+ return {
+ canvas: c ? { w: c.width, h: c.height, cssW: c.clientWidth, cssH: c.clientHeight } : null,
+ stripH: strip?.clientHeight,
+ pathH: path?.scrollHeight,
+ pathClipped: path ? path.scrollHeight > path.parentElement.clientHeight : null,
+ status: document.getElementById('status-text')?.textContent,
+ hud: document.querySelector('.hud')?.innerText.replace(/\n/g, ' | '),
+ ear: document.querySelector('.path .total')?.innerText.replace(/\t/g, ' '),
+ };
+});
+
+await page.screenshot({ path: out });
+console.log(JSON.stringify(probe, null, 2));
+console.log('--- console ---');
+console.log(logs.filter((l) => !l.includes('GL Driver')).join('\n') || '(clean)');
+await browser.close();
diff --git a/src/audio/AudioEngine.ts b/src/audio/AudioEngine.ts
new file mode 100644
index 0000000..c5b3010
--- /dev/null
+++ b/src/audio/AudioEngine.ts
@@ -0,0 +1,740 @@
+import {
+ DOPPLER_MAX_RATIO,
+ DOPPLER_MIN_RATIO,
+ DistanceModel,
+ Vec3,
+ airAbsorptionCutoff,
+ clamp,
+ coneGain,
+ distance,
+ distanceGain,
+ gainToDb,
+ normalize,
+ occlusionResponse,
+ offAxisAngle,
+ ratioToCents,
+ smoothingAlpha,
+} from '../physics';
+import { PresetId, SourceId, createPresetBuffer } from './presets';
+import { AudioFileError, decodeAudioFile } from './userAudio';
+import {
+ RoomAcoustics,
+ RoomDimensions,
+ SurfaceId,
+ computeAcoustics,
+ generateImpulseResponse,
+ getSurface,
+ reverberantGain,
+} from './reverb';
+
+export interface EngineSettings {
+ preset: SourceId;
+ masterVolume: number;
+ surface: SurfaceId;
+ room: RoomDimensions;
+ distanceModel: DistanceModel;
+ rolloffFactor: number;
+ refDistance: number;
+ maxDistance: number;
+ coneInnerAngle: number;
+ coneOuterAngle: number;
+ coneOuterGain: number;
+ speedOfSound: number;
+ /** 1.0 is physically realistic; higher exaggerates high-frequency loss. */
+ airAbsorption: number;
+ /** Time-of-flight delay, which also produces Doppler as a side effect. */
+ propagationEnabled: boolean;
+}
+
+/**
+ * A settings patch. Room dimensions are individually optional so a single
+ * slider can send just its own axis without having to restate the other two.
+ */
+export type EngineSettingsPatch = Partial> & {
+ room?: Partial;
+};
+
+export const DEFAULT_SETTINGS: EngineSettings = {
+ preset: 'sawtooth',
+ masterVolume: 0.7,
+ // A treated room by default: live enough to hear the space, dead enough that
+ // the direct sound still dominates near the source, which is where the
+ // binaural image is worth listening to.
+ surface: 'studio',
+ // Large enough that the critical distance lands several metres out, which is
+ // what lets the opening view sit inside it while still framing the room.
+ room: { width: 26, height: 9, depth: 26 },
+ distanceModel: 'inverse',
+ rolloffFactor: 1,
+ refDistance: 1,
+ maxDistance: 80,
+ coneInnerAngle: 70,
+ coneOuterAngle: 160,
+ coneOuterGain: 0.08,
+ speedOfSound: 343,
+ airAbsorption: 1,
+ propagationEnabled: true,
+};
+
+export interface SpatialInput {
+ sourcePos: Vec3;
+ sourceForward: Vec3;
+ listenerPos: Vec3;
+ listenerForward: Vec3;
+ listenerUp: Vec3;
+ /** 0 = clear line of sight, 1 = fully blocked. */
+ occlusion: number;
+}
+
+/** Everything the UI needs to explain why the scene sounds the way it does. */
+export interface Telemetry {
+ distance: number;
+ distanceGainDb: number;
+ offAxisAngle: number;
+ coneGainDb: number;
+ occlusion: number;
+ occlusionGainDb: number;
+ occlusionCutoff: number;
+ airCutoff: number;
+ /** Frequency ratio actually being produced by the moving delay line. */
+ dopplerRatio: number;
+ dopplerCents: number;
+ /** Closing speed along the source→listener axis, m/s. Positive = approaching. */
+ closingSpeed: number;
+ timeOfFlightMs: number;
+ directGainDb: number;
+ reverbGainDb: number;
+ /** Direct-to-reverberant ratio in dB. Negative means the room dominates. */
+ directToReverbDb: number;
+ criticalDistance: number;
+ t60: number;
+ reverbDominant: boolean;
+ outputLevelDb: number;
+ leftLevelDb: number;
+ rightLevelDb: number;
+ clipping: boolean;
+ running: boolean;
+}
+
+export const SILENT_TELEMETRY: Telemetry = {
+ distance: 0,
+ distanceGainDb: -120,
+ offAxisAngle: 0,
+ coneGainDb: 0,
+ occlusion: 0,
+ occlusionGainDb: 0,
+ occlusionCutoff: 22050,
+ airCutoff: 22050,
+ dopplerRatio: 1,
+ dopplerCents: 0,
+ closingSpeed: 0,
+ timeOfFlightMs: 0,
+ directGainDb: -120,
+ reverbGainDb: -120,
+ directToReverbDb: 0,
+ criticalDistance: 0,
+ t60: 0,
+ reverbDominant: false,
+ outputLevelDb: -120,
+ leftLevelDb: -120,
+ rightLevelDb: -120,
+ clipping: false,
+ running: false,
+};
+
+/**
+ * Schedules AudioParam changes only when the target has meaningfully moved.
+ * Calling setTargetAtTime every frame for every parameter would pile up tens of
+ * thousands of automation events per minute for no audible benefit.
+ */
+class SmoothParam {
+ private last = Number.NaN;
+
+ constructor(
+ private readonly param: AudioParam,
+ private readonly epsilon: number,
+ private readonly tau = 0.02
+ ) {}
+
+ set(value: number, now: number): void {
+ if (Number.isFinite(this.last) && Math.abs(value - this.last) < this.epsilon) return;
+ this.last = value;
+ this.param.setTargetAtTime(value, now, this.tau);
+ }
+}
+
+/**
+ * Longest propagation delay we preallocate. The worst case is the diagonal of
+ * the largest room (60 m cube ≈ 104 m) at the slowest speed of sound the UI
+ * offers (80 m/s), so 1.5 s leaves headroom. Sizing this to the default 343 m/s
+ * would silently saturate the delay line — freezing time of flight and killing
+ * Doppler — the moment anyone dragged the speed slider down.
+ */
+const MAX_DELAY_SECONDS = 1.5;
+/** How fast the delay line is allowed to chase geometry. Also caps Doppler. */
+const PROPAGATION_TAU = 0.09;
+
+export class AudioEngine {
+ private ctx: AudioContext | null = null;
+ private settings: EngineSettings = { ...DEFAULT_SETTINGS };
+
+ private source: AudioBufferSourceNode | null = null;
+ private sourceGain: GainNode | null = null;
+ private delay: DelayNode | null = null;
+ private airFilter: BiquadFilterNode | null = null;
+ private occlusionFilter: BiquadFilterNode | null = null;
+ private directGain: GainNode | null = null;
+ private panner: PannerNode | null = null;
+ private reverbSend: GainNode | null = null;
+ private convolver: ConvolverNode | null = null;
+ private masterGain: GainNode | null = null;
+ private limiter: DynamicsCompressorNode | null = null;
+ private analyser: AnalyserNode | null = null;
+ private leftAnalyser: AnalyserNode | null = null;
+ private rightAnalyser: AnalyserNode | null = null;
+
+ private smoothDirect: SmoothParam | null = null;
+ private smoothAir: SmoothParam | null = null;
+ private smoothOcclusion: SmoothParam | null = null;
+ private smoothReverb: SmoothParam | null = null;
+ private smoothDelay: SmoothParam | null = null;
+
+ private frequencyData = new Uint8Array(1024);
+ private waveformData = new Uint8Array(2048);
+ private earScratch = new Uint8Array(1024);
+
+ private playing = false;
+ private acoustics: RoomAcoustics;
+ private impulseDirty = false;
+ private lastImpulseAt = -Infinity;
+
+ /** Mirrors the delay line's smoothing in JS so Doppler can be reported exactly. */
+ private smoothedDelay = 0;
+ private previousDelay = 0;
+
+ private userBuffer: AudioBuffer | null = null;
+ private userName = '';
+
+ constructor(settings?: Partial) {
+ this.settings = { ...DEFAULT_SETTINGS, ...settings };
+ this.acoustics = computeAcoustics(this.settings.room, getSurface(this.settings.surface).absorption);
+ }
+
+ get context(): AudioContext | null {
+ return this.ctx;
+ }
+
+ get isPlaying(): boolean {
+ return this.playing;
+ }
+
+ get sampleRate(): number {
+ return this.ctx?.sampleRate ?? 48000;
+ }
+
+ getSettings(): EngineSettings {
+ return { ...this.settings, room: { ...this.settings.room } };
+ }
+
+ getAcoustics(): RoomAcoustics {
+ return { ...this.acoustics };
+ }
+
+ /**
+ * Builds the node graph. Must be called from a user gesture: browsers refuse
+ * to let an AudioContext produce sound otherwise.
+ *
+ * source ▶ sourceGain ▶ delay ─┬─▶ air ▶ occlusion ▶ direct ▶ panner(HRTF) ─┐
+ * │ ├▶ master ▶ limiter ▶ analyser ▶ out
+ * └─▶ reverbSend ▶ convolver ──────────────────┘
+ *
+ * Two deliberate choices carry most of the realism:
+ *
+ * 1. `delay` holds the time of flight, distance / speedOfSound. Automating it
+ * resamples the signal, so Doppler falls out of the geometry for free
+ * rather than being faked from a noisy velocity estimate.
+ * 2. The reverb send is tapped *before* distance, cone 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 or what is
+ * in the way. This is what makes walking away sound like distance instead
+ * of like turning down a fader.
+ */
+ async init(): Promise {
+ if (this.ctx) return;
+
+ const Ctor =
+ (typeof window !== 'undefined' &&
+ (window.AudioContext ||
+ (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext)) ||
+ (globalThis as unknown as { AudioContext?: typeof AudioContext }).AudioContext;
+
+ if (typeof Ctor !== 'function') throw new Error('Web Audio is not available in this browser.');
+
+ const ctx = new Ctor();
+ this.ctx = ctx;
+ const now = ctx.currentTime;
+
+ this.sourceGain = ctx.createGain();
+ this.sourceGain.gain.setValueAtTime(1, now);
+
+ this.delay = ctx.createDelay(MAX_DELAY_SECONDS);
+ this.delay.delayTime.setValueAtTime(0, now);
+
+ this.airFilter = ctx.createBiquadFilter();
+ this.airFilter.type = 'lowpass';
+ this.airFilter.Q.setValueAtTime(0.5, now);
+ this.airFilter.frequency.setValueAtTime(22050, now);
+
+ this.occlusionFilter = ctx.createBiquadFilter();
+ this.occlusionFilter.type = 'lowpass';
+ this.occlusionFilter.Q.setValueAtTime(0.7, now);
+ this.occlusionFilter.frequency.setValueAtTime(22050, now);
+
+ this.directGain = ctx.createGain();
+ this.directGain.gain.setValueAtTime(0, now);
+
+ this.panner = ctx.createPanner();
+ this.panner.panningModel = 'HRTF';
+ // Distance and directivity are computed by our own physics and applied to
+ // directGain, so the panner is left to do nothing but binaural placement.
+ this.panner.distanceModel = 'linear';
+ this.panner.rolloffFactor = 0;
+ this.panner.coneInnerAngle = 360;
+ this.panner.coneOuterAngle = 360;
+ this.panner.coneOuterGain = 1;
+
+ this.reverbSend = ctx.createGain();
+ this.reverbSend.gain.setValueAtTime(0, now);
+
+ this.convolver = ctx.createConvolver();
+ this.convolver.normalize = false;
+ this.rebuildImpulseResponse();
+
+ this.masterGain = ctx.createGain();
+ this.masterGain.gain.setValueAtTime(this.settings.masterVolume, now);
+
+ // Catches the peaks when a close, on-axis source stacks with a wet room.
+ this.limiter = ctx.createDynamicsCompressor();
+ this.limiter.threshold.setValueAtTime(-6, now);
+ this.limiter.knee.setValueAtTime(0, now);
+ this.limiter.ratio.setValueAtTime(20, now);
+ this.limiter.attack.setValueAtTime(0.003, now);
+ this.limiter.release.setValueAtTime(0.25, now);
+
+ this.analyser = ctx.createAnalyser();
+ this.analyser.fftSize = 4096;
+ this.analyser.smoothingTimeConstant = 0.75;
+ this.analyser.minDecibels = -100;
+ this.analyser.maxDecibels = -10;
+ this.frequencyData = new Uint8Array(this.analyser.frequencyBinCount);
+ this.waveformData = new Uint8Array(this.analyser.fftSize);
+
+ // Per-ear metering. The main analyser downmixes to mono, so without this
+ // split there is no way to show that the binaural image is doing anything.
+ const splitter = ctx.createChannelSplitter(2);
+ this.leftAnalyser = ctx.createAnalyser();
+ this.rightAnalyser = ctx.createAnalyser();
+ for (const ear of [this.leftAnalyser, this.rightAnalyser]) {
+ ear.fftSize = 1024;
+ ear.smoothingTimeConstant = 0.6;
+ }
+ this.earScratch = new Uint8Array(1024);
+
+ this.sourceGain.connect(this.delay);
+ this.delay.connect(this.airFilter);
+ this.airFilter.connect(this.occlusionFilter);
+ this.occlusionFilter.connect(this.directGain);
+ this.directGain.connect(this.panner);
+ this.panner.connect(this.masterGain);
+
+ this.delay.connect(this.reverbSend);
+ this.reverbSend.connect(this.convolver);
+ this.convolver.connect(this.masterGain);
+
+ this.masterGain.connect(this.limiter);
+ this.limiter.connect(this.analyser);
+ this.analyser.connect(ctx.destination);
+
+ this.limiter.connect(splitter);
+ splitter.connect(this.leftAnalyser, 0);
+ splitter.connect(this.rightAnalyser, 1);
+
+ this.smoothDirect = new SmoothParam(this.directGain.gain, 0.0008);
+ this.smoothAir = new SmoothParam(this.airFilter.frequency, 20, 0.05);
+ this.smoothOcclusion = new SmoothParam(this.occlusionFilter.frequency, 15, 0.04);
+ this.smoothReverb = new SmoothParam(this.reverbSend.gain, 0.002, 0.08);
+ // Tight tau: the JS mirror has already done the smoothing, this only
+ // bridges between frames.
+ this.smoothDelay = new SmoothParam(this.delay.delayTime, 0.00002, 0.012);
+ }
+
+ /** Resumes the context and starts the looping source. Safe to call twice. */
+ async start(): Promise {
+ await this.init();
+ if (!this.ctx || !this.sourceGain) return;
+ if (this.ctx.state === 'suspended') await this.ctx.resume();
+ const now = this.ctx.currentTime;
+ this.sourceGain.gain.cancelScheduledValues(now);
+ this.sourceGain.gain.setValueAtTime(1, now);
+ if (!this.source) this.startSource(now);
+ this.playing = true;
+ }
+
+ stop(): void {
+ if (!this.ctx || !this.sourceGain) {
+ this.playing = false;
+ return;
+ }
+ const now = this.ctx.currentTime;
+ // Fade before stopping, otherwise the abrupt cut clicks.
+ this.sourceGain.gain.cancelScheduledValues(now);
+ this.sourceGain.gain.setValueAtTime(this.sourceGain.gain.value, now);
+ this.sourceGain.gain.linearRampToValueAtTime(0, now + 0.03);
+ this.stopSource(now + 0.04);
+ this.playing = false;
+ }
+
+ async toggle(): Promise {
+ if (this.playing) this.stop();
+ else await this.start();
+ return this.playing;
+ }
+
+ setPreset(preset: SourceId): void {
+ if (preset === this.settings.preset) return;
+ // Selecting "file" with nothing loaded would start a silent source.
+ if (preset === 'file' && !this.userBuffer) return;
+ this.settings.preset = preset;
+ this.restartSource();
+ }
+
+ /** Fades out, swaps the buffer on the silent frame, fades back in. */
+ private restartSource(): void {
+ if (!this.ctx || !this.playing || !this.sourceGain) return;
+ const now = this.ctx.currentTime;
+ const swapAt = now + 0.03;
+ this.sourceGain.gain.cancelScheduledValues(now);
+ this.sourceGain.gain.setValueAtTime(this.sourceGain.gain.value, now);
+ this.sourceGain.gain.linearRampToValueAtTime(0, swapAt);
+ this.stopSource(swapAt);
+ this.startSource(swapAt);
+ this.sourceGain.gain.linearRampToValueAtTime(1, swapAt + 0.03);
+ }
+
+ /** The loaded file, if any: name and duration for the UI. */
+ getUserAudio(): { name: string; duration: number } | null {
+ if (!this.userBuffer) return null;
+ return { name: this.userName, duration: this.userBuffer.duration };
+ }
+
+ /**
+ * Decodes a file from disk and makes it the source.
+ *
+ * Creates the AudioContext if there isn't one yet — decoding needs a context,
+ * and a file picker or a drop is itself a user gesture, so this is a legal
+ * place to do it. Playback still waits for Play.
+ */
+ async loadUserAudio(file: File): Promise<{ name: string; duration: number }> {
+ await this.init();
+ if (!this.ctx) throw new AudioFileError('Web Audio is not available in this browser.');
+
+ const buffer = await decodeAudioFile(this.ctx, file);
+ this.userBuffer = buffer;
+ this.userName = file.name;
+
+ // Force a swap even when 'file' was already selected, so loading a second
+ // track replaces the first instead of silently doing nothing.
+ const wasFile = this.settings.preset === 'file';
+ this.settings.preset = 'file';
+ if (wasFile) this.restartSource();
+ else this.setPreset('file');
+
+ return { name: this.userName, duration: buffer.duration };
+ }
+
+ setMasterVolume(volume: number): void {
+ this.settings.masterVolume = clamp(volume, 0, 1);
+ if (this.ctx && this.masterGain) {
+ this.masterGain.gain.setTargetAtTime(this.settings.masterVolume, this.ctx.currentTime, 0.015);
+ }
+ }
+
+ /**
+ * Applies a settings patch. Changes that alter the room's acoustics mark the
+ * impulse response dirty rather than regenerating it immediately, so dragging
+ * a room slider does not rebuild a multi-second convolution buffer per frame.
+ */
+ update(patch: EngineSettingsPatch): void {
+ const room = { ...this.settings.room, ...patch.room };
+ const roomChanged =
+ room.width !== this.settings.room.width ||
+ room.height !== this.settings.room.height ||
+ room.depth !== this.settings.room.depth ||
+ (patch.surface !== undefined && patch.surface !== this.settings.surface);
+
+ // Preset and volume own their own transitions, so hand them over rather
+ // than letting the spread overwrite the value they compare against.
+ const { preset, masterVolume, ...rest } = patch;
+ this.settings = { ...this.settings, ...rest, room };
+ this.acoustics = computeAcoustics(room, getSurface(this.settings.surface).absorption);
+
+ if (masterVolume !== undefined) this.setMasterVolume(masterVolume);
+ if (preset !== undefined) this.setPreset(preset);
+ if (roomChanged) this.impulseDirty = true;
+ }
+
+ /**
+ * Runs one frame of the simulation: computes the acoustic state from the
+ * geometry, applies it to the graph, and returns the numbers for the UI.
+ *
+ * Works with no AudioContext, so every readout is live and correct before the
+ * user has unlocked audio.
+ */
+ updateSpatial(input: SpatialInput, dt: number): Telemetry {
+ const s = this.settings;
+ const d = distance(input.sourcePos, input.listenerPos);
+
+ const gDistance = distanceGain(d, s.distanceModel, {
+ refDistance: s.refDistance,
+ maxDistance: s.maxDistance,
+ rolloffFactor: s.rolloffFactor,
+ });
+ const gCone = coneGain(input.sourcePos, input.sourceForward, input.listenerPos, {
+ innerAngle: s.coneInnerAngle,
+ outerAngle: s.coneOuterAngle,
+ outerGain: s.coneOuterGain,
+ });
+ const angle = offAxisAngle(input.sourcePos, input.sourceForward, input.listenerPos);
+ const occl = occlusionResponse(input.occlusion);
+ const airCutoff = airAbsorptionCutoff(d, s.airAbsorption);
+
+ // Chase the true time of flight. The lag is what creates Doppler: while the
+ // delay is still catching up, the signal is played back off-rate.
+ const targetDelay = s.propagationEnabled ? Math.min(d / s.speedOfSound, MAX_DELAY_SECONDS) : 0;
+ const step = dt > 0 ? dt : 1 / 60;
+ this.previousDelay = this.smoothedDelay;
+ this.smoothedDelay += (targetDelay - this.smoothedDelay) * smoothingAlpha(step, PROPAGATION_TAU);
+
+ // For y(t) = x(t − D(t)) the instantaneous frequency ratio is exactly
+ // 1 − D'(t). The textbook moving-source form 1/(1 − v/c) agrees only to
+ // first order, and it has a pole that flips the sign of the shift when a
+ // source closes fast — reporting a two-octave drop as it rushes towards you.
+ const delayRate = (this.smoothedDelay - this.previousDelay) / step;
+ const ratio = clamp(1 - delayRate, DOPPLER_MIN_RATIO, DOPPLER_MAX_RATIO);
+
+ const gDirect = gDistance * gCone * occl.gain;
+ const gReverb = reverberantGain(this.acoustics, s.refDistance);
+
+ if (this.ctx && this.playing) {
+ this.applyToGraph(input, gDirect, gReverb, occl.cutoff, airCutoff);
+ }
+
+ const levels = this.measureLevels();
+ // Report the acoustic level whether or not audio is running: the room's
+ // behaviour is a property of the geometry, and blanking it would leave the
+ // direct-to-reverb row disagreeing with the row above it.
+ const reverbDb = gainToDb(gReverb);
+
+ return {
+ distance: d,
+ distanceGainDb: gainToDb(gDistance),
+ offAxisAngle: angle,
+ coneGainDb: gainToDb(gCone),
+ occlusion: clamp(input.occlusion, 0, 1),
+ occlusionGainDb: gainToDb(occl.gain),
+ occlusionCutoff: occl.cutoff,
+ airCutoff,
+ dopplerRatio: ratio,
+ dopplerCents: ratioToCents(ratio),
+ closingSpeed: -delayRate * s.speedOfSound,
+ timeOfFlightMs: this.smoothedDelay * 1000,
+ directGainDb: gainToDb(gDirect),
+ reverbGainDb: reverbDb,
+ directToReverbDb: gainToDb(gDirect) - reverbDb,
+ criticalDistance: this.acoustics.criticalDistance,
+ t60: this.acoustics.t60,
+ // Compare the levels themselves rather than distance against rc. With a
+ // non-inverse falloff law or a reference distance other than 1 m the two
+ // disagree, and this flag sits directly beside the ratio it describes.
+ reverbDominant: gDirect < gReverb,
+ outputLevelDb: levels.rms,
+ leftLevelDb: levels.left,
+ rightLevelDb: levels.right,
+ clipping: levels.clipping,
+ running: this.playing && this.ctx?.state === 'running',
+ };
+ }
+
+ getFrequencyData(): Uint8Array {
+ if (this.analyser) this.analyser.getByteFrequencyData(this.frequencyData);
+ else this.frequencyData.fill(0);
+ return this.frequencyData;
+ }
+
+ getWaveformData(): Uint8Array {
+ if (this.analyser) this.analyser.getByteTimeDomainData(this.waveformData);
+ else this.waveformData.fill(128);
+ return this.waveformData;
+ }
+
+ dispose(): void {
+ this.stop();
+ this.ctx?.close().catch(() => {
+ /* already closing */
+ });
+ this.ctx = null;
+ }
+
+ private applyToGraph(
+ input: SpatialInput,
+ gDirect: number,
+ gReverb: number,
+ occlusionCutoff: number,
+ airCutoff: number
+ ): void {
+ const ctx = this.ctx;
+ if (!ctx || !this.panner) return;
+ const now = ctx.currentTime;
+
+ this.smoothDirect?.set(gDirect, now);
+ this.smoothReverb?.set(gReverb, now);
+ this.smoothOcclusion?.set(occlusionCutoff, now);
+ this.smoothAir?.set(airCutoff, now);
+ this.smoothDelay?.set(this.smoothedDelay, now);
+
+ setPosition(this.panner, input.sourcePos, now);
+ setOrientation(this.panner, normalize(input.sourceForward), now);
+
+ // Both the listener's position *and* its orientation must be published, or
+ // HRTF has no idea which way the ears are facing and the image stays frozen
+ // to the world axes no matter how the camera turns.
+ const listener = ctx.listener;
+ setPosition(listener, input.listenerPos, now);
+ const forward = normalize(input.listenerForward);
+ const up = normalize(input.listenerUp, { x: 0, y: 1, z: 0 });
+ if (listener.forwardX) {
+ // Ramp all six together so forward and up never drift out of square.
+ rampParam(listener.forwardX, forward.x, now);
+ rampParam(listener.forwardY, forward.y, now);
+ rampParam(listener.forwardZ, forward.z, now);
+ rampParam(listener.upX, up.x, now);
+ rampParam(listener.upY, up.y, now);
+ rampParam(listener.upZ, up.z, now);
+ } else if (typeof (listener as unknown as LegacyListener).setOrientation === 'function') {
+ (listener as unknown as LegacyListener).setOrientation(
+ forward.x,
+ forward.y,
+ forward.z,
+ up.x,
+ up.y,
+ up.z
+ );
+ }
+
+ if (this.impulseDirty && now - this.lastImpulseAt > 0.25) this.rebuildImpulseResponse();
+ }
+
+ private startSource(when: number): void {
+ if (!this.ctx || !this.sourceGain) return;
+ const source = this.ctx.createBufferSource();
+ source.buffer =
+ this.settings.preset === 'file' && this.userBuffer
+ ? this.userBuffer
+ : createPresetBuffer(this.ctx, this.settings.preset as PresetId);
+ source.loop = true;
+ source.connect(this.sourceGain);
+ source.start(when);
+ this.source = source;
+ }
+
+ private stopSource(when: number): void {
+ const source = this.source;
+ if (!source) return;
+ this.source = null;
+ try {
+ source.stop(when);
+ } catch {
+ /* never started */
+ }
+ source.onended = () => source.disconnect();
+ }
+
+ private rebuildImpulseResponse(): void {
+ if (!this.ctx || !this.convolver) return;
+ this.convolver.buffer = generateImpulseResponse(
+ this.ctx,
+ this.settings.room,
+ getSurface(this.settings.surface).absorption,
+ this.settings.speedOfSound
+ );
+ this.impulseDirty = false;
+ this.lastImpulseAt = this.ctx.currentTime;
+ }
+
+ private measureLevels(): { rms: number; left: number; right: number; clipping: boolean } {
+ if (!this.analyser || !this.playing) {
+ return { rms: -120, left: -120, right: -120, clipping: false };
+ }
+ this.analyser.getByteTimeDomainData(this.waveformData);
+ let sum = 0;
+ let clipping = false;
+ for (let i = 0; i < this.waveformData.length; i++) {
+ const byte = this.waveformData[i];
+ const sample = (byte - 128) / 128;
+ sum += sample * sample;
+ // Test the raw bytes. The 0..255 range is asymmetric about 128, so full
+ // positive scale is only 127/128 = 0.992 — an amplitude threshold above
+ // that can never trip on a positive peak.
+ if (byte >= 254 || byte <= 1) clipping = true;
+ }
+ return {
+ rms: gainToDb(Math.sqrt(sum / this.waveformData.length)),
+ left: this.earLevel(this.leftAnalyser),
+ right: this.earLevel(this.rightAnalyser),
+ clipping,
+ };
+ }
+
+ private earLevel(analyser: AnalyserNode | null): number {
+ if (!analyser) return -120;
+ analyser.getByteTimeDomainData(this.earScratch);
+ let sum = 0;
+ for (let i = 0; i < this.earScratch.length; i++) {
+ const sample = (this.earScratch[i] - 128) / 128;
+ sum += sample * sample;
+ }
+ return gainToDb(Math.sqrt(sum / this.earScratch.length));
+ }
+}
+
+interface LegacyListener {
+ setPosition(x: number, y: number, z: number): void;
+ setOrientation(fx: number, fy: number, fz: number, ux: number, uy: number, uz: number): void;
+}
+
+function rampParam(param: AudioParam, value: number, now: number): void {
+ param.setTargetAtTime(value, now, 0.02);
+}
+
+function setPosition(node: PannerNode | AudioListener, p: Vec3, now: number): void {
+ if (node.positionX) {
+ rampParam(node.positionX, p.x, now);
+ rampParam(node.positionY, p.y, now);
+ rampParam(node.positionZ, p.z, now);
+ } else if (typeof (node as unknown as LegacyListener).setPosition === 'function') {
+ (node as unknown as LegacyListener).setPosition(p.x, p.y, p.z);
+ }
+}
+
+function setOrientation(panner: PannerNode, forward: Vec3, now: number): void {
+ if (panner.orientationX) {
+ rampParam(panner.orientationX, forward.x, now);
+ rampParam(panner.orientationY, forward.y, now);
+ rampParam(panner.orientationZ, forward.z, now);
+ } else if (
+ typeof (panner as unknown as { setOrientation?: LegacyListener['setOrientation'] })
+ .setOrientation === 'function'
+ ) {
+ (panner as unknown as LegacyListener).setOrientation(forward.x, forward.y, forward.z, 0, 1, 0);
+ }
+}
diff --git a/src/audio/index.ts b/src/audio/index.ts
new file mode 100644
index 0000000..53bec79
--- /dev/null
+++ b/src/audio/index.ts
@@ -0,0 +1,4 @@
+export * from './AudioEngine';
+export * from './presets';
+export * from './reverb';
+export * from './userAudio';
diff --git a/src/audio/presets.ts b/src/audio/presets.ts
new file mode 100644
index 0000000..06b916f
--- /dev/null
+++ b/src/audio/presets.ts
@@ -0,0 +1,282 @@
+export type PresetId =
+ | 'sine'
+ | 'triangle'
+ | 'sawtooth'
+ | 'square'
+ | 'pluck'
+ | 'engine'
+ | 'beacon'
+ | 'pink';
+
+/**
+ * What the source is playing. `'file'` is not a generated preset — it means
+ * "the buffer the user loaded from disk", so it lives outside PRESETS.
+ */
+export type SourceId = PresetId | 'file';
+
+export interface PresetInfo {
+ id: PresetId;
+ label: string;
+ /** One line explaining what this source is good for hearing. */
+ hint: string;
+}
+
+export const PRESETS: PresetInfo[] = [
+ { id: 'sawtooth', label: 'Sawtooth', hint: 'Rich harmonics — best for hearing the cone and occlusion filters' },
+ { id: 'sine', label: 'Sine', hint: 'One pure frequency — the clearest way to hear Doppler pitch shift' },
+ { id: 'triangle', label: 'Triangle', hint: 'Soft, few harmonics — easy on the ears while you explore' },
+ { id: 'square', label: 'Square', hint: 'Hollow odd harmonics — cuts through reverb' },
+ { id: 'beacon', label: 'Beacon', hint: 'Pulsed blips — sharp transients make direction easiest to pinpoint' },
+ { id: 'pluck', label: 'Pluck', hint: 'Repeating string pluck — the decay tail reveals the room reverb' },
+ { id: 'engine', label: 'Engine', hint: 'Procedural motor — the classic Doppler fly-by sound' },
+ { id: 'pink', label: 'Pink noise', hint: 'All frequencies at once — the reference signal for spatial testing' },
+];
+
+const TAU = Math.PI * 2;
+
+/**
+ * Wraps the tail of a buffer into its head with an equal-power crossfade so it
+ * loops without a click. The returned buffer is `fadeSeconds` shorter.
+ */
+function makeSeamless(ctx: BaseAudioContext, source: AudioBuffer, fadeSeconds: number): AudioBuffer {
+ const rate = source.sampleRate;
+ const fade = Math.min(Math.floor(fadeSeconds * rate), Math.floor(source.length / 2));
+ if (fade <= 1) return source;
+
+ const length = source.length - fade;
+ const out = ctx.createBuffer(source.numberOfChannels, length, rate);
+
+ for (let ch = 0; ch < source.numberOfChannels; ch++) {
+ const src = source.getChannelData(ch);
+ const dst = out.getChannelData(ch);
+ dst.set(src.subarray(0, length));
+ for (let i = 0; i < fade; i++) {
+ const t = (i + 1) / (fade + 1);
+ // The sample that plays after dst[length-1] is dst[0], and the natural
+ // successor of src[length-1] is src[length]. So the *tail* must dominate
+ // at i=0 and hand over to the head across the fade — not the other way
+ // round, which would leave a discontinuity at each end of the region.
+ // cos/sin keeps the crossfade equal-power.
+ dst[i] = src[length + i] * Math.cos((t * Math.PI) / 2) + dst[i] * Math.sin((t * Math.PI) / 2);
+ }
+ }
+ return out;
+}
+
+function normalize(buffer: AudioBuffer, peak: number): AudioBuffer {
+ let max = 0;
+ for (let ch = 0; ch < buffer.numberOfChannels; ch++) {
+ const data = buffer.getChannelData(ch);
+ for (let i = 0; i < data.length; i++) {
+ const abs = Math.abs(data[i]);
+ if (abs > max) max = abs;
+ }
+ }
+ if (max > 1e-9) {
+ const scale = peak / max;
+ for (let ch = 0; ch < buffer.numberOfChannels; ch++) {
+ const data = buffer.getChannelData(ch);
+ for (let i = 0; i < data.length; i++) data[i] *= scale;
+ }
+ }
+ return buffer;
+}
+
+/**
+ * Additive band-limited waveform. Summing a finite harmonic series instead of
+ * sampling an ideal sawtooth keeps the spectrum below Nyquist, so the source
+ * stays clean when Doppler shifts it upward. Headroom is reserved for a shift
+ * of `pitchHeadroom`x before harmonics start folding back as aliasing.
+ */
+function bandLimitedTone(
+ ctx: BaseAudioContext,
+ shape: 'sine' | 'triangle' | 'sawtooth' | 'square',
+ frequency: number,
+ duration: number,
+ pitchHeadroom = 2
+): AudioBuffer {
+ const rate = ctx.sampleRate || 48000;
+ // Exact whole number of periods, so the loop point lands on a zero crossing.
+ const periods = Math.max(1, Math.round(duration * frequency));
+ const length = Math.max(1, Math.round((periods * rate) / frequency));
+
+ const nyquist = rate / 2 / pitchHeadroom;
+ const maxHarmonic = shape === 'sine' ? 1 : Math.max(1, Math.floor(nyquist / frequency));
+
+ const buffer = ctx.createBuffer(1, length, rate);
+ const data = buffer.getChannelData(0);
+
+ for (let h = 1; h <= maxHarmonic; h++) {
+ let amplitude = 0;
+ switch (shape) {
+ case 'sine':
+ amplitude = h === 1 ? 1 : 0;
+ break;
+ case 'sawtooth':
+ amplitude = 1 / h;
+ break;
+ case 'square':
+ amplitude = h % 2 === 1 ? 1 / h : 0;
+ break;
+ case 'triangle':
+ amplitude = h % 2 === 1 ? (((h - 1) / 2) % 2 === 0 ? 1 : -1) / (h * h) : 0;
+ break;
+ }
+ if (amplitude === 0) continue;
+
+ const step = (TAU * frequency * h) / rate;
+ for (let i = 0; i < length; i++) data[i] += amplitude * Math.sin(step * i);
+ }
+
+ return normalize(buffer, 0.9);
+}
+
+/** Paul Kellett's pink noise filter — a good approximation of -3 dB/octave. */
+function pinkNoise(ctx: BaseAudioContext, duration: number): AudioBuffer {
+ const rate = ctx.sampleRate || 48000;
+ const length = Math.floor(rate * duration);
+ const buffer = ctx.createBuffer(2, length, rate);
+
+ for (let ch = 0; ch < 2; ch++) {
+ const data = buffer.getChannelData(ch);
+ let b0 = 0;
+ let b1 = 0;
+ let b2 = 0;
+ let b3 = 0;
+ let b4 = 0;
+ let b5 = 0;
+ let b6 = 0;
+ for (let i = 0; i < length; i++) {
+ const white = Math.random() * 2 - 1;
+ b0 = 0.99886 * b0 + white * 0.0555179;
+ b1 = 0.99332 * b1 + white * 0.0750759;
+ b2 = 0.969 * b2 + white * 0.153852;
+ b3 = 0.8665 * b3 + white * 0.3104856;
+ b4 = 0.55 * b4 + white * 0.5329522;
+ b5 = -0.7616 * b5 - white * 0.016898;
+ data[i] = b0 + b1 + b2 + b3 + b4 + b5 + b6 + white * 0.5362;
+ b6 = white * 0.115926;
+ }
+ }
+
+ return makeSeamless(ctx, normalize(buffer, 0.7), 0.05);
+}
+
+/** A four-stroke-ish motor: FM-wobbled harmonic stack plus firing-rate noise. */
+function engine(ctx: BaseAudioContext, duration: number, baseFreq: number): AudioBuffer {
+ const rate = ctx.sampleRate || 48000;
+ const length = Math.floor(rate * duration);
+ const buffer = ctx.createBuffer(2, length, rate);
+ const left = buffer.getChannelData(0);
+ const right = buffer.getChannelData(1);
+
+ // Idle wobble, chosen to complete whole cycles over `duration` so it loops.
+ const wobbleRate = Math.round(7 * duration) / duration;
+ const wobbleDepth = 4;
+
+ let phase = 0;
+ let noiseL = 0;
+ let noiseR = 0;
+
+ for (let i = 0; i < length; i++) {
+ const t = i / rate;
+ const freq = baseFreq + wobbleDepth * Math.sin(TAU * wobbleRate * t);
+ phase += (TAU * freq) / rate;
+
+ const harmonics =
+ 0.5 * Math.sin(phase) +
+ 0.35 * Math.sin(2 * phase) +
+ 0.22 * Math.sin(3 * phase) +
+ 0.14 * Math.sin(4 * phase) +
+ 0.08 * Math.sin(6 * phase);
+
+ // Exhaust noise gated by the firing pulses (two per revolution).
+ const firing = 0.5 + 0.5 * Math.sin(2 * phase);
+ // One-pole lowpass keeps the noise from sounding like hiss.
+ noiseL += 0.25 * ((Math.random() * 2 - 1) * firing - noiseL);
+ noiseR += 0.25 * ((Math.random() * 2 - 1) * firing - noiseR);
+
+ left[i] = harmonics * 0.75 + noiseL * 0.5;
+ right[i] = harmonics * 0.75 + noiseR * 0.5;
+ }
+
+ return makeSeamless(ctx, normalize(buffer, 0.85), 0.04);
+}
+
+/** A repeating plucked string. The decay tail is what makes reverb audible. */
+function pluck(ctx: BaseAudioContext, frequency: number, interval: number, repeats: number): AudioBuffer {
+ const rate = ctx.sampleRate || 48000;
+ const noteSamples = Math.floor(rate * interval);
+ const length = noteSamples * repeats;
+ const buffer = ctx.createBuffer(1, length, rate);
+ const data = buffer.getChannelData(0);
+
+ const weights = [1, 0.6, 0.4, 0.25, 0.15, 0.1, 0.06, 0.03];
+ // Higher harmonics die first, as they do on a real string.
+ const decays = [1.6, 2.4, 3.2, 4.2, 5.4, 6.8, 8.4, 10.2];
+ // Let each note ring well past the next attack, and wrap the overhang back
+ // to the head of the buffer. Notes then sum instead of being chopped off, so
+ // there is no discontinuity at a note boundary or at the loop point.
+ const tailSamples = Math.min(length, noteSamples * 3);
+
+ for (let n = 0; n < repeats; n++) {
+ const offset = n * noteSamples;
+ for (let i = 0; i < tailSamples; i++) {
+ const t = i / rate;
+ const attack = 1 - Math.exp(-t / 0.004);
+ let sample = 0;
+ for (let h = 0; h < weights.length; h++) {
+ sample += weights[h] * Math.exp(-decays[h] * t) * Math.sin(TAU * (h + 1) * frequency * t);
+ }
+ data[(offset + i) % length] += sample * attack;
+ }
+ }
+
+ return normalize(buffer, 0.9);
+}
+
+/** Pulsed tone. Sharp onsets give the ear the timing cues it localises with. */
+function beacon(ctx: BaseAudioContext, frequency: number, interval: number, repeats: number): AudioBuffer {
+ const rate = ctx.sampleRate || 48000;
+ const noteSamples = Math.floor(rate * interval);
+ const length = noteSamples * repeats;
+ const buffer = ctx.createBuffer(1, length, rate);
+ const data = buffer.getChannelData(0);
+
+ const blipSamples = Math.floor(rate * 0.12);
+ for (let n = 0; n < repeats; n++) {
+ const offset = n * noteSamples;
+ // Alternate between two pitches so the pattern reads as deliberate.
+ const f = n % 2 === 0 ? frequency : frequency * 1.5;
+ for (let i = 0; i < blipSamples; i++) {
+ const t = i / rate;
+ // Raised-cosine window: no clicks at either end of the blip.
+ const window = 0.5 - 0.5 * Math.cos((TAU * i) / blipSamples);
+ data[offset + i] = window * (Math.sin(TAU * f * t) * 0.7 + Math.sin(TAU * f * 2 * t) * 0.2);
+ }
+ }
+
+ return normalize(buffer, 0.9);
+}
+
+export function createPresetBuffer(ctx: BaseAudioContext, preset: PresetId): AudioBuffer {
+ switch (preset) {
+ case 'sine':
+ return bandLimitedTone(ctx, 'sine', 330, 1, 1);
+ case 'triangle':
+ return bandLimitedTone(ctx, 'triangle', 220, 1);
+ case 'square':
+ return bandLimitedTone(ctx, 'square', 165, 1);
+ case 'pluck':
+ return pluck(ctx, 196, 0.75, 4);
+ case 'engine':
+ return engine(ctx, 3, 48);
+ case 'beacon':
+ return beacon(ctx, 880, 0.6, 4);
+ case 'pink':
+ return pinkNoise(ctx, 3);
+ case 'sawtooth':
+ default:
+ return bandLimitedTone(ctx, 'sawtooth', 165, 1);
+ }
+}
diff --git a/src/audio/reverb.ts b/src/audio/reverb.ts
new file mode 100644
index 0000000..dcbd9db
--- /dev/null
+++ b/src/audio/reverb.ts
@@ -0,0 +1,156 @@
+import { clamp } from '../physics';
+
+export type SurfaceId = 'anechoic' | 'studio' | 'living' | 'hall' | 'cathedral';
+
+export interface Surface {
+ id: SurfaceId;
+ label: string;
+ /** Average Sabine absorption coefficient of the room's surfaces, 0..1. */
+ absorption: number;
+ hint: string;
+}
+
+export const SURFACES: Surface[] = [
+ { id: 'anechoic', label: 'Anechoic foam', absorption: 0.9, hint: 'Almost no reflections — pure direct sound' },
+ { id: 'studio', label: 'Treated studio', absorption: 0.45, hint: 'Short, controlled decay' },
+ { id: 'living', label: 'Carpet & drapes', absorption: 0.22, hint: 'A normal furnished room' },
+ { id: 'hall', label: 'Wood hall', absorption: 0.09, hint: 'Long, musical reverberation' },
+ { id: 'cathedral', label: 'Stone cathedral', absorption: 0.035, hint: 'Vast, washy, slow to decay' },
+];
+
+export function getSurface(id: SurfaceId): Surface {
+ return SURFACES.find((s) => s.id === id) ?? SURFACES[2];
+}
+
+export interface RoomDimensions {
+ width: number;
+ height: number;
+ depth: number;
+}
+
+export interface RoomAcoustics {
+ /** Reverberation time to -60 dB, in seconds. */
+ t60: number;
+ /** Room constant R = Sα / (1 − α), in m². */
+ roomConstant: number;
+ /** Distance at which the reverberant field equals the direct field, in metres. */
+ criticalDistance: number;
+ /** Total surface area, m². */
+ surfaceArea: number;
+ /** Volume, m³. */
+ volume: number;
+}
+
+/**
+ * Derives the room's acoustic behaviour from its geometry and surface material
+ * using Sabine's equation, T60 = 0.161 V / (S α).
+ *
+ * This is what makes the room-size sliders audible: a bigger or harder room
+ * genuinely rings longer and pushes the critical distance closer to the source.
+ */
+export function computeAcoustics(dims: RoomDimensions, absorption: number): RoomAcoustics {
+ const { width: w, height: h, depth: d } = dims;
+ const volume = Math.max(1, w * h * d);
+ const surfaceArea = Math.max(1, 2 * (w * h + w * d + h * d));
+ const alpha = clamp(absorption, 0.01, 0.99);
+
+ const t60 = clamp((0.161 * volume) / (surfaceArea * alpha), 0.08, 8);
+ const roomConstant = (surfaceArea * alpha) / (1 - alpha);
+ // r_c = sqrt(R / 16π) for an omnidirectional source.
+ const criticalDistance = Math.sqrt(roomConstant / (16 * Math.PI));
+
+ return { t60, roomConstant, criticalDistance, surfaceArea, volume };
+}
+
+/**
+ * Level of the diffuse reverberant field relative to the direct sound measured
+ * at the reference distance.
+ *
+ * The reverberant field is roughly uniform throughout a room, so it is *not*
+ * distance-attenuated. Setting its level to refDistance / criticalDistance
+ * makes wet and dry balance exactly at the critical distance — walk further
+ * than that and the room takes over, which is the effect real rooms have.
+ */
+export function reverberantGain(acoustics: RoomAcoustics, refDistance = 1): number {
+ if (acoustics.criticalDistance < 1e-6) return 1;
+ return clamp(refDistance / acoustics.criticalDistance, 0, 1.4);
+}
+
+/**
+ * Synthesises a stereo impulse response for the given room.
+ *
+ * Structure follows a real room impulse: a direct-path spike, a sparse set of
+ * early reflections whose delays come from the actual wall distances, then an
+ * exponentially decaying diffuse tail that loses its high frequencies as it
+ * goes — because each bounce absorbs treble faster than bass.
+ */
+export function generateImpulseResponse(
+ ctx: BaseAudioContext,
+ dims: RoomDimensions,
+ absorption: number,
+ speedOfSound = 343
+): AudioBuffer {
+ const acoustics = computeAcoustics(dims, absorption);
+ const rate = ctx.sampleRate || 48000;
+ const length = Math.max(1, Math.floor(rate * acoustics.t60));
+ const buffer = ctx.createBuffer(2, length, rate);
+ const left = buffer.getChannelData(0);
+ const right = buffer.getChannelData(1);
+
+ // -60 dB over t60 seconds.
+ const decay = 6.907755 / acoustics.t60;
+ const reflectivity = 1 - clamp(absorption, 0.01, 0.99);
+
+ // Diffuse tail: decaying noise, lowpassed harder as the tail progresses.
+ let lpL = 0;
+ let lpR = 0;
+ for (let i = 0; i < length; i++) {
+ const t = i / rate;
+ const envelope = Math.exp(-decay * t);
+ // Treble dies faster than broadband energy, as it does on every bounce.
+ const coefficient = clamp(Math.exp(-decay * t * 0.9), 0.02, 1);
+ // A one-pole filter fed white noise outputs RMS sqrt(a / (2 - a)), so
+ // narrowing the filter over time would quietly steepen the decay on top of
+ // the envelope and the tail would die well short of the T60 the UI reports.
+ // Dividing it back out leaves the envelope in sole charge of the level.
+ const compensation = Math.sqrt((2 - coefficient) / coefficient);
+ lpL += coefficient * ((Math.random() * 2 - 1) - lpL);
+ lpR += coefficient * ((Math.random() * 2 - 1) - lpR);
+ left[i] = lpL * envelope * compensation;
+ right[i] = lpR * envelope * compensation;
+ }
+
+ // Early reflections off the six surfaces, from a source near the middle.
+ const halfPaths = [dims.width, dims.depth, dims.height, dims.width * 1.5, dims.depth * 1.5];
+ halfPaths.forEach((pathLength, index) => {
+ const delay = pathLength / speedOfSound;
+ const sample = Math.floor(delay * rate);
+ if (sample <= 0 || sample >= length) return;
+ // Each reflection loses energy to the surface and to spreading.
+ const amplitude = (reflectivity / (1 + pathLength * 0.15)) * Math.exp(-decay * delay);
+ const pan = index % 2 === 0 ? 0.35 : -0.35;
+ left[sample] += amplitude * (1 - pan);
+ right[sample] += amplitude * (1 + pan);
+ });
+
+ // Normalise to unit energy per channel, not to unit peak.
+ //
+ // A convolution's output level follows the impulse response's total energy,
+ // so peak-normalising would make a long tail far louder than a short one and
+ // `reverberantGain` would stop meaning anything. With unit energy the
+ // convolver passes signal through at roughly its input level, which lets the
+ // send gain alone set the direct-to-reverberant balance.
+ let energy = 0;
+ for (let i = 0; i < length; i++) {
+ energy += left[i] * left[i] + right[i] * right[i];
+ }
+ if (energy > 1e-12) {
+ const scale = 1 / Math.sqrt(energy / 2);
+ for (let i = 0; i < length; i++) {
+ left[i] *= scale;
+ right[i] *= scale;
+ }
+ }
+
+ return buffer;
+}
diff --git a/src/audio/userAudio.ts b/src/audio/userAudio.ts
new file mode 100644
index 0000000..f6bcc7e
--- /dev/null
+++ b/src/audio/userAudio.ts
@@ -0,0 +1,85 @@
+/** Anything larger than this is almost certainly not what the user meant. */
+export const MAX_FILE_BYTES = 200 * 1024 * 1024;
+
+export class AudioFileError extends Error {}
+
+/**
+ * Collapses a buffer to a single channel.
+ *
+ * A loudspeaker standing at one point in a room radiates one signal. Feeding a
+ * stereo mix into the panner would leave part of the image fixed to the
+ * listener's ears no matter where the source is, which is exactly the illusion
+ * this app exists to avoid. Summing to mono makes the file behave like a real
+ * source in the space.
+ */
+export function downmixToMono(ctx: BaseAudioContext, buffer: AudioBuffer): AudioBuffer {
+ if (buffer.numberOfChannels === 1) return buffer;
+
+ const mono = ctx.createBuffer(1, buffer.length, buffer.sampleRate);
+ const out = mono.getChannelData(0);
+ const channels = buffer.numberOfChannels;
+
+ for (let channel = 0; channel < channels; channel++) {
+ const data = buffer.getChannelData(channel);
+ for (let i = 0; i < data.length; i++) out[i] += data[i];
+ }
+
+ // Average, then pull the peak back under unity if summing pushed it over.
+ let peak = 0;
+ for (let i = 0; i < out.length; i++) {
+ out[i] /= channels;
+ const abs = Math.abs(out[i]);
+ if (abs > peak) peak = abs;
+ }
+ if (peak > 0.98) {
+ const scale = 0.98 / peak;
+ for (let i = 0; i < out.length; i++) out[i] *= scale;
+ }
+
+ return mono;
+}
+
+/**
+ * Decodes a user-supplied audio file into a mono buffer.
+ *
+ * Format support is whatever the browser's decoder handles — typically MP3,
+ * WAV, FLAC, OGG, AAC/M4A. Errors are turned into messages worth showing a
+ * person rather than a bare DOMException.
+ */
+export async function decodeAudioFile(ctx: BaseAudioContext, file: File): Promise {
+ if (file.size === 0) throw new AudioFileError('That file is empty.');
+ if (file.size > MAX_FILE_BYTES) {
+ throw new AudioFileError(
+ `That file is ${(file.size / 1024 / 1024).toFixed(0)} MB. Try something under ${
+ MAX_FILE_BYTES / 1024 / 1024
+ } MB.`
+ );
+ }
+
+ let bytes: ArrayBuffer;
+ try {
+ bytes = await file.arrayBuffer();
+ } catch {
+ throw new AudioFileError('Could not read that file from disk.');
+ }
+
+ let decoded: AudioBuffer;
+ try {
+ decoded = await ctx.decodeAudioData(bytes);
+ } catch {
+ throw new AudioFileError(
+ `Could not decode “${file.name}”. Try MP3, WAV, FLAC, OGG or M4A.`
+ );
+ }
+
+ if (decoded.length === 0) throw new AudioFileError('That file decoded to no audio.');
+ return downmixToMono(ctx, decoded);
+}
+
+/** "3:47" — for a duration readout. */
+export function formatDuration(seconds: number): string {
+ if (!Number.isFinite(seconds) || seconds < 0) return '—';
+ const total = Math.round(seconds);
+ const minutes = Math.floor(total / 60);
+ return `${minutes}:${String(total % 60).padStart(2, '0')}`;
+}
diff --git a/src/main.ts b/src/main.ts
new file mode 100644
index 0000000..ad79f2a
--- /dev/null
+++ b/src/main.ts
@@ -0,0 +1,368 @@
+import * as THREE from 'three';
+import './styles/app.css';
+import {
+ AudioEngine,
+ AudioFileError,
+ DEFAULT_SETTINGS,
+ EngineSettings,
+ EngineSettingsPatch,
+ PRESETS,
+ SILENT_TELEMETRY,
+ Telemetry,
+ formatDuration,
+} from './audio';
+import { MotionMode, Stage } from './scene';
+import { Hud, OutputModule } from './ui/readouts';
+import { Panel } from './ui/panel';
+import { Scope, Spectrum } from './ui/meters';
+import { SignalPath } from './ui/signalPath';
+import { DemoSpec, Onboarding } from './ui/onboarding';
+import { fmt } from './ui/format';
+
+const need = (selector: string): T => {
+ const node = document.querySelector(selector);
+ if (!node) throw new Error(`Missing required element: ${selector}`);
+ return node;
+};
+
+class App {
+ private readonly engine = new AudioEngine();
+ private readonly stage: Stage;
+ private readonly panel: Panel;
+ private readonly hud = new Hud();
+ private readonly output = new OutputModule();
+ private readonly signalPath: SignalPath;
+ private readonly spectrum: Spectrum;
+ private readonly scope: Scope;
+ private readonly onboarding: Onboarding;
+
+ private readonly playButton = need('#play');
+ private readonly statusChip = need('#status');
+ private readonly statusText = need('#status-text');
+ private readonly live = need('#live');
+
+ private settings: EngineSettings = { ...DEFAULT_SETTINGS };
+ private motion: { mode: MotionMode; speed: number } = { mode: 'static', speed: 8 };
+ private telemetry: Telemetry = SILENT_TELEMETRY;
+ private lastFrame = performance.now();
+ private lastAnnounced = '';
+ private frame = 0;
+
+ constructor() {
+ const viewport = need('#viewport');
+ this.stage = new Stage(viewport, this.settings.room);
+ this.stage.setConeAngles(this.settings.coneInnerAngle, this.settings.coneOuterAngle);
+ viewport.append(this.hud.root);
+
+ this.panel = new Panel(this.settings, {
+ onEngine: (patch) => this.applyEngine(patch),
+ onFile: (file) => void this.loadFile(file),
+ onMotion: (mode, speed) => this.setMotion(mode, speed),
+ onYaw: (degrees) => this.stage.setSourceYaw(degrees),
+ onAnnotations: (visible) => this.stage.setAnnotationsVisible(visible),
+ });
+ need('#panel').append(this.panel.root);
+
+ this.signalPath = new SignalPath(() => this.sourceLabel(), () => this.settings.masterVolume);
+ need('#module-path').append(this.signalPath.root);
+ need('#strip').append(this.output.root);
+
+ this.spectrum = new Spectrum(need('#spectrum'), () => this.engine.sampleRate);
+ this.scope = new Scope(need('#scope'));
+
+ this.onboarding = new Onboarding(this.buildDemos(), {
+ onEnableAudio: () => this.play(),
+ onExploreMuted: () => this.onboarding.toast('Exploring muted — press Play whenever you like'),
+ });
+
+ this.bindChrome();
+ this.stage.resetView();
+ this.onboarding.open();
+
+ // Read-only inspection hook for the browser smoke test (scripts/e2e.mjs).
+ // Nothing in the app reads it.
+ (window as unknown as Record).__resonance = {
+ telemetry: () => this.telemetry,
+ settings: () => this.settings,
+ playing: () => this.engine.isPlaying,
+ camera: () => {
+ const p = this.stage.camera.position;
+ return { x: p.x, y: p.y, z: p.z, azimuth: (Math.atan2(p.z, p.x) * 180) / Math.PI };
+ },
+ };
+
+ // Start rendering immediately. The engine computes the full acoustic state
+ // without an AudioContext, so every readout is live and correct before the
+ // user has clicked anything.
+ requestAnimationFrame(this.tick);
+ }
+
+ private applyEngine(patch: EngineSettingsPatch): void {
+ this.engine.update(patch);
+ this.settings = this.engine.getSettings();
+ if (patch.room) this.stage.setRoomDims(this.settings.room);
+ if (patch.coneInnerAngle !== undefined || patch.coneOuterAngle !== undefined) {
+ this.stage.setConeAngles(this.settings.coneInnerAngle, this.settings.coneOuterAngle);
+ }
+ }
+
+ /** What the signal path calls the current source. */
+ private sourceLabel(): string {
+ if (this.settings.preset === 'file') {
+ const name = this.engine.getUserAudio()?.name ?? 'Your file';
+ // The column is narrow; a long filename would push the dB out of line.
+ return name.length > 22 ? `${name.slice(0, 21)}…` : name;
+ }
+ return PRESETS.find((p) => p.id === this.settings.preset)?.label ?? this.settings.preset;
+ }
+
+ /**
+ * Decodes a file the user picked or dropped and makes it the source.
+ *
+ * Starts playback automatically: someone who just handed the app a song
+ * expects to hear it, and the pick itself is the gesture that unlocks audio.
+ */
+ private async loadFile(file: File): Promise {
+ this.panel.file.setStatus(`Decoding “${file.name}”…`, 'busy');
+ try {
+ const { name, duration } = await this.engine.loadUserAudio(file);
+ this.settings = this.engine.getSettings();
+ this.panel.setUserTrack(name, formatDuration(duration));
+ this.panel.file.setStatus(`Loaded “${name}” · ${formatDuration(duration)}`);
+ if (!this.engine.isPlaying) await this.play();
+ this.onboarding.toast(`Playing “${name}” — try Orbit motion to hear it circle you`);
+ } catch (error) {
+ const message =
+ error instanceof AudioFileError
+ ? error.message
+ : `Could not load “${file.name}”.`;
+ this.panel.file.setStatus(message, 'error');
+ this.onboarding.toast(message);
+ }
+ }
+
+ private async play(): Promise {
+ await this.engine.start();
+ this.settings = this.engine.getSettings();
+ this.syncTransport();
+ }
+
+ private async togglePlay(): Promise {
+ if (this.engine.isPlaying) {
+ this.engine.stop();
+ this.syncTransport();
+ } else {
+ try {
+ await this.play();
+ } catch (error) {
+ this.setStatus('error', 'Web Audio blocked');
+ this.onboarding.toast(error instanceof Error ? error.message : 'Could not start audio');
+ }
+ }
+ }
+
+ private syncTransport(): void {
+ const playing = this.engine.isPlaying;
+ this.playButton.textContent = playing ? '⏸ Pause' : '▶ Play';
+ this.playButton.setAttribute('aria-pressed', String(playing));
+ }
+
+ private bindChrome(): void {
+ this.playButton.addEventListener('click', () => void this.togglePlay());
+
+ const volume = need('#volume');
+ const volumeValue = need('#volume-value');
+ volume.addEventListener('input', () => {
+ const value = Number(volume.value);
+ volumeValue.textContent = fmt.volume(value);
+ this.applyEngine({ masterVolume: value });
+ });
+
+ this.bindDropTarget();
+ need('#reset-view').addEventListener('click', () => this.stage.resetView());
+ need('#help-btn').addEventListener('click', () => this.onboarding.toggleHelp());
+
+ const hide = need('#hide-panels');
+ hide.addEventListener('click', () => {
+ const hidden = document.body.classList.toggle('panels-hidden');
+ hide.setAttribute('aria-pressed', String(hidden));
+ hide.textContent = hidden ? 'Show panels' : 'Hide panels';
+ });
+
+ window.addEventListener('keydown', (event) => {
+ if (this.onboarding.isOpen) return;
+ const target = event.target as HTMLElement | null;
+ const tag = target?.tagName?.toLowerCase();
+ if (tag === 'input' || tag === 'select' || tag === 'textarea') return;
+
+ if (event.code === 'KeyK') void this.togglePlay();
+ else if (event.code === 'KeyH') hide.click();
+ else if (event.key === '?') this.onboarding.toggleHelp();
+ else if (event.code === 'Digit1') this.onboarding.runDemo(0);
+ else if (event.code === 'Digit2') this.onboarding.runDemo(1);
+ else if (event.code === 'Digit3') this.onboarding.runDemo(2);
+ else return;
+ event.preventDefault();
+ });
+ }
+
+ /** Drop an audio file anywhere on the 3D view to load it. */
+ private bindDropTarget(): void {
+ const viewport = need('#viewport');
+ let depth = 0;
+
+ const setActive = (active: boolean) => viewport.classList.toggle('drop-active', active);
+
+ // dragenter/dragleave fire for every child element crossed, so count them
+ // rather than toggling, or the highlight flickers as the pointer moves.
+ viewport.addEventListener('dragenter', (event) => {
+ event.preventDefault();
+ depth++;
+ setActive(true);
+ });
+ viewport.addEventListener('dragover', (event) => {
+ event.preventDefault();
+ if (event.dataTransfer) event.dataTransfer.dropEffect = 'copy';
+ });
+ viewport.addEventListener('dragleave', () => {
+ depth = Math.max(0, depth - 1);
+ if (depth === 0) setActive(false);
+ });
+ viewport.addEventListener('drop', (event) => {
+ event.preventDefault();
+ depth = 0;
+ setActive(false);
+ const file = event.dataTransfer?.files?.[0];
+ if (file) void this.loadFile(file);
+ });
+ }
+
+ /**
+ * Three guided scenarios, each isolating one thing the engine models.
+ * Every one ends by syncing the panel, so the controls agree with what the
+ * demo just set up.
+ */
+ private buildDemos(): DemoSpec[] {
+ const withSync = (specs: DemoSpec[]): DemoSpec[] =>
+ specs.map((spec) => ({
+ ...spec,
+ run: () => {
+ spec.run();
+ this.syncPanel();
+ },
+ }));
+
+ return withSync([
+ {
+ id: 'flyby',
+ label: 'Siren fly-by',
+ // Names the signal-path row rather than the Output module, which is
+ // hidden on narrow screens.
+ caption: 'Watch the Doppler shift in Output, and “At your ear” rise as it approaches.',
+ run: () => {
+ this.applyEngine({ preset: 'engine', propagationEnabled: true, speedOfSound: 343 });
+ this.setMotion('flyby', 32);
+ },
+ },
+ {
+ id: 'occlusion',
+ label: 'Behind the pillar',
+ caption: 'Watch the Occlusion row: the lowpass slides from 22 kHz down to 350 Hz.',
+ run: () => {
+ this.applyEngine({ preset: 'pink' });
+ this.setMotion('static', 8);
+ // Park the source directly behind the obstacle, as seen from the ear.
+ const listener = this.stage.camera.getWorldPosition(new THREE.Vector3());
+ const behind = this.stage.obstacle.position
+ .clone()
+ .sub(listener)
+ .setY(0)
+ .normalize()
+ .multiplyScalar(3.5)
+ .add(this.stage.obstacle.position);
+ behind.y = 1.6;
+ this.stage.setSourcePosition(behind);
+ },
+ },
+ {
+ id: 'critical',
+ label: 'Past the critical distance',
+ caption:
+ 'Walk away with W/S: the direct sound falls, the room stays put. That is how you judge distance.',
+ run: () => {
+ this.applyEngine({
+ preset: 'pluck',
+ surface: 'hall',
+ room: { width: 34, height: 12, depth: 34 },
+ });
+ this.setMotion('static', 8);
+ this.stage.setSourcePosition(new THREE.Vector3(0, 1.6, 0));
+ this.stage.resetView();
+ },
+ },
+ ]);
+ }
+
+ private setMotion(mode: MotionMode, speed: number): void {
+ this.motion = { mode, speed };
+ this.stage.setMotion(mode, speed);
+ }
+
+ /** Pushes engine and motion state back into the panel after a demo or reset. */
+ private syncPanel(): void {
+ this.panel.sync(this.settings, this.motion);
+ }
+
+ private setStatus(state: string, text: string): void {
+ if (this.statusChip.dataset.state !== state) this.statusChip.dataset.state = state;
+ if (this.statusText.textContent !== text) this.statusText.textContent = text;
+ }
+
+ private tick = (now: number): void => {
+ const dt = Math.min(0.1, Math.max(0.001, (now - this.lastFrame) / 1000));
+ this.lastFrame = now;
+ this.frame++;
+
+ const pose = this.stage.update(
+ dt,
+ this.telemetry.criticalDistance,
+ this.telemetry.reverbDominant,
+ Math.pow(10, this.telemetry.coneGainDb / 20)
+ );
+ this.telemetry = this.engine.updateSpatial(pose, dt);
+ this.stage.render();
+
+ // The 3D view runs every frame; the DOM does not need to.
+ if (this.frame % 6 === 0) this.renderReadouts();
+ if (this.frame % 2 === 0) {
+ const running = this.telemetry.running;
+ this.spectrum.draw(this.engine.getFrequencyData(), running);
+ this.scope.draw(this.engine.getWaveformData(), running);
+ }
+
+ requestAnimationFrame(this.tick);
+ };
+
+ private renderReadouts(): void {
+ this.hud.update(this.telemetry);
+ this.output.update(this.telemetry);
+ this.signalPath.update(this.telemetry);
+ this.panel.update(this.telemetry, this.stage.getSourceYaw());
+
+ const state = this.engine.context?.state;
+ if (!state) this.setStatus('idle', 'Audio not started');
+ else if (state === 'running' && this.engine.isPlaying)
+ this.setStatus('running', `Running · ${Math.round(this.engine.sampleRate / 1000)} kHz`);
+ else this.setStatus('suspended', 'Paused');
+
+ // Announce state transitions only. Continuously reading out telemetry
+ // would make a screen reader unusable.
+ const announcement = this.telemetry.occlusion > 0.5 ? 'Line of sight blocked' : '';
+ if (announcement !== this.lastAnnounced) {
+ this.lastAnnounced = announcement;
+ this.live.textContent = announcement;
+ }
+ }
+}
+
+new App();
diff --git a/src/physics/attenuation.ts b/src/physics/attenuation.ts
new file mode 100644
index 0000000..520c692
--- /dev/null
+++ b/src/physics/attenuation.ts
@@ -0,0 +1,72 @@
+import { clamp } from './vector';
+
+export type DistanceModel = 'inverse' | 'exponential' | 'linear';
+
+export interface DistanceParams {
+ /** Distance at which gain is unity, in metres. */
+ refDistance: number;
+ /** Distance beyond which the linear model reaches its floor, in metres. */
+ maxDistance: number;
+ /** How quickly loudness falls off. 1.0 is physically neutral for `inverse`. */
+ rolloffFactor: number;
+}
+
+/**
+ * Web Audio `inverse` model. At rolloffFactor 1 this is the physical inverse
+ * distance law for a point source: doubling the distance halves the amplitude
+ * (-6 dB).
+ */
+export function inverseDistanceGain(d: number, p: DistanceParams): number {
+ if (p.refDistance <= 0) return 0;
+ const clamped = Math.max(d, p.refDistance);
+ const denominator = p.refDistance + p.rolloffFactor * (clamped - p.refDistance);
+ return denominator <= 0 ? 0 : clamp(p.refDistance / denominator, 0, 1);
+}
+
+/** Web Audio `exponential` model: (d / refDistance) ^ -rolloffFactor. */
+export function exponentialDistanceGain(d: number, p: DistanceParams): number {
+ if (p.refDistance <= 0) return 0;
+ const clamped = Math.max(d, p.refDistance);
+ return clamp(Math.pow(clamped / p.refDistance, -p.rolloffFactor), 0, 1);
+}
+
+/** Web Audio `linear` model: fades linearly to the floor at maxDistance. */
+export function linearDistanceGain(d: number, p: DistanceParams): number {
+ if (p.refDistance <= 0) return 0;
+ if (p.maxDistance <= p.refDistance) return d >= p.maxDistance ? 0 : 1;
+ const clamped = clamp(d, p.refDistance, p.maxDistance);
+ const t = (clamped - p.refDistance) / (p.maxDistance - p.refDistance);
+ return clamp(1 - p.rolloffFactor * t, 0, 1);
+}
+
+export function distanceGain(d: number, model: DistanceModel, p: DistanceParams): number {
+ switch (model) {
+ case 'exponential':
+ return exponentialDistanceGain(d, p);
+ case 'linear':
+ return linearDistanceGain(d, p);
+ case 'inverse':
+ default:
+ return inverseDistanceGain(d, p);
+ }
+}
+
+/**
+ * Atmospheric absorption of high frequencies over distance, expressed as the
+ * cutoff of a one-pole lowpass.
+ *
+ * Real air at 20 °C / 50 % RH absorbs roughly 0.1 dB per metre at 8 kHz, which
+ * is barely audible across a room. `strength` scales that: 1.0 is realistic,
+ * higher values exaggerate the effect so it can be heard at room scale.
+ */
+export function airAbsorptionCutoff(
+ distanceMetres: number,
+ strength: number,
+ nyquist = 22050
+): number {
+ if (strength <= 0 || distanceMetres <= 0) return nyquist;
+ // Halve the cutoff every `halfLife` metres; ~120 m at realistic strength.
+ const halfLife = 120 / Math.max(strength, 1e-3);
+ const cutoff = nyquist * Math.pow(0.5, distanceMetres / halfLife);
+ return clamp(cutoff, 200, nyquist);
+}
diff --git a/src/physics/cone.ts b/src/physics/cone.ts
new file mode 100644
index 0000000..ae9ea30
--- /dev/null
+++ b/src/physics/cone.ts
@@ -0,0 +1,44 @@
+import { Vec3, clamp, direction, dot, normalize } from './vector';
+
+export interface ConeParams {
+ /** Full angle of the fully-loud cone, in degrees. */
+ innerAngle: number;
+ /** Full angle at which attenuation reaches `outerGain`, in degrees. */
+ outerAngle: number;
+ /** Gain applied outside the outer cone, 0..1. */
+ outerGain: number;
+}
+
+/**
+ * Angle in degrees between the source's forward axis and the listener,
+ * measured from the source. 0° means the listener is dead ahead.
+ */
+export function offAxisAngle(sourcePos: Vec3, sourceForward: Vec3, listenerPos: Vec3): number {
+ const toListener = direction(sourcePos, listenerPos);
+ if (!toListener) return 0;
+ const forward = normalize(sourceForward);
+ return (Math.acos(clamp(dot(forward, toListener), -1, 1)) * 180) / Math.PI;
+}
+
+/**
+ * Directivity gain, following the Web Audio PannerNode cone model: unity
+ * inside the inner cone, linearly interpolated across the transition band,
+ * `outerGain` beyond the outer cone.
+ */
+export function coneGain(
+ sourcePos: Vec3,
+ sourceForward: Vec3,
+ listenerPos: Vec3,
+ p: ConeParams
+): number {
+ const angle = offAxisAngle(sourcePos, sourceForward, listenerPos);
+ const innerHalf = Math.max(0, p.innerAngle / 2);
+ const outerHalf = Math.max(innerHalf, p.outerAngle / 2);
+ const floor = clamp(p.outerGain, 0, 1);
+
+ if (angle <= innerHalf) return 1;
+ if (angle >= outerHalf) return floor;
+
+ const t = (angle - innerHalf) / (outerHalf - innerHalf);
+ return clamp(1 + t * (floor - 1), 0, 1);
+}
diff --git a/src/physics/doppler.ts b/src/physics/doppler.ts
new file mode 100644
index 0000000..2149853
--- /dev/null
+++ b/src/physics/doppler.ts
@@ -0,0 +1,38 @@
+import { Vec3, clamp, direction, dot } from './vector';
+
+/** Playback-rate ratios beyond this are chipmunk/monster territory, not physics. */
+export const DOPPLER_MIN_RATIO = 0.25;
+export const DOPPLER_MAX_RATIO = 4;
+
+/**
+ * Doppler frequency ratio (perceived / emitted) for a source and listener
+ * moving through a medium:
+ *
+ * f' / f = (c - v_listener) / (c - v_source)
+ *
+ * where both velocities are the components along the source→listener axis.
+ * A source approaching the listener raises the pitch; a listener fleeing the
+ * source lowers it.
+ */
+export function dopplerRatio(
+ sourcePos: Vec3,
+ sourceVel: Vec3,
+ listenerPos: Vec3,
+ listenerVel: Vec3,
+ speedOfSound = 343
+): number {
+ const c = speedOfSound > 0 ? speedOfSound : 343;
+ const toListener = direction(sourcePos, listenerPos);
+ if (!toListener) return 1;
+
+ // Positive when the source chases the listener.
+ const vSource = dot(sourceVel, toListener);
+ // Positive when the listener flees the source.
+ const vListener = dot(listenerVel, toListener);
+
+ // Keep the source subsonic so the denominator never collapses.
+ const denominator = c - Math.min(vSource, 0.9 * c);
+ if (denominator <= 0) return DOPPLER_MAX_RATIO;
+
+ return clamp((c - vListener) / denominator, DOPPLER_MIN_RATIO, DOPPLER_MAX_RATIO);
+}
diff --git a/src/physics/index.ts b/src/physics/index.ts
new file mode 100644
index 0000000..e4c9aef
--- /dev/null
+++ b/src/physics/index.ts
@@ -0,0 +1,5 @@
+export * from './vector';
+export * from './attenuation';
+export * from './cone';
+export * from './doppler';
+export * from './occlusion';
diff --git a/src/physics/occlusion.ts b/src/physics/occlusion.ts
new file mode 100644
index 0000000..a34f3f8
--- /dev/null
+++ b/src/physics/occlusion.ts
@@ -0,0 +1,32 @@
+import { clamp } from './vector';
+
+export interface OcclusionResponse {
+ /** Lowpass cutoff applied to the direct path, in Hz. */
+ cutoff: number;
+ /** Broadband gain applied to the direct path, 0..1. */
+ gain: number;
+}
+
+/** Cutoff of the direct path when fully blocked. */
+const BLOCKED_CUTOFF = 350;
+const OPEN_CUTOFF = 22050;
+/** Broadband loss when fully blocked; the rest of the energy diffracts around. */
+const BLOCKED_GAIN = 0.22;
+
+/**
+ * Maps a fractional occlusion (0 = clear line of sight, 1 = fully blocked) to
+ * filter settings for the direct path.
+ *
+ * Interpolating the cutoff geometrically rather than linearly keeps the sweep
+ * perceptually even, since pitch perception is logarithmic. The reverberant
+ * path is deliberately left untouched: sound that has bounced off the walls
+ * still reaches the listener when the direct path is blocked, which is exactly
+ * why an occluded source sounds muffled and far away rather than silent.
+ */
+export function occlusionResponse(occlusion: number): OcclusionResponse {
+ const t = clamp(occlusion, 0, 1);
+ return {
+ cutoff: OPEN_CUTOFF * Math.pow(BLOCKED_CUTOFF / OPEN_CUTOFF, t),
+ gain: 1 + t * (BLOCKED_GAIN - 1),
+ };
+}
diff --git a/src/physics/vector.ts b/src/physics/vector.ts
new file mode 100644
index 0000000..6236bba
--- /dev/null
+++ b/src/physics/vector.ts
@@ -0,0 +1,56 @@
+export interface Vec3 {
+ x: number;
+ y: number;
+ z: number;
+}
+
+export function distance(a: Vec3, b: Vec3): number {
+ const dx = a.x - b.x;
+ const dy = a.y - b.y;
+ const dz = a.z - b.z;
+ return Math.sqrt(dx * dx + dy * dy + dz * dz);
+}
+
+export function dot(a: Vec3, b: Vec3): number {
+ return a.x * b.x + a.y * b.y + a.z * b.z;
+}
+
+export function length(v: Vec3): number {
+ return Math.sqrt(dot(v, v));
+}
+
+/** Returns a unit vector, or `fallback` when `v` is degenerate. */
+export function normalize(v: Vec3, fallback: Vec3 = { x: 0, y: 0, z: -1 }): Vec3 {
+ const len = length(v);
+ if (len < 1e-9) return { ...fallback };
+ return { x: v.x / len, y: v.y / len, z: v.z / len };
+}
+
+/** Unit vector pointing from `from` towards `to`, or null if they coincide. */
+export function direction(from: Vec3, to: Vec3): Vec3 | null {
+ const d = { x: to.x - from.x, y: to.y - from.y, z: to.z - from.z };
+ return length(d) < 1e-9 ? null : normalize(d);
+}
+
+export function clamp(value: number, min: number, max: number): number {
+ return value < min ? min : value > max ? max : value;
+}
+
+/** Linear amplitude ratio to decibels. Silence reports -Infinity's practical stand-in, -120 dB. */
+export function gainToDb(gain: number): number {
+ return gain <= 1e-6 ? -120 : 20 * Math.log10(gain);
+}
+
+/** Frequency ratio to cents (1200 cents = one octave). */
+export function ratioToCents(ratio: number): number {
+ return ratio <= 0 ? 0 : 1200 * Math.log2(ratio);
+}
+
+/**
+ * Frame-rate independent exponential smoothing coefficient.
+ * `tau` is the time in seconds for the signal to cover ~63% of the gap.
+ */
+export function smoothingAlpha(dt: number, tau: number): number {
+ if (tau <= 0) return 1;
+ return 1 - Math.exp(-dt / tau);
+}
diff --git a/src/scene/Annotations.ts b/src/scene/Annotations.ts
new file mode 100644
index 0000000..20df6db
--- /dev/null
+++ b/src/scene/Annotations.ts
@@ -0,0 +1,122 @@
+import * as THREE from 'three';
+import { PALETTE } from './palette';
+
+/**
+ * In-world explanations of the acoustics: the line the direct sound travels
+ * along, the sphere inside which direct sound beats the room, and a marker on
+ * the floor showing where the listener is standing.
+ */
+export class Annotations {
+ readonly group = new THREE.Group();
+
+ private readonly ray: THREE.Line;
+ private readonly rayMaterial: THREE.LineBasicMaterial;
+ private readonly criticalRing: THREE.Mesh;
+ private readonly listenerRing: THREE.Mesh;
+ private readonly listenerStem: THREE.Line;
+
+ constructor() {
+ this.group.name = 'Annotations';
+
+ this.rayMaterial = new THREE.LineBasicMaterial({
+ color: PALETTE.linkStrong,
+ transparent: true,
+ opacity: 0.8,
+ });
+ this.ray = new THREE.Line(
+ new THREE.BufferGeometry().setFromPoints([new THREE.Vector3(), new THREE.Vector3()]),
+ this.rayMaterial
+ );
+ this.ray.frustumCulled = false;
+ this.group.add(this.ray);
+
+ // Flat ring on the floor at the critical distance: step outside it and the
+ // reverberant field is louder than the direct sound.
+ // Deliberately hairline: this is an annotation the eye should be able to
+ // ignore, not a wall across the room.
+ this.criticalRing = new THREE.Mesh(
+ new THREE.RingGeometry(0.994, 1, 128),
+ new THREE.MeshBasicMaterial({
+ color: PALETTE.criticalField,
+ transparent: true,
+ opacity: 0.4,
+ side: THREE.DoubleSide,
+ depthWrite: false,
+ })
+ );
+ this.criticalRing.rotation.x = -Math.PI / 2;
+ this.group.add(this.criticalRing);
+
+ this.listenerRing = new THREE.Mesh(
+ new THREE.RingGeometry(0.34, 0.42, 48),
+ new THREE.MeshBasicMaterial({
+ color: PALETTE.listener,
+ transparent: true,
+ opacity: 0.85,
+ side: THREE.DoubleSide,
+ depthWrite: false,
+ })
+ );
+ this.listenerRing.rotation.x = -Math.PI / 2;
+ this.group.add(this.listenerRing);
+
+ this.listenerStem = new THREE.Line(
+ new THREE.BufferGeometry().setFromPoints([new THREE.Vector3(), new THREE.Vector3()]),
+ new THREE.LineBasicMaterial({ color: PALETTE.listener, transparent: true, opacity: 0.35 })
+ );
+ this.listenerStem.frustumCulled = false;
+ this.group.add(this.listenerStem);
+ }
+
+ update(options: {
+ sourcePos: THREE.Vector3;
+ listenerPos: THREE.Vector3;
+ criticalDistance: number;
+ occlusion: number;
+ reverbDominant: boolean;
+ }): void {
+ const { sourcePos, listenerPos, criticalDistance, occlusion, reverbDominant } = options;
+
+ setLine(this.ray, sourcePos, listenerPos);
+ // Blocked line of sight turns the ray amber and fades it, matching the
+ // occlusion row in the panel.
+ this.rayMaterial.color.setHex(occlusion > 0.05 ? 0xf0a22e : PALETTE.linkStrong);
+ this.rayMaterial.opacity = 0.85 - 0.5 * occlusion;
+
+ const radius = Math.max(0.2, criticalDistance);
+ this.criticalRing.position.set(sourcePos.x, 0.02, sourcePos.z);
+ this.criticalRing.scale.setScalar(radius);
+ (this.criticalRing.material as THREE.MeshBasicMaterial).color.setHex(
+ reverbDominant ? PALETTE.criticalField : 0x3fcf8e
+ );
+
+ this.listenerRing.position.set(listenerPos.x, 0.02, listenerPos.z);
+ setLine(
+ this.listenerStem,
+ new THREE.Vector3(listenerPos.x, 0.02, listenerPos.z),
+ listenerPos
+ );
+ }
+
+ setVisible(visible: boolean): void {
+ this.group.visible = visible;
+ }
+
+ dispose(): void {
+ this.group.traverse((object) => {
+ const mesh = object as THREE.Mesh | THREE.Line;
+ if (mesh.geometry) mesh.geometry.dispose();
+ const material = (mesh as THREE.Mesh).material;
+ if (Array.isArray(material)) material.forEach((m) => m.dispose());
+ else if (material) (material as THREE.Material).dispose();
+ });
+ }
+}
+
+function setLine(line: THREE.Line, from: THREE.Vector3, to: THREE.Vector3): void {
+ const positions = line.geometry.getAttribute('position') as THREE.BufferAttribute;
+ positions.setXYZ(0, from.x, from.y, from.z);
+ positions.setXYZ(1, to.x, to.y, to.z);
+ positions.needsUpdate = true;
+ line.geometry.computeBoundingSphere();
+}
diff --git a/src/scene/Room.ts b/src/scene/Room.ts
new file mode 100644
index 0000000..c828d51
--- /dev/null
+++ b/src/scene/Room.ts
@@ -0,0 +1,125 @@
+import * as THREE from 'three';
+import { PALETTE } from './palette';
+
+export interface RoomDims {
+ width: number;
+ height: number;
+ depth: number;
+}
+
+/**
+ * The bounding room: floor, walls seen from the inside, a grid for scale, and
+ * bright edges so the volume reads clearly from any camera angle.
+ */
+export class Room {
+ readonly group = new THREE.Group();
+
+ private dims: RoomDims;
+ private readonly shell: THREE.Mesh;
+ private readonly floor: THREE.Mesh;
+ private readonly edges: THREE.LineSegments;
+ private grid: THREE.GridHelper;
+ private readonly shellGeometry: THREE.BoxGeometry;
+
+ constructor(dims: RoomDims) {
+ this.dims = { ...dims };
+ this.group.name = 'Room';
+
+ this.shellGeometry = new THREE.BoxGeometry(1, 1, 1);
+ this.shell = new THREE.Mesh(
+ this.shellGeometry,
+ new THREE.MeshStandardMaterial({
+ color: PALETTE.wall,
+ side: THREE.BackSide,
+ roughness: 0.95,
+ metalness: 0,
+ })
+ );
+ this.shell.name = 'RoomShell';
+ this.shell.receiveShadow = true;
+ this.group.add(this.shell);
+
+ this.floor = new THREE.Mesh(
+ new THREE.PlaneGeometry(1, 1),
+ new THREE.MeshStandardMaterial({ color: PALETTE.floor, roughness: 0.9, metalness: 0.05 })
+ );
+ this.floor.name = 'Floor';
+ this.floor.rotation.x = -Math.PI / 2;
+ this.floor.receiveShadow = true;
+ this.group.add(this.floor);
+
+ this.edges = new THREE.LineSegments(
+ new THREE.EdgesGeometry(this.shellGeometry),
+ new THREE.LineBasicMaterial({ color: PALETTE.edge, transparent: true, opacity: 0.55 })
+ );
+ this.edges.name = 'RoomEdges';
+ this.group.add(this.edges);
+
+ this.grid = this.buildGrid();
+ this.group.add(this.grid);
+
+ this.applyDims();
+ }
+
+ getDims(): RoomDims {
+ return { ...this.dims };
+ }
+
+ setDims(dims: RoomDims): void {
+ if (dims.width === this.dims.width && dims.height === this.dims.height && dims.depth === this.dims.depth) {
+ return;
+ }
+ this.dims = { ...dims };
+ this.applyDims();
+
+ // Only the grid needs rebuilding — everything else is a scaled unit mesh.
+ this.group.remove(this.grid);
+ this.grid.geometry.dispose();
+ (this.grid.material as THREE.Material).dispose();
+ this.grid = this.buildGrid();
+ this.group.add(this.grid);
+ }
+
+ /** Keeps a point inside the room, leaving `margin` metres of clearance. */
+ clamp(position: THREE.Vector3, margin = 0.6): THREE.Vector3 {
+ const halfW = Math.max(margin, this.dims.width / 2 - margin);
+ const halfD = Math.max(margin, this.dims.depth / 2 - margin);
+ position.x = THREE.MathUtils.clamp(position.x, -halfW, halfW);
+ position.y = THREE.MathUtils.clamp(position.y, margin, Math.max(margin, this.dims.height - margin));
+ position.z = THREE.MathUtils.clamp(position.z, -halfD, halfD);
+ return position;
+ }
+
+ dispose(): void {
+ this.shellGeometry.dispose();
+ (this.shell.material as THREE.Material).dispose();
+ this.floor.geometry.dispose();
+ (this.floor.material as THREE.Material).dispose();
+ this.edges.geometry.dispose();
+ (this.edges.material as THREE.Material).dispose();
+ this.grid.geometry.dispose();
+ (this.grid.material as THREE.Material).dispose();
+ }
+
+ private applyDims(): void {
+ const { width, height, depth } = this.dims;
+ this.shell.scale.set(width, height, depth);
+ this.shell.position.set(0, height / 2, 0);
+ this.edges.scale.copy(this.shell.scale);
+ this.edges.position.copy(this.shell.position);
+ this.floor.scale.set(width, depth, 1);
+ }
+
+ private buildGrid(): THREE.GridHelper {
+ const span = Math.max(this.dims.width, this.dims.depth);
+ // One grid line per metre, capped so huge rooms do not turn into moiré.
+ const divisions = Math.min(60, Math.max(4, Math.round(span)));
+ const grid = new THREE.GridHelper(span, divisions, PALETTE.gridMajor, PALETTE.gridMinor);
+ grid.name = 'FloorGrid';
+ grid.position.y = 0.008;
+ const material = grid.material as THREE.Material;
+ material.transparent = true;
+ material.opacity = 0.5;
+ return grid;
+ }
+}
diff --git a/src/scene/SoundSource.ts b/src/scene/SoundSource.ts
new file mode 100644
index 0000000..4ee72b4
--- /dev/null
+++ b/src/scene/SoundSource.ts
@@ -0,0 +1,202 @@
+import * as THREE from 'three';
+import { PALETTE } from './palette';
+
+const LOBE_RADIUS = 1.8;
+
+/**
+ * The loudspeaker, plus a directivity lobe that shows where it is actually
+ * radiating. The lobe is a spherical sector rather than a cone so it stays
+ * well-defined all the way out to a 360° omnidirectional pattern.
+ */
+export class SoundSource extends THREE.Group {
+ private readonly body: THREE.Mesh;
+ private readonly cabinet: THREE.Mesh;
+ private readonly arrow: THREE.ArrowHelper;
+ private innerLobe: THREE.Mesh;
+ private outerRim: THREE.LineSegments;
+
+ private innerAngle = 70;
+ private outerAngle = 160;
+
+ constructor() {
+ super();
+ this.name = 'SoundSource';
+
+ this.cabinet = new THREE.Mesh(
+ new THREE.BoxGeometry(0.62, 0.86, 0.5),
+ new THREE.MeshStandardMaterial({ color: 0x2b2016, roughness: 0.75, metalness: 0.15 })
+ );
+ this.cabinet.castShadow = true;
+ this.add(this.cabinet);
+
+ // The driver, facing local −Z, which is the convention the physics uses.
+ this.body = new THREE.Mesh(
+ new THREE.CylinderGeometry(0.26, 0.3, 0.12, 32),
+ new THREE.MeshStandardMaterial({
+ color: PALETTE.source,
+ emissive: PALETTE.sourceEmissive,
+ emissiveIntensity: 0.5,
+ roughness: 0.35,
+ metalness: 0.6,
+ })
+ );
+ this.body.rotation.x = Math.PI / 2;
+ this.body.position.z = -0.27;
+ this.body.castShadow = true;
+ this.add(this.body);
+
+ this.arrow = new THREE.ArrowHelper(
+ new THREE.Vector3(0, 0, -1),
+ new THREE.Vector3(0, 0, -0.35),
+ 1.3,
+ PALETTE.source,
+ 0.32,
+ 0.18
+ );
+ this.add(this.arrow);
+
+ this.innerLobe = this.buildLobe();
+ this.add(this.innerLobe);
+ this.outerRim = this.buildRim();
+ this.add(this.outerRim);
+ }
+
+ getConeAngles(): { inner: number; outer: number } {
+ return { inner: this.innerAngle, outer: this.outerAngle };
+ }
+
+ setConeAngles(inner: number, outer: number): void {
+ const nextInner = THREE.MathUtils.clamp(inner, 0, 360);
+ const nextOuter = THREE.MathUtils.clamp(Math.max(outer, inner), 0, 360);
+ if (nextInner === this.innerAngle && nextOuter === this.outerAngle) return;
+ this.innerAngle = nextInner;
+ this.outerAngle = nextOuter;
+
+ this.remove(this.innerLobe);
+ disposeMesh(this.innerLobe);
+ this.innerLobe = this.buildLobe();
+ this.add(this.innerLobe);
+
+ this.remove(this.outerRim);
+ this.outerRim.geometry.dispose();
+ (this.outerRim.material as THREE.Material).dispose();
+ this.outerRim = this.buildRim();
+ this.add(this.outerRim);
+ }
+
+ /** Fades the lobe with the gain the listener is actually receiving. */
+ setReceivedGain(gain: number): void {
+ const material = this.innerLobe.material as THREE.MeshBasicMaterial;
+ material.opacity = 0.05 + 0.16 * THREE.MathUtils.clamp(gain, 0, 1);
+ }
+
+ getForward(): THREE.Vector3 {
+ return new THREE.Vector3(0, 0, -1).applyQuaternion(this.quaternion).normalize();
+ }
+
+ /**
+ * Turns the speaker to face `target`.
+ *
+ * Object3D.lookAt points local +Z at the target for non-camera objects, but
+ * the acoustic forward axis is −Z (where the driver and the arrow point), so
+ * aiming directly would face the speaker's back at the listener. Reflecting
+ * the target through the source position flips it the right way round.
+ */
+ aimAt(target: THREE.Vector3): void {
+ this.lookAt(this.position.clone().multiplyScalar(2).sub(target));
+ }
+
+ dispose(): void {
+ disposeMesh(this.body);
+ disposeMesh(this.cabinet);
+ disposeMesh(this.innerLobe);
+ this.outerRim.geometry.dispose();
+ (this.outerRim.material as THREE.Material).dispose();
+ this.arrow.dispose();
+ }
+
+ /** A spherical sector of half-angle innerAngle/2, opening along local −Z. */
+ private buildLobe(): THREE.Mesh {
+ const geometry = new THREE.SphereGeometry(
+ LOBE_RADIUS,
+ 40,
+ 20,
+ 0,
+ Math.PI * 2,
+ 0,
+ THREE.MathUtils.degToRad(Math.max(this.innerAngle, 1) / 2)
+ );
+ geometry.rotateX(-Math.PI / 2);
+ return new THREE.Mesh(
+ geometry,
+ new THREE.MeshBasicMaterial({
+ color: PALETTE.coneInner,
+ transparent: true,
+ opacity: 0.18,
+ side: THREE.DoubleSide,
+ depthWrite: false,
+ })
+ );
+ }
+
+ /**
+ * A single ring at the outer angle, marking where directivity bottoms out.
+ *
+ * A full wireframe sphere here reads as a ball of noise around the speaker;
+ * one rim plus four meridians states the same angle and leaves the scene
+ * readable.
+ */
+ private buildRim(): THREE.LineSegments {
+ const half = THREE.MathUtils.degToRad(Math.max(this.outerAngle, 1) / 2);
+ const radius = LOBE_RADIUS * 1.02;
+ const points: THREE.Vector3[] = [];
+
+ const ringRadius = Math.sin(half) * radius;
+ const ringZ = -Math.cos(half) * radius;
+ const segments = 64;
+ for (let i = 0; i < segments; i++) {
+ const a = (i / segments) * Math.PI * 2;
+ const b = ((i + 1) / segments) * Math.PI * 2;
+ points.push(
+ new THREE.Vector3(Math.cos(a) * ringRadius, Math.sin(a) * ringRadius, ringZ),
+ new THREE.Vector3(Math.cos(b) * ringRadius, Math.sin(b) * ringRadius, ringZ)
+ );
+ }
+
+ // Four meridians from the apex out to the rim, so the opening angle is
+ // legible even when the ring is edge-on to the camera.
+ for (let m = 0; m < 4; m++) {
+ const azimuth = (m / 4) * Math.PI * 2;
+ const steps = 10;
+ for (let i = 0; i < steps; i++) {
+ points.push(
+ meridianPoint(azimuth, (i / steps) * half, radius),
+ meridianPoint(azimuth, ((i + 1) / steps) * half, radius)
+ );
+ }
+ }
+
+ return new THREE.LineSegments(
+ new THREE.BufferGeometry().setFromPoints(points),
+ new THREE.LineBasicMaterial({
+ color: PALETTE.coneOuter,
+ transparent: true,
+ opacity: 0.5,
+ depthWrite: false,
+ })
+ );
+ }
+}
+
+/** Point on a sphere of `radius`, `polar` radians off the local −Z axis. */
+function meridianPoint(azimuth: number, polar: number, radius: number): THREE.Vector3 {
+ const ring = Math.sin(polar) * radius;
+ return new THREE.Vector3(Math.cos(azimuth) * ring, Math.sin(azimuth) * ring, -Math.cos(polar) * radius);
+}
+
+function disposeMesh(mesh: THREE.Mesh): void {
+ mesh.geometry.dispose();
+ const material = mesh.material;
+ if (Array.isArray(material)) material.forEach((m) => m.dispose());
+ else material.dispose();
+}
diff --git a/src/scene/Stage.ts b/src/scene/Stage.ts
new file mode 100644
index 0000000..a3d2f89
--- /dev/null
+++ b/src/scene/Stage.ts
@@ -0,0 +1,516 @@
+import * as THREE from 'three';
+import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
+import { TransformControls } from 'three/addons/controls/TransformControls.js';
+import { Annotations } from './Annotations';
+import { Room, RoomDims } from './Room';
+import { SoundSource } from './SoundSource';
+import { PALETTE } from './palette';
+import { MotionMode, motionPosition } from './motion';
+
+export type GizmoMode = 'translate' | 'rotate';
+
+export interface StagePose {
+ sourcePos: THREE.Vector3;
+ sourceForward: THREE.Vector3;
+ listenerPos: THREE.Vector3;
+ listenerForward: THREE.Vector3;
+ listenerUp: THREE.Vector3;
+ /** 0 = clear line of sight, 1 = fully blocked. */
+ occlusion: number;
+}
+
+/**
+ * Probe offsets across the head, as [right, up] in metres. Applied in the frame
+ * perpendicular to the source→listener ray, not in world axes.
+ */
+const PROBE_OFFSETS: Array<[number, number]> = [
+ [0, 0],
+ [0.32, 0],
+ [-0.32, 0],
+ [0, 0.3],
+ [0, -0.3],
+];
+
+const WORLD_UP = new THREE.Vector3(0, 1, 0);
+
+const WALK_SPEED = 4.2;
+
+/**
+ * Owns the WebGL view. The camera *is* the microphone: its world position and
+ * orientation are what get published to the AudioContext listener, so orbiting
+ * the view genuinely swings the sound around the user's head.
+ */
+export class Stage {
+ readonly scene = new THREE.Scene();
+ readonly camera: THREE.PerspectiveCamera;
+ readonly renderer: THREE.WebGLRenderer;
+ readonly controls: OrbitControls;
+ readonly source = new SoundSource();
+ readonly room: Room;
+ readonly obstacle: THREE.Mesh;
+
+ private readonly annotations = new Annotations();
+ private readonly gizmo: TransformControls;
+ private readonly raycaster = new THREE.Raycaster();
+ private readonly resizeObserver: ResizeObserver;
+ private readonly keys = new Set();
+ private readonly container: HTMLElement;
+
+ private motionMode: MotionMode = 'static';
+ private motionSpeed = 8;
+ private motionPhase = 0;
+ private dragging = false;
+
+ private readonly scratch = new THREE.Vector3();
+ private readonly probeTarget = new THREE.Vector3();
+ private readonly probeRight = new THREE.Vector3();
+ private readonly probeUp = new THREE.Vector3();
+ private readonly scratchAxis = new THREE.Vector3();
+ private readonly scratchDir = new THREE.Vector3();
+ private readonly obstacleBounds = new THREE.Box3();
+ private disposers: Array<() => void> = [];
+
+ constructor(container: HTMLElement, dims: RoomDims) {
+ this.container = container;
+ this.scene.background = new THREE.Color(PALETTE.background);
+ this.scene.fog = new THREE.Fog(PALETTE.fog, 30, 130);
+
+ const width = Math.max(1, container.clientWidth);
+ const height = Math.max(1, container.clientHeight);
+
+ this.camera = new THREE.PerspectiveCamera(58, width / height, 0.1, 400);
+ this.camera.position.set(6.5, 2.4, 8.5);
+
+ this.renderer = new THREE.WebGLRenderer({ antialias: true, powerPreference: 'high-performance' });
+ this.renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
+ // Size the renderer up front. Skipping this leaves the canvas at its
+ // 300x150 default until the first window resize event that may never come.
+ this.renderer.setSize(width, height, false);
+ this.renderer.outputColorSpace = THREE.SRGBColorSpace;
+ this.renderer.toneMapping = THREE.ACESFilmicToneMapping;
+ this.renderer.toneMappingExposure = 1.05;
+ this.renderer.shadowMap.enabled = true;
+ this.renderer.shadowMap.type = THREE.PCFSoftShadowMap;
+ this.renderer.domElement.classList.add('viewport-canvas');
+ this.renderer.domElement.tabIndex = 0;
+ container.appendChild(this.renderer.domElement);
+
+ this.controls = new OrbitControls(this.camera, this.renderer.domElement);
+ this.controls.enableDamping = true;
+ this.controls.dampingFactor = 0.07;
+ // Panning would move the orbit target without moving the camera, which is
+ // the ear. Disabling it keeps "the camera is the microphone" an invariant.
+ this.controls.enablePan = false;
+ this.controls.minDistance = 0.8;
+ this.controls.maxDistance = 46;
+ this.controls.maxPolarAngle = Math.PI / 2 - 0.04;
+ this.controls.target.set(0, 1.5, 0);
+ this.controls.update();
+
+ this.room = new Room(dims);
+ this.scene.add(this.room.group);
+
+ this.source.position.set(-2.8, 1.6, -1.6);
+ this.scene.add(this.source);
+
+ this.obstacle = new THREE.Mesh(
+ new THREE.BoxGeometry(2.6, 3.2, 0.4),
+ new THREE.MeshStandardMaterial({ color: PALETTE.obstacle, roughness: 0.8, metalness: 0.1 })
+ );
+ this.obstacle.name = 'Obstacle';
+ // Well away from both the source and the arc the camera starts on, so the
+ // default scene has clear line of sight. Occlusion is something the user
+ // should discover by walking behind it, not the first thing the app reports.
+ this.obstacle.position.set(0.8, 1.6, 3.2);
+ this.obstacle.castShadow = true;
+ this.obstacle.receiveShadow = true;
+ this.scene.add(this.obstacle);
+
+ this.scene.add(this.annotations.group);
+ this.addLights();
+
+ this.gizmo = new TransformControls(this.camera, this.renderer.domElement);
+ this.gizmo.setSize(0.62);
+ this.gizmo.attach(this.source);
+ this.scene.add(this.gizmo);
+
+ this.bindEvents();
+
+ this.resizeObserver = new ResizeObserver(() => this.resize());
+ this.resizeObserver.observe(container);
+ }
+
+ setRoomDims(dims: RoomDims): void {
+ this.room.setDims(dims);
+ this.room.clamp(this.source.position);
+ this.room.clamp(this.controls.target);
+ this.room.clamp(this.obstacle.position, 0.3);
+ this.clampCamera();
+ }
+
+ setConeAngles(inner: number, outer: number): void {
+ this.source.setConeAngles(inner, outer);
+ }
+
+ setMotion(mode: MotionMode, speed: number): void {
+ this.motionMode = mode;
+ this.motionSpeed = speed;
+
+ const interactive = mode === 'static';
+ // Disabling the gizmo mid-drag means its 'dragging-changed' event never
+ // fires, so OrbitControls would stay disabled forever and the source would
+ // stay stuck to the pointer. Unwind the drag by hand first.
+ if (!interactive && this.dragging) {
+ this.dragging = false;
+ this.controls.enabled = true;
+ }
+ this.gizmo.enabled = interactive;
+ this.gizmo.visible = interactive;
+ }
+
+ setGizmoMode(mode: GizmoMode): void {
+ this.gizmo.setMode(mode);
+ }
+
+ getGizmoMode(): GizmoMode {
+ return this.gizmo.getMode() as GizmoMode;
+ }
+
+ setAnnotationsVisible(visible: boolean): void {
+ this.annotations.setVisible(visible);
+ }
+
+ setSourcePosition(position: THREE.Vector3): void {
+ this.source.position.copy(position);
+ this.room.clamp(this.source.position);
+ }
+
+ setSourceYaw(degrees: number): void {
+ this.source.rotation.set(0, THREE.MathUtils.degToRad(degrees), 0);
+ }
+
+ /**
+ * Yaw derived from the forward vector rather than from `rotation.y`.
+ *
+ * `aimAt` and the rotate gizmo both write a quaternion, and reading back the
+ * default XYZ Euler folds any aim beyond ±90° back into range — so the slider
+ * would show the wrong angle and, worse, writing that folded value back would
+ * spin the speaker the moment anyone touched it.
+ */
+ getSourceYaw(): number {
+ const forward = this.source.getForward();
+ return THREE.MathUtils.radToDeg(Math.atan2(-forward.x, -forward.z));
+ }
+
+ /**
+ * Frames the scene from a three-quarter angle, close enough that the source
+ * is comfortably loud. The standoff is capped rather than scaled with the
+ * room, so enlarging the room does not park the listener against a wall.
+ */
+ resetView(): void {
+ const { width, depth } = this.room.getDims();
+ // Orbit about the room centre, standing roughly 80° around from the
+ // source. The orbit axis has to be offset from the source: OrbitControls
+ // always points the camera at its target, so orbiting about the source
+ // itself would pin it dead ahead and the binaural image would never move.
+ const radius = THREE.MathUtils.clamp(Math.min(width, depth) * 0.24, 4, 8);
+ const azimuth = Math.atan2(this.source.position.z, this.source.position.x) + Math.PI * 0.45;
+ this.controls.target.set(0, 1.5, 0);
+ this.camera.position.set(Math.cos(azimuth) * radius, 2.4, Math.sin(azimuth) * radius);
+ this.clampCamera();
+ this.controls.update();
+ this.source.aimAt(this.camera.position);
+ }
+
+ /** Advances camera, source motion and annotations by one frame. */
+ update(dt: number, criticalDistance: number, reverbDominant: boolean, receivedGain: number): StagePose {
+ this.walk(dt);
+
+ if (this.motionMode !== 'static') {
+ // Integrate distance travelled rather than sampling f(time × speed), so
+ // moving the speed slider changes the pace without teleporting the source.
+ this.motionPhase += this.motionSpeed * dt;
+ const next = motionPosition(
+ this.motionMode,
+ this.motionPhase,
+ this.room.getDims(),
+ new THREE.Vector3(0, 0, 0),
+ this.scratch
+ );
+ if (next) {
+ this.source.position.copy(next);
+ // Keep the speaker aimed at the listener while it moves, so a fly-by
+ // demonstrates Doppler rather than directivity dropout.
+ this.source.aimAt(this.camera.position);
+ }
+ }
+
+ this.controls.update();
+ this.clampCamera();
+ this.scene.updateMatrixWorld(true);
+
+ const listenerPos = this.camera.getWorldPosition(new THREE.Vector3());
+ const sourcePos = this.source.getWorldPosition(new THREE.Vector3());
+ const occlusion = this.sampleOcclusion(sourcePos, listenerPos);
+
+ this.source.setReceivedGain(receivedGain);
+ this.annotations.update({
+ sourcePos,
+ listenerPos,
+ criticalDistance,
+ occlusion,
+ reverbDominant,
+ });
+
+ return {
+ sourcePos,
+ sourceForward: this.source.getForward(),
+ listenerPos,
+ listenerForward: this.camera.getWorldDirection(new THREE.Vector3()),
+ listenerUp: this.camera.up.clone().applyQuaternion(this.camera.quaternion).normalize(),
+ occlusion,
+ };
+ }
+
+ render(): void {
+ this.renderer.render(this.scene, this.camera);
+ }
+
+ dispose(): void {
+ this.resizeObserver.disconnect();
+ this.disposers.forEach((off) => off());
+ this.disposers = [];
+ this.gizmo.detach();
+ this.gizmo.dispose();
+ this.scene.remove(this.gizmo);
+ this.annotations.dispose();
+ this.room.dispose();
+ this.source.dispose();
+ this.obstacle.geometry.dispose();
+ (this.obstacle.material as THREE.Material).dispose();
+ this.renderer.dispose();
+ this.renderer.domElement.remove();
+ }
+
+ private addLights(): void {
+ // Enough ambient fill that the walls read as surfaces rather than as void;
+ // the room has to be legible before any of the overlays mean anything.
+ this.scene.add(new THREE.AmbientLight(0xffffff, 0.5));
+ this.scene.add(new THREE.HemisphereLight(0xa8c0ff, 0x2a2118, 1.1));
+
+ const key = new THREE.DirectionalLight(0xfff2e0, 1.9);
+ key.position.set(7, 14, 6);
+ key.castShadow = true;
+ key.shadow.mapSize.set(2048, 2048);
+ key.shadow.camera.near = 1;
+ key.shadow.camera.far = 60;
+ key.shadow.camera.left = -22;
+ key.shadow.camera.right = 22;
+ key.shadow.camera.top = 22;
+ key.shadow.camera.bottom = -22;
+ key.shadow.bias = -0.0006;
+ this.scene.add(key);
+
+ const fill = new THREE.DirectionalLight(0x6c8cff, 0.35);
+ fill.position.set(-8, 6, -7);
+ this.scene.add(fill);
+ }
+
+ /**
+ * Fraction of the head that the obstacle hides, sampled with a small bundle
+ * of rays. A single centre ray would snap between 0 and 1 as the listener
+ * crosses the shadow edge; spreading the probes makes the filter sweep in
+ * smoothly, the way walking behind a pillar actually sounds.
+ */
+ private sampleOcclusion(sourcePos: THREE.Vector3, listenerPos: THREE.Vector3): number {
+ // A source buried in the obstacle casts no rays that hit it from the
+ // inside, and would otherwise be reported as a clear line of sight.
+ this.obstacleBounds.setFromObject(this.obstacle);
+ if (this.obstacleBounds.containsPoint(sourcePos)) return 1;
+
+ const axis = this.scratchAxis.subVectors(listenerPos, sourcePos);
+ const span = axis.length();
+ if (span < 0.05) return 0;
+ axis.divideScalar(span);
+
+ // Spread the probes across the plane perpendicular to the ray. Fixed
+ // world-axis offsets collapse onto the ray whenever the source happens to
+ // lie along that axis, and occlusion snaps between 0 and 1 as you cross the
+ // shadow edge instead of sweeping in.
+ this.probeRight.crossVectors(axis, WORLD_UP);
+ if (this.probeRight.lengthSq() < 1e-6) this.probeRight.set(1, 0, 0);
+ this.probeRight.normalize();
+ this.probeUp.crossVectors(this.probeRight, axis).normalize();
+
+ let blocked = 0;
+ for (const [right, up] of PROBE_OFFSETS) {
+ this.probeTarget
+ .copy(listenerPos)
+ .addScaledVector(this.probeRight, right)
+ .addScaledVector(this.probeUp, up);
+ const direction = this.scratchDir.subVectors(this.probeTarget, sourcePos);
+ const reach = direction.length();
+ if (reach < 0.05) continue;
+ direction.divideScalar(reach);
+ this.raycaster.set(sourcePos, direction);
+ this.raycaster.far = reach - 0.02;
+ if (this.raycaster.intersectObject(this.obstacle, false).length > 0) blocked++;
+ }
+ return blocked / PROBE_OFFSETS.length;
+ }
+
+ private walk(dt: number): void {
+ if (this.keys.size === 0) return;
+ const move = new THREE.Vector3();
+ if (this.keys.has('KeyW')) move.z -= 1;
+ if (this.keys.has('KeyS')) move.z += 1;
+ if (this.keys.has('KeyA')) move.x -= 1;
+ if (this.keys.has('KeyD')) move.x += 1;
+ if (this.keys.has('KeyE')) move.y += 1;
+ if (this.keys.has('KeyQ')) move.y -= 1;
+
+ const sourceMove = new THREE.Vector3();
+ if (this.keys.has('ArrowUp')) sourceMove.z -= 1;
+ if (this.keys.has('ArrowDown')) sourceMove.z += 1;
+ if (this.keys.has('ArrowLeft')) sourceMove.x -= 1;
+ if (this.keys.has('ArrowRight')) sourceMove.x += 1;
+ if (this.keys.has('PageUp')) sourceMove.y += 1;
+ if (this.keys.has('PageDown')) sourceMove.y -= 1;
+
+ const multiplier = this.keys.has('ShiftLeft') || this.keys.has('ShiftRight') ? 3 : 1;
+ const step = WALK_SPEED * dt * multiplier;
+
+ // Both the listener and the source move in the camera's yaw frame, so
+ // "forward" means the same thing whichever one you are steering.
+ const yaw = new THREE.Euler(0, 0, 0, 'YXZ');
+ yaw.setFromQuaternion(this.camera.quaternion);
+ yaw.x = 0;
+ yaw.z = 0;
+
+ if (move.lengthSq() > 0) {
+ move.normalize().applyEuler(yaw).multiplyScalar(step);
+ this.camera.position.add(move);
+ this.controls.target.add(move);
+ this.clampCamera();
+ }
+
+ if (sourceMove.lengthSq() > 0 && this.motionMode === 'static') {
+ sourceMove.normalize().applyEuler(yaw).multiplyScalar(step);
+ this.source.position.add(sourceMove);
+ this.room.clamp(this.source.position);
+ }
+ }
+
+ private clampCamera(): void {
+ const before = this.camera.position.clone();
+ this.room.clamp(this.camera.position, 0.5);
+ const correction = this.camera.position.clone().sub(before);
+ // Move the orbit target with the camera so the view does not swing wildly
+ // when the listener is pushed off a wall.
+ if (correction.lengthSq() > 1e-8) this.controls.target.add(correction);
+ this.room.clamp(this.controls.target, 0.4);
+ }
+
+ private bindEvents(): void {
+ const canvas = this.renderer.domElement;
+
+ const onKeyDown = (event: KeyboardEvent) => {
+ if (!isSceneKeyTarget(event.target)) return;
+ if (event.code === 'KeyG') this.gizmo.setMode('translate');
+ if (event.code === 'KeyR') this.gizmo.setMode('rotate');
+ if (NAV_KEYS.has(event.code)) {
+ event.preventDefault();
+ this.keys.add(event.code);
+ }
+ };
+ const onKeyUp = (event: KeyboardEvent) => this.keys.delete(event.code);
+ // Holding a key while the tab loses focus would otherwise send the listener
+ // drifting forever, since the keyup never arrives.
+ const onBlur = () => this.keys.clear();
+
+ const onDoubleClick = (event: MouseEvent) => {
+ if (this.motionMode !== 'static') return;
+ const rect = canvas.getBoundingClientRect();
+ const ndc = new THREE.Vector2(
+ ((event.clientX - rect.left) / rect.width) * 2 - 1,
+ -((event.clientY - rect.top) / rect.height) * 2 + 1
+ );
+ this.raycaster.setFromCamera(ndc, this.camera);
+ this.raycaster.far = Infinity;
+ const hit = new THREE.Vector3();
+ if (this.raycaster.ray.intersectPlane(GROUND_PLANE, hit)) {
+ hit.y = this.source.position.y;
+ this.setSourcePosition(hit);
+ }
+ };
+
+ const onDraggingChanged = (event: { value: boolean }) => {
+ this.dragging = event.value;
+ this.controls.enabled = !event.value;
+ };
+ const onGizmoChange = () => {
+ if (this.dragging) this.room.clamp(this.source.position);
+ };
+
+ window.addEventListener('keydown', onKeyDown);
+ window.addEventListener('keyup', onKeyUp);
+ window.addEventListener('blur', onBlur);
+ document.addEventListener('visibilitychange', onBlur);
+ canvas.addEventListener('dblclick', onDoubleClick);
+ this.gizmo.addEventListener('dragging-changed', onDraggingChanged as never);
+ this.gizmo.addEventListener('change', onGizmoChange);
+
+ this.disposers.push(
+ () => window.removeEventListener('keydown', onKeyDown),
+ () => window.removeEventListener('keyup', onKeyUp),
+ () => window.removeEventListener('blur', onBlur),
+ () => document.removeEventListener('visibilitychange', onBlur),
+ () => canvas.removeEventListener('dblclick', onDoubleClick),
+ () => this.gizmo.removeEventListener('dragging-changed', onDraggingChanged as never),
+ () => this.gizmo.removeEventListener('change', onGizmoChange)
+ );
+ }
+
+ private resize(): void {
+ const width = Math.max(1, this.container.clientWidth);
+ const height = Math.max(1, this.container.clientHeight);
+ this.camera.aspect = width / height;
+ this.camera.updateProjectionMatrix();
+ this.renderer.setSize(width, height, false);
+ }
+}
+
+const GROUND_PLANE = new THREE.Plane(new THREE.Vector3(0, 1, 0), 0);
+
+const NAV_KEYS = new Set([
+ 'KeyW',
+ 'KeyA',
+ 'KeyS',
+ 'KeyD',
+ 'KeyQ',
+ 'KeyE',
+ 'ArrowUp',
+ 'ArrowDown',
+ 'ArrowLeft',
+ 'ArrowRight',
+ 'PageUp',
+ 'PageDown',
+ 'ShiftLeft',
+ 'ShiftRight',
+]);
+
+/**
+ * Whether a keystroke belongs to the scene rather than to a control.
+ *
+ * Claiming the arrow keys globally makes radiogroups, selects and dialogs
+ * unusable by keyboard, and lets WASD walk the listener around behind an open
+ * modal. Scene keys are only accepted when focus is on nothing in particular —
+ * the body or the canvas.
+ */
+function isSceneKeyTarget(target: EventTarget | null): boolean {
+ if (document.querySelector('dialog[open]')) return false;
+ const element = target as HTMLElement | null;
+ if (!element || element === document.body) return true;
+ if (element.isContentEditable) return false;
+ return !element.closest('input, select, textarea, button, a, summary, dialog, [role="radio"]');
+}
diff --git a/src/scene/index.ts b/src/scene/index.ts
new file mode 100644
index 0000000..9594525
--- /dev/null
+++ b/src/scene/index.ts
@@ -0,0 +1,6 @@
+export * from './Stage';
+export * from './Room';
+export * from './SoundSource';
+export * from './Annotations';
+export * from './motion';
+export * from './palette';
diff --git a/src/scene/motion.ts b/src/scene/motion.ts
new file mode 100644
index 0000000..91bbd4d
--- /dev/null
+++ b/src/scene/motion.ts
@@ -0,0 +1,75 @@
+import * as THREE from 'three';
+
+export type MotionMode = 'static' | 'orbit' | 'flyby' | 'pendulum';
+
+export interface MotionOption {
+ id: MotionMode;
+ label: string;
+ hint: string;
+}
+
+export const MOTION_MODES: MotionOption[] = [
+ { id: 'static', label: 'Parked', hint: 'You place the source yourself' },
+ { id: 'orbit', label: 'Orbit', hint: 'Circles you — the clearest way to hear the binaural image move' },
+ { id: 'flyby', label: 'Fly-by', hint: 'Sweeps past and back: the classic Doppler swoop, in both directions' },
+ { id: 'pendulum', label: 'Pendulum', hint: 'Swings side to side through the room' },
+];
+
+/**
+ * Position of an automatically-moving source after travelling `phase` metres
+ * along its path. Returns null for 'static' — "leave it where the user put it".
+ *
+ * The parameter is distance travelled, not elapsed time, and the caller
+ * integrates it as `phase += speed * dt`. Driving the path from `time * speed`
+ * instead would teleport the source the instant anyone touched the speed
+ * slider, because every point on the path would be re-evaluated at a new
+ * parameter. Integrating keeps position continuous under any speed change.
+ */
+export function motionPosition(
+ mode: MotionMode,
+ phase: number,
+ room: { width: number; height: number; depth: number },
+ centre: THREE.Vector3,
+ out = new THREE.Vector3()
+): THREE.Vector3 | null {
+ const halfW = Math.max(1.5, room.width / 2 - 1.5);
+ const halfD = Math.max(1.5, room.depth / 2 - 1.5);
+ const height = THREE.MathUtils.clamp(1.6, 0.8, room.height - 0.8);
+
+ switch (mode) {
+ case 'orbit': {
+ const radius = Math.min(halfW, halfD) * 0.8;
+ const angle = phase / Math.max(radius, 0.5);
+ return out.set(
+ centre.x + Math.cos(angle) * radius,
+ height,
+ centre.z + Math.sin(angle) * radius
+ );
+ }
+ case 'flyby': {
+ // Ping-pong along X. A sawtooth would snap the source back across the
+ // room at the end of every pass, which reads as a violent pitch dive and
+ // an impossible closing speed.
+ return out.set(
+ -halfW + pingPong(phase, halfW * 2),
+ height,
+ centre.z - Math.min(halfD, 3)
+ );
+ }
+ case 'pendulum': {
+ const span = halfD * 0.85;
+ // Sinusoidal swing, so the turnaround has no velocity discontinuity.
+ return out.set(centre.x, height, Math.sin(phase / Math.max(span, 0.5)) * span);
+ }
+ case 'static':
+ default:
+ return null;
+ }
+}
+
+/** Triangle wave: 0 → span → 0, continuous in position at every turn. */
+function pingPong(value: number, span: number): number {
+ if (span <= 0) return 0;
+ const wrapped = ((value % (span * 2)) + span * 2) % (span * 2);
+ return wrapped <= span ? wrapped : span * 2 - wrapped;
+}
diff --git a/src/scene/palette.ts b/src/scene/palette.ts
new file mode 100644
index 0000000..c18dda1
--- /dev/null
+++ b/src/scene/palette.ts
@@ -0,0 +1,23 @@
+/** Scene colours. Kept in sync with the CSS custom properties in app.css. */
+export const PALETTE = {
+ background: 0x0e0f12,
+ fog: 0x0e0f12,
+
+ floor: 0x232a36,
+ gridMajor: 0x4a5a70,
+ gridMinor: 0x2f3948,
+ wall: 0x1c222c,
+ edge: 0x59677e,
+
+ source: 0xffb545,
+ sourceEmissive: 0xd97706,
+ coneInner: 0xffb545,
+ coneOuter: 0x7c6a4a,
+
+ listener: 0x4fd1c5,
+ obstacle: 0x4a5568,
+
+ linkStrong: 0x4fd1c5,
+ linkWeak: 0x6b7280,
+ criticalField: 0x8b5cf6,
+} as const;
diff --git a/src/styles/app.css b/src/styles/app.css
new file mode 100644
index 0000000..19cc5eb
--- /dev/null
+++ b/src/styles/app.css
@@ -0,0 +1,971 @@
+@import './tokens.css';
+
+*,
+*::before,
+*::after {
+ box-sizing: border-box;
+}
+
+html,
+body {
+ height: 100%;
+ margin: 0;
+ background: var(--bg);
+ color: var(--ink-1);
+ font-family: var(--sans);
+ font-size: 14px;
+ font-variant-numeric: tabular-nums;
+ -webkit-font-smoothing: antialiased;
+ overflow: hidden;
+}
+
+button,
+input,
+select {
+ font: inherit;
+ color: inherit;
+}
+
+:focus-visible {
+ outline: 2px solid var(--accent-hi);
+ outline-offset: 2px;
+ border-radius: inherit;
+}
+
+.visually-hidden {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ margin: -1px;
+ padding: 0;
+ overflow: hidden;
+ clip-path: inset(50%);
+ white-space: nowrap;
+}
+
+/* ── Shell ────────────────────────────────────────────────────────────── */
+
+#app {
+ display: grid;
+ height: 100dvh;
+ grid-template-columns: 1fr var(--panel-w);
+ grid-template-rows: var(--bar-h) 1fr var(--strip-h);
+ grid-template-areas:
+ 'bar bar'
+ 'view panel'
+ 'strip strip';
+}
+
+body.panels-hidden #app {
+ grid-template-columns: 1fr 0;
+ grid-template-rows: var(--bar-h) 1fr 0;
+}
+
+body.panels-hidden #panel,
+body.panels-hidden #strip {
+ display: none;
+}
+
+/* ── Top bar ──────────────────────────────────────────────────────────── */
+
+#bar {
+ grid-area: bar;
+ display: flex;
+ align-items: center;
+ gap: 16px;
+ padding: 0 16px;
+ background: var(--surface-1);
+ border-bottom: 1px solid var(--line);
+}
+
+.wordmark {
+ display: flex;
+ align-items: baseline;
+ gap: 8px;
+ margin: 0;
+ font-size: 14px;
+ font-weight: 600;
+ letter-spacing: 0.005em;
+}
+
+.wordmark span {
+ font-size: 12px;
+ font-weight: 400;
+ color: var(--ink-3);
+}
+
+.bar-spacer {
+ flex: 1;
+}
+
+.status-chip {
+ display: inline-flex;
+ align-items: center;
+ gap: 7px;
+ padding: 5px 10px;
+ border: 1px solid var(--line);
+ border-radius: 999px;
+ background: var(--surface-2);
+ font-size: 12px;
+ color: var(--ink-2);
+ white-space: nowrap;
+}
+
+.status-chip .dot {
+ width: 7px;
+ height: 7px;
+ border-radius: 50%;
+ background: var(--ink-3);
+}
+
+.status-chip[data-state='running'] .dot {
+ background: var(--ok);
+}
+
+.status-chip[data-state='suspended'] .dot {
+ background: var(--warn);
+}
+
+.status-chip[data-state='error'] .dot {
+ background: var(--alert);
+}
+
+/* ── Buttons ──────────────────────────────────────────────────────────── */
+
+.btn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 7px;
+ min-height: 32px;
+ padding: 0 12px;
+ border: 1px solid var(--line-strong);
+ border-radius: var(--r-input);
+ background: var(--surface-2);
+ color: var(--ink-1);
+ font-size: 13px;
+ cursor: pointer;
+ transition: background 120ms var(--ease), border-color 120ms var(--ease);
+}
+
+.btn:hover {
+ background: var(--surface-3);
+}
+
+.btn:active {
+ transform: translateY(0.5px);
+}
+
+.btn-primary {
+ min-height: 36px;
+ padding: 0 16px;
+ border-color: transparent;
+ background: var(--accent);
+ color: var(--on-accent);
+ font-weight: 600;
+}
+
+.btn-primary:hover {
+ background: var(--accent-hi);
+}
+
+.btn-primary:active {
+ background: var(--accent-press);
+}
+
+.btn-icon {
+ width: 36px;
+ min-height: 36px;
+ padding: 0;
+}
+
+.btn-lg {
+ min-height: 44px;
+ padding: 0 20px;
+ font-size: 15px;
+}
+
+@media (pointer: coarse) {
+ .btn,
+ .btn-icon {
+ min-height: 44px;
+ }
+}
+
+/* ── Viewport ─────────────────────────────────────────────────────────── */
+
+#viewport {
+ grid-area: view;
+ position: relative;
+ min-width: 0;
+ min-height: 0;
+ overflow: hidden;
+ background: var(--bg);
+}
+
+.viewport-canvas {
+ display: block;
+ width: 100%;
+ height: 100%;
+}
+
+.viewport-canvas:focus-visible {
+ outline: 2px solid var(--accent-hi);
+ outline-offset: -2px;
+}
+
+.hud {
+ position: absolute;
+ top: 16px;
+ left: 16px;
+ z-index: 2;
+ min-width: 208px;
+ padding: 12px 14px;
+ border: 1px solid var(--line);
+ border-radius: var(--r-card);
+ background: var(--surface-2);
+ box-shadow: 0 1px 2px rgb(0 0 0 / 0.4);
+ pointer-events: none;
+}
+
+.hud-row {
+ display: flex;
+ align-items: baseline;
+ justify-content: space-between;
+ gap: 16px;
+}
+
+.hud-value {
+ font-family: var(--mono);
+ font-size: 24px;
+ line-height: 1.1;
+ font-weight: 450;
+}
+
+.hud-label {
+ margin-top: 3px;
+ font-size: 11.5px;
+ color: var(--ink-3);
+}
+
+.hud-flag {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ margin-top: 9px;
+ padding-top: 9px;
+ border-top: 1px solid var(--line);
+ font-size: 12px;
+ color: var(--ok);
+}
+
+.hud-flag[data-dominant='room'] {
+ color: var(--warn);
+}
+
+.hud-flag[data-blocked='true'] {
+ color: var(--alert);
+}
+
+.viewport-tools {
+ position: absolute;
+ top: 16px;
+ right: 16px;
+ display: flex;
+ gap: 8px;
+}
+
+/* Drop target for audio files */
+
+#viewport.drop-active::after {
+ content: 'Drop to load this track';
+ position: absolute;
+ inset: 12px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border: 2px dashed var(--accent-hi);
+ border-radius: var(--r-card);
+ background: rgb(108 140 255 / 0.12);
+ color: var(--ink-1);
+ font-size: 17px;
+ font-weight: 600;
+ pointer-events: none;
+ z-index: 3;
+}
+
+.file-btn {
+ width: 100%;
+ justify-content: flex-start;
+}
+
+.field-hint[data-tone='error'] {
+ color: var(--alert);
+}
+
+.field-hint[data-tone='busy'] {
+ color: var(--accent-hi);
+}
+
+.legend {
+ position: absolute;
+ left: 16px;
+ bottom: 14px;
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px 14px;
+ max-width: calc(100% - 32px);
+ font-size: 11.5px;
+ color: var(--ink-3);
+ pointer-events: none;
+}
+
+.legend kbd {
+ padding: 1px 5px;
+ border: 1px solid var(--line-strong);
+ border-radius: var(--r-tick);
+ background: var(--surface-2);
+ font-family: var(--mono);
+ font-size: 10.5px;
+ color: var(--ink-2);
+}
+
+/* ── Side panel ───────────────────────────────────────────────────────── */
+
+#panel {
+ grid-area: panel;
+ overflow-y: auto;
+ padding: 20px;
+ background: var(--surface-1);
+ border-left: 1px solid var(--line);
+ scrollbar-width: thin;
+}
+
+.section {
+ margin-bottom: 20px;
+ border: 1px solid var(--line);
+ border-radius: var(--r-card);
+ background: var(--surface-2);
+}
+
+.section > summary {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 12px 16px;
+ font-size: 13px;
+ font-weight: 600;
+ cursor: pointer;
+ list-style: none;
+ border-radius: var(--r-card);
+}
+
+.section > summary::-webkit-details-marker {
+ display: none;
+}
+
+.section > summary::before {
+ content: '';
+ width: 0;
+ height: 0;
+ border-left: 5px solid var(--ink-3);
+ border-top: 4px solid transparent;
+ border-bottom: 4px solid transparent;
+ transition: transform 240ms var(--ease);
+}
+
+.section[open] > summary::before {
+ transform: rotate(90deg);
+}
+
+.section-body {
+ padding: 4px 16px 16px;
+}
+
+/* ── Controls ─────────────────────────────────────────────────────────── */
+
+.field {
+ margin-bottom: 14px;
+}
+
+.field:last-child {
+ margin-bottom: 0;
+}
+
+.field-head {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 10px;
+ margin-bottom: 6px;
+}
+
+.field-head label {
+ font-size: 13px;
+ font-weight: 500;
+ color: var(--ink-2);
+}
+
+.field-value {
+ font-family: var(--mono);
+ font-size: 13px;
+ font-weight: 500;
+ color: var(--ink-1);
+}
+
+.field-hint {
+ margin-top: 5px;
+ font-size: 12px;
+ line-height: 1.42;
+ color: var(--ink-3);
+}
+
+input[type='range'] {
+ -webkit-appearance: none;
+ appearance: none;
+ display: block;
+ width: 100%;
+ height: 20px;
+ padding: 8px 0;
+ background: transparent;
+ cursor: pointer;
+}
+
+input[type='range']::-webkit-slider-runnable-track {
+ height: 4px;
+ border-radius: 2px;
+ background: var(--line-strong);
+}
+
+input[type='range']::-moz-range-track {
+ height: 4px;
+ border-radius: 2px;
+ background: var(--line-strong);
+}
+
+input[type='range']::-webkit-slider-thumb {
+ -webkit-appearance: none;
+ appearance: none;
+ width: 18px;
+ height: 18px;
+ margin-top: -7px;
+ border: 1px solid var(--bg);
+ border-radius: 999px;
+ background: var(--ink-1);
+ transition: background 120ms var(--ease);
+}
+
+input[type='range']::-moz-range-thumb {
+ width: 18px;
+ height: 18px;
+ border: 1px solid var(--bg);
+ border-radius: 999px;
+ background: var(--ink-1);
+}
+
+input[type='range']:hover::-webkit-slider-thumb {
+ background: var(--accent-hi);
+}
+
+select {
+ width: 100%;
+ height: 32px;
+ padding: 0 10px;
+ border: 1px solid var(--line-strong);
+ border-radius: var(--r-input);
+ background: var(--surface-3);
+ cursor: pointer;
+}
+
+.segmented {
+ display: flex;
+ gap: 2px;
+ padding: 2px;
+ border: 1px solid var(--line-strong);
+ border-radius: var(--r-input);
+ background: var(--surface-3);
+}
+
+.segmented button {
+ flex: 1;
+ min-height: 26px;
+ padding: 0 8px;
+ border: 0;
+ border-radius: var(--r-tick);
+ background: transparent;
+ color: var(--ink-2);
+ font-size: 12.5px;
+ cursor: pointer;
+ transition: background 180ms var(--ease);
+}
+
+.segmented button[aria-checked='true'] {
+ background: var(--accent);
+ color: var(--on-accent);
+ font-weight: 600;
+}
+
+.switch {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 10px;
+ cursor: pointer;
+}
+
+.switch input {
+ width: 18px;
+ height: 18px;
+ accent-color: var(--accent);
+ cursor: pointer;
+}
+
+/* ── Instrument strip ─────────────────────────────────────────────────── */
+
+#strip {
+ grid-area: strip;
+ display: grid;
+ grid-template-columns: minmax(340px, 1.5fr) minmax(240px, 1fr) minmax(190px, 0.8fr) 200px;
+ gap: 1px;
+ min-height: 0;
+ background: var(--line);
+ border-top: 1px solid var(--line);
+}
+
+.module {
+ display: flex;
+ flex-direction: column;
+ min-width: 0;
+ min-height: 0;
+ overflow: hidden;
+ padding: 10px 14px 12px;
+ background: var(--surface-1);
+}
+
+.module-title {
+ display: flex;
+ align-items: baseline;
+ justify-content: space-between;
+ gap: 10px;
+ margin-bottom: 8px;
+ font-size: 10.5px;
+ font-weight: 600;
+ letter-spacing: 0.09em;
+ text-transform: uppercase;
+ color: var(--ink-3);
+}
+
+.module-title em {
+ font-style: normal;
+ letter-spacing: 0;
+ text-transform: none;
+ font-weight: 400;
+}
+
+.module canvas {
+ display: block;
+ width: 100%;
+ flex: 1;
+ min-height: 0;
+ border-radius: var(--r-card);
+ background: var(--well);
+}
+
+/* Signal path table */
+
+/*
+ * Scrollable as a last resort. At very short window heights the eight stages
+ * cannot all fit, and silently cropping the bottom rows would hide exactly the
+ * totals the table exists to show.
+ */
+#module-path {
+ overflow-y: auto;
+ scrollbar-width: thin;
+}
+
+.path {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: 12px;
+}
+
+.path th {
+ font-weight: 500;
+ text-align: left;
+ color: var(--ink-2);
+ padding: 1.5px 0;
+ white-space: nowrap;
+}
+
+.path td {
+ padding: 1.5px 0 1.5px 10px;
+ font-family: var(--mono);
+ color: var(--ink-1);
+ white-space: nowrap;
+}
+
+.path td.detail {
+ color: var(--ink-3);
+ text-align: right;
+ width: 1%;
+}
+
+.path td.db {
+ text-align: right;
+ width: 1%;
+}
+
+.path td.bar {
+ width: 34%;
+ padding-left: 12px;
+}
+
+.path tr[data-flag='warn'] th,
+.path tr[data-flag='warn'] td.db {
+ color: var(--warn);
+}
+
+.path tr.total th,
+.path tr.total td {
+ padding-top: 6px;
+ border-top: 1px solid var(--line);
+ font-weight: 600;
+}
+
+.meter {
+ position: relative;
+ height: 7px;
+ border-radius: 2px;
+ background: var(--well);
+ overflow: hidden;
+}
+
+.meter i {
+ position: absolute;
+ inset: 0 auto 0 0;
+ display: block;
+ border-radius: 2px;
+ background: var(--seg-survive);
+}
+
+.meter i[data-seg='distance'] {
+ background: var(--seg-distance);
+}
+
+.meter i[data-seg='directivity'] {
+ background: var(--seg-directivity);
+}
+
+.meter i[data-seg='occlusion'] {
+ background: var(--seg-occlusion);
+}
+
+.meter i[data-seg='room'] {
+ background: var(--warn);
+}
+
+/* Output module */
+
+.ears {
+ display: grid;
+ grid-template-columns: auto 1fr auto;
+ align-items: center;
+ gap: 5px 8px;
+ margin-top: 8px;
+ font-size: 12px;
+}
+
+.ears span {
+ color: var(--ink-2);
+}
+
+.ears .db {
+ font-family: var(--mono);
+ color: var(--ink-1);
+ text-align: right;
+}
+
+.readout {
+ display: flex;
+ align-items: baseline;
+ justify-content: space-between;
+ gap: 10px;
+ padding: 1.5px 0;
+ font-size: 12px;
+ color: var(--ink-2);
+}
+
+.readout b {
+ font-family: var(--mono);
+ font-weight: 500;
+ color: var(--ink-1);
+}
+
+.clip-led {
+ padding: 1px 6px;
+ border-radius: var(--r-tick);
+ background: var(--well);
+ color: var(--ink-3);
+ font-size: 10.5px;
+ font-weight: 600;
+ letter-spacing: 0.06em;
+}
+
+.clip-led[data-on='true'] {
+ background: var(--alert);
+ color: var(--on-accent);
+}
+
+/* ── Dialog ───────────────────────────────────────────────────────────── */
+
+dialog {
+ width: min(560px, calc(100vw - 32px));
+ padding: 0;
+ border: 1px solid var(--line-strong);
+ border-radius: var(--r-dialog);
+ background: var(--surface-3);
+ color: var(--ink-1);
+ box-shadow: 0 32px 64px -24px rgb(0 0 0 / 0.8);
+}
+
+dialog::backdrop {
+ background: rgb(6 7 9 / 0.68);
+}
+
+.dialog-body {
+ padding: 28px;
+}
+
+.dialog-body h2 {
+ margin: 0 0 10px;
+ font-family: var(--serif);
+ font-size: 34px;
+ line-height: 1.12;
+ font-weight: 400;
+ letter-spacing: -0.02em;
+}
+
+.dialog-body p {
+ margin: 0 0 16px;
+ font-size: 15px;
+ line-height: 1.6;
+ color: var(--ink-2);
+}
+
+.dialog-note {
+ display: flex;
+ gap: 9px;
+ padding: 11px 13px;
+ border: 1px solid var(--line);
+ border-radius: var(--r-card);
+ background: var(--surface-2);
+ font-size: 13px;
+ line-height: 1.5;
+ color: var(--ink-2);
+}
+
+.dialog-actions {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ margin: 20px 0 8px;
+}
+
+.key-table {
+ display: grid;
+ grid-template-columns: auto 1fr auto 1fr;
+ gap: 7px 12px;
+ margin-top: 18px;
+ padding-top: 18px;
+ border-top: 1px solid var(--line);
+ font-size: 12.5px;
+ color: var(--ink-2);
+}
+
+.key-table kbd {
+ padding: 1px 6px;
+ border: 1px solid var(--line-strong);
+ border-radius: var(--r-tick);
+ background: var(--surface-2);
+ font-family: var(--mono);
+ font-size: 11px;
+ white-space: nowrap;
+}
+
+.demo-row {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+ margin-top: 16px;
+}
+
+.toast {
+ position: fixed;
+ left: 50%;
+ bottom: calc(var(--strip-h) + 20px);
+ transform: translateX(-50%);
+ padding: 10px 16px;
+ border: 1px solid var(--line-strong);
+ border-radius: 999px;
+ background: var(--surface-3);
+ font-size: 13px;
+ box-shadow: 0 8px 24px rgb(0 0 0 / 0.5);
+ z-index: 20;
+}
+
+/* ── Responsive ───────────────────────────────────────────────────────── */
+
+/*
+ * The strip keeps its full height at every width. Dropping the scope buys the
+ * space instead: the signal path is the product, and a clipped gain budget is
+ * worse than a missing oscilloscope.
+ */
+@media (max-width: 1280px) {
+ :root {
+ --panel-w: 330px;
+ }
+
+ #strip {
+ grid-template-columns: minmax(300px, 1.4fr) minmax(200px, 1fr) 180px;
+ }
+
+ #module-scope {
+ display: none;
+ }
+}
+
+/*
+ * Stacked layout. The viewport keeps a hard minimum and the panel is capped in
+ * vh: without both, a 1024x768 window gives the 3D view about 150px, which is
+ * useless in an app whose subject is the geometry.
+ */
+@media (max-width: 1024px) {
+ #app {
+ grid-template-columns: 1fr;
+ grid-template-rows: var(--bar-h) minmax(260px, 1fr) auto var(--strip-h);
+ grid-template-areas:
+ 'bar'
+ 'view'
+ 'panel'
+ 'strip';
+ }
+
+ #panel {
+ max-height: 26vh;
+ padding: 14px;
+ border-left: 0;
+ border-top: 1px solid var(--line);
+ }
+
+ /* Flow the sections into columns rather than stretching one 900px slider. */
+ #panel > div {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(270px, 1fr));
+ gap: 14px;
+ align-items: start;
+ }
+
+ #panel .section {
+ margin-bottom: 0;
+ }
+
+ /* Output stays: the fly-by demo tells the user to watch it. */
+ #strip {
+ grid-template-columns: minmax(260px, 1.4fr) minmax(150px, 1fr) 170px;
+ }
+
+ .legend {
+ display: none;
+ }
+
+ .wordmark span {
+ display: none;
+ }
+}
+
+/*
+ * Phone widths. The meter strip cannot fit three modules side by side without
+ * pushing the shell wider than the viewport, so it drops to the signal path
+ * alone — everything else has a home in the panel or the HUD.
+ */
+@media (max-width: 640px) {
+ :root {
+ --bar-h: 52px;
+ }
+
+ #bar {
+ gap: 8px;
+ padding: 0 10px;
+ }
+
+ #volume,
+ #volume-value,
+ .status-chip {
+ display: none;
+ }
+
+ #strip {
+ grid-template-columns: 1fr;
+ }
+
+ #module-spectrum,
+ #module-output {
+ display: none;
+ }
+
+ .viewport-tools {
+ top: 10px;
+ right: 10px;
+ }
+
+ .hud {
+ top: 10px;
+ left: 10px;
+ min-width: 0;
+ padding: 9px 11px;
+ }
+
+ .hud-value {
+ font-size: 19px;
+ }
+}
+
+/*
+ * Short windows: the strip gives up height first, and the signal path tightens
+ * its rows rather than dropping any of them. Every stage has to stay visible
+ * for the budget to add up.
+ */
+@media (max-height: 760px) {
+ :root {
+ --strip-h: 172px;
+ }
+
+ .path {
+ font-size: 11px;
+ }
+
+ .path th,
+ .path td {
+ padding-top: 0;
+ padding-bottom: 0;
+ line-height: 1.42;
+ }
+
+ .path tr.total th,
+ .path tr.total td {
+ padding-top: 4px;
+ }
+
+ .module-title {
+ margin-bottom: 5px;
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ *,
+ *::before,
+ *::after {
+ transition-duration: 0.01ms !important;
+ animation-duration: 0.01ms !important;
+ }
+}
diff --git a/src/styles/tokens.css b/src/styles/tokens.css
new file mode 100644
index 0000000..c9de37c
--- /dev/null
+++ b/src/styles/tokens.css
@@ -0,0 +1,68 @@
+/*
+ * Design tokens.
+ *
+ * One accent hue for interaction, three semantic hues for state, everything
+ * else achromatic — so the most colourful thing on screen is always live
+ * acoustic data rather than chrome.
+ */
+
+:root {
+ color-scheme: dark;
+
+ --bg: #0e0f12;
+ --surface-1: #17191e;
+ --surface-2: #1d2027;
+ --surface-3: #232833;
+ --well: #101216;
+
+ --line: #2a2f3a;
+ --line-strong: #697285;
+
+ --ink-1: #e9e7e2;
+ --ink-2: #b3b8c4;
+ --ink-3: #8e95a3;
+
+ --accent: #6c8cff;
+ --accent-hi: #8ca4ff;
+ --accent-press: #4e6fe8;
+ --on-accent: #0e0f12;
+
+ --ok: #3fcf8e;
+ --warn: #f0a22e;
+ --alert: #ff5d5d;
+
+ /* Gain-budget segments: loss reads as desaturation, survival as chroma. */
+ --seg-survive: #8ca4ff;
+ --seg-distance: #3e4a6b;
+ --seg-directivity: #4a4560;
+ --seg-occlusion: #6e5326;
+
+ --sans: ui-sans-serif, system-ui, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
+ --serif: ui-serif, 'Iowan Old Style', 'Palatino Linotype', Georgia, serif;
+ --mono: ui-monospace, 'SF Mono', 'JetBrains Mono', Menlo, Consolas, monospace;
+
+ --r-tick: 3px;
+ --r-input: 5px;
+ --r-card: 8px;
+ --r-dialog: 12px;
+
+ --ease: cubic-bezier(0.2, 0, 0, 1);
+
+ --panel-w: 380px;
+ --bar-h: 56px;
+ --strip-h: 218px;
+}
+
+/*
+ * Dark only, deliberately. The viewport is a lit 3D room rendered against a
+ * near-black background; wrapping it in a light chrome puts a white picture
+ * frame around a dark photograph, and re-lighting the scene for a light theme
+ * would mean a second set of materials for no real gain.
+ */
+
+@media (prefers-contrast: more) {
+ :root {
+ --line: var(--line-strong);
+ --ink-3: var(--ink-2);
+ }
+}
diff --git a/src/ui/controls.ts b/src/ui/controls.ts
new file mode 100644
index 0000000..0138b91
--- /dev/null
+++ b/src/ui/controls.ts
@@ -0,0 +1,282 @@
+type Props = Record;
+
+/** Terse element builder. Keys starting with `aria`/`data` become attributes. */
+export function el(
+ tag: K,
+ props: Props = {},
+ children: Array = []
+): HTMLElementTagNameMap[K] {
+ const node = document.createElement(tag);
+ for (const [key, value] of Object.entries(props)) {
+ if (value === undefined || value === false) continue;
+ if (key === 'class') node.className = String(value);
+ else if (key === 'text') node.textContent = String(value);
+ else if (key === 'html') node.innerHTML = String(value);
+ else if (key in node && !key.startsWith('aria') && !key.startsWith('data'))
+ (node as unknown as Record)[key] = value;
+ else node.setAttribute(key, String(value));
+ }
+ for (const child of children) node.append(child);
+ return node;
+}
+
+export interface Field {
+ root: HTMLElement;
+ set(value: T): void;
+}
+
+export interface SliderSpec {
+ id: string;
+ label: string;
+ min: number;
+ max: number;
+ step: number;
+ value: number;
+ hint?: string;
+ format: (value: number) => string;
+ onInput: (value: number) => void;
+}
+
+/**
+ * A labelled slider with a live value readout. Native `input[type=range]` keeps
+ * keyboard support, screen-reader semantics and touch behaviour for free.
+ */
+export function slider(spec: SliderSpec): Field {
+ // is an implicit aria-live=polite region. Left on, every slider
+ // readout would be announced continuously as the simulation runs, which makes
+ // the app unusable with a screen reader. The value stays reachable through
+ // the slider's own accessible value.
+ const value = el('output', {
+ class: 'field-value',
+ for: spec.id,
+ text: spec.format(spec.value),
+ 'aria-live': 'off',
+ });
+ const input = el('input', {
+ id: spec.id,
+ type: 'range',
+ min: spec.min,
+ max: spec.max,
+ step: spec.step,
+ value: spec.value,
+ });
+
+ const head = el('div', { class: 'field-head' }, [
+ el('label', { for: spec.id, text: spec.label }),
+ value,
+ ]);
+ const children: Node[] = [head, input];
+ if (spec.hint) {
+ const hint = el('p', { class: 'field-hint', id: `${spec.id}-hint`, text: spec.hint });
+ input.setAttribute('aria-describedby', hint.id);
+ children.push(hint);
+ }
+
+ input.addEventListener('input', () => {
+ const next = Number(input.value);
+ value.textContent = spec.format(next);
+ spec.onInput(next);
+ });
+ // Double-clicking the label restores the value the app shipped with.
+ head.addEventListener('dblclick', () => {
+ input.value = String(spec.value);
+ input.dispatchEvent(new Event('input'));
+ });
+
+ return {
+ root: el('div', { class: 'field' }, children),
+ set(next: number) {
+ input.value = String(next);
+ value.textContent = spec.format(next);
+ },
+ };
+}
+
+export interface SelectSpec {
+ id: string;
+ label: string;
+ value: T;
+ options: Array<{ id: T; label: string; hint?: string }>;
+ onChange: (value: T) => void;
+}
+
+export interface SelectField extends Field {
+ /** Adds an option, or relabels it if the id is already present. */
+ upsert(option: { id: T; label: string; hint?: string }): void;
+}
+
+/** A labelled select whose hint line tracks the chosen option. */
+export function select(spec: SelectSpec): SelectField {
+ const options = [...spec.options];
+ const node = el('select', { id: spec.id });
+ for (const option of options) {
+ node.append(el('option', { value: option.id, text: option.label, selected: option.id === spec.value }));
+ }
+ const hint = el('p', { class: 'field-hint', id: `${spec.id}-hint` });
+ node.setAttribute('aria-describedby', hint.id);
+
+ const syncHint = (value: string) => {
+ hint.textContent = options.find((o) => o.id === value)?.hint ?? '';
+ };
+ syncHint(spec.value);
+
+ node.addEventListener('change', () => {
+ syncHint(node.value);
+ spec.onChange(node.value as T);
+ });
+
+ return {
+ root: el('div', { class: 'field' }, [
+ el('div', { class: 'field-head' }, [el('label', { for: spec.id, text: spec.label })]),
+ node,
+ hint,
+ ]),
+ set(next: T) {
+ node.value = next;
+ syncHint(next);
+ },
+ upsert(option) {
+ const existing = options.find((o) => o.id === option.id);
+ if (existing) {
+ Object.assign(existing, option);
+ const el = node.querySelector(`option[value="${option.id}"]`);
+ if (el) el.textContent = option.label;
+ } else {
+ options.push(option);
+ node.append(el('option', { value: option.id, text: option.label }));
+ }
+ if (node.value === option.id) syncHint(option.id);
+ },
+ };
+}
+
+export interface SegmentedSpec {
+ label: string;
+ value: T;
+ options: Array<{ id: T; label: string }>;
+ onChange: (value: T) => void;
+}
+
+/** A radiogroup styled as a segmented control. */
+export function segmented(spec: SegmentedSpec): Field {
+ const group = el('div', { class: 'segmented', role: 'radiogroup', 'aria-label': spec.label });
+ const buttons = spec.options.map((option) => {
+ const button = el('button', {
+ type: 'button',
+ role: 'radio',
+ text: option.label,
+ 'aria-checked': String(option.id === spec.value),
+ });
+ button.addEventListener('click', () => {
+ apply(option.id);
+ spec.onChange(option.id);
+ });
+ group.append(button);
+ return { id: option.id, button };
+ });
+
+ function apply(value: T) {
+ for (const entry of buttons) {
+ entry.button.setAttribute('aria-checked', String(entry.id === value));
+ }
+ }
+
+ return {
+ root: el('div', { class: 'field' }, [
+ el('div', { class: 'field-head' }, [el('label', { text: spec.label })]),
+ group,
+ ]),
+ set: apply,
+ };
+}
+
+export interface SwitchSpec {
+ id: string;
+ label: string;
+ checked: boolean;
+ hint?: string;
+ onChange: (checked: boolean) => void;
+}
+
+export function toggle(spec: SwitchSpec): Field {
+ const input = el('input', { id: spec.id, type: 'checkbox', checked: spec.checked });
+ input.addEventListener('change', () => spec.onChange(input.checked));
+
+ const children: Node[] = [
+ el('label', { class: 'switch', for: spec.id }, [
+ el('span', { text: spec.label }),
+ input,
+ ]),
+ ];
+ if (spec.hint) children.push(el('p', { class: 'field-hint', text: spec.hint }));
+
+ return {
+ root: el('div', { class: 'field' }, children),
+ set(next: boolean) {
+ input.checked = next;
+ },
+ };
+}
+
+export interface FilePickerSpec {
+ id: string;
+ label: string;
+ accept: string;
+ hint: string;
+ onPick: (file: File) => void;
+}
+
+export interface FilePicker {
+ root: HTMLElement;
+ /** Shows progress, the loaded track, or an error, in place of the hint. */
+ setStatus(text: string, tone?: 'idle' | 'busy' | 'error'): void;
+}
+
+/**
+ * A file input styled as a button. The native input stays in the DOM and keeps
+ * its label association, so it remains keyboard-operable and announced; the
+ * button simply forwards its click.
+ */
+export function filePicker(spec: FilePickerSpec): FilePicker {
+ const input = el('input', {
+ id: spec.id,
+ type: 'file',
+ accept: spec.accept,
+ class: 'visually-hidden',
+ });
+ const button = el('button', { class: 'btn file-btn', type: 'button' }, [
+ el('span', { text: '⤒' }),
+ el('span', { text: spec.label }),
+ ]);
+ const status = el('p', { class: 'field-hint', id: `${spec.id}-status`, text: spec.hint });
+ input.setAttribute('aria-describedby', status.id);
+
+ button.addEventListener('click', () => input.click());
+ input.addEventListener('change', () => {
+ const file = input.files?.[0];
+ if (file) spec.onPick(file);
+ // Clear, so picking the same file twice still fires a change event.
+ input.value = '';
+ });
+
+ return {
+ root: el('div', { class: 'field' }, [
+ el('label', { for: spec.id, class: 'visually-hidden', text: spec.label }),
+ input,
+ button,
+ status,
+ ]),
+ setStatus(text, tone = 'idle') {
+ status.textContent = text;
+ status.dataset.tone = tone;
+ },
+ };
+}
+
+/** A collapsible panel section. */
+export function section(title: string, open: boolean, fields: HTMLElement[]): HTMLElement {
+ const details = el('details', { class: 'section', open });
+ details.append(el('summary', { text: title }));
+ details.append(el('div', { class: 'section-body' }, fields));
+ return details;
+}
diff --git a/src/ui/format.ts b/src/ui/format.ts
new file mode 100644
index 0000000..c8f444e
--- /dev/null
+++ b/src/ui/format.ts
@@ -0,0 +1,56 @@
+/** U+2212. A hyphen is not a minus sign, and it reads short next to digits. */
+const MINUS = '−';
+/** U+2009, so "12.4 m" cannot wrap between the number and its unit. */
+const THIN = ' ';
+const DASH = '—';
+
+/**
+ * Fixed-decimal rendering with a true minus sign.
+ *
+ * The sign is decided from the *rendered* text rather than the raw value, so a
+ * Doppler ratio a hair under 1 reads "0.0", never "−0.0".
+ */
+function fixed(value: number, decimals: number): string {
+ const text = Math.abs(value).toFixed(decimals);
+ return value < 0 && Number(text) !== 0 ? MINUS + text : text;
+}
+
+/** Wraps a formatter so any non-finite input renders as a bare em dash. */
+function guard(format: (value: number) => string): (value: number) => string {
+ return (value: number) => (Number.isFinite(value) ? format(value) : DASH);
+}
+
+export const fmt = {
+ metres: guard((v) => `${fixed(v, 2)}${THIN}m`),
+
+ db: guard((v) => (v <= -119 ? `${MINUS}∞${THIN}dB` : `${fixed(v, 1)}${THIN}dB`)),
+
+ degrees: guard((v) => `${fixed(v, 1)}°`),
+
+ ratio: guard((v) => `×${v.toFixed(4)}`),
+
+ cents: guard((v) => {
+ const text = Math.abs(v).toFixed(1);
+ if (Number(text) === 0) return `0.0${THIN}¢`;
+ return `${v > 0 ? '+' : MINUS}${text}${THIN}¢`;
+ }),
+
+ seconds: guard((v) => `${fixed(v, 2)}${THIN}s`),
+
+ ms: guard((v) => `${fixed(v, 1)}${THIN}ms`),
+
+ percent: guard((v) => `${Math.round(v * 100)}${THIN}%`),
+
+ speed: guard((v) => `${fixed(v, 1)}${THIN}m/s`),
+
+ /** Three significant figures, switching to a k suffix past 1 kHz. */
+ hz: guard((v) => {
+ if (v >= 1000) {
+ const k = v / 1000;
+ return `${k >= 10 ? k.toFixed(1) : k.toFixed(2)}${THIN}kHz`;
+ }
+ return `${Math.round(v)}${THIN}Hz`;
+ }),
+
+ volume: guard((v) => `${Math.round(v * 100)}${THIN}%`),
+};
diff --git a/src/ui/meters.ts b/src/ui/meters.ts
new file mode 100644
index 0000000..6df005b
--- /dev/null
+++ b/src/ui/meters.ts
@@ -0,0 +1,222 @@
+import { clamp } from '../physics';
+
+function cssVar(name: string, fallback: string): string {
+ const value = getComputedStyle(document.documentElement).getPropertyValue(name).trim();
+ return value || fallback;
+}
+
+/** Keeps the backing store matched to the CSS box so nothing renders blurry. */
+class HiDpiCanvas {
+ readonly ctx: CanvasRenderingContext2D;
+ width = 0;
+ height = 0;
+
+ constructor(readonly canvas: HTMLCanvasElement) {
+ const ctx = canvas.getContext('2d');
+ if (!ctx) throw new Error('2D canvas context unavailable');
+ this.ctx = ctx;
+ }
+
+ /** Returns false when the canvas has no area worth drawing into. */
+ sync(): boolean {
+ const dpr = Math.min(window.devicePixelRatio || 1, 2);
+ const width = Math.round(this.canvas.clientWidth * dpr);
+ const height = Math.round(this.canvas.clientHeight * dpr);
+ if (width === 0 || height === 0) return false;
+ if (width !== this.canvas.width || height !== this.canvas.height) {
+ this.canvas.width = width;
+ this.canvas.height = height;
+ }
+ this.width = width;
+ this.height = height;
+ this.ctx.setTransform(1, 0, 0, 1, 0, 0);
+ return true;
+ }
+
+ emptyState(message: string): void {
+ const { ctx } = this;
+ ctx.clearRect(0, 0, this.width, this.height);
+ ctx.fillStyle = cssVar('--ink-3', '#8e95a3');
+ ctx.font = `${Math.round(this.height * 0.11)}px ui-sans-serif, system-ui, sans-serif`;
+ ctx.textAlign = 'center';
+ ctx.textBaseline = 'middle';
+ ctx.fillText(message, this.width / 2, this.height / 2);
+ }
+}
+
+const OCTAVE_LABELS = [31.25, 125, 500, 2000, 8000];
+
+/**
+ * Log-frequency spectrum with a decaying peak-hold trace.
+ *
+ * A linear FFT axis wastes nine tenths of its width on the top three octaves,
+ * where almost nothing interesting happens; mapping to log frequency puts the
+ * bass and mids where the eye can actually read them.
+ */
+export class Spectrum {
+ private readonly surface: HiDpiCanvas;
+ private peaks: Float32Array | null = null;
+
+ constructor(canvas: HTMLCanvasElement, private readonly sampleRate: () => number) {
+ this.surface = new HiDpiCanvas(canvas);
+ }
+
+ draw(data: Uint8Array, running: boolean): void {
+ if (!this.surface.sync()) return;
+ if (!running) {
+ this.peaks = null;
+ this.surface.emptyState('Audio suspended — press Play');
+ return;
+ }
+
+ const { ctx, width, height } = this.surface;
+ ctx.clearRect(0, 0, width, height);
+
+ const nyquist = this.sampleRate() / 2;
+ const minHz = 20;
+ const maxHz = Math.min(20000, nyquist);
+ const logMin = Math.log10(minHz);
+ const logSpan = Math.log10(maxHz) - logMin;
+ const columns = Math.max(24, Math.floor(width / 3));
+
+ if (!this.peaks || this.peaks.length !== columns) this.peaks = new Float32Array(columns);
+ const peaks = this.peaks;
+
+ this.drawGrid(ctx, width, height, logMin, logSpan);
+
+ const gradient = ctx.createLinearGradient(0, height, 0, 0);
+ gradient.addColorStop(0, cssVar('--accent', '#6c8cff'));
+ gradient.addColorStop(1, cssVar('--accent-hi', '#8ca4ff'));
+
+ ctx.beginPath();
+ ctx.moveTo(0, height);
+ for (let i = 0; i < columns; i++) {
+ // Each column covers one slice of the log axis; take the loudest bin in it.
+ const fromHz = Math.pow(10, logMin + (i / columns) * logSpan);
+ const toHz = Math.pow(10, logMin + ((i + 1) / columns) * logSpan);
+ const fromBin = Math.floor((fromHz / nyquist) * data.length);
+ const toBin = Math.max(fromBin + 1, Math.ceil((toHz / nyquist) * data.length));
+
+ let peak = 0;
+ for (let bin = fromBin; bin < toBin && bin < data.length; bin++) {
+ if (data[bin] > peak) peak = data[bin];
+ }
+ const level = peak / 255;
+ peaks[i] = Math.max(level, peaks[i] - 0.012);
+
+ const x = (i / columns) * width;
+ ctx.lineTo(x, height - level * height);
+ }
+ ctx.lineTo(width, height);
+ ctx.closePath();
+ ctx.fillStyle = gradient;
+ ctx.globalAlpha = 0.85;
+ ctx.fill();
+ ctx.globalAlpha = 1;
+
+ ctx.beginPath();
+ for (let i = 0; i < columns; i++) {
+ const x = (i / columns) * width;
+ const y = height - peaks[i] * height;
+ if (i === 0) ctx.moveTo(x, y);
+ else ctx.lineTo(x, y);
+ }
+ ctx.strokeStyle = cssVar('--ink-2', '#b3b8c4');
+ ctx.lineWidth = Math.max(1, height / 110);
+ ctx.stroke();
+ }
+
+ private drawGrid(
+ ctx: CanvasRenderingContext2D,
+ width: number,
+ height: number,
+ logMin: number,
+ logSpan: number
+ ): void {
+ ctx.strokeStyle = cssVar('--line', '#2a2f3a');
+ ctx.lineWidth = 1;
+ ctx.fillStyle = cssVar('--ink-3', '#8e95a3');
+ ctx.font = `${Math.round(height * 0.11)}px ui-sans-serif, system-ui, sans-serif`;
+ // Labels sit along the top: the spectrum grows from the bottom, so anything
+ // printed down there ends up buried under the loudest part of the signal.
+ ctx.textBaseline = 'top';
+ ctx.textAlign = 'left';
+
+ for (const hz of OCTAVE_LABELS) {
+ const x = ((Math.log10(hz) - logMin) / logSpan) * width;
+ if (x < 0 || x > width) continue;
+ ctx.beginPath();
+ ctx.moveTo(x, 0);
+ ctx.lineTo(x, height);
+ ctx.stroke();
+ ctx.fillText(hz >= 1000 ? `${hz / 1000}k` : String(Math.round(hz)), x + 3, 2);
+ }
+
+ // getByteFrequencyData spans minDecibels..maxDecibels, i.e. −100..−10 dB.
+ for (const fraction of [0.25, 0.5, 0.75]) {
+ const y = height * fraction;
+ ctx.beginPath();
+ ctx.moveTo(0, y);
+ ctx.lineTo(width, y);
+ ctx.stroke();
+ }
+ }
+}
+
+/** Oscilloscope with rising-edge triggering, so the trace stands still. */
+export class Scope {
+ private readonly surface: HiDpiCanvas;
+
+ constructor(canvas: HTMLCanvasElement) {
+ this.surface = new HiDpiCanvas(canvas);
+ }
+
+ draw(data: Uint8Array, running: boolean): void {
+ if (!this.surface.sync()) return;
+ if (!running) {
+ this.surface.emptyState('No signal');
+ return;
+ }
+
+ const { ctx, width, height } = this.surface;
+ ctx.clearRect(0, 0, width, height);
+
+ ctx.strokeStyle = cssVar('--line', '#2a2f3a');
+ ctx.lineWidth = 1;
+ for (let i = 1; i < 4; i++) {
+ const y = (height / 4) * i;
+ ctx.beginPath();
+ ctx.moveTo(0, y);
+ ctx.lineTo(width, y);
+ ctx.stroke();
+ }
+
+ // Find the first upward zero crossing so successive frames line up.
+ const span = Math.floor(data.length / 2);
+ let start = 0;
+ for (let i = 1; i < span; i++) {
+ if (data[i - 1] < 128 && data[i] >= 128) {
+ start = i;
+ break;
+ }
+ }
+
+ ctx.beginPath();
+ for (let i = 0; i < span; i++) {
+ const sample = (data[start + i] - 128) / 128;
+ const x = (i / span) * width;
+ const y = height / 2 - sample * (height / 2) * 0.92;
+ if (i === 0) ctx.moveTo(x, y);
+ else ctx.lineTo(x, y);
+ }
+ ctx.strokeStyle = cssVar('--ok', '#3fcf8e');
+ ctx.lineWidth = Math.max(1.2, height / 90);
+ ctx.lineJoin = 'round';
+ ctx.stroke();
+ }
+}
+
+/** Maps a dB reading onto a 0..1 meter fill over a fixed −60..0 dB window. */
+export function dbToFill(db: number, floor = -60): number {
+ return clamp((db - floor) / -floor, 0, 1);
+}
diff --git a/src/ui/onboarding.ts b/src/ui/onboarding.ts
new file mode 100644
index 0000000..61c55ff
--- /dev/null
+++ b/src/ui/onboarding.ts
@@ -0,0 +1,190 @@
+import { el } from './controls';
+
+const SEEN_KEY = 'resonance.seen';
+
+export const KEY_HELP: Array<[string, string]> = [
+ ['W A S D', 'walk (you are the microphone)'],
+ ['Q / E', 'move down / up'],
+ ['↑ ↓ ← →', 'move the source'],
+ ['Page ↑ / ↓', 'source height'],
+ ['Shift', 'move 3× faster'],
+ ['drag', 'orbit — swings the sound around your head'],
+ ['G / R', 'gizmo: move / turn the source'],
+ ['double-click', 'place the source on the floor'],
+ ['K', 'play / pause'],
+ ['H', 'hide the panels'],
+ ['?', 'this list'],
+];
+
+export interface DemoSpec {
+ id: string;
+ label: string;
+ caption: string;
+ run: () => void;
+}
+
+function keyTable(): HTMLElement {
+ const grid = el('div', { class: 'key-table' });
+ for (const [keys, description] of KEY_HELP) {
+ grid.append(el('kbd', { text: keys }), el('span', { text: description }));
+ }
+ return grid;
+}
+
+export interface OnboardingHandlers {
+ onEnableAudio: () => Promise;
+ onExploreMuted: () => void;
+}
+
+/** First-run explainer, the shortcut sheet, and transient captions. */
+export class Onboarding {
+ private readonly welcome: HTMLDialogElement;
+ private readonly help: HTMLDialogElement;
+ private readonly startButton: HTMLButtonElement;
+ private readonly startError = el('p', { class: 'field-hint', role: 'alert' });
+ private toastTimer = 0;
+
+ constructor(
+ private readonly demos: DemoSpec[],
+ private readonly handlers: OnboardingHandlers
+ ) {
+ this.startButton = el('button', {
+ class: 'btn btn-primary btn-lg',
+ type: 'button',
+ text: '▶ Enable audio & start',
+ autofocus: true,
+ });
+
+ const muted = el('button', { class: 'btn', type: 'button', text: 'Explore muted' });
+
+ const demoRow = el('div', { class: 'demo-row' });
+ demos.forEach((demo, index) => {
+ const button = el('button', {
+ class: 'btn',
+ type: 'button',
+ text: `${index + 1} ${demo.label}`,
+ });
+ button.addEventListener('click', async () => {
+ await this.enableAudio();
+ demo.run();
+ this.toast(demo.caption);
+ });
+ demoRow.append(button);
+ });
+
+ this.welcome = el('dialog', { id: 'welcome' }, [
+ el('div', { class: 'dialog-body' }, [
+ el('h2', { text: 'Hear a room.' }),
+ el('p', {
+ text:
+ 'Move a loudspeaker through a 3D space and hear exactly what distance, direction, ' +
+ 'obstacles and the room itself do to the sound. Your camera is the microphone.',
+ }),
+ el('div', { class: 'dialog-note' }, [
+ el('span', { text: '🎧' }),
+ el('span', {
+ text:
+ 'Headphones strongly recommended — the binaural simulation collapses on laptop speakers.',
+ }),
+ ]),
+ el('div', { class: 'dialog-actions' }, [this.startButton, muted]),
+ el('p', { class: 'field-hint', text: 'Browsers need one click before sound can play.' }),
+ this.startError,
+ el('p', { class: 'field-hint', text: 'Or start with a demo:' }),
+ demoRow,
+ keyTable(),
+ ]),
+ ]) as HTMLDialogElement;
+
+ this.help = el('dialog', { id: 'help' }, [
+ el('div', { class: 'dialog-body' }, [
+ el('h2', { text: 'Controls' }),
+ keyTable(),
+ el('div', { class: 'dialog-actions' }, [
+ el('button', { class: 'btn btn-primary', type: 'button', text: 'Close', value: 'close' }),
+ ]),
+ ]),
+ ]) as HTMLDialogElement;
+
+ this.startButton.addEventListener('click', () => this.enableAudio());
+ muted.addEventListener('click', () => {
+ handlers.onExploreMuted();
+ this.dismiss();
+ });
+ this.help.querySelector('button')?.addEventListener('click', () => this.help.close());
+ // Esc on the welcome card means "explore muted" rather than a dead end.
+ this.welcome.addEventListener('cancel', () => handlers.onExploreMuted());
+ this.welcome.addEventListener('close', () => markSeen());
+
+ document.body.append(this.welcome, this.help);
+ }
+
+ /** Shows the card on a first visit, or a quiet hint on a return visit. */
+ open(): void {
+ if (hasSeen()) {
+ this.toast('Press ? for controls');
+ return;
+ }
+ this.welcome.showModal();
+ }
+
+ toggleHelp(): void {
+ if (this.help.open) this.help.close();
+ else this.help.showModal();
+ }
+
+ get isOpen(): boolean {
+ return this.welcome.open || this.help.open;
+ }
+
+ runDemo(index: number): void {
+ const demo = this.demos[index];
+ if (!demo) return;
+ demo.run();
+ this.toast(demo.caption);
+ }
+
+ toast(message: string): void {
+ document.querySelector('.toast')?.remove();
+ window.clearTimeout(this.toastTimer);
+ const node = el('div', { class: 'toast', role: 'status', text: message });
+ document.body.append(node);
+ this.toastTimer = window.setTimeout(() => node.remove(), 5200);
+ }
+
+ private async enableAudio(): Promise {
+ this.startButton.disabled = true;
+ this.startButton.textContent = 'Starting…';
+ try {
+ await this.handlers.onEnableAudio();
+ this.dismiss();
+ } catch (error) {
+ // Report inline: a console error is invisible to the person who is stuck.
+ this.startError.textContent = `Your browser blocked Web Audio — the 3D view still works. (${
+ error instanceof Error ? error.message : String(error)
+ })`;
+ this.startButton.disabled = false;
+ this.startButton.textContent = 'Retry';
+ }
+ }
+
+ private dismiss(): void {
+ if (this.welcome.open) this.welcome.close();
+ }
+}
+
+function hasSeen(): boolean {
+ try {
+ return localStorage.getItem(SEEN_KEY) === '1';
+ } catch {
+ return false;
+ }
+}
+
+function markSeen(): void {
+ try {
+ localStorage.setItem(SEEN_KEY, '1');
+ } catch {
+ /* private browsing */
+ }
+}
diff --git a/src/ui/panel.ts b/src/ui/panel.ts
new file mode 100644
index 0000000..c97b60c
--- /dev/null
+++ b/src/ui/panel.ts
@@ -0,0 +1,299 @@
+import { DistanceModel } from '../physics';
+import {
+ EngineSettings,
+ EngineSettingsPatch,
+ PRESETS,
+ SURFACES,
+ SourceId,
+ SurfaceId,
+ Telemetry,
+} from '../audio';
+import { MOTION_MODES, MotionMode } from '../scene';
+import { el, filePicker, section, segmented, select, slider, toggle } from './controls';
+import { fmt } from './format';
+
+export interface PanelHandlers {
+ onEngine(patch: EngineSettingsPatch): void;
+ onFile(file: File): void;
+ onMotion(mode: MotionMode, speed: number): void;
+ onYaw(degrees: number): void;
+ onAnnotations(visible: boolean): void;
+}
+
+/**
+ * The parameter panel. Every control writes straight through to the engine, and
+ * every section carries a plain-language hint, because a slider labelled
+ * "rolloff factor" teaches nobody anything on its own.
+ */
+export class Panel {
+ readonly root: HTMLElement;
+
+ private readonly yaw: ReturnType;
+ private readonly motionSpeed: ReturnType;
+ private readonly motion: ReturnType>;
+ private readonly preset: ReturnType>;
+ readonly file: ReturnType;
+ private readonly surface: ReturnType>;
+ private readonly model: ReturnType>;
+ private readonly dimensions: Record<'width' | 'height' | 'depth', ReturnType>;
+ private readonly roomSummary = el('p', { class: 'field-hint' });
+ private motionMode: MotionMode = 'static';
+ private speed = 8;
+
+ constructor(settings: EngineSettings, h: PanelHandlers) {
+
+ this.preset = select({
+ id: 'preset',
+ label: 'Sound',
+ value: settings.preset,
+ options: PRESETS.map((p) => ({ id: p.id, label: p.label, hint: p.hint })),
+ onChange: (value) => h.onEngine({ preset: value }),
+ });
+
+ this.file = filePicker({
+ id: 'audio-file',
+ label: 'Load a track from your computer',
+ accept: 'audio/*,.mp3,.wav,.flac,.ogg,.m4a,.aac,.opus',
+ hint: 'Or drop a file onto the 3D view. MP3, WAV, FLAC, OGG, M4A. Summed to mono, because a loudspeaker at one point in a room radiates one signal.',
+ onPick: (file) => h.onFile(file),
+ });
+
+ this.motion = segmented({
+ label: 'Motion',
+ value: 'static',
+ options: MOTION_MODES.map((m) => ({ id: m.id, label: m.label })),
+ onChange: (value) => {
+ this.motionMode = value;
+ h.onMotion(value, this.speed);
+ },
+ });
+
+ this.motionSpeed = slider({
+ id: 'motion-speed',
+ label: 'Motion speed',
+ min: 1,
+ max: 45,
+ step: 0.5,
+ value: this.speed,
+ format: fmt.speed,
+ hint: 'Doppler needs movement. Above roughly 10 m/s the pitch shift becomes obvious.',
+ onInput: (value) => {
+ this.speed = value;
+ h.onMotion(this.motionMode, value);
+ },
+ });
+
+ this.yaw = slider({
+ id: 'source-yaw',
+ label: 'Source aim',
+ min: -180,
+ max: 180,
+ step: 1,
+ value: 0,
+ format: fmt.degrees,
+ onInput: (value) => h.onYaw(value),
+ });
+
+ this.surface = select({
+ id: 'surface',
+ label: 'Surfaces',
+ value: settings.surface,
+ options: SURFACES.map((s) => ({ id: s.id, label: s.label, hint: s.hint })),
+ onChange: (value) => h.onEngine({ surface: value }),
+ });
+
+ const dimension = (
+ id: string,
+ label: string,
+ key: 'width' | 'height' | 'depth',
+ value: number
+ ) =>
+ slider({
+ id,
+ label,
+ min: 4,
+ max: 60,
+ step: 1,
+ value,
+ format: fmt.metres,
+ onInput: (next) => h.onEngine({ room: { [key]: next } }),
+ });
+
+ this.dimensions = {
+ width: dimension('room-w', 'Width', 'width', settings.room.width),
+ height: dimension('room-h', 'Height', 'height', settings.room.height),
+ depth: dimension('room-d', 'Depth', 'depth', settings.room.depth),
+ };
+
+ this.model = segmented({
+ label: 'Falloff law',
+ value: settings.distanceModel,
+ options: [
+ { id: 'inverse', label: 'Inverse' },
+ { id: 'exponential', label: 'Exponential' },
+ { id: 'linear', label: 'Linear' },
+ ],
+ onChange: (value) => h.onEngine({ distanceModel: value }),
+ });
+
+ this.root = el('div', {}, [
+ el('h1', { class: 'visually-hidden', text: 'Simulation parameters' }),
+
+ section('Source', true, [
+ this.preset.root,
+ this.file.root,
+ this.motion.root,
+ this.motionSpeed.root,
+ this.yaw.root,
+ ]),
+
+ section('Room', true, [
+ this.surface.root,
+ this.dimensions.width.root,
+ this.dimensions.height.root,
+ this.dimensions.depth.root,
+ this.roomSummary,
+ ]),
+
+ section('Distance', false, [
+ this.model.root,
+ slider({
+ id: 'rolloff',
+ label: 'Rolloff',
+ min: 0,
+ max: 5,
+ step: 0.1,
+ value: settings.rolloffFactor,
+ format: (v) => `×${v.toFixed(1)}`,
+ hint: '1.0 is the physical inverse-distance law: every doubling of distance costs 6 dB.',
+ onInput: (value) => h.onEngine({ rolloffFactor: value }),
+ }).root,
+ slider({
+ id: 'ref-distance',
+ label: 'Reference distance',
+ min: 0.25,
+ max: 10,
+ step: 0.25,
+ value: settings.refDistance,
+ format: fmt.metres,
+ hint: 'The distance at which the source plays at full level.',
+ onInput: (value) => h.onEngine({ refDistance: value }),
+ }).root,
+ ]),
+
+ section('Directivity', false, [
+ slider({
+ id: 'cone-inner',
+ label: 'Inner angle',
+ min: 0,
+ max: 360,
+ step: 1,
+ value: settings.coneInnerAngle,
+ format: fmt.degrees,
+ hint: 'Inside this cone the source is at full level. 360° is omnidirectional.',
+ onInput: (value) => h.onEngine({ coneInnerAngle: value }),
+ }).root,
+ slider({
+ id: 'cone-outer',
+ label: 'Outer angle',
+ min: 0,
+ max: 360,
+ step: 1,
+ value: settings.coneOuterAngle,
+ format: fmt.degrees,
+ onInput: (value) => h.onEngine({ coneOuterAngle: value }),
+ }).root,
+ slider({
+ id: 'cone-gain',
+ label: 'Behind-the-speaker level',
+ min: 0,
+ max: 1,
+ step: 0.01,
+ value: settings.coneOuterGain,
+ format: (v) => fmt.db(20 * Math.log10(Math.max(v, 1e-4))),
+ onInput: (value) => h.onEngine({ coneOuterGain: value }),
+ }).root,
+ ]),
+
+ section('Advanced', false, [
+ toggle({
+ id: 'propagation',
+ label: 'Propagation delay',
+ checked: settings.propagationEnabled,
+ hint: 'Sound takes time to arrive. Chasing that delay is what produces Doppler.',
+ onChange: (checked) => h.onEngine({ propagationEnabled: checked }),
+ }).root,
+ slider({
+ id: 'speed-of-sound',
+ label: 'Speed of sound',
+ min: 80,
+ max: 900,
+ step: 1,
+ value: settings.speedOfSound,
+ format: (v) => `${Math.round(v)} m/s`,
+ hint: '343 m/s is dry air at 20 °C. Lower it to exaggerate Doppler.',
+ onInput: (value) => h.onEngine({ speedOfSound: value }),
+ }).root,
+ slider({
+ id: 'air',
+ label: 'Air absorption',
+ min: 0,
+ max: 8,
+ step: 0.1,
+ value: settings.airAbsorption,
+ format: (v) => (v === 0 ? 'off' : `×${v.toFixed(1)}`),
+ hint: 'Air swallows treble over distance. 1.0 is realistic — barely audible indoors.',
+ onInput: (value) => h.onEngine({ airAbsorption: value }),
+ }).root,
+ toggle({
+ id: 'annotations',
+ label: 'Show acoustic overlays',
+ checked: true,
+ hint: 'The ray to your ear and the critical-distance ring on the floor.',
+ onChange: (checked) => h.onAnnotations(checked),
+ }).root,
+ ]),
+ ]);
+ }
+
+ /** Registers a loaded track in the Sound dropdown and selects it. */
+ setUserTrack(name: string, duration: string): void {
+ this.preset.upsert({
+ id: 'file',
+ label: `♪ ${name}`,
+ hint: `Your file · ${duration}. It loops, and Doppler pitches it as the source moves.`,
+ });
+ this.preset.set('file');
+ }
+
+ /**
+ * Pushes engine state back into the controls.
+ *
+ * Anything that changes settings without going through a control — the demos,
+ * a reset — must call this. Otherwise the panel keeps displaying the old
+ * value, and the next touch of that control writes the stale value back,
+ * silently undoing the change the user just asked for.
+ */
+ sync(settings: EngineSettings, motion: { mode: MotionMode; speed: number }): void {
+ this.preset.set(settings.preset);
+ this.surface.set(settings.surface);
+ this.model.set(settings.distanceModel);
+ this.dimensions.width.set(settings.room.width);
+ this.dimensions.height.set(settings.room.height);
+ this.dimensions.depth.set(settings.room.depth);
+
+ this.motionMode = motion.mode;
+ this.speed = motion.speed;
+ this.motion.set(motion.mode);
+ this.motionSpeed.set(motion.speed);
+ }
+
+ /** Reflects derived room acoustics and gizmo-driven rotation back into the UI. */
+ update(t: Telemetry, sourceYaw: number): void {
+ const summary = `Reverberation ${fmt.seconds(t.t60)} · critical distance ${fmt.metres(
+ t.criticalDistance
+ )}. Past that, the room is louder than the source.`;
+ if (this.roomSummary.textContent !== summary) this.roomSummary.textContent = summary;
+ this.yaw.set(Math.round(sourceYaw));
+ }
+}
diff --git a/src/ui/readouts.ts b/src/ui/readouts.ts
new file mode 100644
index 0000000..14df75e
--- /dev/null
+++ b/src/ui/readouts.ts
@@ -0,0 +1,89 @@
+import { Telemetry } from '../audio';
+import { el } from './controls';
+import { fmt } from './format';
+import { dbToFill } from './meters';
+
+/**
+ * The one floating element over the 3D view: what the listener is hearing right
+ * now, placed where the eye already is rather than across the screen in a panel.
+ */
+export class Hud {
+ readonly root: HTMLElement;
+
+ private readonly distance = el('span', { class: 'hud-value' });
+ private readonly level = el('span', { class: 'hud-value' });
+ private readonly flag = el('p', { class: 'hud-flag' });
+
+ constructor() {
+ this.root = el('div', { class: 'hud', role: 'group', 'aria-label': 'At your ear' }, [
+ el('div', { class: 'hud-row' }, [this.distance, this.level]),
+ el('div', { class: 'hud-row' }, [
+ el('span', { class: 'hud-label', text: 'distance' }),
+ el('span', { class: 'hud-label', text: 'at your ear' }),
+ ]),
+ this.flag,
+ ]);
+ }
+
+ update(t: Telemetry): void {
+ this.distance.textContent = fmt.metres(t.distance);
+ this.level.textContent = fmt.db(t.directGainDb);
+
+ const blocked = t.occlusion > 0.15;
+ this.flag.dataset.dominant = t.reverbDominant ? 'room' : 'direct';
+ this.flag.dataset.blocked = String(blocked);
+ this.flag.textContent = blocked
+ ? `Line of sight ${fmt.percent(t.occlusion)} blocked`
+ : t.reverbDominant
+ ? `Room-dominant (rc ${fmt.metres(t.criticalDistance)})`
+ : `Direct-dominant (rc ${fmt.metres(t.criticalDistance)})`;
+ }
+}
+
+/** Output metering: per-ear levels, Doppler, time of flight, clip indicator. */
+export class OutputModule {
+ readonly root: HTMLElement;
+
+ private readonly leftFill = el('i');
+ private readonly rightFill = el('i');
+ private readonly leftDb = el('span', { class: 'db' });
+ private readonly rightDb = el('span', { class: 'db' });
+ private readonly rms = el('b');
+ private readonly ratio = el('b');
+ private readonly cents = el('b');
+ private readonly speed = el('b');
+ private readonly clip = el('span', { class: 'clip-led', text: 'CLIP' });
+
+ constructor() {
+ this.root = el('section', { class: 'module', id: 'module-output' }, [
+ el('h2', { class: 'module-title' }, [
+ document.createTextNode('Output'),
+ this.clip,
+ ]),
+ el('div', { class: 'readout' }, [el('span', { text: 'rms' }), this.rms]),
+ el('div', { class: 'ears' }, [
+ el('span', { text: 'L' }),
+ el('div', { class: 'meter' }, [this.leftFill]),
+ this.leftDb,
+ el('span', { text: 'R' }),
+ el('div', { class: 'meter' }, [this.rightFill]),
+ this.rightDb,
+ ]),
+ el('div', { class: 'readout' }, [el('span', { text: 'doppler' }), this.ratio]),
+ el('div', { class: 'readout' }, [el('span', { text: 'shift' }), this.cents]),
+ el('div', { class: 'readout' }, [el('span', { text: 'closing' }), this.speed]),
+ ]);
+ }
+
+ update(t: Telemetry): void {
+ this.rms.textContent = fmt.db(t.outputLevelDb);
+ this.leftDb.textContent = fmt.db(t.leftLevelDb);
+ this.rightDb.textContent = fmt.db(t.rightLevelDb);
+ this.leftFill.style.width = `${(dbToFill(t.leftLevelDb) * 100).toFixed(1)}%`;
+ this.rightFill.style.width = `${(dbToFill(t.rightLevelDb) * 100).toFixed(1)}%`;
+ this.ratio.textContent = fmt.ratio(t.dopplerRatio);
+ this.cents.textContent = fmt.cents(t.dopplerCents);
+ this.speed.textContent = fmt.speed(t.closingSpeed);
+ this.clip.dataset.on = String(t.clipping);
+ }
+}
diff --git a/src/ui/signalPath.ts b/src/ui/signalPath.ts
new file mode 100644
index 0000000..0cfa408
--- /dev/null
+++ b/src/ui/signalPath.ts
@@ -0,0 +1,123 @@
+import { Telemetry } from '../audio';
+import { el } from './controls';
+import { fmt } from './format';
+import { dbToFill } from './meters';
+
+interface Row {
+ tr: HTMLTableRowElement;
+ detail: HTMLTableCellElement;
+ db: HTMLTableCellElement;
+ fill: HTMLElement | null;
+}
+
+function row(label: string, segment: string | null, isTotal = false): Row {
+ const detail = el('td', { class: 'detail' });
+ const db = el('td', { class: 'db' });
+ const cells: HTMLElement[] = [el('th', { scope: 'row', text: label }), detail, db];
+
+ let fill: HTMLElement | null = null;
+ if (segment) {
+ fill = el('i', { 'data-seg': segment });
+ cells.push(el('td', { class: 'bar' }, [el('div', { class: 'meter' }, [fill])]));
+ } else {
+ cells.push(el('td', { class: 'bar' }));
+ }
+
+ const tr = el('tr', { class: isTotal ? 'total' : '' }, cells) as HTMLTableRowElement;
+ return { tr, detail, db, fill };
+}
+
+/**
+ * The gain budget: every stage between the source and the ear, with its loss in
+ * dB on one shared −60…0 dB scale so the bars are directly comparable.
+ *
+ * This is the answer to "why does it sound like that". It is a real DOM table
+ * rather than a canvas so the most important telemetry is not trapped in pixels
+ * where a screen reader cannot reach it.
+ */
+export class SignalPath {
+ readonly root: HTMLElement;
+
+ private readonly source = row('Source', 'survive');
+ private readonly distance = row('Distance', 'distance');
+ private readonly directivity = row('Directivity', 'directivity');
+ private readonly occlusion = row('Occlusion', 'occlusion');
+ private readonly air = row('Air', null);
+ private readonly direct = row('At your ear', 'survive', true);
+ private readonly room = row('Room (wet)', 'room');
+ private readonly ratio = row('Direct / room', null);
+
+ private cache = new Map();
+
+ constructor(private readonly presetLabel: () => string, private readonly volume: () => number) {
+ const body = el('tbody', {}, [
+ this.source.tr,
+ this.distance.tr,
+ this.directivity.tr,
+ this.occlusion.tr,
+ this.air.tr,
+ this.direct.tr,
+ this.room.tr,
+ this.ratio.tr,
+ ]);
+ this.root = el('table', { class: 'path' }, [
+ el('caption', { class: 'visually-hidden', text: 'Signal path: gain at each stage' }),
+ body,
+ ]);
+ }
+
+ update(t: Telemetry): void {
+ this.text(this.source.detail, this.presetLabel());
+ this.text(this.source.db, fmt.db(20 * Math.log10(Math.max(this.volume(), 1e-4))));
+ this.bar(this.source.fill, 1);
+
+ this.text(this.distance.detail, fmt.metres(t.distance));
+ this.text(this.distance.db, fmt.db(t.distanceGainDb));
+ this.bar(this.distance.fill, dbToFill(t.distanceGainDb));
+
+ this.text(this.directivity.detail, `${fmt.degrees(t.offAxisAngle)} off-axis`);
+ this.text(this.directivity.db, fmt.db(t.coneGainDb));
+ this.bar(this.directivity.fill, dbToFill(t.coneGainDb));
+
+ const blocked = t.occlusion > 0.01;
+ this.text(
+ this.occlusion.detail,
+ blocked ? `${fmt.percent(t.occlusion)} blocked · LP ${fmt.hz(t.occlusionCutoff)}` : 'clear'
+ );
+ this.text(this.occlusion.db, fmt.db(t.occlusionGainDb));
+ this.bar(this.occlusion.fill, dbToFill(t.occlusionGainDb));
+ this.occlusion.tr.dataset.flag = blocked ? 'warn' : '';
+
+ // Air absorption only shapes tone; it has no broadband dB to report, so
+ // printing one would break the guarantee that the stages sum to the total.
+ this.text(this.air.detail, `tone only · LP ${fmt.hz(t.airCutoff)}`);
+ this.text(this.air.db, '—');
+
+ this.text(this.direct.detail, `${fmt.ms(t.timeOfFlightMs)} flight`);
+ this.text(this.direct.db, fmt.db(t.directGainDb));
+ this.bar(this.direct.fill, dbToFill(t.directGainDb));
+
+ this.text(this.room.detail, `t60 ${fmt.seconds(t.t60)}`);
+ this.text(this.room.db, fmt.db(t.reverbGainDb));
+ this.bar(this.room.fill, dbToFill(t.reverbGainDb));
+
+ this.text(
+ this.ratio.detail,
+ t.reverbDominant ? 'room-dominant' : `direct-dominant · rc ${fmt.metres(t.criticalDistance)}`
+ );
+ this.text(this.ratio.db, fmt.db(t.directToReverbDb));
+ this.ratio.tr.dataset.flag = t.reverbDominant ? 'warn' : '';
+ }
+
+ /** Writes only when the rendered string actually changed. */
+ private text(node: HTMLElement, value: string): void {
+ if (this.cache.get(node) === value) return;
+ this.cache.set(node, value);
+ node.textContent = value;
+ }
+
+ private bar(node: HTMLElement | null, fraction: number): void {
+ if (!node) return;
+ node.style.width = `${(fraction * 100).toFixed(1)}%`;
+ }
+}
diff --git a/test/engine.test.ts b/test/engine.test.ts
new file mode 100644
index 0000000..2583a24
--- /dev/null
+++ b/test/engine.test.ts
@@ -0,0 +1,440 @@
+import { afterEach, beforeEach, describe, expect, it } from 'vitest';
+import { AudioEngine, DEFAULT_SETTINGS, SpatialInput } from '../src/audio/AudioEngine';
+import { MockAudioContext, MockParam, installMockAudio } from './mockAudioContext';
+
+let audio: ReturnType;
+
+beforeEach(() => {
+ audio = installMockAudio();
+});
+
+afterEach(() => {
+ audio.restore();
+});
+
+const ctxOf = () => audio.instances[0] as MockAudioContext;
+
+function poseAt(x: number, z = 0, extra: Partial = {}): SpatialInput {
+ return {
+ sourcePos: { x: 0, y: 1.5, z: 0 },
+ sourceForward: { x: 0, y: 0, z: -1 },
+ listenerPos: { x, y: 1.5, z },
+ listenerForward: { x: 0, y: 0, z: -1 },
+ listenerUp: { x: 0, y: 1, z: 0 },
+ occlusion: 0,
+ ...extra,
+ };
+}
+
+/** Settles the delay-line smoothing so telemetry reflects a steady state. */
+function settle(engine: AudioEngine, pose: SpatialInput, frames = 400) {
+ let telemetry = engine.updateSpatial(pose, 1 / 60);
+ for (let i = 0; i < frames; i++) telemetry = engine.updateSpatial(pose, 1 / 60);
+ return telemetry;
+}
+
+describe('graph topology', () => {
+ it('wires the documented signal path', async () => {
+ const engine = new AudioEngine();
+ await engine.start();
+ const edges = ctxOf().edges();
+
+ expect(edges).toContain('gain->delay'); // sourceGain -> delay
+ expect(edges).toContain('delay->biquad'); // -> air absorption
+ expect(edges).toContain('biquad->biquad'); // air -> occlusion
+ expect(edges).toContain('biquad->gain'); // occlusion -> directGain
+ expect(edges).toContain('gain->panner');
+ expect(edges).toContain('gain->convolver'); // reverbSend -> convolver
+ expect(edges).toContain('convolver->gain'); // wet -> master
+ expect(edges).toContain('gain->compressor'); // master -> limiter
+ expect(edges).toContain('compressor->analyser');
+ expect(edges).toContain('analyser->destination');
+ });
+
+ it('taps the reverb send before distance, cone and occlusion', async () => {
+ const engine = new AudioEngine();
+ await engine.start();
+ const ctx = ctxOf();
+
+ // The wet send must hang off the delay, not off the direct gain: a room's
+ // reverberant field does not get quieter because you walked away.
+ const feedsConvolver = ctx.connections.find((c) => c.to.kind === 'convolver');
+ const sendSource = ctx.connections.find((c) => c.to === feedsConvolver?.from);
+ expect(sendSource?.from.kind).toBe('delay');
+ });
+
+ it('neuters the panner so only our own physics sets level', async () => {
+ const engine = new AudioEngine();
+ await engine.start();
+ const panner = ctxOf().nodeOf('panner') as unknown as Record;
+ expect(panner.panningModel).toBe('HRTF');
+ expect(panner.rolloffFactor).toBe(0);
+ expect(panner.coneOuterGain).toBe(1);
+ });
+});
+
+describe('listener orientation', () => {
+ it('publishes forward and up, not just position', async () => {
+ const engine = new AudioEngine();
+ await engine.start();
+ const listener = ctxOf().listener;
+
+ engine.updateSpatial(
+ {
+ ...poseAt(5),
+ listenerForward: { x: 1, y: 0, z: 0 },
+ listenerUp: { x: 0, y: 1, z: 0 },
+ },
+ 1 / 60
+ );
+
+ expect(listener.forwardX.value).toBeCloseTo(1, 6);
+ expect(listener.forwardZ.value).toBeCloseTo(0, 6);
+ expect(listener.upY.value).toBeCloseTo(1, 6);
+ expect(listener.forwardX.events.length).toBeGreaterThan(0);
+ });
+
+ it('tracks a turning head', async () => {
+ const engine = new AudioEngine();
+ await engine.start();
+ const listener = ctxOf().listener;
+
+ engine.updateSpatial({ ...poseAt(5), listenerForward: { x: 0, y: 0, z: -1 } }, 1 / 60);
+ expect(listener.forwardZ.value).toBeCloseTo(-1, 6);
+
+ engine.updateSpatial({ ...poseAt(5), listenerForward: { x: 0, y: 0, z: 1 } }, 1 / 60);
+ expect(listener.forwardZ.value).toBeCloseTo(1, 6);
+ });
+
+ it('normalises whatever it is handed', async () => {
+ const engine = new AudioEngine();
+ await engine.start();
+ const listener = ctxOf().listener;
+
+ engine.updateSpatial({ ...poseAt(5), listenerForward: { x: 0, y: 0, z: -9 } }, 1 / 60);
+ expect(listener.forwardZ.value).toBeCloseTo(-1, 6);
+ });
+
+ it('keeps the panner on the source', async () => {
+ const engine = new AudioEngine();
+ await engine.start();
+ const panner = ctxOf().nodeOf('panner') as unknown as Record;
+
+ engine.updateSpatial(
+ { ...poseAt(5), sourcePos: { x: -3, y: 2, z: 7 } },
+ 1 / 60
+ );
+ expect(panner.positionX.value).toBeCloseTo(-3, 6);
+ expect(panner.positionZ.value).toBeCloseTo(7, 6);
+ });
+});
+
+describe('gain staging', () => {
+ it('drives the direct gain from distance, cone and occlusion together', async () => {
+ const engine = new AudioEngine();
+ await engine.start();
+ const ctx = ctxOf();
+ const directGain = ctx.feederOf('gain', 'panner');
+
+ const telemetry = engine.updateSpatial(poseAt(4), 1 / 60);
+ const expected = Math.pow(10, telemetry.directGainDb / 20);
+ expect(directGain.gain.value).toBeCloseTo(expected, 5);
+ });
+
+ it('keeps the reverb send level independent of listener distance', async () => {
+ const engine = new AudioEngine();
+ await engine.start();
+ const send = ctxOf().feederOf('gain', 'convolver');
+
+ engine.updateSpatial(poseAt(2), 1 / 60);
+ const near = send.gain.value;
+ engine.updateSpatial(poseAt(40), 1 / 60);
+ const far = send.gain.value;
+
+ expect(near).toBeGreaterThan(0);
+ expect(far).toBeCloseTo(near, 6);
+ });
+
+ it('applies master volume on its own stage, not folded into the physics', async () => {
+ const engine = new AudioEngine();
+ await engine.start();
+ const ctx = ctxOf();
+ const directGain = ctx.feederOf('gain', 'panner');
+ const master = ctx.feederOf('gain', 'compressor');
+
+ const before = (engine.updateSpatial(poseAt(4), 1 / 60), directGain.gain.value);
+ engine.setMasterVolume(0.25);
+ engine.updateSpatial(poseAt(4), 1 / 60);
+
+ expect(master.gain.value).toBeCloseTo(0.25, 6);
+ // Changing the volume must not move the acoustic gain, or the signal-path
+ // readout would stop describing the physics.
+ expect(directGain.gain.value).toBeCloseTo(before, 6);
+ });
+
+ it('lowers the direct level as the listener walks away', async () => {
+ const engine = new AudioEngine();
+ await engine.start();
+ const near = engine.updateSpatial(poseAt(2), 1 / 60).directGainDb;
+ const far = engine.updateSpatial(poseAt(20), 1 / 60).directGainDb;
+ expect(far).toBeLessThan(near - 15);
+ });
+
+ it('attenuates and muffles a blocked path', async () => {
+ const engine = new AudioEngine();
+ await engine.start();
+ const clear = engine.updateSpatial(poseAt(6, 0, { occlusion: 0 }), 1 / 60);
+ const blocked = engine.updateSpatial(poseAt(6, 0, { occlusion: 1 }), 1 / 60);
+
+ expect(blocked.directGainDb).toBeLessThan(clear.directGainDb);
+ expect(blocked.occlusionCutoff).toBeLessThan(clear.occlusionCutoff / 10);
+ });
+
+ it('attenuates when the source is aimed away', async () => {
+ const engine = new AudioEngine();
+ await engine.start();
+ const onAxis = engine.updateSpatial(
+ { ...poseAt(0, -6), sourceForward: { x: 0, y: 0, z: -1 } },
+ 1 / 60
+ );
+ const offAxis = engine.updateSpatial(
+ { ...poseAt(0, -6), sourceForward: { x: 0, y: 0, z: 1 } },
+ 1 / 60
+ );
+ expect(offAxis.coneGainDb).toBeLessThan(onAxis.coneGainDb - 10);
+ });
+});
+
+describe('propagation and doppler', () => {
+ it('settles the delay line on the true time of flight', async () => {
+ const engine = new AudioEngine();
+ await engine.start();
+ const telemetry = settle(engine, poseAt(34.3));
+ // 34.3 m at 343 m/s is 100 ms.
+ expect(telemetry.timeOfFlightMs).toBeCloseTo(100, 0);
+ });
+
+ it('reports no shift once the geometry is still', async () => {
+ const engine = new AudioEngine();
+ await engine.start();
+ const telemetry = settle(engine, poseAt(10));
+ expect(telemetry.dopplerRatio).toBeCloseTo(1, 3);
+ expect(Math.abs(telemetry.dopplerCents)).toBeLessThan(2);
+ });
+
+ it('raises pitch as the gap closes and lowers it as the gap opens', async () => {
+ const engine = new AudioEngine();
+ await engine.start();
+ settle(engine, poseAt(30));
+
+ // Sweep the listener inwards: the delay shortens, so pitch must rise.
+ let approaching = 1;
+ for (let d = 30; d > 10; d -= 0.5) {
+ approaching = engine.updateSpatial(poseAt(d), 1 / 60).dopplerRatio;
+ }
+ expect(approaching).toBeGreaterThan(1.0005);
+
+ let receding = 1;
+ for (let d = 10; d < 30; d += 0.5) {
+ receding = engine.updateSpatial(poseAt(d), 1 / 60).dopplerRatio;
+ }
+ expect(receding).toBeLessThan(0.9995);
+ });
+
+ it('stays silent about doppler when propagation is switched off', async () => {
+ const engine = new AudioEngine({ propagationEnabled: false });
+ await engine.start();
+ settle(engine, poseAt(30));
+ for (let d = 30; d > 10; d -= 0.5) engine.updateSpatial(poseAt(d), 1 / 60);
+ const telemetry = engine.updateSpatial(poseAt(10), 1 / 60);
+ expect(telemetry.timeOfFlightMs).toBeCloseTo(0, 3);
+ expect(telemetry.dopplerRatio).toBeCloseTo(1, 6);
+ });
+
+ it('reports the shift a delay line actually produces, 1 − dD/dt', async () => {
+ const engine = new AudioEngine();
+ await engine.start();
+ settle(engine, poseAt(30));
+
+ // Close at a steady rate and compare the reported ratio against 1 − D'
+ // reconstructed from the reported time of flight.
+ let previousFlight = engine.updateSpatial(poseAt(30), 1 / 60).timeOfFlightMs;
+ let checked = 0;
+ for (let d = 29.5; d > 12; d -= 0.5) {
+ const t = engine.updateSpatial(poseAt(d), 1 / 60);
+ const delayRate = (t.timeOfFlightMs - previousFlight) / 1000 / (1 / 60);
+ previousFlight = t.timeOfFlightMs;
+ expect(t.dopplerRatio).toBeCloseTo(1 - delayRate, 6);
+ checked++;
+ }
+ expect(checked).toBeGreaterThan(10);
+ });
+
+ it('keeps the shift on the correct side of unity even when closing hard', async () => {
+ // The textbook 1/(1 − v/c) form has a pole that flips sign here, reporting
+ // a two-octave drop for a source rushing towards the listener.
+ const engine = new AudioEngine({ speedOfSound: 80 });
+ await engine.start();
+ settle(engine, poseAt(40));
+ for (let d = 39; d > 2; d -= 3) {
+ const t = engine.updateSpatial(poseAt(d), 1 / 60);
+ expect(t.dopplerRatio).toBeGreaterThanOrEqual(1);
+ expect(t.closingSpeed).toBeGreaterThan(0);
+ }
+ });
+
+ it('does not saturate the delay line at the slowest speed of sound', async () => {
+ const engine = new AudioEngine({ speedOfSound: 80, room: { width: 60, height: 60, depth: 60 } });
+ await engine.start();
+ // Room diagonal is ~104 m; at 80 m/s that is 1.3 s of flight.
+ const telemetry = settle(engine, poseAt(100), 2000);
+ expect(telemetry.timeOfFlightMs).toBeCloseTo(1250, -1);
+ });
+
+ it('never lets a violent jump produce an absurd pitch', async () => {
+ const engine = new AudioEngine();
+ await engine.start();
+ settle(engine, poseAt(60));
+ // Teleport the listener: a naive velocity estimate would chirp an octave.
+ const telemetry = engine.updateSpatial(poseAt(0.5), 1 / 60);
+ expect(telemetry.dopplerRatio).toBeLessThanOrEqual(4);
+ expect(telemetry.dopplerRatio).toBeGreaterThanOrEqual(0.25);
+ expect(Number.isFinite(telemetry.dopplerCents)).toBe(true);
+ });
+});
+
+describe('lifecycle', () => {
+ it('reports live telemetry before any AudioContext exists', () => {
+ const engine = new AudioEngine();
+ const telemetry = engine.updateSpatial(poseAt(8), 1 / 60);
+
+ expect(engine.context).toBeNull();
+ expect(telemetry.running).toBe(false);
+ expect(telemetry.distance).toBeCloseTo(8, 6);
+ expect(telemetry.distanceGainDb).toBeLessThan(0);
+ expect(telemetry.criticalDistance).toBeGreaterThan(0);
+ expect(audio.instances).toHaveLength(0);
+ });
+
+ it('resumes a suspended context on start', async () => {
+ const engine = new AudioEngine();
+ await engine.start();
+ expect(ctxOf().state).toBe('running');
+ expect(engine.isPlaying).toBe(true);
+ });
+
+ it('is idempotent across repeated start and stop', async () => {
+ const engine = new AudioEngine();
+ await engine.start();
+ await engine.start();
+ expect(ctxOf().started).toHaveLength(1);
+
+ engine.stop();
+ engine.stop();
+ expect(engine.isPlaying).toBe(false);
+
+ await engine.start();
+ expect(engine.isPlaying).toBe(true);
+ expect(ctxOf().started).toHaveLength(2);
+ });
+
+ it('swaps presets without tearing down the graph', async () => {
+ const engine = new AudioEngine();
+ await engine.start();
+ const edgeCount = ctxOf().connections.length;
+
+ engine.update({ preset: 'engine' });
+ engine.update({ preset: 'beacon' });
+
+ expect(engine.getSettings().preset).toBe('beacon');
+ // A new source node reconnects; the rest of the graph must be untouched.
+ expect(ctxOf().connections.length).toBeLessThanOrEqual(edgeCount + 4);
+ });
+
+ it('rebuilds room acoustics when the geometry changes', async () => {
+ const engine = new AudioEngine();
+ await engine.start();
+ const before = engine.getAcoustics();
+
+ engine.update({ room: { width: 40, height: 16, depth: 40 }, surface: 'cathedral' });
+ const after = engine.getAcoustics();
+
+ expect(after.t60).toBeGreaterThan(before.t60);
+ expect(after.criticalDistance).toBeLessThan(before.criticalDistance);
+ });
+
+ it('merges partial room patches instead of dropping dimensions', () => {
+ const engine = new AudioEngine();
+ engine.update({ room: { width: 30 } as never });
+ const room = engine.getSettings().room;
+ expect(room.width).toBe(30);
+ expect(room.height).toBe(DEFAULT_SETTINGS.room.height);
+ expect(room.depth).toBe(DEFAULT_SETTINGS.room.depth);
+ });
+
+ it('clamps master volume', () => {
+ const engine = new AudioEngine();
+ engine.setMasterVolume(5);
+ expect(engine.getSettings().masterVolume).toBe(1);
+ engine.setMasterVolume(-2);
+ expect(engine.getSettings().masterVolume).toBe(0);
+ });
+
+ it('produces finite telemetry for degenerate geometry', () => {
+ const engine = new AudioEngine();
+ const coincident = engine.updateSpatial(
+ {
+ sourcePos: { x: 1, y: 1, z: 1 },
+ sourceForward: { x: 0, y: 0, z: 0 },
+ listenerPos: { x: 1, y: 1, z: 1 },
+ listenerForward: { x: 0, y: 0, z: 0 },
+ listenerUp: { x: 0, y: 0, z: 0 },
+ occlusion: 0,
+ },
+ 0
+ );
+ for (const value of Object.values(coincident)) {
+ if (typeof value === 'number') expect(Number.isFinite(value)).toBe(true);
+ }
+ });
+});
+
+describe('telemetry consistency', () => {
+ it('sums the stage losses into the total, exactly', async () => {
+ const engine = new AudioEngine();
+ await engine.start();
+ const t = engine.updateSpatial(
+ poseAt(0, -7, { occlusion: 0.6, sourceForward: { x: 1, y: 0, z: 0 } }),
+ 1 / 60
+ );
+ expect(t.distanceGainDb + t.coneGainDb + t.occlusionGainDb).toBeCloseTo(t.directGainDb, 6);
+ });
+
+ it('derives the direct-to-reverb ratio from the two levels it reports', async () => {
+ const engine = new AudioEngine();
+ await engine.start();
+ const t = engine.updateSpatial(poseAt(9), 1 / 60);
+ expect(t.directToReverbDb).toBeCloseTo(t.directGainDb - t.reverbGainDb, 6);
+ });
+
+ it('flags room dominance at the critical distance when the listener is on-axis', async () => {
+ const engine = new AudioEngine();
+ await engine.start();
+ const rc = engine.getAcoustics().criticalDistance;
+ // poseAt(0, -d) puts the listener dead ahead of a source facing −Z, so the
+ // cone contributes no attenuation and distance alone decides.
+ expect(engine.updateSpatial(poseAt(0, -rc * 0.5), 1 / 60).reverbDominant).toBe(false);
+ expect(engine.updateSpatial(poseAt(0, -rc * 2), 1 / 60).reverbDominant).toBe(true);
+ });
+
+ it('counts directivity towards room dominance, not just distance', async () => {
+ const engine = new AudioEngine();
+ await engine.start();
+ const rc = engine.getAcoustics().criticalDistance;
+ // Well inside the critical distance, but aimed away: the direct sound loses
+ // to the reverberant field even though the source is close.
+ const behind = engine.updateSpatial(poseAt(0, rc * 0.5), 1 / 60);
+ expect(behind.distance).toBeLessThan(rc);
+ expect(behind.reverbDominant).toBe(true);
+ });
+});
diff --git a/test/format.test.ts b/test/format.test.ts
new file mode 100644
index 0000000..8a03777
--- /dev/null
+++ b/test/format.test.ts
@@ -0,0 +1,50 @@
+import { describe, expect, it } from 'vitest';
+import { fmt } from '../src/ui/format';
+
+const stripThin = (s: string) => s.replace(/ /g, ' ');
+
+describe('value formatting', () => {
+ it('uses a real minus sign, not a hyphen', () => {
+ expect(fmt.db(-12.3)).toContain('−');
+ expect(fmt.db(-12.3)).not.toContain('-');
+ });
+
+ it('never renders a negative zero', () => {
+ // A Doppler ratio a hair under 1 must read "0.0 ¢", not "−0.0 ¢".
+ expect(stripThin(fmt.cents(-0.0001))).toBe('0.0 ¢');
+ expect(stripThin(fmt.cents(0))).toBe('0.0 ¢');
+ expect(stripThin(fmt.speed(-0.004))).toBe('0.0 m/s');
+ expect(stripThin(fmt.db(-0.02))).toBe('0.0 dB');
+ });
+
+ it('signs real values', () => {
+ expect(stripThin(fmt.cents(38.2))).toBe('+38.2 ¢');
+ expect(stripThin(fmt.cents(-38.2))).toBe('−38.2 ¢');
+ });
+
+ it('renders silence as −∞ rather than a huge number', () => {
+ expect(stripThin(fmt.db(-120))).toBe('−∞ dB');
+ expect(stripThin(fmt.db(-500))).toBe('−∞ dB');
+ });
+
+ it('survives non-finite input', () => {
+ for (const formatter of [fmt.metres, fmt.db, fmt.degrees, fmt.seconds, fmt.ms, fmt.speed]) {
+ expect(formatter(Number.NaN)).toBe('—');
+ expect(formatter(Number.POSITIVE_INFINITY)).toBe('—');
+ }
+ expect(fmt.hz(Number.NaN)).toBe('—');
+ expect(fmt.cents(Number.NaN)).toBe('—');
+ });
+
+ it('switches Hz to kHz with three significant figures', () => {
+ expect(stripThin(fmt.hz(350))).toBe('350 Hz');
+ expect(stripThin(fmt.hz(1700))).toBe('1.70 kHz');
+ expect(stripThin(fmt.hz(22050))).toBe('22.1 kHz');
+ });
+
+ it('keeps decimals fixed so numbers do not jitter in width', () => {
+ expect(stripThin(fmt.metres(5))).toBe('5.00 m');
+ expect(stripThin(fmt.metres(12.345))).toBe('12.35 m');
+ expect(stripThin(fmt.ratio(1))).toBe('×1.0000');
+ });
+});
diff --git a/test/mockAudioContext.ts b/test/mockAudioContext.ts
new file mode 100644
index 0000000..2156777
--- /dev/null
+++ b/test/mockAudioContext.ts
@@ -0,0 +1,258 @@
+/**
+ * A minimal Web Audio stand-in: enough of the API for AudioEngine to build its
+ * graph, with the connections recorded so tests can assert on the topology
+ * rather than on implementation details.
+ */
+
+export interface Connection {
+ from: MockNode;
+ to: MockNode;
+}
+
+let nodeCounter = 0;
+
+export class MockParam {
+ value: number;
+ readonly events: Array<{ type: string; value: number; time: number }> = [];
+
+ constructor(value = 0) {
+ this.value = value;
+ }
+
+ setValueAtTime(value: number, time: number): this {
+ this.value = value;
+ this.events.push({ type: 'setValueAtTime', value, time });
+ return this;
+ }
+
+ setTargetAtTime(value: number, time: number): this {
+ this.value = value;
+ this.events.push({ type: 'setTargetAtTime', value, time });
+ return this;
+ }
+
+ linearRampToValueAtTime(value: number, time: number): this {
+ this.value = value;
+ this.events.push({ type: 'linearRamp', value, time });
+ return this;
+ }
+
+ cancelScheduledValues(): this {
+ return this;
+ }
+}
+
+export class MockNode {
+ readonly id: string;
+ readonly outputs: MockNode[] = [];
+
+ constructor(readonly kind: string, readonly ctx: MockAudioContext) {
+ this.id = `${kind}#${nodeCounter++}`;
+ ctx.nodes.push(this);
+ }
+
+ connect(target: MockNode): MockNode {
+ this.outputs.push(target);
+ this.ctx.connections.push({ from: this, to: target });
+ return target;
+ }
+
+ disconnect(): void {
+ this.outputs.length = 0;
+ }
+}
+
+class MockAudioParamNode extends MockNode {
+ readonly gain = new MockParam(1);
+}
+
+export class MockAudioContext {
+ readonly connections: Connection[] = [];
+ readonly nodes: MockNode[] = [];
+ readonly sampleRate = 48000;
+ currentTime = 0;
+ state: AudioContextState = 'suspended';
+
+ readonly destination = new MockNode('destination', this);
+ readonly listener = {
+ positionX: new MockParam(),
+ positionY: new MockParam(),
+ positionZ: new MockParam(),
+ forwardX: new MockParam(0),
+ forwardY: new MockParam(0),
+ forwardZ: new MockParam(-1),
+ upX: new MockParam(0),
+ upY: new MockParam(1),
+ upZ: new MockParam(0),
+ };
+
+ readonly started: MockNode[] = [];
+
+ async resume(): Promise {
+ this.state = 'running';
+ }
+
+ async close(): Promise {
+ this.state = 'closed';
+ }
+
+ createGain() {
+ return new MockAudioParamNode('gain', this);
+ }
+
+ createDelay(maxDelay = 1) {
+ const node = new MockNode('delay', this) as MockNode & {
+ delayTime: MockParam;
+ maxDelayTime: number;
+ };
+ node.delayTime = new MockParam(0);
+ node.maxDelayTime = maxDelay;
+ return node;
+ }
+
+ createBiquadFilter() {
+ const node = new MockNode('biquad', this) as MockNode & {
+ type: string;
+ frequency: MockParam;
+ Q: MockParam;
+ };
+ node.type = 'lowpass';
+ node.frequency = new MockParam(350);
+ node.Q = new MockParam(1);
+ return node;
+ }
+
+ createPanner() {
+ const node = new MockNode('panner', this) as MockNode & Record;
+ node.panningModel = 'equalpower';
+ node.distanceModel = 'inverse';
+ node.rolloffFactor = 1;
+ node.coneInnerAngle = 360;
+ node.coneOuterAngle = 360;
+ node.coneOuterGain = 0;
+ node.positionX = new MockParam();
+ node.positionY = new MockParam();
+ node.positionZ = new MockParam();
+ node.orientationX = new MockParam(1);
+ node.orientationY = new MockParam(0);
+ node.orientationZ = new MockParam(0);
+ return node;
+ }
+
+ createConvolver() {
+ const node = new MockNode('convolver', this) as MockNode & {
+ buffer: AudioBuffer | null;
+ normalize: boolean;
+ };
+ node.buffer = null;
+ node.normalize = true;
+ return node;
+ }
+
+ createDynamicsCompressor() {
+ const node = new MockNode('compressor', this) as MockNode & Record;
+ for (const key of ['threshold', 'knee', 'ratio', 'attack', 'release']) {
+ node[key] = new MockParam(0);
+ }
+ return node;
+ }
+
+ createAnalyser() {
+ const node = new MockNode('analyser', this) as MockNode & Record;
+ node.fftSize = 2048;
+ node.frequencyBinCount = 1024;
+ node.smoothingTimeConstant = 0.8;
+ node.minDecibels = -100;
+ node.maxDecibels = -30;
+ node.getByteFrequencyData = (array: Uint8Array) => array.fill(0);
+ node.getByteTimeDomainData = (array: Uint8Array) => array.fill(128);
+ return node;
+ }
+
+ createChannelSplitter() {
+ return new MockNode('splitter', this);
+ }
+
+ createBufferSource() {
+ const ctx = this;
+ const node = new MockNode('bufferSource', this) as MockNode & Record;
+ node.buffer = null;
+ node.loop = false;
+ node.playbackRate = new MockParam(1);
+ node.start = () => {
+ ctx.started.push(node);
+ };
+ node.stop = () => {};
+ return node;
+ }
+
+ /**
+ * Stands in for the browser decoder. A payload whose first byte is 0 is
+ * treated as undecodable, which is how tests exercise the failure path.
+ */
+ async decodeAudioData(bytes: ArrayBuffer): Promise {
+ const view = new Uint8Array(bytes);
+ if (view.length === 0 || view[0] === 0) throw new Error('EncodingError');
+
+ // Two channels of a recognisable ramp, so downmixing can be checked.
+ const length = 4096;
+ const buffer = this.createBuffer(2, length, this.sampleRate);
+ const left = buffer.getChannelData(0);
+ const right = buffer.getChannelData(1);
+ for (let i = 0; i < length; i++) {
+ left[i] = Math.sin((i / length) * Math.PI * 2) * 0.8;
+ right[i] = -left[i] * 0.5;
+ }
+ return buffer;
+ }
+
+ createBuffer(channels: number, length: number, sampleRate: number): AudioBuffer {
+ const data = Array.from({ length: channels }, () => new Float32Array(length));
+ return {
+ numberOfChannels: channels,
+ length,
+ sampleRate,
+ duration: length / sampleRate,
+ getChannelData: (channel: number) => data[channel],
+ copyFromChannel: () => {},
+ copyToChannel: () => {},
+ } as unknown as AudioBuffer;
+ }
+
+ /** Every edge in the graph, as "from->to" strings. */
+ edges(): string[] {
+ return this.connections.map((c) => `${c.from.kind}->${c.to.kind}`);
+ }
+
+ /** The single node of `kind` that feeds a node of `intoKind`. */
+ feederOf(kind: string, intoKind: string): MockNode & { gain: MockParam } {
+ const edge = this.connections.find((c) => c.from.kind === kind && c.to.kind === intoKind);
+ if (!edge) throw new Error(`No ${kind} -> ${intoKind} edge in the graph`);
+ return edge.from as MockNode & { gain: MockParam };
+ }
+
+ nodeOf(kind: string): T {
+ const node = this.nodes.find((n) => n.kind === kind);
+ if (!node) throw new Error(`No ${kind} node in the graph`);
+ return node as T;
+ }
+}
+
+/** Installs the mock as the global AudioContext and returns a reset function. */
+export function installMockAudio(): { restore: () => void; instances: MockAudioContext[] } {
+ const instances: MockAudioContext[] = [];
+ const previous = (globalThis as Record).AudioContext;
+
+ (globalThis as Record).AudioContext = function MockCtor() {
+ const ctx = new MockAudioContext();
+ instances.push(ctx);
+ return ctx;
+ } as unknown as typeof AudioContext;
+
+ return {
+ instances,
+ restore() {
+ (globalThis as Record).AudioContext = previous;
+ },
+ };
+}
diff --git a/test/physics.test.ts b/test/physics.test.ts
new file mode 100644
index 0000000..cdc4d0c
--- /dev/null
+++ b/test/physics.test.ts
@@ -0,0 +1,260 @@
+import { describe, expect, it } from 'vitest';
+import {
+ airAbsorptionCutoff,
+ coneGain,
+ distanceGain,
+ dopplerRatio,
+ gainToDb,
+ inverseDistanceGain,
+ linearDistanceGain,
+ occlusionResponse,
+ offAxisAngle,
+ ratioToCents,
+ smoothingAlpha,
+} from '../src/physics';
+
+const at = (x: number, y = 0, z = 0) => ({ x, y, z });
+const params = { refDistance: 1, maxDistance: 100, rolloffFactor: 1 };
+
+describe('distance attenuation', () => {
+ it('holds unity gain inside the reference distance', () => {
+ expect(inverseDistanceGain(0, params)).toBe(1);
+ expect(inverseDistanceGain(0.5, params)).toBe(1);
+ expect(inverseDistanceGain(1, params)).toBe(1);
+ });
+
+ it('loses 6 dB per doubling of distance, the inverse-distance law', () => {
+ const near = gainToDb(inverseDistanceGain(4, params));
+ const far = gainToDb(inverseDistanceGain(8, params));
+ expect(near - far).toBeCloseTo(6.02, 1);
+ });
+
+ it('never attenuates when rolloff is zero', () => {
+ const flat = { ...params, rolloffFactor: 0 };
+ expect(inverseDistanceGain(50, flat)).toBe(1);
+ });
+
+ it('reaches silence at maxDistance under the linear model', () => {
+ expect(linearDistanceGain(100, params)).toBe(0);
+ expect(linearDistanceGain(1000, params)).toBe(0);
+ });
+
+ it('degrades gracefully when maxDistance is below refDistance', () => {
+ const inverted = { refDistance: 10, maxDistance: 2, rolloffFactor: 1 };
+ expect(linearDistanceGain(1, inverted)).toBe(1);
+ expect(linearDistanceGain(5, inverted)).toBe(0);
+ });
+
+ it('stays within [0, 1] for every model across a wide sweep', () => {
+ for (const model of ['inverse', 'exponential', 'linear'] as const) {
+ for (const d of [0, 0.001, 1, 7, 99, 1e4]) {
+ for (const rolloff of [0, 1, 5]) {
+ const gain = distanceGain(d, model, { ...params, rolloffFactor: rolloff });
+ expect(gain).toBeGreaterThanOrEqual(0);
+ expect(gain).toBeLessThanOrEqual(1);
+ expect(Number.isFinite(gain)).toBe(true);
+ }
+ }
+ }
+ });
+
+ it('decreases monotonically with distance', () => {
+ for (const model of ['inverse', 'exponential', 'linear'] as const) {
+ let previous = Infinity;
+ for (let d = 0; d <= 60; d += 2) {
+ const gain = distanceGain(d, model, params);
+ expect(gain).toBeLessThanOrEqual(previous + 1e-12);
+ previous = gain;
+ }
+ }
+ });
+});
+
+describe('air absorption', () => {
+ it('is transparent at zero strength or zero distance', () => {
+ expect(airAbsorptionCutoff(50, 0)).toBe(22050);
+ expect(airAbsorptionCutoff(0, 4)).toBe(22050);
+ });
+
+ it('rolls the cutoff down as distance grows', () => {
+ const near = airAbsorptionCutoff(5, 1);
+ const far = airAbsorptionCutoff(80, 1);
+ expect(far).toBeLessThan(near);
+ expect(far).toBeGreaterThan(200);
+ });
+
+ it('is barely audible at realistic strength across a room', () => {
+ // Real air costs very little treble over 20 m; the default must reflect that.
+ expect(airAbsorptionCutoff(20, 1)).toBeGreaterThan(15000);
+ });
+
+ it('never falls below the floor even at extreme strength', () => {
+ expect(airAbsorptionCutoff(1000, 8)).toBeGreaterThanOrEqual(200);
+ });
+});
+
+describe('directivity cone', () => {
+ const cone = { innerAngle: 60, outerAngle: 180, outerGain: 0.1 };
+ const source = at(0, 0, 0);
+ const forward = at(0, 0, -1);
+
+ it('is at full level dead ahead', () => {
+ expect(coneGain(source, forward, at(0, 0, -5), cone)).toBe(1);
+ });
+
+ it('is at full level anywhere inside the inner cone', () => {
+ // 25° off-axis, inside the 30° inner half-angle.
+ const listener = at(Math.sin(0.436) * 5, 0, -Math.cos(0.436) * 5);
+ expect(coneGain(source, forward, listener, cone)).toBe(1);
+ });
+
+ it('falls to the floor gain directly behind the source', () => {
+ expect(coneGain(source, forward, at(0, 0, 5), cone)).toBeCloseTo(0.1, 6);
+ });
+
+ it('interpolates across the transition band', () => {
+ // 60° off-axis: past the 30° inner half-angle, short of the 90° outer one.
+ const listener = at(Math.sin(Math.PI / 3) * 5, 0, -Math.cos(Math.PI / 3) * 5);
+ const gain = coneGain(source, forward, listener, cone);
+ expect(gain).toBeGreaterThan(0.1);
+ expect(gain).toBeLessThan(1);
+ });
+
+ it('reaches the floor exactly at the outer half-angle', () => {
+ expect(coneGain(source, forward, at(5, 0, 0), cone)).toBeCloseTo(0.1, 6);
+ });
+
+ it('falls monotonically as the listener swings off-axis', () => {
+ let previous = Infinity;
+ for (let deg = 0; deg <= 180; deg += 10) {
+ const rad = (deg * Math.PI) / 180;
+ const listener = at(Math.sin(rad) * 5, 0, -Math.cos(rad) * 5);
+ const gain = coneGain(source, forward, listener, cone);
+ expect(gain).toBeLessThanOrEqual(previous + 1e-9);
+ previous = gain;
+ }
+ });
+
+ it('is omnidirectional at 360 degrees', () => {
+ const omni = { innerAngle: 360, outerAngle: 360, outerGain: 0 };
+ for (const listener of [at(5), at(-5), at(0, 5), at(0, 0, 5)]) {
+ expect(coneGain(source, forward, listener, omni)).toBe(1);
+ }
+ });
+
+ it('treats a coincident listener as on-axis rather than dividing by zero', () => {
+ expect(coneGain(source, forward, source, cone)).toBe(1);
+ });
+
+ it('survives a degenerate forward vector', () => {
+ const gain = coneGain(source, at(0, 0, 0), at(0, 0, -5), cone);
+ expect(Number.isFinite(gain)).toBe(true);
+ });
+
+ it('reports the off-axis angle in degrees', () => {
+ expect(offAxisAngle(source, forward, at(0, 0, -5))).toBeCloseTo(0, 6);
+ expect(offAxisAngle(source, forward, at(5, 0, 0))).toBeCloseTo(90, 6);
+ expect(offAxisAngle(source, forward, at(0, 0, 5))).toBeCloseTo(180, 6);
+ });
+});
+
+describe('doppler', () => {
+ const still = at(0, 0, 0);
+
+ it('is unity when nothing moves', () => {
+ expect(dopplerRatio(at(0), still, at(10), still)).toBe(1);
+ });
+
+ it('raises pitch when the source approaches', () => {
+ // Source at origin, listener at +10 x, source moving towards it.
+ expect(dopplerRatio(at(0), at(30), at(10), still)).toBeGreaterThan(1);
+ });
+
+ it('lowers pitch when the source recedes', () => {
+ expect(dopplerRatio(at(0), at(-30), at(10), still)).toBeLessThan(1);
+ });
+
+ it('lowers pitch when the listener flees', () => {
+ expect(dopplerRatio(at(0), still, at(10), at(30))).toBeLessThan(1);
+ });
+
+ it('matches the textbook ratio for an approaching source', () => {
+ // f'/f = c / (c - v) = 343 / (343 - 34.3) = 1.1111…
+ expect(dopplerRatio(at(0), at(34.3), at(10), still, 343)).toBeCloseTo(1.1111, 3);
+ });
+
+ it('ignores motion perpendicular to the line of sight', () => {
+ expect(dopplerRatio(at(0), at(0, 0, 40), at(10), still)).toBeCloseTo(1, 6);
+ });
+
+ it('stays finite and bounded at and beyond the speed of sound', () => {
+ for (const v of [343, 400, 5000]) {
+ const ratio = dopplerRatio(at(0), at(v), at(10), still, 343);
+ expect(Number.isFinite(ratio)).toBe(true);
+ expect(ratio).toBeLessThanOrEqual(4);
+ expect(ratio).toBeGreaterThanOrEqual(0.25);
+ }
+ });
+
+ it('falls back to a sane medium when given a nonsense speed of sound', () => {
+ expect(dopplerRatio(at(0), still, at(10), still, 0)).toBe(1);
+ });
+
+ it('returns unity for a coincident source and listener', () => {
+ expect(dopplerRatio(at(5), at(10), at(5), still)).toBe(1);
+ });
+});
+
+describe('occlusion', () => {
+ it('is transparent with a clear line of sight', () => {
+ const clear = occlusionResponse(0);
+ expect(clear.gain).toBe(1);
+ expect(clear.cutoff).toBeCloseTo(22050, 0);
+ });
+
+ it('muffles but does not silence a fully blocked path', () => {
+ const blocked = occlusionResponse(1);
+ expect(blocked.cutoff).toBeCloseTo(350, 0);
+ expect(blocked.gain).toBeGreaterThan(0);
+ expect(blocked.gain).toBeLessThan(0.3);
+ });
+
+ it('sweeps the cutoff monotonically and geometrically', () => {
+ let previous = Infinity;
+ for (let t = 0; t <= 1; t += 0.1) {
+ const { cutoff } = occlusionResponse(t);
+ expect(cutoff).toBeLessThan(previous);
+ previous = cutoff;
+ }
+ // Geometric interpolation puts the halfway point at the geometric mean,
+ // which is what keeps the sweep sounding even.
+ expect(occlusionResponse(0.5).cutoff).toBeCloseTo(Math.sqrt(350 * 22050), 0);
+ });
+
+ it('clamps out-of-range input', () => {
+ expect(occlusionResponse(-3).gain).toBe(1);
+ expect(occlusionResponse(9).gain).toBeCloseTo(occlusionResponse(1).gain, 12);
+ });
+});
+
+describe('unit helpers', () => {
+ it('converts gain to decibels', () => {
+ expect(gainToDb(1)).toBeCloseTo(0, 9);
+ expect(gainToDb(0.5)).toBeCloseTo(-6.02, 2);
+ expect(gainToDb(0)).toBe(-120);
+ });
+
+ it('converts frequency ratios to cents', () => {
+ expect(ratioToCents(1)).toBeCloseTo(0, 9);
+ expect(ratioToCents(2)).toBeCloseTo(1200, 6);
+ expect(ratioToCents(0.5)).toBeCloseTo(-1200, 6);
+ });
+
+ it('produces frame-rate independent smoothing', () => {
+ // Two 8 ms steps must land where one 16 ms step lands.
+ const single = smoothingAlpha(0.016, 0.1);
+ const a = smoothingAlpha(0.008, 0.1);
+ const twice = 1 - (1 - a) * (1 - a);
+ expect(twice).toBeCloseTo(single, 9);
+ });
+});
diff --git a/test/presets.test.ts b/test/presets.test.ts
new file mode 100644
index 0000000..57168e2
--- /dev/null
+++ b/test/presets.test.ts
@@ -0,0 +1,100 @@
+import { describe, expect, it } from 'vitest';
+import { PRESETS, createPresetBuffer } from '../src/audio/presets';
+import { MockAudioContext } from './mockAudioContext';
+
+const ctx = new MockAudioContext() as unknown as BaseAudioContext;
+
+describe('sound presets', () => {
+ it('offers a labelled, explained option for every id', () => {
+ for (const preset of PRESETS) {
+ expect(preset.label.length).toBeGreaterThan(0);
+ expect(preset.hint.length).toBeGreaterThan(0);
+ }
+ expect(new Set(PRESETS.map((p) => p.id)).size).toBe(PRESETS.length);
+ });
+
+ it('renders every preset as finite, audible, unclipped audio', () => {
+ for (const preset of PRESETS) {
+ const buffer = createPresetBuffer(ctx, preset.id);
+ expect(buffer.length).toBeGreaterThan(1000);
+
+ const data = buffer.getChannelData(0);
+ let peak = 0;
+ let energy = 0;
+ for (let i = 0; i < data.length; i++) {
+ expect(Number.isFinite(data[i])).toBe(true);
+ peak = Math.max(peak, Math.abs(data[i]));
+ energy += data[i] * data[i];
+ }
+ expect(peak).toBeGreaterThan(0.05);
+ expect(peak).toBeLessThanOrEqual(1);
+ expect(energy).toBeGreaterThan(0);
+ }
+ });
+
+ it('falls back to a known preset for an unrecognised id', () => {
+ expect(createPresetBuffer(ctx, 'nonsense' as never).length).toBeGreaterThan(0);
+ });
+
+ it('loops without a discontinuity', () => {
+ for (const preset of PRESETS) {
+ const data = createPresetBuffer(ctx, preset.id).getChannelData(0);
+ // Measure against the 99.9th percentile of the internal sample-to-sample
+ // steps, never the maximum. A bug that injects one discontinuity would
+ // raise the maximum and so raise the bar it is being measured against —
+ // it would manufacture its own alibi.
+ const steps = new Float64Array(data.length - 1);
+ for (let i = 1; i < data.length; i++) steps[i - 1] = Math.abs(data[i] - data[i - 1]);
+ steps.sort();
+ const typical = steps[Math.floor(steps.length * 0.999)];
+
+ const seam = Math.abs(data[data.length - 1] - data[0]);
+ expect(seam).toBeLessThanOrEqual(typical * 2 + 0.02);
+ }
+ });
+
+ it('introduces no discontinuity of its own at the crossfade boundary', () => {
+ // The wrap-around crossfade must not swap a seam at index 0 for a seam at
+ // the end of the fade region.
+ for (const preset of ['pink', 'engine'] as const) {
+ const data = createPresetBuffer(ctx, preset).getChannelData(0);
+ const steps = new Float64Array(data.length - 1);
+ for (let i = 1; i < data.length; i++) steps[i - 1] = Math.abs(data[i] - data[i - 1]);
+ const sorted = Float64Array.from(steps).sort();
+ const typical = sorted[Math.floor(sorted.length * 0.999)];
+ let worst = 0;
+ for (const step of steps) worst = Math.max(worst, step);
+ expect(worst).toBeLessThanOrEqual(typical * 3);
+ }
+ });
+
+ it('band-limits the harmonic waveforms so Doppler cannot alias them', () => {
+ // A naive sawtooth carries energy right up to Nyquist; pitching it up folds
+ // the top harmonics back down as inharmonic noise. Building the wave from a
+ // bounded harmonic series leaves an octave of clean headroom instead.
+ const rate = 48000;
+ const fundamental = 165;
+ const data = createPresetBuffer(ctx, 'sawtooth').getChannelData(0);
+
+ const inBand = magnitudeAt(data, fundamental * 20, rate);
+ const nearLimit = magnitudeAt(data, fundamental * 70, rate);
+ const aboveLimit = magnitudeAt(data, fundamental * 90, rate);
+
+ expect(inBand).toBeGreaterThan(0);
+ // Harmonics run out below rate/4, leaving room for a 2x Doppler shift.
+ expect(nearLimit).toBeGreaterThan(aboveLimit * 20);
+ expect(aboveLimit).toBeLessThan(inBand * 0.02);
+ });
+});
+
+/** Single-bin DFT magnitude, for probing one frequency without a full FFT. */
+function magnitudeAt(data: Float32Array, frequency: number, sampleRate: number): number {
+ const omega = (2 * Math.PI * frequency) / sampleRate;
+ let real = 0;
+ let imaginary = 0;
+ for (let i = 0; i < data.length; i++) {
+ real += data[i] * Math.cos(omega * i);
+ imaginary += data[i] * Math.sin(omega * i);
+ }
+ return Math.hypot(real, imaginary) / data.length;
+}
diff --git a/test/reverb.test.ts b/test/reverb.test.ts
new file mode 100644
index 0000000..dce1ff2
--- /dev/null
+++ b/test/reverb.test.ts
@@ -0,0 +1,138 @@
+import { describe, expect, it } from 'vitest';
+import {
+ SURFACES,
+ computeAcoustics,
+ generateImpulseResponse,
+ getSurface,
+ reverberantGain,
+} from '../src/audio/reverb';
+import { MockAudioContext } from './mockAudioContext';
+
+const room = { width: 20, height: 8, depth: 20 };
+
+describe('room acoustics', () => {
+ it('matches Sabine for a worked example', () => {
+ // V = 3200 m³, S = 1440 m², α = 0.22 → T60 = 0.161·3200 / (1440·0.22)
+ const { t60, volume, surfaceArea } = computeAcoustics(room, 0.22);
+ expect(volume).toBe(3200);
+ expect(surfaceArea).toBe(1440);
+ expect(t60).toBeCloseTo((0.161 * 3200) / (1440 * 0.22), 4);
+ });
+
+ it('rings longer in a bigger room', () => {
+ const small = computeAcoustics({ width: 6, height: 3, depth: 6 }, 0.22);
+ const large = computeAcoustics({ width: 40, height: 15, depth: 40 }, 0.22);
+ expect(large.t60).toBeGreaterThan(small.t60);
+ });
+
+ it('rings longer on harder surfaces', () => {
+ const soft = computeAcoustics(room, 0.9);
+ const hard = computeAcoustics(room, 0.035);
+ expect(hard.t60).toBeGreaterThan(soft.t60);
+ });
+
+ it('clamps T60 to a usable range for absurd geometry', () => {
+ const huge = computeAcoustics({ width: 500, height: 200, depth: 500 }, 0.01);
+ expect(huge.t60).toBeLessThanOrEqual(8);
+ expect(huge.t60).toBeGreaterThan(0);
+ });
+
+ it('puts the critical distance closer in a live room than a dead one', () => {
+ const dead = computeAcoustics(room, 0.9);
+ const live = computeAcoustics(room, 0.035);
+ expect(live.criticalDistance).toBeLessThan(dead.criticalDistance);
+ });
+
+ it('produces a physically sensible critical distance for a normal room', () => {
+ // A furnished 20x8x20 room should cross over a couple of metres out.
+ const { criticalDistance } = computeAcoustics(room, 0.22);
+ expect(criticalDistance).toBeGreaterThan(1);
+ expect(criticalDistance).toBeLessThan(6);
+ });
+});
+
+describe('reverberant field level', () => {
+ it('balances wet against dry exactly at the critical distance', () => {
+ const acoustics = computeAcoustics(room, 0.22);
+ // Dry follows 1/r with refDistance 1, so at r = rc it equals 1/rc.
+ expect(reverberantGain(acoustics, 1)).toBeCloseTo(1 / acoustics.criticalDistance, 6);
+ });
+
+ it('is louder in a more reflective room', () => {
+ const dead = reverberantGain(computeAcoustics(room, 0.9));
+ const live = reverberantGain(computeAcoustics(room, 0.035));
+ expect(live).toBeGreaterThan(dead);
+ });
+
+ it('stays bounded', () => {
+ for (const surface of SURFACES) {
+ const gain = reverberantGain(computeAcoustics(room, surface.absorption));
+ expect(gain).toBeGreaterThanOrEqual(0);
+ expect(gain).toBeLessThanOrEqual(1.4);
+ }
+ });
+});
+
+describe('impulse response', () => {
+ const ctx = new MockAudioContext() as unknown as BaseAudioContext;
+
+ it('is stereo, finite and non-silent', () => {
+ const ir = generateImpulseResponse(ctx, room, 0.22);
+ expect(ir.numberOfChannels).toBe(2);
+ expect(ir.length).toBeGreaterThan(1000);
+
+ const left = ir.getChannelData(0);
+ let energy = 0;
+ for (let i = 0; i < left.length; i++) {
+ expect(Number.isFinite(left[i])).toBe(true);
+ expect(Math.abs(left[i])).toBeLessThanOrEqual(1);
+ energy += left[i] * left[i];
+ }
+ expect(energy).toBeGreaterThan(0);
+ });
+
+ it('decays, rather than sustaining or growing', () => {
+ const ir = generateImpulseResponse(ctx, room, 0.22);
+ const data = ir.getChannelData(0);
+ const rms = (from: number, to: number) => {
+ let sum = 0;
+ for (let i = from; i < to; i++) sum += data[i] * data[i];
+ return Math.sqrt(sum / (to - from));
+ };
+ const head = rms(0, Math.floor(data.length * 0.1));
+ const tail = rms(Math.floor(data.length * 0.85), data.length);
+ expect(tail).toBeLessThan(head * 0.2);
+ });
+
+ it('gets longer on harder surfaces', () => {
+ const soft = generateImpulseResponse(ctx, room, 0.9);
+ const hard = generateImpulseResponse(ctx, room, 0.035);
+ expect(hard.length).toBeGreaterThan(soft.length);
+ });
+
+ it('places early reflections at the geometric arrival times', () => {
+ const ir = generateImpulseResponse(ctx, room, 0.09, 343);
+ const data = ir.getChannelData(0);
+ // A 20 m wall path arrives at 20/343 s.
+ const expected = Math.floor((20 / 343) * ir.sampleRate);
+ const neighbourhood = Math.max(
+ ...Array.from({ length: 5 }, (_, i) => Math.abs(data[expected - 2 + i]))
+ );
+ const background = Math.abs(data[expected + 2000]);
+ expect(neighbourhood).toBeGreaterThan(background);
+ });
+});
+
+describe('surfaces', () => {
+ it('exposes an absorption coefficient for every preset', () => {
+ for (const surface of SURFACES) {
+ expect(surface.absorption).toBeGreaterThan(0);
+ expect(surface.absorption).toBeLessThan(1);
+ expect(surface.hint.length).toBeGreaterThan(0);
+ }
+ });
+
+ it('falls back to a normal room for an unknown id', () => {
+ expect(getSurface('nonsense' as never).id).toBe('living');
+ });
+});
diff --git a/test/userAudio.test.ts b/test/userAudio.test.ts
new file mode 100644
index 0000000..1fd105b
--- /dev/null
+++ b/test/userAudio.test.ts
@@ -0,0 +1,185 @@
+import { afterEach, beforeEach, describe, expect, it } from 'vitest';
+import { AudioEngine } from '../src/audio/AudioEngine';
+import {
+ AudioFileError,
+ MAX_FILE_BYTES,
+ decodeAudioFile,
+ downmixToMono,
+ formatDuration,
+} from '../src/audio/userAudio';
+import { MockAudioContext, installMockAudio } from './mockAudioContext';
+
+const ctx = new MockAudioContext() as unknown as BaseAudioContext;
+
+/** A File whose bytes are controllable; first byte 0 means "undecodable". */
+function fakeFile(name: string, bytes: number[], size?: number): File {
+ const data = new Uint8Array(bytes);
+ return {
+ name,
+ size: size ?? data.length,
+ arrayBuffer: async () => data.buffer,
+ } as unknown as File;
+}
+
+describe('downmix to mono', () => {
+ it('averages the channels', () => {
+ const stereo = ctx.createBuffer(2, 4, 48000);
+ stereo.getChannelData(0).set([1, 0, 0.5, -1]);
+ stereo.getChannelData(1).set([0, 1, 0.5, -1]);
+
+ const mono = downmixToMono(ctx, stereo);
+ expect(mono.numberOfChannels).toBe(1);
+ // Averages to [0.5, 0.5, 0.5, -1], then the −1 peak is pulled to 0.98.
+ const data = mono.getChannelData(0);
+ expect(data[0]).toBeCloseTo(0.49, 5);
+ expect(data[1]).toBeCloseTo(0.49, 5);
+ expect(data[2]).toBeCloseTo(0.49, 5);
+ expect(data[3]).toBeCloseTo(-0.98, 5);
+ });
+
+ it('leaves a mono buffer untouched', () => {
+ const source = ctx.createBuffer(1, 4, 48000);
+ expect(downmixToMono(ctx, source)).toBe(source);
+ });
+
+ it('pulls the peak back under unity', () => {
+ const stereo = ctx.createBuffer(2, 2, 48000);
+ stereo.getChannelData(0).set([1, 1]);
+ stereo.getChannelData(1).set([1, 1]);
+ const mono = downmixToMono(ctx, stereo);
+ // Float32 rounding can land a hair above the target, hence the epsilon.
+ for (const sample of mono.getChannelData(0)) expect(Math.abs(sample)).toBeLessThan(0.981);
+ });
+
+ it('preserves length and sample rate', () => {
+ const stereo = ctx.createBuffer(2, 777, 44100);
+ const mono = downmixToMono(ctx, stereo);
+ expect(mono.length).toBe(777);
+ expect(mono.sampleRate).toBe(44100);
+ });
+});
+
+describe('decoding a file', () => {
+ it('returns a mono buffer for good audio', async () => {
+ const buffer = await decodeAudioFile(ctx, fakeFile('song.mp3', [1, 2, 3, 4]));
+ expect(buffer.numberOfChannels).toBe(1);
+ expect(buffer.length).toBeGreaterThan(0);
+ });
+
+ it('rejects an empty file with a readable message', async () => {
+ await expect(decodeAudioFile(ctx, fakeFile('empty.mp3', []))).rejects.toBeInstanceOf(
+ AudioFileError
+ );
+ });
+
+ it('rejects an absurdly large file before reading it', async () => {
+ const huge = fakeFile('huge.wav', [1], MAX_FILE_BYTES + 1);
+ await expect(decodeAudioFile(ctx, huge)).rejects.toThrow(/MB/);
+ });
+
+ it('turns a decoder failure into advice, not a DOMException', async () => {
+ const bad = fakeFile('notes.txt', [0, 0, 0]);
+ await expect(decodeAudioFile(ctx, bad)).rejects.toThrow(/MP3, WAV, FLAC/);
+ await expect(decodeAudioFile(ctx, bad)).rejects.toBeInstanceOf(AudioFileError);
+ });
+
+ it('names the offending file in the error', async () => {
+ await expect(decodeAudioFile(ctx, fakeFile('holiday.docx', [0]))).rejects.toThrow(
+ /holiday\.docx/
+ );
+ });
+});
+
+describe('duration formatting', () => {
+ it('renders minutes and seconds', () => {
+ expect(formatDuration(0)).toBe('0:00');
+ expect(formatDuration(9)).toBe('0:09');
+ expect(formatDuration(227)).toBe('3:47');
+ expect(formatDuration(3600)).toBe('60:00');
+ });
+
+ it('survives nonsense', () => {
+ expect(formatDuration(Number.NaN)).toBe('—');
+ expect(formatDuration(-5)).toBe('—');
+ });
+});
+
+describe('engine integration', () => {
+ let audio: ReturnType;
+
+ beforeEach(() => {
+ audio = installMockAudio();
+ });
+ afterEach(() => {
+ audio.restore();
+ });
+
+ it('loads a file, selects it, and reports it', async () => {
+ const engine = new AudioEngine();
+ const info = await engine.loadUserAudio(fakeFile('track.flac', [1, 2, 3]));
+
+ expect(info.name).toBe('track.flac');
+ expect(info.duration).toBeGreaterThan(0);
+ expect(engine.getSettings().preset).toBe('file');
+ expect(engine.getUserAudio()?.name).toBe('track.flac');
+ });
+
+ it('creates the AudioContext on demand so a picker click can decode', async () => {
+ const engine = new AudioEngine();
+ expect(engine.context).toBeNull();
+ await engine.loadUserAudio(fakeFile('track.wav', [1]));
+ expect(engine.context).not.toBeNull();
+ });
+
+ it('plays the loaded buffer rather than a generated preset', async () => {
+ const engine = new AudioEngine();
+ await engine.loadUserAudio(fakeFile('track.wav', [1]));
+ await engine.start();
+
+ const ctxInstance = audio.instances[0];
+ const started = ctxInstance.started.at(-1) as unknown as { buffer: AudioBuffer };
+ expect(started.buffer).toBe(
+ (engine as unknown as { userBuffer: AudioBuffer }).userBuffer
+ );
+ expect(started.buffer.numberOfChannels).toBe(1);
+ });
+
+ it('swaps to a second file while the first is playing', async () => {
+ const engine = new AudioEngine();
+ await engine.loadUserAudio(fakeFile('one.wav', [1]));
+ await engine.start();
+ const startsAfterFirst = audio.instances[0].started.length;
+
+ await engine.loadUserAudio(fakeFile('two.wav', [2]));
+
+ expect(engine.getUserAudio()?.name).toBe('two.wav');
+ // A second load must actually restart the source, not silently no-op
+ // because the selected source id was already 'file'.
+ expect(audio.instances[0].started.length).toBeGreaterThan(startsAfterFirst);
+ });
+
+ it('leaves the source alone when a bad file is offered', async () => {
+ const engine = new AudioEngine();
+ await engine.start();
+ await expect(engine.loadUserAudio(fakeFile('bad.txt', [0]))).rejects.toBeInstanceOf(
+ AudioFileError
+ );
+ expect(engine.getSettings().preset).toBe('sawtooth');
+ expect(engine.getUserAudio()).toBeNull();
+ });
+
+ it('ignores a request to select the file source when nothing is loaded', () => {
+ const engine = new AudioEngine();
+ engine.update({ preset: 'file' });
+ expect(engine.getSettings().preset).toBe('sawtooth');
+ });
+
+ it('can switch back to a generated preset after loading a file', async () => {
+ const engine = new AudioEngine();
+ await engine.loadUserAudio(fakeFile('track.wav', [1]));
+ engine.update({ preset: 'pink' });
+ expect(engine.getSettings().preset).toBe('pink');
+ // The file stays loaded, so the dropdown entry remains valid.
+ expect(engine.getUserAudio()?.name).toBe('track.wav');
+ });
+});
diff --git a/tsconfig.json b/tsconfig.json
new file mode 100644
index 0000000..888a1a2
--- /dev/null
+++ b/tsconfig.json
@@ -0,0 +1,23 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "useDefineForClassFields": true,
+ "module": "ESNext",
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
+ "skipLibCheck": true,
+
+ /* Bundler mode */
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "noEmit": true,
+
+ /* Linting / Strict */
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "noFallthroughCasesInSwitch": true
+ },
+ "include": ["src", "test"]
+}
diff --git a/vite.config.ts b/vite.config.ts
new file mode 100644
index 0000000..ab6e462
--- /dev/null
+++ b/vite.config.ts
@@ -0,0 +1,22 @@
+///
+import { defineConfig } from 'vite';
+
+export default defineConfig({
+ build: {
+ rollupOptions: {
+ output: {
+ // Three.js is most of the bundle and changes far less often than the
+ // app, so give it its own long-lived chunk.
+ manualChunks: { three: ['three'] },
+ },
+ },
+ },
+ test: {
+ // The unit suite covers pure physics and the audio graph, neither of which
+ // needs a DOM. Browser behaviour is covered by scripts/e2e.mjs against real
+ // Chrome, where a real WebGL context and a real AudioContext exist.
+ environment: 'node',
+ globals: true,
+ include: ['test/**/*.test.ts'],
+ },
+});