Skip to main content

Spatial Audio

Spatial audio places sounds in a 3D scene. A sound to your left comes from the left, a sound far away is quieter, and with headphones a sound behind you can sound like it is behind you. soundhub builds this on the Web Audio PannerNode and AudioListener.

How it fits together​

There are three things you can place:

WhatMethodsUse it for
A soundsetSpatialPosition, setSpatialOrientationEach sound gets its own panner. A helicopter, footsteps, a television.
The listenersetListenerPosition, setListenerOrientationThe ear in the scene. Move it with a first-person camera.
The master pannersetMasterSpatialPosition, setMasterSpatialOrientationOne panner on the master output that moves the whole mix at once.

Every sound is heard from the listener. Moving the listener and moving the sounds use the same panner nodes, so you can mix both.

A sound is either stereo panned or spatial, never both. setSpatialPosition removes a sound's stereo panner, and setPan removes its 3D panner. See SoundPanType.

Coordinates​

The listener starts at 0, 0, 0, looking down negative z with up along positive y. From there, positive x is to the right, positive y is up and negative z is in front. Distances have no fixed unit: refDistance (default 1) decides what counts as close.

Checking support​

Spatial audio is on by default. The constructor switches it off when the browser lacks support.

import { SoundHub } from 'soundhub';

const soundHub = new SoundHub();

soundHub.isSpatialAudioSupported(); // the browser can do it
soundHub.isSpatialAudioEnabled(); // supported and not switched off in the config

While isSpatialAudioEnabled is false, the methods for sounds and the listener do nothing. Pass spatialAudio: false in the hub config to turn it off yourself.

Positioning a sound​

Give a position when the sound starts, through the play options, or move it at any time with setSpatialPosition.

import { SoundHub, SoundPanType } from 'soundhub';

const soundHub = new SoundHub();
await soundHub.loadSound('rain', '/audio/rain.mp3');

soundHub.play('rain', {
loop: true,
panType: SoundPanType.Spatial,
panSpatialPosition: { x: 5, y: 0, z: -10 },
});

// Move it later
soundHub.setSpatialPosition(10, 0, -20, 'rain');
soundHub.getSpatialPosition('rain'); // { x: 10, y: 0, z: -20 }

PlayOptions has no field for panner settings. They come from pannerNodeConfig in the hub config, the fifth argument of setSpatialPosition, or updatePannerConfigById afterwards.

To go back to plain playback, use removeSpatialEffect or switch the sound to stereo with setPan.

Pointing a sound​

A panner can have a cone: full volume inside coneInnerAngle, coneOuterGain outside coneOuterAngle. setSpatialOrientation says where the cone points.

soundHub.updatePannerConfigById('rain', {
coneInnerAngle: 60,
coneOuterAngle: 180,
coneOuterGain: 0.2,
});
soundHub.setSpatialOrientation('rain', 0, 0, 1); // facing the listener

Moving the listener​

For a first-person camera, keep the sounds where they are and move the listener.

function onFrame() {
soundHub.setListenerPosition(player.x, 0, player.z, true);
soundHub.setListenerOrientation(camera.forwardX, 0, camera.forwardZ, 0, 1, 0, true);
requestAnimationFrame(onFrame);
}
requestAnimationFrame(onFrame);

resetListener puts it back at the centre.

The master panner​

setMasterSpatialPosition adds a panner to the master output, so the whole mix moves together, on top of any position a sound has of its own.

import { PanningModel, DistanceModel } from 'soundhub';

soundHub.setMasterSpatialPosition(4, 0, 0, {
panningModel: PanningModel.HRTF,
distanceModel: DistanceModel.Linear,
maxDistance: 100,
});

soundHub.getMasterSpatialPosition(); // { x: 4, y: 0, z: 0 }

// Take it out again
soundHub.resetMasterSpatialPosition();

Panner settings​

SoundPannerConfig​

The settings for a panner node. Every field is optional.

export interface SoundPannerConfig {
panningModel?: PanningModel;
distanceModel?: DistanceModel;
refDistance?: number;
maxDistance?: number;
rolloffFactor?: number;
coneInnerAngle?: number;
coneOuterAngle?: number;
coneOuterGain?: number;
}
FieldTypeDefaultDescription
panningModelPanningModelPanningModel.HRTFThe spatialisation algorithm.
distanceModelDistanceModelDistanceModel.InverseHow volume drops as the sound moves away from the listener.
refDistancenumber1Distance at which the volume starts to drop. At least 0.
maxDistancenumber10000Distance after which the volume drops no further. At least refDistance.
rolloffFactornumber1How quickly the volume drops. 0 to 1 for the linear model, 0 or more for the others.
coneInnerAnglenumber360Angle in degrees, 0 to 360, of the cone with no volume reduction. 360 means no cone.
coneOuterAnglenumber360Angle in degrees, 0 to 360, outside which the volume is reduced to coneOuterGain.
coneOuterGainnumber0Gain outside the outer cone, 0 to 1.

These defaults are exported as DEFAULT_PANNER_CONFIG. A sound's panner starts from them, then pannerNodeConfig from the hub config, then the config passed to setSpatialPosition. The master panner starts from the browser's PannerNode defaults instead.

PanningModel​

export enum PanningModel {
HRTF = "HRTF",
EqualPower = "equalpower",
}
ValueDescription
PanningModel.HRTFHead-related transfer function. The more convincing 3D effect, best on headphones, and heavier on the CPU.
PanningModel.EqualPowerBasic equal-power panning. Cheaper, with less sense of front and back.

DistanceModel​

export enum DistanceModel {
Linear = "linear",
Inverse = "inverse",
Exponential = "exponential",
}
ValueDescription
DistanceModel.LinearVolume drops in a straight line from refDistance to maxDistance.
DistanceModel.InverseVolume drops inversely with distance. The most natural of the three, and the default.
DistanceModel.ExponentialVolume drops exponentially with distance.

Example: a fly circling your head​

import { SoundHub, SoundPanType, PanningModel, DistanceModel } from 'soundhub';

const soundHub = new SoundHub();
await soundHub.loadSound('fly', '/audio/fly-buzz.mp3');

soundHub.play('fly', {
loop: true,
panType: SoundPanType.Spatial,
panSpatialPosition: { x: 0, y: 0, z: -3 },
});
soundHub.updatePannerConfigById('fly', {
panningModel: PanningModel.HRTF,
distanceModel: DistanceModel.Inverse,
refDistance: 0.5,
rolloffFactor: 2,
});

let angle = 0;
const radius = 3;

const flyInterval = setInterval(() => {
angle += 0.05;
const x = Math.cos(angle) * radius;
const z = Math.sin(angle) * radius;
soundHub.setSpatialPosition(x, 0.5, z, 'fly', undefined, true);
}, 50);

// Stop after ten seconds
setTimeout(() => {
clearInterval(flyInterval);
soundHub.stop('fly');
}, 10000);

Example: a weather scene​

import { SoundHub, SoundPanType, DistanceModel } from 'soundhub';

const soundHub = new SoundHub();
await soundHub.loadSounds([
{ id: 'rain', url: '/audio/rain.mp3' },
{ id: 'wind', url: '/audio/wind.mp3' },
{ id: 'thunder', url: '/audio/thunder.mp3' },
]);

// Rain above and ahead
soundHub.play('rain', {
loop: true,
volume: 0.4,
panType: SoundPanType.Spatial,
panSpatialPosition: { x: 0, y: 5, z: -10 },
});
soundHub.updatePannerConfigById('rain', { refDistance: 5 });

// Wind from the left
soundHub.play('wind', {
loop: true,
volume: 0.3,
panType: SoundPanType.Spatial,
panSpatialPosition: { x: -15, y: 3, z: 0 },
});

// Distant thunder after three seconds
setTimeout(() => {
soundHub.play('thunder', {
volume: 0.8,
panType: SoundPanType.Spatial,
panSpatialPosition: { x: 20, y: 10, z: -30 },
});
soundHub.updatePannerConfigById('thunder', {
distanceModel: DistanceModel.Exponential,
refDistance: 10,
});
}, 3000);

Try it​

Try it

Place a sound around you

Idle
front
0.0
belowabove
x3.0
y0.0
z-4.0
Distance5.0

Use headphones. Drag the dot behind you, then far away: with HRTF you hear direction, and the inverse distance model makes it quieter beyond 3 units.

Code
Your clicks show up here as soundhub calls

Good to know​

  • For a sound played without overlap, a non-zero panSpatialPosition is what switches it to spatial. With a position of 0, 0, 0, panType: SoundPanType.Spatial alone leaves it stereo. Call setSpatialPosition if you want a sound at the centre.
  • Positions and directions set through the sound and listener methods are rounded to 2 decimals.
  • Updating a position every frame? Pass skipDispatchEvent: true so the event bus is not flooded.
  • Since 6.3.2, adding or removing the master panner no longer disconnects nodes your app connected to getMasterOutput.

See also​