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; beforeEach(() => { audio = installMockAudio(); }); afterEach(() => { audio.restore(); }); const ctxOf = () => audio.instances[0] as MockAudioContext; function poseAt(x: number, z = 0, extra: Partial = {}): 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('panner') as unknown as Record; 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; 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); }); });