Files
ddidderr 95d5125d3c 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
2026-07-26 18:51:27 +02:00

323 lines
12 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import type { ConeParams } from '../src/physics';
import {
airAbsorptionCutoff,
coneGain,
directivityFactor,
distanceGain,
dopplerRatio,
gainToDb,
inverseDistanceGain,
linearDistanceGain,
occlusionResponse,
offAxisAngle,
ratioToCents,
smoothingAlpha,
} from '../src/physics';
const at = (x: number, y = 0, z = 0) => ({ x, y, z });
const params = { refDistance: 1, maxDistance: 100, rolloffFactor: 1 };
describe('distance attenuation', () => {
it('holds unity gain inside the reference distance', () => {
expect(inverseDistanceGain(0, params)).toBe(1);
expect(inverseDistanceGain(0.5, params)).toBe(1);
expect(inverseDistanceGain(1, params)).toBe(1);
});
it('loses 6 dB per doubling of distance, the inverse-distance law', () => {
const near = gainToDb(inverseDistanceGain(4, params));
const far = gainToDb(inverseDistanceGain(8, params));
expect(near - far).toBeCloseTo(6.02, 1);
});
it('never attenuates when rolloff is zero', () => {
const flat = { ...params, rolloffFactor: 0 };
expect(inverseDistanceGain(50, flat)).toBe(1);
});
it('reaches silence at maxDistance under the linear model', () => {
expect(linearDistanceGain(100, params)).toBe(0);
expect(linearDistanceGain(1000, params)).toBe(0);
});
it('degrades gracefully when maxDistance is below refDistance', () => {
const inverted = { refDistance: 10, maxDistance: 2, rolloffFactor: 1 };
expect(linearDistanceGain(1, inverted)).toBe(1);
expect(linearDistanceGain(5, inverted)).toBe(0);
});
it('stays within [0, 1] for every model across a wide sweep', () => {
for (const model of ['inverse', 'exponential', 'linear'] as const) {
for (const d of [0, 0.001, 1, 7, 99, 1e4]) {
for (const rolloff of [0, 1, 5]) {
const gain = distanceGain(d, model, { ...params, rolloffFactor: rolloff });
expect(gain).toBeGreaterThanOrEqual(0);
expect(gain).toBeLessThanOrEqual(1);
expect(Number.isFinite(gain)).toBe(true);
}
}
}
});
it('decreases monotonically with distance', () => {
for (const model of ['inverse', 'exponential', 'linear'] as const) {
let previous = Infinity;
for (let d = 0; d <= 60; d += 2) {
const gain = distanceGain(d, model, params);
expect(gain).toBeLessThanOrEqual(previous + 1e-12);
previous = gain;
}
}
});
});
describe('air absorption', () => {
it('is transparent at zero strength or zero distance', () => {
expect(airAbsorptionCutoff(50, 0)).toBe(22050);
expect(airAbsorptionCutoff(0, 4)).toBe(22050);
});
it('rolls the cutoff down as distance grows', () => {
const near = airAbsorptionCutoff(5, 1);
const far = airAbsorptionCutoff(80, 1);
expect(far).toBeLessThan(near);
expect(far).toBeGreaterThan(200);
});
it('is barely audible at realistic strength across a room', () => {
// Real air costs very little treble over 20 m; the default must reflect that.
expect(airAbsorptionCutoff(20, 1)).toBeGreaterThan(15000);
});
it('never falls below the floor even at extreme strength', () => {
expect(airAbsorptionCutoff(1000, 8)).toBeGreaterThanOrEqual(200);
});
});
describe('directivity cone', () => {
const cone = { innerAngle: 60, outerAngle: 180, outerGain: 0.1 };
const source = at(0, 0, 0);
const forward = at(0, 0, -1);
it('is at full level dead ahead', () => {
expect(coneGain(source, forward, at(0, 0, -5), cone)).toBe(1);
});
it('is at full level anywhere inside the inner cone', () => {
// 25° off-axis, inside the 30° inner half-angle.
const listener = at(Math.sin(0.436) * 5, 0, -Math.cos(0.436) * 5);
expect(coneGain(source, forward, listener, cone)).toBe(1);
});
it('falls to the floor gain directly behind the source', () => {
expect(coneGain(source, forward, at(0, 0, 5), cone)).toBeCloseTo(0.1, 6);
});
it('interpolates across the transition band', () => {
// 60° off-axis: past the 30° inner half-angle, short of the 90° outer one.
const listener = at(Math.sin(Math.PI / 3) * 5, 0, -Math.cos(Math.PI / 3) * 5);
const gain = coneGain(source, forward, listener, cone);
expect(gain).toBeGreaterThan(0.1);
expect(gain).toBeLessThan(1);
});
it('reaches the floor exactly at the outer half-angle', () => {
expect(coneGain(source, forward, at(5, 0, 0), cone)).toBeCloseTo(0.1, 6);
});
it('falls monotonically as the listener swings off-axis', () => {
let previous = Infinity;
for (let deg = 0; deg <= 180; deg += 10) {
const rad = (deg * Math.PI) / 180;
const listener = at(Math.sin(rad) * 5, 0, -Math.cos(rad) * 5);
const gain = coneGain(source, forward, listener, cone);
expect(gain).toBeLessThanOrEqual(previous + 1e-9);
previous = gain;
}
});
it('is omnidirectional at 360 degrees', () => {
const omni = { innerAngle: 360, outerAngle: 360, outerGain: 0 };
for (const listener of [at(5), at(-5), at(0, 5), at(0, 0, 5)]) {
expect(coneGain(source, forward, listener, omni)).toBe(1);
}
});
it('treats a coincident listener as on-axis rather than dividing by zero', () => {
expect(coneGain(source, forward, source, cone)).toBe(1);
});
it('survives a degenerate forward vector', () => {
const gain = coneGain(source, at(0, 0, 0), at(0, 0, -5), cone);
expect(Number.isFinite(gain)).toBe(true);
});
it('reports the off-axis angle in degrees', () => {
expect(offAxisAngle(source, forward, at(0, 0, -5))).toBeCloseTo(0, 6);
expect(offAxisAngle(source, forward, at(5, 0, 0))).toBeCloseTo(90, 6);
expect(offAxisAngle(source, forward, at(0, 0, 5))).toBeCloseTo(180, 6);
});
});
describe('doppler', () => {
const still = at(0, 0, 0);
it('is unity when nothing moves', () => {
expect(dopplerRatio(at(0), still, at(10), still)).toBe(1);
});
it('raises pitch when the source approaches', () => {
// Source at origin, listener at +10 x, source moving towards it.
expect(dopplerRatio(at(0), at(30), at(10), still)).toBeGreaterThan(1);
});
it('lowers pitch when the source recedes', () => {
expect(dopplerRatio(at(0), at(-30), at(10), still)).toBeLessThan(1);
});
it('lowers pitch when the listener flees', () => {
expect(dopplerRatio(at(0), still, at(10), at(30))).toBeLessThan(1);
});
it('matches the textbook ratio for an approaching source', () => {
// f'/f = c / (c - v) = 343 / (343 - 34.3) = 1.1111…
expect(dopplerRatio(at(0), at(34.3), at(10), still, 343)).toBeCloseTo(1.1111, 3);
});
it('ignores motion perpendicular to the line of sight', () => {
expect(dopplerRatio(at(0), at(0, 0, 40), at(10), still)).toBeCloseTo(1, 6);
});
it('stays finite and bounded at and beyond the speed of sound', () => {
for (const v of [343, 400, 5000]) {
const ratio = dopplerRatio(at(0), at(v), at(10), still, 343);
expect(Number.isFinite(ratio)).toBe(true);
expect(ratio).toBeLessThanOrEqual(4);
expect(ratio).toBeGreaterThanOrEqual(0.25);
}
});
it('falls back to a sane medium when given a nonsense speed of sound', () => {
expect(dopplerRatio(at(0), still, at(10), still, 0)).toBe(1);
});
it('returns unity for a coincident source and listener', () => {
expect(dopplerRatio(at(5), at(10), at(5), still)).toBe(1);
});
});
describe('occlusion', () => {
it('is transparent with a clear line of sight', () => {
const clear = occlusionResponse(0);
expect(clear.gain).toBe(1);
expect(clear.cutoff).toBeCloseTo(22050, 0);
});
it('muffles but does not silence a fully blocked path', () => {
const blocked = occlusionResponse(1);
expect(blocked.cutoff).toBeCloseTo(350, 0);
expect(blocked.gain).toBeGreaterThan(0);
expect(blocked.gain).toBeLessThan(0.3);
});
it('sweeps the cutoff monotonically and geometrically', () => {
let previous = Infinity;
for (let t = 0; t <= 1; t += 0.1) {
const { cutoff } = occlusionResponse(t);
expect(cutoff).toBeLessThan(previous);
previous = cutoff;
}
// Geometric interpolation puts the halfway point at the geometric mean,
// which is what keeps the sweep sounding even.
expect(occlusionResponse(0.5).cutoff).toBeCloseTo(Math.sqrt(350 * 22050), 0);
});
it('clamps out-of-range input', () => {
expect(occlusionResponse(-3).gain).toBe(1);
expect(occlusionResponse(9).gain).toBeCloseTo(occlusionResponse(1).gain, 12);
});
});
describe('unit helpers', () => {
it('converts gain to decibels', () => {
expect(gainToDb(1)).toBeCloseTo(0, 9);
expect(gainToDb(0.5)).toBeCloseTo(-6.02, 2);
expect(gainToDb(0)).toBe(-120);
});
it('converts frequency ratios to cents', () => {
expect(ratioToCents(1)).toBeCloseTo(0, 9);
expect(ratioToCents(2)).toBeCloseTo(1200, 6);
expect(ratioToCents(0.5)).toBeCloseTo(-1200, 6);
});
it('produces frame-rate independent smoothing', () => {
// Two 8 ms steps must land where one 16 ms step lands.
const single = smoothingAlpha(0.016, 0.1);
const a = smoothingAlpha(0.008, 0.1);
const twice = 1 - (1 - a) * (1 - a);
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);
});
});