import { describe, expect, it } from 'vitest'; import type { ConeParams } from '../src/physics'; import { airAbsorptionCutoff, coneGain, directivityFactor, distanceGain, dopplerRatio, gainToDb, inverseDistanceGain, linearDistanceGain, occlusionResponse, offAxisAngle, ratioToCents, smoothingAlpha, } from '../src/physics'; const at = (x: number, y = 0, z = 0) => ({ x, y, z }); const params = { refDistance: 1, maxDistance: 100, rolloffFactor: 1 }; describe('distance attenuation', () => { it('holds unity gain inside the reference distance', () => { expect(inverseDistanceGain(0, params)).toBe(1); expect(inverseDistanceGain(0.5, params)).toBe(1); expect(inverseDistanceGain(1, params)).toBe(1); }); it('loses 6 dB per doubling of distance, the inverse-distance law', () => { const near = gainToDb(inverseDistanceGain(4, params)); const far = gainToDb(inverseDistanceGain(8, params)); expect(near - far).toBeCloseTo(6.02, 1); }); it('never attenuates when rolloff is zero', () => { const flat = { ...params, rolloffFactor: 0 }; expect(inverseDistanceGain(50, flat)).toBe(1); }); it('reaches silence at maxDistance under the linear model', () => { expect(linearDistanceGain(100, params)).toBe(0); expect(linearDistanceGain(1000, params)).toBe(0); }); it('degrades gracefully when maxDistance is below refDistance', () => { const inverted = { refDistance: 10, maxDistance: 2, rolloffFactor: 1 }; expect(linearDistanceGain(1, inverted)).toBe(1); expect(linearDistanceGain(5, inverted)).toBe(0); }); it('stays within [0, 1] for every model across a wide sweep', () => { for (const model of ['inverse', 'exponential', 'linear'] as const) { for (const d of [0, 0.001, 1, 7, 99, 1e4]) { for (const rolloff of [0, 1, 5]) { const gain = distanceGain(d, model, { ...params, rolloffFactor: rolloff }); expect(gain).toBeGreaterThanOrEqual(0); expect(gain).toBeLessThanOrEqual(1); expect(Number.isFinite(gain)).toBe(true); } } } }); it('decreases monotonically with distance', () => { for (const model of ['inverse', 'exponential', 'linear'] as const) { let previous = Infinity; for (let d = 0; d <= 60; d += 2) { const gain = distanceGain(d, model, params); expect(gain).toBeLessThanOrEqual(previous + 1e-12); previous = gain; } } }); }); describe('air absorption', () => { it('is transparent at zero strength or zero distance', () => { expect(airAbsorptionCutoff(50, 0)).toBe(22050); expect(airAbsorptionCutoff(0, 4)).toBe(22050); }); it('rolls the cutoff down as distance grows', () => { const near = airAbsorptionCutoff(5, 1); const far = airAbsorptionCutoff(80, 1); expect(far).toBeLessThan(near); expect(far).toBeGreaterThan(200); }); it('is barely audible at realistic strength across a room', () => { // Real air costs very little treble over 20 m; the default must reflect that. expect(airAbsorptionCutoff(20, 1)).toBeGreaterThan(15000); }); it('never falls below the floor even at extreme strength', () => { expect(airAbsorptionCutoff(1000, 8)).toBeGreaterThanOrEqual(200); }); }); describe('directivity cone', () => { const cone = { innerAngle: 60, outerAngle: 180, outerGain: 0.1 }; const source = at(0, 0, 0); const forward = at(0, 0, -1); it('is at full level dead ahead', () => { expect(coneGain(source, forward, at(0, 0, -5), cone)).toBe(1); }); it('is at full level anywhere inside the inner cone', () => { // 25° off-axis, inside the 30° inner half-angle. const listener = at(Math.sin(0.436) * 5, 0, -Math.cos(0.436) * 5); expect(coneGain(source, forward, listener, cone)).toBe(1); }); it('falls to the floor gain directly behind the source', () => { expect(coneGain(source, forward, at(0, 0, 5), cone)).toBeCloseTo(0.1, 6); }); it('interpolates across the transition band', () => { // 60° off-axis: past the 30° inner half-angle, short of the 90° outer one. const listener = at(Math.sin(Math.PI / 3) * 5, 0, -Math.cos(Math.PI / 3) * 5); const gain = coneGain(source, forward, listener, cone); expect(gain).toBeGreaterThan(0.1); expect(gain).toBeLessThan(1); }); it('reaches the floor exactly at the outer half-angle', () => { expect(coneGain(source, forward, at(5, 0, 0), cone)).toBeCloseTo(0.1, 6); }); it('falls monotonically as the listener swings off-axis', () => { let previous = Infinity; for (let deg = 0; deg <= 180; deg += 10) { const rad = (deg * Math.PI) / 180; const listener = at(Math.sin(rad) * 5, 0, -Math.cos(rad) * 5); const gain = coneGain(source, forward, listener, cone); expect(gain).toBeLessThanOrEqual(previous + 1e-9); previous = gain; } }); it('is omnidirectional at 360 degrees', () => { const omni = { innerAngle: 360, outerAngle: 360, outerGain: 0 }; for (const listener of [at(5), at(-5), at(0, 5), at(0, 0, 5)]) { expect(coneGain(source, forward, listener, omni)).toBe(1); } }); it('treats a coincident listener as on-axis rather than dividing by zero', () => { expect(coneGain(source, forward, source, cone)).toBe(1); }); it('survives a degenerate forward vector', () => { const gain = coneGain(source, at(0, 0, 0), at(0, 0, -5), cone); expect(Number.isFinite(gain)).toBe(true); }); it('reports the off-axis angle in degrees', () => { expect(offAxisAngle(source, forward, at(0, 0, -5))).toBeCloseTo(0, 6); expect(offAxisAngle(source, forward, at(5, 0, 0))).toBeCloseTo(90, 6); expect(offAxisAngle(source, forward, at(0, 0, 5))).toBeCloseTo(180, 6); }); }); describe('doppler', () => { const still = at(0, 0, 0); it('is unity when nothing moves', () => { expect(dopplerRatio(at(0), still, at(10), still)).toBe(1); }); it('raises pitch when the source approaches', () => { // Source at origin, listener at +10 x, source moving towards it. expect(dopplerRatio(at(0), at(30), at(10), still)).toBeGreaterThan(1); }); it('lowers pitch when the source recedes', () => { expect(dopplerRatio(at(0), at(-30), at(10), still)).toBeLessThan(1); }); it('lowers pitch when the listener flees', () => { expect(dopplerRatio(at(0), still, at(10), at(30))).toBeLessThan(1); }); it('matches the textbook ratio for an approaching source', () => { // f'/f = c / (c - v) = 343 / (343 - 34.3) = 1.1111… expect(dopplerRatio(at(0), at(34.3), at(10), still, 343)).toBeCloseTo(1.1111, 3); }); it('ignores motion perpendicular to the line of sight', () => { expect(dopplerRatio(at(0), at(0, 0, 40), at(10), still)).toBeCloseTo(1, 6); }); it('stays finite and bounded at and beyond the speed of sound', () => { for (const v of [343, 400, 5000]) { const ratio = dopplerRatio(at(0), at(v), at(10), still, 343); expect(Number.isFinite(ratio)).toBe(true); expect(ratio).toBeLessThanOrEqual(4); expect(ratio).toBeGreaterThanOrEqual(0.25); } }); it('falls back to a sane medium when given a nonsense speed of sound', () => { expect(dopplerRatio(at(0), still, at(10), still, 0)).toBe(1); }); it('returns unity for a coincident source and listener', () => { expect(dopplerRatio(at(5), at(10), at(5), still)).toBe(1); }); }); describe('occlusion', () => { it('is transparent with a clear line of sight', () => { const clear = occlusionResponse(0); expect(clear.gain).toBe(1); expect(clear.cutoff).toBeCloseTo(22050, 0); }); it('muffles but does not silence a fully blocked path', () => { const blocked = occlusionResponse(1); expect(blocked.cutoff).toBeCloseTo(350, 0); expect(blocked.gain).toBeGreaterThan(0); expect(blocked.gain).toBeLessThan(0.3); }); it('sweeps the cutoff monotonically and geometrically', () => { let previous = Infinity; for (let t = 0; t <= 1; t += 0.1) { const { cutoff } = occlusionResponse(t); expect(cutoff).toBeLessThan(previous); previous = cutoff; } // Geometric interpolation puts the halfway point at the geometric mean, // which is what keeps the sweep sounding even. expect(occlusionResponse(0.5).cutoff).toBeCloseTo(Math.sqrt(350 * 22050), 0); }); it('clamps out-of-range input', () => { expect(occlusionResponse(-3).gain).toBe(1); expect(occlusionResponse(9).gain).toBeCloseTo(occlusionResponse(1).gain, 12); }); }); describe('unit helpers', () => { it('converts gain to decibels', () => { expect(gainToDb(1)).toBeCloseTo(0, 9); expect(gainToDb(0.5)).toBeCloseTo(-6.02, 2); expect(gainToDb(0)).toBe(-120); }); it('converts frequency ratios to cents', () => { expect(ratioToCents(1)).toBeCloseTo(0, 9); expect(ratioToCents(2)).toBeCloseTo(1200, 6); expect(ratioToCents(0.5)).toBeCloseTo(-1200, 6); }); it('produces frame-rate independent smoothing', () => { // Two 8 ms steps must land where one 16 ms step lands. const single = smoothingAlpha(0.016, 0.1); const a = smoothingAlpha(0.008, 0.1); const twice = 1 - (1 - a) * (1 - a); 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); }); });