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
+38 -9
View File
@@ -6,6 +6,7 @@ import {
airAbsorptionCutoff,
clamp,
coneGain,
directivityFactor,
distance,
distanceGain,
gainToDb,
@@ -22,6 +23,7 @@ import {
RoomDimensions,
SurfaceId,
computeAcoustics,
criticalDistanceFor,
generateImpulseResponse,
getSurface,
reverberantGain,
@@ -106,6 +108,9 @@ export interface Telemetry {
reverbGainDb: number;
/** Direct-to-reverberant ratio in dB. Negative means the room dominates. */
directToReverbDb: number;
/** Source directivity index, 10·log10(Q). 0 dB is omnidirectional. */
directivityIndexDb: number;
/** On-axis critical distance, in metres — where the room overtakes the source. */
criticalDistance: number;
t60: number;
reverbDominant: boolean;
@@ -132,6 +137,7 @@ export const SILENT_TELEMETRY: Telemetry = {
directGainDb: -120,
reverbGainDb: -120,
directToReverbDb: 0,
directivityIndexDb: 0,
criticalDistance: 0,
t60: 0,
reverbDominant: false,
@@ -205,6 +211,8 @@ export class AudioEngine {
private playing = false;
private acoustics: RoomAcoustics;
/** Source directivity factor Q, cached because it only moves with the cone. */
private directivity: number;
private impulseDirty = false;
private lastImpulseAt = -Infinity;
@@ -218,6 +226,15 @@ export class AudioEngine {
constructor(settings?: Partial<EngineSettings>) {
this.settings = { ...DEFAULT_SETTINGS, ...settings };
this.acoustics = computeAcoustics(this.settings.room, getSurface(this.settings.surface).absorption);
this.directivity = directivityFactor(this.coneParams());
}
private coneParams(): { innerAngle: number; outerAngle: number; outerGain: number } {
return {
innerAngle: this.settings.coneInnerAngle,
outerAngle: this.settings.coneOuterAngle,
outerGain: this.settings.coneOuterGain,
};
}
get context(): AudioContext | null {
@@ -258,6 +275,11 @@ export class AudioEngine {
* power the source radiates, not on where the listener stands or what is
* in the way. This is what makes walking away sound like distance instead
* of like turning down a fader.
*
* Total radiated power is where directivity enters: the send is referenced
* to the on-axis direct level, so it has to be divided by the cone's Q to
* describe a source that radiates that much *forward* rather than in every
* direction. See `reverberantGain`.
*/
async init(): Promise<void> {
if (this.ctx) return;
@@ -313,9 +335,18 @@ export class AudioEngine {
this.masterGain = ctx.createGain();
this.masterGain.gain.setValueAtTime(this.settings.masterVolume, now);
// Catches the peaks when a close, on-axis source stacks with a wet room.
// A safety net for the peaks when a close, on-axis source stacks with a wet
// room — deliberately not a compressor.
//
// The threshold sits just under 0 dBFS because this limiter is the only
// thing in the graph that sees absolute level, and every dB it takes off the
// loud position is a dB of front-to-back contrast the simulation loses. At
// the old -6 dB it was gain-reducing the near-field case by ~2.9 dB at the
// default volume (and ~5.7 dB at full) while leaving the quiet, mostly
// reverberant position untouched — squashing the very difference the user is
// meant to hear when they walk behind the speaker.
this.limiter = ctx.createDynamicsCompressor();
this.limiter.threshold.setValueAtTime(-6, now);
this.limiter.threshold.setValueAtTime(-1.5, now);
this.limiter.knee.setValueAtTime(0, now);
this.limiter.ratio.setValueAtTime(20, now);
this.limiter.attack.setValueAtTime(0.003, now);
@@ -477,6 +508,7 @@ export class AudioEngine {
const { preset, masterVolume, ...rest } = patch;
this.settings = { ...this.settings, ...rest, room };
this.acoustics = computeAcoustics(room, getSurface(this.settings.surface).absorption);
this.directivity = directivityFactor(this.coneParams());
if (masterVolume !== undefined) this.setMasterVolume(masterVolume);
if (preset !== undefined) this.setPreset(preset);
@@ -499,11 +531,7 @@ export class AudioEngine {
maxDistance: s.maxDistance,
rolloffFactor: s.rolloffFactor,
});
const gCone = coneGain(input.sourcePos, input.sourceForward, input.listenerPos, {
innerAngle: s.coneInnerAngle,
outerAngle: s.coneOuterAngle,
outerGain: s.coneOuterGain,
});
const gCone = coneGain(input.sourcePos, input.sourceForward, input.listenerPos, this.coneParams());
const angle = offAxisAngle(input.sourcePos, input.sourceForward, input.listenerPos);
const occl = occlusionResponse(input.occlusion);
const airCutoff = airAbsorptionCutoff(d, s.airAbsorption);
@@ -523,7 +551,7 @@ export class AudioEngine {
const ratio = clamp(1 - delayRate, DOPPLER_MIN_RATIO, DOPPLER_MAX_RATIO);
const gDirect = gDistance * gCone * occl.gain;
const gReverb = reverberantGain(this.acoustics, s.refDistance);
const gReverb = reverberantGain(this.acoustics, s.refDistance, this.directivity);
if (this.ctx && this.playing) {
this.applyToGraph(input, gDirect, gReverb, occl.cutoff, airCutoff);
@@ -551,7 +579,8 @@ export class AudioEngine {
directGainDb: gainToDb(gDirect),
reverbGainDb: reverbDb,
directToReverbDb: gainToDb(gDirect) - reverbDb,
criticalDistance: this.acoustics.criticalDistance,
directivityIndexDb: 10 * Math.log10(this.directivity),
criticalDistance: criticalDistanceFor(this.acoustics, this.directivity),
t60: this.acoustics.t60,
// Compare the levels themselves rather than distance against rc. With a
// non-inverse falloff law or a reference distance other than 1 m the two
+32 -4
View File
@@ -56,12 +56,25 @@ export function computeAcoustics(dims: RoomDimensions, absorption: number): Room
const t60 = clamp((0.161 * volume) / (surfaceArea * alpha), 0.08, 8);
const roomConstant = (surfaceArea * alpha) / (1 - alpha);
// r_c = sqrt(R / 16π) for an omnidirectional source.
// r_c = sqrt(R / 16π) for an omnidirectional source. Directivity is a property
// of the source, not the room, so it is applied at the point of use by
// `criticalDistanceFor` rather than baked in here.
const criticalDistance = Math.sqrt(roomConstant / (16 * Math.PI));
return { t60, roomConstant, criticalDistance, surfaceArea, volume };
}
/**
* Critical distance along the source's axis, r_c = sqrt(Q R / 16π).
*
* A directional source has to be followed further before the room catches up
* with it, because on its axis it is louder by sqrt(Q) while the room it excites
* is not. Pass Q = 1 to recover the omnidirectional radius.
*/
export function criticalDistanceFor(acoustics: RoomAcoustics, directivity = 1): number {
return acoustics.criticalDistance * Math.sqrt(Math.max(directivity, 1e-6));
}
/**
* Level of the diffuse reverberant field relative to the direct sound measured
* at the reference distance.
@@ -70,10 +83,25 @@ export function computeAcoustics(dims: RoomDimensions, absorption: number): Room
* distance-attenuated. Setting its level to refDistance / criticalDistance
* makes wet and dry balance exactly at the critical distance — walk further
* than that and the room takes over, which is the effect real rooms have.
*
* `directivity` is the source's Q. It matters because this gain is referenced to
* the *on-axis* direct sound at refDistance, where both the distance law and the
* cone return exactly 1. That reference already contains the source's sqrt(Q) of
* directivity gain, but the reverberant field does not: a cone that throws its
* energy forward excites the room with the Q-th part of the power an omni source
* of the same on-axis level would. Dividing it back out is the difference
* between the textbook Lp = Lw + 10·log10(Q/4πr² + 4/R) and a source that is
* mysteriously still loud from behind — with the default cone, 10·log10(5.07)
* = 7.1 dB of it.
*/
export function reverberantGain(acoustics: RoomAcoustics, refDistance = 1): number {
if (acoustics.criticalDistance < 1e-6) return 1;
return clamp(refDistance / acoustics.criticalDistance, 0, 1.4);
export function reverberantGain(
acoustics: RoomAcoustics,
refDistance = 1,
directivity = 1
): number {
const rc = criticalDistanceFor(acoustics, directivity);
if (rc < 1e-6) return 1;
return clamp(refDistance / rc, 0, 1.4);
}
/**
+40
View File
@@ -42,3 +42,43 @@ export function coneGain(
const t = (angle - innerHalf) / (outerHalf - innerHalf);
return clamp(1 + t * (floor - 1), 0, 1);
}
/**
* Directivity factor Q = 4π / ∫ g(θ)² dΩ — how much more intensity the source
* puts on its axis than an omnidirectional source radiating the same total
* power. Q = 1 is omnidirectional; the default 70°/160°/0.08 cone gives 5.07,
* i.e. a directivity index of 7.05 dB.
*
* This is what couples the cone to the room. A directional source pushes far
* less power into the space than its on-axis level suggests, so the reverberant
* field it excites is weaker by exactly this factor — see `reverberantGain`.
*
* Evaluated in closed form. The cone model interpolates linearly *in gain*, so
* over the transition band g(θ)² is a quadratic in θ and the integral
* ∫ (c₀ + c₁θ)² sin θ dθ has an elementary antiderivative. Cheap enough to call
* every time the cone sliders move, and exact rather than quadrature-noisy.
*/
export function directivityFactor(p: ConeParams): number {
const toRad = Math.PI / 180;
const a = clamp((Math.max(0, p.innerAngle) / 2) * toRad, 0, Math.PI);
const b = clamp((Math.max(0, p.outerAngle) / 2) * toRad, a, Math.PI);
const f = clamp(p.outerGain, 0, 1);
// Inside the inner cone g = 1; outside the outer cone g = outerGain.
let integral = 2 * Math.PI * (1 - Math.cos(a)) + 2 * Math.PI * f * f * (1 + Math.cos(b));
if (b - a > 1e-9) {
const c1 = (f - 1) / (b - a);
const c0 = 1 - c1 * a;
// d/dθ of ∫(c₀ + c₁θ)² sin θ dθ, expanded term by term.
const F = (t: number): number =>
c0 * c0 * -Math.cos(t) +
2 * c0 * c1 * (Math.sin(t) - t * Math.cos(t)) +
c1 * c1 * (2 * t * Math.sin(t) - (t * t - 2) * Math.cos(t));
integral += 2 * Math.PI * (F(b) - F(a));
}
// g ≤ 1 everywhere, so the integral cannot exceed 4π and Q cannot fall below
// 1. Guard the division anyway: a degenerate cone would otherwise return ∞.
return integral > 1e-9 ? Math.max(1, (4 * Math.PI) / integral) : 1;
}
+9 -1
View File
@@ -97,7 +97,15 @@ export class SignalPath {
this.text(this.direct.db, fmt.db(t.directGainDb));
this.bar(this.direct.fill, dbToFill(t.directGainDb));
this.text(this.room.detail, `t60 ${fmt.seconds(t.t60)}`);
// The room level is set by t60 and by how much power the cone actually
// throws into the room, so both belong on the row. Without the directivity
// index the wet level looks arbitrarily low next to a loud direct path.
this.text(
this.room.detail,
t.directivityIndexDb > 0.05
? `t60 ${fmt.seconds(t.t60)} · directivity ${fmt.db(t.directivityIndexDb)}`
: `t60 ${fmt.seconds(t.t60)}`
);
this.text(this.room.db, fmt.db(t.reverbGainDb));
this.bar(this.room.fill, dbToFill(t.reverbGainDb));