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

464 lines
17 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { AudioEngine, DEFAULT_SETTINGS, SpatialInput } from '../src/audio/AudioEngine';
import { MockAudioContext, MockParam, installMockAudio } from './mockAudioContext';
let audio: ReturnType<typeof installMockAudio>;
beforeEach(() => {
audio = installMockAudio();
});
afterEach(() => {
audio.restore();
});
const ctxOf = () => audio.instances[0] as MockAudioContext;
function poseAt(x: number, z = 0, extra: Partial<SpatialInput> = {}): SpatialInput {
return {
sourcePos: { x: 0, y: 1.5, z: 0 },
sourceForward: { x: 0, y: 0, z: -1 },
listenerPos: { x, y: 1.5, z },
listenerForward: { x: 0, y: 0, z: -1 },
listenerUp: { x: 0, y: 1, z: 0 },
occlusion: 0,
...extra,
};
}
/** Settles the delay-line smoothing so telemetry reflects a steady state. */
function settle(engine: AudioEngine, pose: SpatialInput, frames = 400) {
let telemetry = engine.updateSpatial(pose, 1 / 60);
for (let i = 0; i < frames; i++) telemetry = engine.updateSpatial(pose, 1 / 60);
return telemetry;
}
describe('graph topology', () => {
it('wires the documented signal path', async () => {
const engine = new AudioEngine();
await engine.start();
const edges = ctxOf().edges();
expect(edges).toContain('gain->delay'); // sourceGain -> delay
expect(edges).toContain('delay->biquad'); // -> air absorption
expect(edges).toContain('biquad->biquad'); // air -> occlusion
expect(edges).toContain('biquad->gain'); // occlusion -> directGain
expect(edges).toContain('gain->panner');
expect(edges).toContain('gain->convolver'); // reverbSend -> convolver
expect(edges).toContain('convolver->gain'); // wet -> master
expect(edges).toContain('gain->compressor'); // master -> limiter
expect(edges).toContain('compressor->analyser');
expect(edges).toContain('analyser->destination');
});
it('taps the reverb send before distance, cone and occlusion', async () => {
const engine = new AudioEngine();
await engine.start();
const ctx = ctxOf();
// The wet send must hang off the delay, not off the direct gain: a room's
// reverberant field does not get quieter because you walked away.
const feedsConvolver = ctx.connections.find((c) => c.to.kind === 'convolver');
const sendSource = ctx.connections.find((c) => c.to === feedsConvolver?.from);
expect(sendSource?.from.kind).toBe('delay');
});
it('neuters the panner so only our own physics sets level', async () => {
const engine = new AudioEngine();
await engine.start();
const panner = ctxOf().nodeOf<never>('panner') as unknown as Record<string, unknown>;
expect(panner.panningModel).toBe('HRTF');
expect(panner.rolloffFactor).toBe(0);
expect(panner.coneOuterGain).toBe(1);
});
});
describe('listener orientation', () => {
it('publishes forward and up, not just position', async () => {
const engine = new AudioEngine();
await engine.start();
const listener = ctxOf().listener;
engine.updateSpatial(
{
...poseAt(5),
listenerForward: { x: 1, y: 0, z: 0 },
listenerUp: { x: 0, y: 1, z: 0 },
},
1 / 60
);
expect(listener.forwardX.value).toBeCloseTo(1, 6);
expect(listener.forwardZ.value).toBeCloseTo(0, 6);
expect(listener.upY.value).toBeCloseTo(1, 6);
expect(listener.forwardX.events.length).toBeGreaterThan(0);
});
it('tracks a turning head', async () => {
const engine = new AudioEngine();
await engine.start();
const listener = ctxOf().listener;
engine.updateSpatial({ ...poseAt(5), listenerForward: { x: 0, y: 0, z: -1 } }, 1 / 60);
expect(listener.forwardZ.value).toBeCloseTo(-1, 6);
engine.updateSpatial({ ...poseAt(5), listenerForward: { x: 0, y: 0, z: 1 } }, 1 / 60);
expect(listener.forwardZ.value).toBeCloseTo(1, 6);
});
it('normalises whatever it is handed', async () => {
const engine = new AudioEngine();
await engine.start();
const listener = ctxOf().listener;
engine.updateSpatial({ ...poseAt(5), listenerForward: { x: 0, y: 0, z: -9 } }, 1 / 60);
expect(listener.forwardZ.value).toBeCloseTo(-1, 6);
});
it('keeps the panner on the source', async () => {
const engine = new AudioEngine();
await engine.start();
const panner = ctxOf().nodeOf('panner') as unknown as Record<string, MockParam>;
engine.updateSpatial(
{ ...poseAt(5), sourcePos: { x: -3, y: 2, z: 7 } },
1 / 60
);
expect(panner.positionX.value).toBeCloseTo(-3, 6);
expect(panner.positionZ.value).toBeCloseTo(7, 6);
});
});
describe('gain staging', () => {
it('drives the direct gain from distance, cone and occlusion together', async () => {
const engine = new AudioEngine();
await engine.start();
const ctx = ctxOf();
const directGain = ctx.feederOf('gain', 'panner');
const telemetry = engine.updateSpatial(poseAt(4), 1 / 60);
const expected = Math.pow(10, telemetry.directGainDb / 20);
expect(directGain.gain.value).toBeCloseTo(expected, 5);
});
it('keeps the reverb send level independent of listener distance', async () => {
const engine = new AudioEngine();
await engine.start();
const send = ctxOf().feederOf('gain', 'convolver');
engine.updateSpatial(poseAt(2), 1 / 60);
const near = send.gain.value;
engine.updateSpatial(poseAt(40), 1 / 60);
const far = send.gain.value;
expect(near).toBeGreaterThan(0);
expect(far).toBeCloseTo(near, 6);
});
it('applies master volume on its own stage, not folded into the physics', async () => {
const engine = new AudioEngine();
await engine.start();
const ctx = ctxOf();
const directGain = ctx.feederOf('gain', 'panner');
const master = ctx.feederOf('gain', 'compressor');
const before = (engine.updateSpatial(poseAt(4), 1 / 60), directGain.gain.value);
engine.setMasterVolume(0.25);
engine.updateSpatial(poseAt(4), 1 / 60);
expect(master.gain.value).toBeCloseTo(0.25, 6);
// Changing the volume must not move the acoustic gain, or the signal-path
// readout would stop describing the physics.
expect(directGain.gain.value).toBeCloseTo(before, 6);
});
it('lowers the direct level as the listener walks away', async () => {
const engine = new AudioEngine();
await engine.start();
const near = engine.updateSpatial(poseAt(2), 1 / 60).directGainDb;
const far = engine.updateSpatial(poseAt(20), 1 / 60).directGainDb;
expect(far).toBeLessThan(near - 15);
});
it('attenuates and muffles a blocked path', async () => {
const engine = new AudioEngine();
await engine.start();
const clear = engine.updateSpatial(poseAt(6, 0, { occlusion: 0 }), 1 / 60);
const blocked = engine.updateSpatial(poseAt(6, 0, { occlusion: 1 }), 1 / 60);
expect(blocked.directGainDb).toBeLessThan(clear.directGainDb);
expect(blocked.occlusionCutoff).toBeLessThan(clear.occlusionCutoff / 10);
});
it('attenuates when the source is aimed away', async () => {
const engine = new AudioEngine();
await engine.start();
const onAxis = engine.updateSpatial(
{ ...poseAt(0, -6), sourceForward: { x: 0, y: 0, z: -1 } },
1 / 60
);
const offAxis = engine.updateSpatial(
{ ...poseAt(0, -6), sourceForward: { x: 0, y: 0, z: 1 } },
1 / 60
);
expect(offAxis.coneGainDb).toBeLessThan(onAxis.coneGainDb - 10);
});
});
describe('propagation and doppler', () => {
it('settles the delay line on the true time of flight', async () => {
const engine = new AudioEngine();
await engine.start();
const telemetry = settle(engine, poseAt(34.3));
// 34.3 m at 343 m/s is 100 ms.
expect(telemetry.timeOfFlightMs).toBeCloseTo(100, 0);
});
it('reports no shift once the geometry is still', async () => {
const engine = new AudioEngine();
await engine.start();
const telemetry = settle(engine, poseAt(10));
expect(telemetry.dopplerRatio).toBeCloseTo(1, 3);
expect(Math.abs(telemetry.dopplerCents)).toBeLessThan(2);
});
it('raises pitch as the gap closes and lowers it as the gap opens', async () => {
const engine = new AudioEngine();
await engine.start();
settle(engine, poseAt(30));
// Sweep the listener inwards: the delay shortens, so pitch must rise.
let approaching = 1;
for (let d = 30; d > 10; d -= 0.5) {
approaching = engine.updateSpatial(poseAt(d), 1 / 60).dopplerRatio;
}
expect(approaching).toBeGreaterThan(1.0005);
let receding = 1;
for (let d = 10; d < 30; d += 0.5) {
receding = engine.updateSpatial(poseAt(d), 1 / 60).dopplerRatio;
}
expect(receding).toBeLessThan(0.9995);
});
it('stays silent about doppler when propagation is switched off', async () => {
const engine = new AudioEngine({ propagationEnabled: false });
await engine.start();
settle(engine, poseAt(30));
for (let d = 30; d > 10; d -= 0.5) engine.updateSpatial(poseAt(d), 1 / 60);
const telemetry = engine.updateSpatial(poseAt(10), 1 / 60);
expect(telemetry.timeOfFlightMs).toBeCloseTo(0, 3);
expect(telemetry.dopplerRatio).toBeCloseTo(1, 6);
});
it('reports the shift a delay line actually produces, 1 dD/dt', async () => {
const engine = new AudioEngine();
await engine.start();
settle(engine, poseAt(30));
// Close at a steady rate and compare the reported ratio against 1 D'
// reconstructed from the reported time of flight.
let previousFlight = engine.updateSpatial(poseAt(30), 1 / 60).timeOfFlightMs;
let checked = 0;
for (let d = 29.5; d > 12; d -= 0.5) {
const t = engine.updateSpatial(poseAt(d), 1 / 60);
const delayRate = (t.timeOfFlightMs - previousFlight) / 1000 / (1 / 60);
previousFlight = t.timeOfFlightMs;
expect(t.dopplerRatio).toBeCloseTo(1 - delayRate, 6);
checked++;
}
expect(checked).toBeGreaterThan(10);
});
it('keeps the shift on the correct side of unity even when closing hard', async () => {
// The textbook 1/(1 v/c) form has a pole that flips sign here, reporting
// a two-octave drop for a source rushing towards the listener.
const engine = new AudioEngine({ speedOfSound: 80 });
await engine.start();
settle(engine, poseAt(40));
for (let d = 39; d > 2; d -= 3) {
const t = engine.updateSpatial(poseAt(d), 1 / 60);
expect(t.dopplerRatio).toBeGreaterThanOrEqual(1);
expect(t.closingSpeed).toBeGreaterThan(0);
}
});
it('does not saturate the delay line at the slowest speed of sound', async () => {
const engine = new AudioEngine({ speedOfSound: 80, room: { width: 60, height: 60, depth: 60 } });
await engine.start();
// Room diagonal is ~104 m; at 80 m/s that is 1.3 s of flight.
const telemetry = settle(engine, poseAt(100), 2000);
expect(telemetry.timeOfFlightMs).toBeCloseTo(1250, -1);
});
it('never lets a violent jump produce an absurd pitch', async () => {
const engine = new AudioEngine();
await engine.start();
settle(engine, poseAt(60));
// Teleport the listener: a naive velocity estimate would chirp an octave.
const telemetry = engine.updateSpatial(poseAt(0.5), 1 / 60);
expect(telemetry.dopplerRatio).toBeLessThanOrEqual(4);
expect(telemetry.dopplerRatio).toBeGreaterThanOrEqual(0.25);
expect(Number.isFinite(telemetry.dopplerCents)).toBe(true);
});
});
describe('lifecycle', () => {
it('reports live telemetry before any AudioContext exists', () => {
const engine = new AudioEngine();
const telemetry = engine.updateSpatial(poseAt(8), 1 / 60);
expect(engine.context).toBeNull();
expect(telemetry.running).toBe(false);
expect(telemetry.distance).toBeCloseTo(8, 6);
expect(telemetry.distanceGainDb).toBeLessThan(0);
expect(telemetry.criticalDistance).toBeGreaterThan(0);
expect(audio.instances).toHaveLength(0);
});
it('resumes a suspended context on start', async () => {
const engine = new AudioEngine();
await engine.start();
expect(ctxOf().state).toBe('running');
expect(engine.isPlaying).toBe(true);
});
it('is idempotent across repeated start and stop', async () => {
const engine = new AudioEngine();
await engine.start();
await engine.start();
expect(ctxOf().started).toHaveLength(1);
engine.stop();
engine.stop();
expect(engine.isPlaying).toBe(false);
await engine.start();
expect(engine.isPlaying).toBe(true);
expect(ctxOf().started).toHaveLength(2);
});
it('swaps presets without tearing down the graph', async () => {
const engine = new AudioEngine();
await engine.start();
const edgeCount = ctxOf().connections.length;
engine.update({ preset: 'engine' });
engine.update({ preset: 'beacon' });
expect(engine.getSettings().preset).toBe('beacon');
// A new source node reconnects; the rest of the graph must be untouched.
expect(ctxOf().connections.length).toBeLessThanOrEqual(edgeCount + 4);
});
it('rebuilds room acoustics when the geometry changes', async () => {
const engine = new AudioEngine();
await engine.start();
const before = engine.getAcoustics();
engine.update({ room: { width: 40, height: 16, depth: 40 }, surface: 'cathedral' });
const after = engine.getAcoustics();
expect(after.t60).toBeGreaterThan(before.t60);
expect(after.criticalDistance).toBeLessThan(before.criticalDistance);
});
it('merges partial room patches instead of dropping dimensions', () => {
const engine = new AudioEngine();
engine.update({ room: { width: 30 } as never });
const room = engine.getSettings().room;
expect(room.width).toBe(30);
expect(room.height).toBe(DEFAULT_SETTINGS.room.height);
expect(room.depth).toBe(DEFAULT_SETTINGS.room.depth);
});
it('clamps master volume', () => {
const engine = new AudioEngine();
engine.setMasterVolume(5);
expect(engine.getSettings().masterVolume).toBe(1);
engine.setMasterVolume(-2);
expect(engine.getSettings().masterVolume).toBe(0);
});
it('produces finite telemetry for degenerate geometry', () => {
const engine = new AudioEngine();
const coincident = engine.updateSpatial(
{
sourcePos: { x: 1, y: 1, z: 1 },
sourceForward: { x: 0, y: 0, z: 0 },
listenerPos: { x: 1, y: 1, z: 1 },
listenerForward: { x: 0, y: 0, z: 0 },
listenerUp: { x: 0, y: 0, z: 0 },
occlusion: 0,
},
0
);
for (const value of Object.values(coincident)) {
if (typeof value === 'number') expect(Number.isFinite(value)).toBe(true);
}
});
});
describe('telemetry consistency', () => {
it('sums the stage losses into the total, exactly', async () => {
const engine = new AudioEngine();
await engine.start();
const t = engine.updateSpatial(
poseAt(0, -7, { occlusion: 0.6, sourceForward: { x: 1, y: 0, z: 0 } }),
1 / 60
);
expect(t.distanceGainDb + t.coneGainDb + t.occlusionGainDb).toBeCloseTo(t.directGainDb, 6);
});
it('derives the direct-to-reverb ratio from the two levels it reports', async () => {
const engine = new AudioEngine();
await engine.start();
const t = engine.updateSpatial(poseAt(9), 1 / 60);
expect(t.directToReverbDb).toBeCloseTo(t.directGainDb - t.reverbGainDb, 6);
});
it('flags room dominance at the critical distance when the listener is on-axis', async () => {
const engine = new AudioEngine();
await engine.start();
// 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 () => {
const engine = new AudioEngine();
await engine.start();
const rc = engine.getAcoustics().criticalDistance;
// Well inside the critical distance, but aimed away: the direct sound loses
// to the reverberant field even though the source is close.
const behind = engine.updateSpatial(poseAt(0, rc * 0.5), 1 / 60);
expect(behind.distance).toBeLessThan(rc);
expect(behind.reverbDominant).toBe(true);
});
});