Audio System
The @web-engine-dev/audio package provides a game-oriented audio engine built on the Web Audio API. It handles the concerns that game audio requires beyond basic playback: hierarchical bus mixing, 3D spatial positioning, voice management with polyphony limits, sound pooling, audio occlusion, and an adaptive music system with beat-synchronized transitions.
Architecture Overview
AudioManager (main controller)
├── AudioListener (player's ears in 3D space)
├── AudioBus hierarchy (mixing + effects)
│ ├── master
│ ├── sfx → master
│ ├── music → master
│ └── voice → master
├── AudioClip registry (loaded audio data)
├── AudioSource pool (active playback)
├── SoundPool (frequent sounds with LRU eviction)
├── VoiceManager (allocation + virtualization)
├── AudioNodePool (Web Audio node reuse)
├── AudioOcclusionSystem (line-of-sight occlusion)
└── MusicSystem (adaptive layered music)Basic Setup
import { AudioManager } from '@web-engine-dev/audio';
const audioManager = new AudioManager();
await audioManager.initialize({
masterVolume: 1.0,
maxSources: 32,
defaultDistanceModel: 'inverse',
defaultPanningModel: 'HRTF',
spatialAudioEnabled: true,
});
// Load audio clips
await audioManager.loadClip('explosion', '/sounds/explosion.mp3');
await audioManager.loadClip('footstep', '/sounds/footstep.wav');
await audioManager.loadClip('theme', '/music/theme.mp3');
// Play a sound
audioManager.play('explosion', { volume: 0.8, bus: 'sfx' });
// Update each frame (required for spatial audio, voice management, etc.)
function gameLoop(deltaTime: number) {
audioManager.update(deltaTime);
}Web Audio Context
Browsers require user interaction before audio can play. Call audioManager.resume() in response to a click or key press:
document.addEventListener('click', async () => {
await audioManager.resume();
}, { once: true });Audio Buses
Buses form a hierarchical mixing graph. Each bus can have its own volume, effects, and mute state. Audio routed to a child bus flows through its parent, enabling group-level control.
// Create bus hierarchy
const sfxBus = audioManager.createBus('sfx', 'master');
const musicBus = audioManager.createBus('music', 'master');
const ambientBus = audioManager.createBus('sfx-ambient', 'sfx');
// Control bus volume
sfxBus.setVolume(0.8);
musicBus.mute(); // Mute all music
musicBus.unmute(); // Restore
// Add effects to buses
sfxBus.addEffect({ type: 'reverb', roomSize: 0.3, wet: 0.2 });
sfxBus.addEffect({ type: 'compressor', threshold: -24, ratio: 4 });Effect Types
| Effect | Description |
|---|---|
gain | Volume control |
lowpass | Low-frequency filter |
highpass | High-frequency filter |
bandpass | Band filter |
reverb | Room simulation |
delay | Echo effect |
compressor | Dynamic range compression |
distortion | Overdrive |
chorus | Modulation effect |
flanger | Sweep effect |
equalizer | Multi-band EQ |
Playback Options
Every play() call accepts a PlaybackOptions object:
audioManager.play('explosion', {
loop: false,
volume: 0.8,
pitch: 1.0,
startTime: 0,
fadeInDuration: 0.5,
priority: 'high',
bus: 'sfx',
});Priority levels: lowest, low, normal, high, highest, critical. When the voice pool is full, lower-priority sounds are stolen first.
Spatial Audio
The audio system supports full 3D spatial audio with distance attenuation and directional panning.
Listener
The listener represents the player's "ears" -- typically positioned at the camera:
audioManager.listener.setFromTransform(
cameraPosition, // Vec3Like: { x, y, z }
cameraForward, // Vec3Like: forward direction
cameraUp // Vec3Like: up direction
);Playing Spatial Sounds
audioManager.playAtPosition('explosion', { x: 10, y: 0, z: 5 }, {
distanceModel: 'inverse',
panningModel: 'HRTF',
refDistance: 1,
maxDistance: 100,
rolloffFactor: 1,
});Distance Models
| Model | Formula | Behavior |
|---|---|---|
linear | Linear falloff from refDistance to maxDistance | Volume drops linearly |
inverse | refDistance / (refDistance + rolloff * (dist - refDistance)) | Realistic falloff (default) |
exponential | (dist / refDistance) ^ -rolloff | Aggressive falloff |
Panning Models
| Model | Description |
|---|---|
equalpower | Simple stereo panning |
HRTF | Head-Related Transfer Function for realistic spatial positioning |
Audio Occlusion
The AudioOcclusionSystem provides line-of-sight occlusion using simple geometric primitives. When a wall or object blocks the direct path between the listener and a sound source, the sound is attenuated and low-pass filtered.
import { AudioOcclusionSystem } from '@web-engine-dev/audio';
const occlusion = new AudioOcclusionSystem({
maxOcclusion: 0.9,
maxObstruction: 0.8,
});
// Add occluder shapes
occlusion.addOccluder({
id: 'wall-1',
shape: 'aabb',
bounds: { minX: 0, minY: 0, minZ: 0, maxX: 10, maxY: 3, maxZ: 0.5 },
occlusion: 0.7,
obstruction: 0.3,
});
occlusion.addOccluder({
id: 'pillar',
shape: 'sphere',
center: { x: 5, y: 1.5, z: 3 },
radius: 0.5,
occlusion: 0.5,
});
// Update each frame
occlusion.update(listenerPosition, sources);
// Query occlusion
const blocked = occlusion.isOccluded(listenerPos, sourcePos);Occluder shapes: aabb (axis-aligned bounding box) and sphere.
Sound Pools
For frequently played sounds (footsteps, gunshots), sound pools pre-allocate voices and handle voice stealing automatically:
const footstepPool = audioManager.createSoundPool('footstep', 8);
// Pre-warm for reduced first-play latency
footstepPool.warmUp(4);
// Configure voice stealing
footstepPool.setStealingPolicy('combined');
// Play from pool
footstepPool.play({
volume: 0.5,
pitch: 0.9 + Math.random() * 0.2, // Slight variation
position: characterPosition,
priority: 'normal',
});
// Pool statistics
const stats = footstepPool.getStats();
console.log(`Plays: ${stats.totalPlays}, Steals: ${stats.stealCount}`);Voice Stealing Policies
| Policy | Behavior |
|---|---|
oldest | Steal the oldest playing voice |
newest | Steal the most recently started voice |
lowest-priority | Steal the lowest priority voice |
least-audible | Steal based on distance and volume |
combined | Weighted score of priority + audibility (default) |
Voice Management
The VoiceManager handles global voice allocation and virtualization. When the maximum voice count is reached, less important sounds are virtualized (tracked but not rendered to audio hardware).
const voiceManager = audioManager.voiceManager;
voiceManager.setMaxVoices(64);
voiceManager.setStealingPolicy('combined');
// Per-bus voice limits
voiceManager.setBusVoiceLimit('sfx', 16);
voiceManager.setBusVoiceLimit('music', 4);
// Spatial culling -- automatically stop sounds beyond max distance
voiceManager.setSpatialCulling({
enabled: true,
cullMultiplier: 1.5, // Cull at 1.5x maxDistance
hysteresis: 0.1, // Prevent thrashing at boundary
updateInterval: 100, // Check every 100ms
});Voice states: real (playing through audio hardware), virtual (tracked but silent), stopped.
Adaptive Music System
The music system supports layered tracks with beat-synchronized transitions, enabling dynamic soundtracks that respond to game state.
Defining Tracks
const music = audioManager.getMusicSystem();
const combatTrack = {
id: 'combat',
name: 'Combat Theme',
bpm: 140,
beatsPerBar: 4,
layers: [
{ name: 'drums', clip: drumsClip, volume: 1.0, enabled: true },
{ name: 'bass', clip: bassClip, volume: 0.8, enabled: true },
{ name: 'tension', clip: tensionClip, volume: 0.7, enabled: false },
],
loopStart: 0,
loopEnd: 32.0,
};
await music.loadTrack(combatTrack);
music.play('combat', { type: 'fade', duration: 2.0 });Dynamic Layer Control
// Enable/disable layers based on game state
music.enableLayer('tension', { type: 'fade', duration: 0.5 });
music.setLayerVolume('drums', 0.9);
music.disableLayer('bass', { type: 'fade', duration: 1.0 });Transitions
music.transitionTo('exploration', {
type: 'onBar', // Wait for the next bar boundary
duration: 4.0,
});| Transition | Description |
|---|---|
immediate | Instant switch |
fade | Fade out then fade in |
crossfade | Simultaneous fade (overlap) |
onBeat | Sync transition to beat boundary |
onBar | Sync transition to bar boundary |
onMarker | Sync to a named marker in the track |
Adaptive Triggers
Define game-state-driven music changes that respond automatically to parameter updates:
music.addTrigger({
id: 'danger-high',
parameter: 'danger',
condition: 'greater', // 'greater' | 'less' | 'equals' | 'between'
value: 0.7,
action: 'enableLayer', // 'enableLayer' | 'disableLayer' | 'transition' | 'setVolume'
target: 'tension',
});
// Update from game logic
music.setParameter('danger', calculateDangerLevel());Audio Node Pool
The AudioNodePool reuses Web Audio GainNode and PannerNode instances to reduce garbage collection pressure:
const nodePool = audioManager.nodePool;
// Pool statistics
const stats = nodePool.getStats();
console.log(`Gain nodes: ${stats.gainNodes.inUse}/${stats.gainNodes.total}`);Configuration Reference
interface AudioSystemConfig {
masterVolume?: number; // Default: 1.0
maxSources?: number; // Default: 32
defaultDistanceModel?: DistanceModel; // Default: 'inverse'
defaultPanningModel?: PanningModel; // Default: 'HRTF'
defaultRefDistance?: number; // Default: 1
defaultMaxDistance?: number; // Default: 100
defaultRolloff?: number; // Default: 1
spatialAudioEnabled?: boolean; // Default: true
dopplerEnabled?: boolean; // Default: false
reverbEnabled?: boolean; // Default: true
}Performance Tips
- Sound pools -- Use
createSoundPool()for frequently played sounds to avoid allocation overhead - Pool warm-up -- Call
pool.warmUp()during loading screens to pre-allocate audio nodes - Bus hierarchy -- Group sounds by type for efficient volume and effects control
- Spatial culling -- Enable automatic culling of distant sounds to save Web Audio resources
- Node pooling -- The
AudioNodePoolreusesGainNode/PannerNodeto reduce GC pressure - Clip preloading -- Load clips during loading screens, not during gameplay
- Spatial updates -- Only update listener/source positions for moving objects
Cleanup
audioManager.stopAll();
audioManager.dispose();Related Packages
@web-engine-dev/ecs-- Entity Component System for structuring audio systems@web-engine-dev/math--Vec3Liketypes used for spatial positions