Previously, diffuse field reverberant gain and critical distance calculations assumed an omnidirectional sound source (Q = 1), regardless of cone settings. Directional sources concentrate sound energy in specific directions, reducing the total acoustic power injected into the room and lowering the reverberant field level relative to the direct sound on-axis. Incorporate source directivity factor Q into the acoustics model: - Compute closed-form directivity factor Q in src/physics/cone.ts by integrating squared cone gain over the unit sphere. - Scale reverberant field gain by 1 / sqrt(Q) (-10·log10(Q) dB) and adjust critical distance by sqrt(Q) in src/audio/reverb.ts. - Update AudioEngine and signal path UI to present directivity index (DI) and adjust room dominance evaluations. - Add unit tests verifying Q against brute-force numerical quadrature and confirming critical distance behavior. Test Plan: - `npm run typecheck` -- passed clean - `npm test` -- 125/125 tests passed (6 test suites) - `git diff --cached --check` -- no whitespace errors
162 lines
8.5 KiB
Markdown
162 lines
8.5 KiB
Markdown
# Resonance — a spatial audio simulator in Three.js
|
||
|
||
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.
|
||
|
||
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 = √(Q·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.
|
||
|
||
`Q` is the source's directivity factor — how much more intensity it puts on its
|
||
axis than an omnidirectional source of the same total power. It belongs in that
|
||
formula because the reverberant field is fed by *radiated power*, while the ring
|
||
is measured against the *on-axis* direct sound. A cone that throws its energy
|
||
forward excites the room far less than its on-axis level suggests: the default
|
||
70°/160° cone has `Q = 5.07`, a directivity index of 7.1 dB, which pushes the
|
||
ring from 6.1 m out to 13.7 m. Drop `Q` and reflections stay conspicuously loud
|
||
when you walk behind the speaker — the room is being driven as if the speaker
|
||
radiated backwards as hard as it radiates forwards.
|
||
|
||
Press <kbd>3</kbd> for a guided walk through it.
|
||
|
||
## Controls
|
||
|
||
| | | | |
|
||
| --- | --- | --- | --- |
|
||
| <kbd>W</kbd><kbd>A</kbd><kbd>S</kbd><kbd>D</kbd> | walk (you are the mic) | <kbd>Q</kbd> <kbd>E</kbd> | down / up |
|
||
| <kbd>↑</kbd><kbd>↓</kbd><kbd>←</kbd><kbd>→</kbd> | move the source | <kbd>PgUp</kbd> <kbd>PgDn</kbd> | source height |
|
||
| drag | orbit | <kbd>Shift</kbd> | move 3× faster |
|
||
| <kbd>G</kbd> / <kbd>R</kbd> | gizmo: move / turn | double-click | place the source |
|
||
| <kbd>K</kbd> | play / pause | <kbd>H</kbd> | hide panels |
|
||
| <kbd>1</kbd><kbd>2</kbd><kbd>3</kbd> | guided demos | <kbd>?</kbd> | 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.
|