feat(audio): account for source directivity in diffuse reverb calculations

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
This commit is contained in:
2026-07-26 18:51:27 +02:00
parent 1a827b8b0d
commit 95d5125d3c
8 changed files with 245 additions and 16 deletions
+24 -1
View File
@@ -420,11 +420,34 @@ describe('telemetry consistency', () => {
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;
// The reported critical distance is the on-axis one, sqrt(Q) further out
// than the bare room radius, because the source is directional. Taking the
// room's own radius here would test the crossover of a source this engine
// is not simulating.
const rc = engine.updateSpatial(poseAt(0, -1), 1 / 60).criticalDistance;
expect(rc).toBeGreaterThan(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);
// And it crosses over where it says it does, to within a hair.
expect(engine.updateSpatial(poseAt(0, -rc), 1 / 60).directToReverbDb).toBeCloseTo(0, 6);
});
it('quietens the reverberant field by the cone directivity index', async () => {
const engine = new AudioEngine();
await engine.start();
const directional = engine.updateSpatial(poseAt(0, -1), 1 / 60);
expect(directional.directivityIndexDb).toBeCloseTo(7.05, 2);
// Open the cone right up and the same room gets louder by exactly that much.
engine.update({ coneInnerAngle: 360, coneOuterAngle: 360 });
const omni = engine.updateSpatial(poseAt(0, -1), 1 / 60);
expect(omni.directivityIndexDb).toBeCloseTo(0, 6);
expect(omni.reverbGainDb - directional.reverbGainDb).toBeCloseTo(
directional.directivityIndexDb,
6
);
});
it('counts directivity towards room dominance, not just distance', async () => {