import { describe, expect, it } from 'vitest'; import { SURFACES, computeAcoustics, generateImpulseResponse, getSurface, reverberantGain, } from '../src/audio/reverb'; 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('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'); }); });