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:
+24
-1
@@ -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 () => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,10 +2,12 @@ import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
SURFACES,
|
||||
computeAcoustics,
|
||||
criticalDistanceFor,
|
||||
generateImpulseResponse,
|
||||
getSurface,
|
||||
reverberantGain,
|
||||
} from '../src/audio/reverb';
|
||||
import { directivityFactor } from '../src/physics';
|
||||
import { MockAudioContext } from './mockAudioContext';
|
||||
|
||||
const room = { width: 20, height: 8, depth: 20 };
|
||||
@@ -58,6 +60,33 @@ describe('reverberant field level', () => {
|
||||
expect(reverberantGain(acoustics, 1)).toBeCloseTo(1 / acoustics.criticalDistance, 6);
|
||||
});
|
||||
|
||||
it('still balances at the critical distance once the source is directional', () => {
|
||||
const acoustics = computeAcoustics(room, 0.22);
|
||||
const q = directivityFactor({ innerAngle: 70, outerAngle: 160, outerGain: 0.08 });
|
||||
const rc = criticalDistanceFor(acoustics, q);
|
||||
// The invariant is unchanged, it just moves outward: on axis the dry path is
|
||||
// 1/r and the wet path is flat, so they must still cross at exactly rc.
|
||||
expect(reverberantGain(acoustics, 1, q)).toBeCloseTo(1 / rc, 6);
|
||||
expect(rc).toBeCloseTo(acoustics.criticalDistance * Math.sqrt(q), 6);
|
||||
});
|
||||
|
||||
it('drops the wet level by the directivity index of the cone', () => {
|
||||
const acoustics = computeAcoustics(room, 0.22);
|
||||
const q = directivityFactor({ innerAngle: 70, outerAngle: 160, outerGain: 0.08 });
|
||||
const omni = reverberantGain(acoustics, 1, 1);
|
||||
const directional = reverberantGain(acoustics, 1, q);
|
||||
// 10·log10(Q) = 7.05 dB for the default cone.
|
||||
expect(20 * Math.log10(directional / omni)).toBeCloseTo(-10 * Math.log10(q), 6);
|
||||
expect(10 * Math.log10(q)).toBeCloseTo(7.05, 2);
|
||||
});
|
||||
|
||||
it('is unchanged for an omnidirectional source', () => {
|
||||
const acoustics = computeAcoustics(room, 0.22);
|
||||
const q = directivityFactor({ innerAngle: 360, outerAngle: 360, outerGain: 0.08 });
|
||||
expect(q).toBeCloseTo(1, 12);
|
||||
expect(reverberantGain(acoustics, 1, q)).toBeCloseTo(reverberantGain(acoustics, 1), 12);
|
||||
});
|
||||
|
||||
it('is louder in a more reflective room', () => {
|
||||
const dead = reverberantGain(computeAcoustics(room, 0.9));
|
||||
const live = reverberantGain(computeAcoustics(room, 0.035));
|
||||
|
||||
Reference in New Issue
Block a user