Spatial Audio Testing in VR: Positioning, HRTF, and 3D Sound Verification
Spatial audio is what makes VR feel convincing — or breaks the illusion. A sound that comes from the wrong direction, clips under load, or doesn't occlude properly behind walls destroys presence immediately. Yet spatial audio is among the least-tested parts of most VR applications because "it sounds right" doesn't translate easily to automated assertions.
This guide covers testable aspects of spatial audio systems that can be verified without a human listener.
What Can Be Tested in Spatial Audio
Spatial audio has more testable properties than most developers realize:
- Position accuracy: Is the audio source placed at the correct 3D coordinates?
- Volume falloff: Does loudness decrease correctly with distance?
- Occlusion coefficient: Does audio attenuate correctly when blocked by geometry?
- HRTF application: Is the correct HRTF profile being used?
- Doppler effect: Does velocity affect pitch correctly?
- Audio pooling: Are audio sources properly managed and released?
- Mixer routing: Is each audio type (ambient, SFX, voice) routed to the correct mixer channel?
- Performance: Do audio computations stay within budget?
Testing Audio Source Positioning
// Tests/EditMode/SpatialAudioTests.cs
using NUnit.Framework;
using UnityEngine;
[TestFixture]
public class SpatialAudioPositioningTests
{
private GameObject _listenerGo;
private AudioListener _listener;
[SetUp]
public void SetUp()
{
_listenerGo = new GameObject("Listener");
_listener = _listenerGo.AddComponent<AudioListener>();
_listenerGo.transform.position = Vector3.zero;
}
[TearDown]
public void TearDown()
{
Object.DestroyImmediate(_listenerGo);
}
[Test]
public void AudioSource_AtKnownPosition_HasCorrectSpatialBlend()
{
var sourceGo = new GameObject("AudioSource");
var source = sourceGo.AddComponent<AudioSource>();
sourceGo.transform.position = new Vector3(5, 0, 0);
source.spatialBlend = 1.0f; // Full 3D
Assert.AreEqual(1.0f, source.spatialBlend,
"VR audio sources must be fully spatial (spatialBlend = 1.0)");
Object.DestroyImmediate(sourceGo);
}
[Test]
public void AudioSource_Volume_DecreasesWithDistance()
{
var sourceGo = new GameObject("AudioSource");
var source = sourceGo.AddComponent<AudioSource>();
source.spatialBlend = 1.0f;
source.rolloffMode = AudioRolloffMode.Logarithmic;
source.minDistance = 1f;
source.maxDistance = 20f;
// Calculate expected volumes at different distances
float volumeAt1m = AudioUtility.GetVolumeAtDistance(source, 1f);
float volumeAt5m = AudioUtility.GetVolumeAtDistance(source, 5f);
float volumeAt10m = AudioUtility.GetVolumeAtDistance(source, 10f);
Assert.Greater(volumeAt1m, volumeAt5m,
"Volume at 1m must be greater than at 5m");
Assert.Greater(volumeAt5m, volumeAt10m,
"Volume at 5m must be greater than at 10m");
Object.DestroyImmediate(sourceGo);
}
[Test]
public void AudioSource_BeyondMaxDistance_HasMinimumVolume()
{
var sourceGo = new GameObject("AudioSource");
var source = sourceGo.AddComponent<AudioSource>();
source.spatialBlend = 1.0f;
source.maxDistance = 10f;
float volumeBeyondMax = AudioUtility.GetVolumeAtDistance(source, 50f);
Assert.LessOrEqual(volumeBeyondMax, 0.01f,
"Audio source should be nearly silent beyond max distance");
Object.DestroyImmediate(sourceGo);
}
}Testing Audio Occlusion
[TestFixture]
public class AudioOcclusionTests
{
[Test]
public void OcclusionSystem_WallBetweenSourceAndListener_AttenuatesVolume()
{
var system = new AudioOcclusionSystem();
var listenerPos = new Vector3(0, 0, 0);
var sourcePos = new Vector3(10, 0, 0);
// No wall — full volume
float openVolume = system.CalculateOcclusion(
listenerPos, sourcePos,
obstacles: new List<Bounds>()
);
// Wall between listener and source
var wall = new Bounds(new Vector3(5, 0, 0), new Vector3(0.5f, 3f, 5f));
float occludedVolume = system.CalculateOcclusion(
listenerPos, sourcePos,
obstacles: new List<Bounds> { wall }
);
Assert.Greater(openVolume, occludedVolume,
"Occluded audio should be quieter than unoccluded");
Assert.That(occludedVolume, Is.InRange(0.1f, 0.7f),
"Occlusion should partially attenuate, not mute completely");
}
[Test]
public void OcclusionSystem_NoObstacles_ReturnsFullVolume()
{
var system = new AudioOcclusionSystem();
float volume = system.CalculateOcclusion(
new Vector3(0, 0, 0),
new Vector3(5, 0, 0),
obstacles: new List<Bounds>()
);
Assert.AreApproximatelyEqual(1.0f, volume, delta: 0.01f);
}
[Test]
public void OcclusionSystem_SamePosition_ReturnsFullVolume()
{
var system = new AudioOcclusionSystem();
var pos = new Vector3(3, 1, 2);
float volume = system.CalculateOcclusion(pos, pos, new List<Bounds>());
Assert.AreApproximatelyEqual(1.0f, volume, delta: 0.01f);
}
}Testing Doppler Effect
[TestFixture]
public class DopplerEffectTests
{
[Test]
public void DopplerCalculator_ApproachingSource_HasHigherPitch()
{
var calculator = new DopplerCalculator(speedOfSound: 343f);
// Source moving toward listener at 10 m/s
float approachingPitch = calculator.CalculatePitch(
sourceVelocity: new Vector3(10, 0, 0), // moving toward listener
listenerVelocity: Vector3.zero,
sourcePosition: new Vector3(20, 0, 0),
listenerPosition: Vector3.zero
);
// Source stationary
float stationaryPitch = calculator.CalculatePitch(
sourceVelocity: Vector3.zero,
listenerVelocity: Vector3.zero,
sourcePosition: new Vector3(20, 0, 0),
listenerPosition: Vector3.zero
);
Assert.Greater(approachingPitch, stationaryPitch,
"Approaching source should have higher pitch (Doppler effect)");
}
[Test]
public void DopplerCalculator_StaticSource_ReturnsPitchOne()
{
var calculator = new DopplerCalculator(speedOfSound: 343f);
float pitch = calculator.CalculatePitch(
sourceVelocity: Vector3.zero,
listenerVelocity: Vector3.zero,
sourcePosition: new Vector3(5, 0, 0),
listenerPosition: Vector3.zero
);
Assert.AreApproximatelyEqual(1.0f, pitch, delta: 0.001f);
}
}Testing Audio Mixer Routing
[TestFixture]
public class AudioMixerTests
{
[Test]
public void SFXSounds_AreRoutedToSFXMixerGroup()
{
var audioManager = new AudioManager();
var sfxSource = audioManager.GetAudioSource(AudioCategory.SFX);
Assert.AreEqual("SFX", sfxSource.outputAudioMixerGroup.name,
"SFX audio sources must route to the SFX mixer group");
}
[Test]
public void VoiceChat_IsRoutedToVoiceMixerGroup()
{
var audioManager = new AudioManager();
var voiceSource = audioManager.GetAudioSource(AudioCategory.Voice);
Assert.AreEqual("Voice", voiceSource.outputAudioMixerGroup.name,
"Voice audio must route to Voice mixer, not Master or SFX");
}
[Test]
public void AmbientSounds_AreRoutedToAmbientMixerGroup()
{
var audioManager = new AudioManager();
var ambientSource = audioManager.GetAudioSource(AudioCategory.Ambient);
Assert.AreEqual("Ambient", ambientSource.outputAudioMixerGroup.name);
}
[Test]
public void AllMixerGroupVolumes_AreWithinValidRange()
{
var audioMixer = Resources.Load<UnityEngine.Audio.AudioMixer>("MainMixer");
string[] groups = { "Master", "SFX", "Voice", "Ambient", "Music" };
foreach (string group in groups)
{
audioMixer.GetFloat($"{group}Volume", out float value);
Assert.That(value, Is.InRange(-80f, 20f),
$"Mixer group '{group}' volume {value}dB is outside valid range");
}
}
}Testing Audio Performance
Spatial audio in VR has strict CPU budgets — too many simultaneous sources cause frame drops:
using Unity.PerformanceTesting;
[TestFixture, Category("Performance")]
public class SpatialAudioPerformanceTests
{
[Test, Performance]
public void MaxConcurrentSources_StaysWithinCPUBudget()
{
var sources = new List<AudioSource>();
// Create maximum concurrent sources
for (int i = 0; i < 32; i++)
{
var go = new GameObject($"AudioSource_{i}");
var source = go.AddComponent<AudioSource>();
source.spatialBlend = 1.0f;
source.transform.position = Random.insideUnitSphere * 10f;
sources.Add(source);
}
using (Measure.ProfilerMarkers("AudioManager.FixedUpdate"))
{
// Simulate 100 frames with all sources playing
for (int frame = 0; frame < 100; frame++)
{
foreach (var source in sources)
{
// Update positions (triggers HRTF recalculation)
source.transform.position = Random.insideUnitSphere * 10f;
}
}
}
foreach (var source in sources)
Object.DestroyImmediate(source.gameObject);
}
[Test]
public void AudioSourcePool_ReturnsSourcesWithinTimeLimit()
{
var pool = new AudioSourcePool(capacity: 32);
var stopwatch = System.Diagnostics.Stopwatch.StartNew();
// Rapid acquire/release cycle
for (int i = 0; i < 1000; i++)
{
var source = pool.Acquire();
pool.Release(source);
}
stopwatch.Stop();
Assert.Less(stopwatch.ElapsedMilliseconds, 10,
$"Audio pool operations took {stopwatch.ElapsedMilliseconds}ms — too slow for VR frame budget");
}
}Testing HRTF Configuration
HRTF (Head-Related Transfer Function) gives audio its sense of height and direction. Test that it's configured correctly:
[TestFixture]
public class HRTFConfigurationTests
{
[Test]
public void SpatialAudioSources_HaveSpatializerEnabled()
{
// Find all spatial audio sources in the scene
var spatialSources = Object.FindObjectsOfType<AudioSource>()
.Where(s => s.spatialBlend > 0.5f)
.ToArray();
foreach (var source in spatialSources)
{
Assert.IsTrue(source.spatialize,
$"Audio source '{source.gameObject.name}' has spatialBlend > 0.5 but spatialize is false — HRTF not applied");
}
}
[Test]
public void SpatialAudioManager_InitializesHRTFPlugin()
{
var manager = new SpatialAudioManager();
manager.Initialize();
Assert.IsTrue(manager.IsHRTFActive,
"HRTF plugin must be active for spatial audio in VR");
Assert.AreEqual(manager.SampleRate, 48000,
"HRTF processing requires 48kHz sample rate");
}
}CI Integration
# .github/workflows/spatial-audio-tests.yml
name: Spatial Audio Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
lfs: true
- uses: game-ci/unity-test-runner@v4
env:
UNITY_LICENSE: ${{ secrets.UNITY_LICENSE }}
UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }}
UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }}
with:
testMode: editmode
projectPath: .
unityVersion: 2023.3.0f1
testCategory: "Audio,SpatialAudio"
- uses: actions/upload-artifact@v4
if: always()
with:
name: audio-test-results
path: artifacts/Common Spatial Audio Testing Mistakes
Testing only with 2D audio: Many spatial audio bugs only appear with spatialBlend = 1.0. Always test with fully spatial sources.
Not testing occlusion: Audio passing through walls is jarring in VR. Test occlusion coefficients explicitly.
No pooling tests: Creating and destroying AudioSource components is expensive. Test that your audio pool correctly manages source lifecycle.
Ignoring mixer routing: Audio routed to the wrong mixer group bypasses user volume settings. Test every audio category's mixer assignment.
No performance tests for concurrent sources: VR applications often have many simultaneous 3D sounds. Test that your system handles the expected maximum without frame drops.
Spatial audio testing focuses on the configurable and calculable aspects — positioning, volume falloff, occlusion coefficients, mixer routing, and performance — rather than the perceptual quality that requires human listeners. These automated tests catch the class of bugs (wrong attenuation, missing HRTF, incorrect mixer routing) that are objectively wrong, leaving subjective quality assessment to human QA sessions.