Files
threejs-sound-sim/test/reverb.test.ts
T
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

168 lines
6.4 KiB
TypeScript
Raw 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 { 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 };
describe('room acoustics', () => {
it('matches Sabine for a worked example', () => {
// V = 3200 m³, S = 1440 m², α = 0.22 → T60 = 0.161·3200 / (1440·0.22)
const { t60, volume, surfaceArea } = computeAcoustics(room, 0.22);
expect(volume).toBe(3200);
expect(surfaceArea).toBe(1440);
expect(t60).toBeCloseTo((0.161 * 3200) / (1440 * 0.22), 4);
});
it('rings longer in a bigger room', () => {
const small = computeAcoustics({ width: 6, height: 3, depth: 6 }, 0.22);
const large = computeAcoustics({ width: 40, height: 15, depth: 40 }, 0.22);
expect(large.t60).toBeGreaterThan(small.t60);
});
it('rings longer on harder surfaces', () => {
const soft = computeAcoustics(room, 0.9);
const hard = computeAcoustics(room, 0.035);
expect(hard.t60).toBeGreaterThan(soft.t60);
});
it('clamps T60 to a usable range for absurd geometry', () => {
const huge = computeAcoustics({ width: 500, height: 200, depth: 500 }, 0.01);
expect(huge.t60).toBeLessThanOrEqual(8);
expect(huge.t60).toBeGreaterThan(0);
});
it('puts the critical distance closer in a live room than a dead one', () => {
const dead = computeAcoustics(room, 0.9);
const live = computeAcoustics(room, 0.035);
expect(live.criticalDistance).toBeLessThan(dead.criticalDistance);
});
it('produces a physically sensible critical distance for a normal room', () => {
// A furnished 20x8x20 room should cross over a couple of metres out.
const { criticalDistance } = computeAcoustics(room, 0.22);
expect(criticalDistance).toBeGreaterThan(1);
expect(criticalDistance).toBeLessThan(6);
});
});
describe('reverberant field level', () => {
it('balances wet against dry exactly at the critical distance', () => {
const acoustics = computeAcoustics(room, 0.22);
// Dry follows 1/r with refDistance 1, so at r = rc it equals 1/rc.
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));
expect(live).toBeGreaterThan(dead);
});
it('stays bounded', () => {
for (const surface of SURFACES) {
const gain = reverberantGain(computeAcoustics(room, surface.absorption));
expect(gain).toBeGreaterThanOrEqual(0);
expect(gain).toBeLessThanOrEqual(1.4);
}
});
});
describe('impulse response', () => {
const ctx = new MockAudioContext() as unknown as BaseAudioContext;
it('is stereo, finite and non-silent', () => {
const ir = generateImpulseResponse(ctx, room, 0.22);
expect(ir.numberOfChannels).toBe(2);
expect(ir.length).toBeGreaterThan(1000);
const left = ir.getChannelData(0);
let energy = 0;
for (let i = 0; i < left.length; i++) {
expect(Number.isFinite(left[i])).toBe(true);
expect(Math.abs(left[i])).toBeLessThanOrEqual(1);
energy += left[i] * left[i];
}
expect(energy).toBeGreaterThan(0);
});
it('decays, rather than sustaining or growing', () => {
const ir = generateImpulseResponse(ctx, room, 0.22);
const data = ir.getChannelData(0);
const rms = (from: number, to: number) => {
let sum = 0;
for (let i = from; i < to; i++) sum += data[i] * data[i];
return Math.sqrt(sum / (to - from));
};
const head = rms(0, Math.floor(data.length * 0.1));
const tail = rms(Math.floor(data.length * 0.85), data.length);
expect(tail).toBeLessThan(head * 0.2);
});
it('gets longer on harder surfaces', () => {
const soft = generateImpulseResponse(ctx, room, 0.9);
const hard = generateImpulseResponse(ctx, room, 0.035);
expect(hard.length).toBeGreaterThan(soft.length);
});
it('places early reflections at the geometric arrival times', () => {
const ir = generateImpulseResponse(ctx, room, 0.09, 343);
const data = ir.getChannelData(0);
// A 20 m wall path arrives at 20/343 s.
const expected = Math.floor((20 / 343) * ir.sampleRate);
const neighbourhood = Math.max(
...Array.from({ length: 5 }, (_, i) => Math.abs(data[expected - 2 + i]))
);
const background = Math.abs(data[expected + 2000]);
expect(neighbourhood).toBeGreaterThan(background);
});
});
describe('surfaces', () => {
it('exposes an absorption coefficient for every preset', () => {
for (const surface of SURFACES) {
expect(surface.absorption).toBeGreaterThan(0);
expect(surface.absorption).toBeLessThan(1);
expect(surface.hint.length).toBeGreaterThan(0);
}
});
it('falls back to a normal room for an unknown id', () => {
expect(getSurface('nonsense' as never).id).toBe('living');
});
});