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:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user