Audio
SAGE provides a channel-based audio system built on BabylonJS AudioV2. You define named channels (like music, sfx, ambient) and the system creates an audio bus graph with independent volume and mute controls per channel. This gives your players the standard per-category volume sliders they expect from a settings menu.
The audio system is opt-in. If you don't pass audioChannels to createGameEngine(), no audio infrastructure is created, SoundBehavior builds no sounds, and the sound handler creates none.
Architecture
The audio system follows SAGE's iDesign layering:
┌────────────────────────────────────────────┐
│ AudioManager │ ← Orchestration layer
│ - Channel volume/mute │
│ - Master volume/mute │
│ - Sound creation (routed to channels) │
├────────────────────────────────────────────┤
│ AudioEngine │ ← Engine layer (wraps BabylonJS AudioV2)
│ - WebAudio engine lifecycle │
│ - Bus creation & management │
│ - Raw sound creation │
├────────────────────────────────────────────┤
│ BabylonJS AudioV2 │ ← Underlying implementation
│ - WebAudio API │
│ - AudioBus, StaticSound │
└────────────────────────────────────────────┘Audio graph:
MainBus (master volume)
├── music bus
├── sfx bus
├── ambient bus
└── ... (one bus per channel)Each sound is routed to a channel bus. Adjusting a channel's volume affects all sounds on that bus. The master volume sits at the top of the graph and scales everything.
Setup
Pass audioChannels to createGameEngine():
import { createGameEngine } from '@skewedaspect/sage';
const engine = await createGameEngine(canvas, entityDefs, {
audioChannels: [ 'music', 'sfx', 'ambient', 'voice' ],
});This creates an AudioEngine and AudioManager, both accessible on the engine instance:
engine.engines.audioEngine // AudioEngine (low-level)
engine.managers.audioManager // AudioManager (use this one)Scene integration
Every NodeEntity reaches the AudioManager through its audio getter, and a behavior through this.entity.audio. It answers with undefined for a game that never opted in. The Blender sound handler reads it from the engine. No per-level wiring is needed.
AudioManager
The AudioManager is the primary interface for game code. It handles channel routing, volume, muting, and sound creation.
Creating sounds
const audioManager = engine.managers.audioManager;
// Create a sound on the 'sfx' channel
const jumpSound = await audioManager.createSound('jump', 'audio/jump.ogg', 'sfx');
// Create a sound on the 'music' channel with options
const bgMusic = await audioManager.createSound('bgm', 'audio/theme.ogg', 'music', {
loop: true,
autoplay: true,
volume: 0.7,
});
// No channel -- routes to the main bus directly
const uiClick = await audioManager.createSound('click', 'audio/click.ogg');Signature:
async createSound(
name : string,
url : string,
channel ?: string,
options ?: Partial<IStaticSoundOptions>
) : Promise<StaticSound>| Parameter | Type | Description |
|---|---|---|
name | string | Unique identifier for the sound |
url | string | Path to the audio file |
channel | string | Channel name to route to (optional) |
options | Partial<IStaticSoundOptions> | BabylonJS AudioV2 sound options (optional) |
If channel doesn't match a registered channel, a warning is logged and the sound routes to the main bus.
Master volume
// Set master volume (0-1)
audioManager.setMasterVolume(0.8);
// Get current master volume
const vol = audioManager.getMasterVolume();
// Mute/unmute everything
audioManager.setMasterMuted(true);
audioManager.setMasterMuted(false); // restores previous volume
// Check mute state
if(audioManager.isMasterMuted()) { /* ... */ }Master mute stores the current volume and sets it to 0. Unmuting restores the stored volume, so setMasterVolume() and setMasterMuted() work independently without stepping on each other.
Channel volume
// Set channel volume (0-1)
audioManager.setChannelVolume('music', 0.5);
audioManager.setChannelVolume('sfx', 1.0);
// Get channel volume
const musicVol = audioManager.getChannelVolume('music');
// Mute/unmute a channel
audioManager.setChannelMuted('music', true);
audioManager.setChannelMuted('music', false); // restores previous volume
// Check mute state
if(audioManager.isChannelMuted('music')) { /* ... */ }Channel mute works the same way as master mute -- volume is stored and restored on unmute.
Querying channels
// Get all registered channel names
const channels = audioManager.getChannels();
// => ['music', 'sfx', 'ambient', 'voice']This is useful for building settings UIs dynamically.
AudioEngine
The AudioEngine is the lower-level wrapper around BabylonJS AudioV2. You typically don't need to use it directly -- AudioManager covers the common cases. It's exposed for advanced scenarios like creating custom bus topologies.
API
| Method | Signature | Description |
|---|---|---|
initialize() | () => Promise<void> | Creates the WebAudio engine and main bus |
createBus(name) | (string) => Promise<AudioBus> | Create a named bus routed to the main bus |
getBus(name) | (string) => AudioBus | undefined | Look up a bus by name |
createSound(name, url, bus?, options?) | (...) => Promise<StaticSound> | Create a sound, optionally routed to a bus |
setMasterVolume(volume) | (number) => void | Set the engine-level master volume |
getMasterVolume() | () => number | Get the engine-level master volume |
setBusVolume(bus, volume) | (AudioBus, number) => void | Set volume on a specific bus |
Settings UI example
A typical audio settings implementation using AudioManager:
import type { AudioManager } from '@skewedaspect/sage';
function buildAudioSettings(audioManager : AudioManager)
{
// Build sliders for each channel
for(const channel of audioManager.getChannels())
{
const volume = audioManager.getChannelVolume(channel);
const muted = audioManager.isChannelMuted(channel);
// Create your UI slider with these values...
// On slider change:
// audioManager.setChannelVolume(channel, newValue);
// On mute toggle:
// audioManager.setChannelMuted(channel, !muted);
}
// Master volume
const masterVol = audioManager.getMasterVolume();
// audioManager.setMasterVolume(newValue);
// audioManager.setMasterMuted(true/false);
}SoundBehavior
SoundBehavior, provided by sage, gives a NodeEntity one or more named sound sources built from its own state, played and stopped through ops.
import { SoundBehavior } from '@skewedaspect/sage';
import type { SoundConfig, SoundState } from '@skewedaspect/sage';State
interface SoundState
{
sounds : Record<string, SoundConfig>;
}
interface SoundConfig
{
url : string;
volume ?: number;
loop ?: boolean;
spatial ?: boolean;
maxDistance ?: number;
channel ?: string;
}| Field | Default | Meaning |
|---|---|---|
url | required | Path to the audio file |
volume | 1 | Volume, 0 to 1 |
loop | false | Loop, and start playing as soon as the sound is built |
spatial | false | Attach to the entity's node and follow it |
maxDistance | 100 | Audible distance for a spatial sound |
channel | none | The channel bus to route to. Unset routes to the main bus. |
sounds defaults to {}. Every field is JSON, so the map is as snapshot-safe as the rest of an entity's state:
const behaviors = [ SoundBehavior, DoorBehavior ] as const;
export class DoorEntity extends NodeEntity
{
static override readonly behaviors = behaviors;
declare readonly state : StateFor<typeof behaviors>;
declare readonly ops : OpsFor<typeof behaviors>;
}
const door = simulation.spawn('DoorEntity', undefined, {
sounds: {
open: { url: 'audio/door-open.ogg', spatial: true, channel: 'sfx' },
hum: { url: 'audio/hum.ogg', loop: true, spatial: true, channel: 'ambient' },
},
});Setup
SoundBehavior builds one sound for every entry in its sounds map in onMeshLoaded, whether the entity was created or restored, and whether its node was built, pooled, or adopted. An entity with no AudioManager builds none. Adding a key to sounds after onMeshLoaded has run builds nothing.
A spatial sound attaches to the entity's node once built and follows it. A non-spatial sound is never positioned.
Autoplay
A loop sound starts playing as soon as it is built, on a fresh spawn and on a restored one alike. A non-looping sound never starts on its own. State never records that a sound is currently playing, so a one-shot never replays on restore.
Operations
play(name) starts the named sound from the beginning; stop(name) stops it.
if(door instanceof DoorEntity)
{
door.ops.play('open');
}A name the entity's sounds map does not declare throws. A declared sound that has not finished building yet, or that never will because there is no AudioManager, answers play and stop by doing nothing. A behavior that needs a call to land reaches for it from its own onMeshLoaded, after SoundBehavior's in attachment order.
Destruction
SoundBehavior stops and disposes every sound it built in onDestroy, poolable entity or not. A sound still being built when its entity is destroyed is disposed the moment its build finishes. Sound sources are never pooled.
Without AudioManager
When audioChannels is not provided (or is empty), the audio system is not initialized:
engine.engines.audioEngineisundefinedengine.managers.audioManagerisundefinedNodeEntity.audioanswers withundefinedSoundBehaviorbuilds no sounds, andplayandstopdo nothing- The Blender sound handler logs a warning and creates no sounds
To enable audio, pass audioChannels to createGameEngine().
