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:
| What | Methods | Use it for |
|---|---|---|
| A sound | setSpatialPosition, setSpatialOrientation | Each sound gets its own panner. A helicopter, footsteps, a television. |
| The listener | setListenerPosition, setListenerOrientation | The ear in the scene. Move it with a first-person camera. |
| The master panner | setMasterSpatialPosition, setMasterSpatialOrientation | One 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;
}
| Field | Type | Default | Description |
|---|---|---|---|
panningModel | PanningModel | PanningModel.HRTF | The spatialisation algorithm. |
distanceModel | DistanceModel | DistanceModel.Inverse | How volume drops as the sound moves away from the listener. |
refDistance | number | 1 | Distance at which the volume starts to drop. At least 0. |
maxDistance | number | 10000 | Distance after which the volume drops no further. At least refDistance. |
rolloffFactor | number | 1 | How quickly the volume drops. 0 to 1 for the linear model, 0 or more for the others. |
coneInnerAngle | number | 360 | Angle in degrees, 0 to 360, of the cone with no volume reduction. 360 means no cone. |
coneOuterAngle | number | 360 | Angle in degrees, 0 to 360, outside which the volume is reduced to coneOuterGain. |
coneOuterGain | number | 0 | Gain 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",
}
| Value | Description |
|---|---|
PanningModel.HRTF | Head-related transfer function. The more convincing 3D effect, best on headphones, and heavier on the CPU. |
PanningModel.EqualPower | Basic equal-power panning. Cheaper, with less sense of front and back. |
DistanceModel
export enum DistanceModel {
Linear = "linear",
Inverse = "inverse",
Exponential = "exponential",
}
| Value | Description |
|---|---|
DistanceModel.Linear | Volume drops in a straight line from refDistance to maxDistance. |
DistanceModel.Inverse | Volume drops inversely with distance. The most natural of the three, and the default. |
DistanceModel.Exponential | Volume 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
Place a sound around you
IdleUse 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.
Good to know
- For a sound played without
overlap, a non-zeropanSpatialPositionis what switches it to spatial. With a position of0, 0, 0,panType: SoundPanType.Spatialalone leaves it stereo. CallsetSpatialPositionif 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: trueso 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
setSpatialPosition: place a sound in 3D.setSpatialOrientation: point a sound.updatePannerConfigById: change a sound's panner settings.removeSpatialEffect: take the 3D panner off a sound.setListenerPosition: move the listener.setMasterSpatialPosition: position the whole mix.isSpatialAudioActive: check whether a sound is spatial.SoundPanType: stereo or spatial.setPan: stereo panning instead.