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
+62
View File
@@ -1,7 +1,9 @@
import { describe, expect, it } from 'vitest';
import type { ConeParams } from '../src/physics';
import {
airAbsorptionCutoff,
coneGain,
directivityFactor,
distanceGain,
dopplerRatio,
gainToDb,
@@ -258,3 +260,63 @@ describe('unit helpers', () => {
expect(twice).toBeCloseTo(single, 9);
});
});
describe('cone directivity factor', () => {
/** Q = 4π / ∫g²dΩ by brute-force quadrature, to check the closed form. */
const quadrature = (p: ConeParams, steps = 200000): number => {
const forward = { x: 0, y: 0, z: -1 };
let integral = 0;
for (let i = 0; i < steps; i++) {
const theta = ((i + 0.5) / steps) * Math.PI;
// Place a listener at angle theta from the source's forward axis.
const listener = { x: Math.sin(theta), y: 0, z: -Math.cos(theta) };
const g = coneGain({ x: 0, y: 0, z: 0 }, forward, listener, p);
integral += g * g * Math.sin(theta) * (Math.PI / steps);
}
return (4 * Math.PI) / (2 * Math.PI * integral);
};
it('is 1 for an omnidirectional source', () => {
expect(directivityFactor({ innerAngle: 360, outerAngle: 360, outerGain: 0.08 })).toBeCloseTo(1, 12);
expect(directivityFactor({ innerAngle: 70, outerAngle: 160, outerGain: 1 })).toBeCloseTo(1, 12);
});
it('matches quadrature for the default cone', () => {
const p = { innerAngle: 70, outerAngle: 160, outerGain: 0.08 };
expect(directivityFactor(p)).toBeCloseTo(quadrature(p), 4);
// A 7.05 dB directivity index — the amount the reverb send must come down.
expect(10 * Math.log10(directivityFactor(p))).toBeCloseTo(7.05, 2);
});
it('matches quadrature across the range the sliders can reach', () => {
const cases: ConeParams[] = [
{ innerAngle: 0, outerAngle: 360, outerGain: 0 },
{ innerAngle: 70, outerAngle: 70, outerGain: 0.08 },
{ innerAngle: 30, outerAngle: 90, outerGain: 0.001 },
{ innerAngle: 10, outerAngle: 20, outerGain: 0.5 },
{ innerAngle: 180, outerAngle: 360, outerGain: 0.3 },
{ innerAngle: 200, outerAngle: 100, outerGain: 0.2 },
];
for (const p of cases) {
expect(directivityFactor(p)).toBeCloseTo(quadrature(p), 3);
}
});
it('never reports less than omnidirectional, whatever the cone', () => {
for (let inner = 0; inner <= 360; inner += 40) {
for (let outer = 0; outer <= 360; outer += 40) {
for (const outerGain of [0, 0.08, 0.5, 1]) {
const q = directivityFactor({ innerAngle: inner, outerAngle: outer, outerGain });
expect(Number.isFinite(q)).toBe(true);
expect(q).toBeGreaterThanOrEqual(1);
}
}
}
});
it('gets more directional as the cone narrows', () => {
const wide = directivityFactor({ innerAngle: 160, outerAngle: 250, outerGain: 0.08 });
const narrow = directivityFactor({ innerAngle: 20, outerAngle: 60, outerGain: 0.08 });
expect(narrow).toBeGreaterThan(wide);
});
});