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 + +| | | | | +| --- | --- | --- | --- | +| WASD | 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 | +| 123 | 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 + + +
+
+

Resonance spatial audio studio

+ + + + 70 % + +

+ Audio not started +

+ +
+ +
+
+ + +
+

+ WASD walk + ↑↓←→ move source + drag orbit + G/R move / turn + ? all controls +

+
+ + + + +
+ + + + + + 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'], + }, +});