diff --git a/README.md b/README.md
index affc62a..0e900bf 100644
--- a/README.md
+++ b/README.md
@@ -40,11 +40,21 @@ locally in the page.
The one idea the app is built around. The direct sound falls as `1/r`; the
reverberant field does not fall at all. They cross at the **critical distance**,
-`rc = √(R / 16π)`, drawn as a ring on the floor:
+`rc = √(Q·R / 16π)`, drawn as a ring on the floor:
- **Inside the ring** the source dominates. You can point at it with your eyes shut.
- **Outside it** the room dominates. It does not get quieter as you back away — and that constancy is a large part of how your ears judge distance.
+`Q` is the source's directivity factor — how much more intensity it puts on its
+axis than an omnidirectional source of the same total power. It belongs in that
+formula because the reverberant field is fed by *radiated power*, while the ring
+is measured against the *on-axis* direct sound. A cone that throws its energy
+forward excites the room far less than its on-axis level suggests: the default
+70°/160° cone has `Q = 5.07`, a directivity index of 7.1 dB, which pushes the
+ring from 6.1 m out to 13.7 m. Drop `Q` and reflections stay conspicuously loud
+when you walk behind the speaker — the room is being driven as if the speaker
+radiated backwards as hard as it radiates forwards.
+
Press 3 for a guided walk through it.
## Controls
diff --git a/src/audio/AudioEngine.ts b/src/audio/AudioEngine.ts
index c5b3010..d38c873 100644
--- a/src/audio/AudioEngine.ts
+++ b/src/audio/AudioEngine.ts
@@ -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) {
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 {
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
diff --git a/src/audio/reverb.ts b/src/audio/reverb.ts
index dcbd9db..2eff03d 100644
--- a/src/audio/reverb.ts
+++ b/src/audio/reverb.ts
@@ -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);
}
/**
diff --git a/src/physics/cone.ts b/src/physics/cone.ts
index ae9ea30..085541d 100644
--- a/src/physics/cone.ts
+++ b/src/physics/cone.ts
@@ -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;
+}
diff --git a/src/ui/signalPath.ts b/src/ui/signalPath.ts
index 0cfa408..8754e55 100644
--- a/src/ui/signalPath.ts
+++ b/src/ui/signalPath.ts
@@ -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));
diff --git a/test/engine.test.ts b/test/engine.test.ts
index 2583a24..6679599 100644
--- a/test/engine.test.ts
+++ b/test/engine.test.ts
@@ -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 () => {
diff --git a/test/physics.test.ts b/test/physics.test.ts
index cdc4d0c..1c39fe1 100644
--- a/test/physics.test.ts
+++ b/test/physics.test.ts
@@ -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);
+ });
+});
diff --git a/test/reverb.test.ts b/test/reverb.test.ts
index dce1ff2..962c40d 100644
--- a/test/reverb.test.ts
+++ b/test/reverb.test.ts
@@ -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));