Audio
sage owns audio the way it owns scene nodes, meshes, and physics. AudioEngine wraps BabylonJS's audio engine and its buses. AudioManager sits above it: named channels, each its own bus, and createSound, which builds a sound routed to a channel's bus or to the main bus when the caller names none.
A game opts into audio by naming its channels when it builds the engine. A game that names none gets no AudioManager at all, and nothing else in sage requires one.
Architecture
AudioManager channel volume and mute, master volume and mute, createSound
|
AudioEngine the WebAudio engine, bus creation, raw sound creation
|
BabylonJS AudioV2 AudioBus, StaticSoundEach sound is routed to a channel's bus. Adjusting a channel's volume affects every sound on that bus. The master volume sits above every channel and scales all of them together.
Setup
Pass audioChannels to createGameEngine:
import { createGameEngine } from '@skewedaspect/sage';
const entityClasses = {};
const engine = await createGameEngine(canvas, entityClasses, {
audioChannels: [ 'music', 'sfx', 'ambient', 'voice' ],
});This builds an AudioEngine and an AudioManager, both on the engine:
engine.engines.audioEngine;
engine.managers.audioManager;audioManager is the one a game reaches for; audioEngine is exposed for the rarer case of building a custom bus topology directly.
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
Creating sounds
declare function createSound(
name : string,
url : string,
channel ?: string,
options ?: Partial<IStaticSoundOptions>
) : Promise<StaticSound>;| Parameter | Meaning |
|---|---|
name | The sound's own identifier |
url | Path to the audio file |
channel | The channel to route to. Unset routes to the main bus. |
options | BabylonJS AudioV2 sound options, unchanged by anything here |
A channel naming a channel the game never declared throws, naming the channel and the sound. A sound has exactly two routes: a named channel's own bus, or the main bus when the caller names none; a name that is neither is an error, not a third route. engine.managers.audioManager is undefined for a game that named no channels; see Without AudioManager.
const jumpSound = await audioManager.createSound('jump', 'audio/jump.ogg', 'sfx');
const bgMusic = await audioManager.createSound('bgm', 'audio/theme.ogg', 'music', {
loop: true,
volume: 0.7,
});
// No channel -- routes straight to the main bus.
const uiClick = await audioManager.createSound('click', 'audio/click.ogg');Master volume
audioManager.masterVolume = 0.8;
const volume = audioManager.masterVolume;
audioManager.masterMuted = true;
audioManager.masterMuted = false; // restores the volume from before the mute
if(audioManager.masterMuted) { /* ... */ }Muting stores the current volume and sets it to zero; unmuting restores what was stored. Writing masterVolume while muted stores the new value for when the mute lifts, rather than sounding it immediately.
Channel volume
audioManager.setChannelVolume('music', 0.5);
audioManager.setChannelVolume('sfx', 1.0);
const musicVolume = audioManager.getChannelVolume('music');
audioManager.setChannelMuted('music', true);
audioManager.setChannelMuted('music', false); // restores the volume from before the mute
if(audioManager.isChannelMuted('music')) { /* ... */ }Channel mute works the same way master mute does: the volume is stored and restored around the mute. All four channel-keyed methods throw, naming the channel, for a name that is not one of the game's declared channels.
Querying channels
const channels = audioManager.channels;
// [ 'music', 'sfx', 'ambient', 'voice' ]A settings screen builds its volume sliders from this list.
AudioEngine
The lower-level wrapper around BabylonJS AudioV2. AudioManager covers ordinary game code; AudioEngine is for building a bus topology of a game's own.
| Method | Meaning |
|---|---|
initialize() | Creates the WebAudio engine and the main bus |
createBus(name) | Creates a named bus routed to the main bus |
getBus(name) | The bus under that name, or undefined |
createSound(name, url, bus?, options?) | Creates a sound, optionally routed to a bus |
setMasterVolume(volume) | Sets the engine-level master volume |
getMasterVolume() | The engine-level master volume |
setBusVolume(bus, volume) | Sets the volume on one bus |
Settings UI example
import type { AudioManager } from '@skewedaspect/sage';
function buildAudioSettings(audioManager : AudioManager) : void
{
for(const channel of audioManager.channels)
{
const volume = audioManager.getChannelVolume(channel);
const muted = audioManager.isChannelMuted(channel);
// Build a slider from volume and muted, calling setChannelVolume and setChannelMuted on change.
void volume;
void muted;
}
const masterVolume = audioManager.masterVolume;
void masterVolume;
}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 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 ] as const;
export class DoorEntity extends NodeEntity<typeof behaviors>
{
static override readonly behaviors = behaviors;
}
const door = simulation.spawn('DoorEntity', {
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) calls Babylon's play() on the named sound; stop(name) calls its stop().
door.ops.play('open');A name the entity's sounds map does not declare throws. A declared sound that has not finished building yet answers play and stop by doing nothing, without throwing: nothing waits on a sound any more than anything waits on a mesh. An entity carrying no AudioManager is a different case, and throws naming the entity: not yet ready and never coming are not the same answer. 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 passed, or is empty, the audio system is not built at all.
engine.engines.audioEngineisundefinedengine.managers.audioManagerisundefinedNodeEntity.audioanswers withundefinedSoundBehaviorbuilds no sounds, andplayandstopthrow, naming the entity- The Blender sound handler logs a warning and creates no sounds
