Skip to content

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, StaticSound

Each 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:

typescript
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:

typescript
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 ​

typescript
declare function createSound(
    name : string,
    url : string,
    channel ?: string,
    options ?: Partial<IStaticSoundOptions>
) : Promise<StaticSound>;
ParameterMeaning
nameThe sound's own identifier
urlPath to the audio file
channelThe channel to route to. Unset routes to the main bus.
optionsBabylonJS 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.

typescript
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 ​

typescript
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 ​

typescript
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 ​

typescript
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.

MethodMeaning
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 ​

typescript
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.

typescript
import { SoundBehavior } from '@skewedaspect/sage';
import type { SoundConfig, SoundState } from '@skewedaspect/sage';

State ​

typescript
interface SoundState
{
    sounds : Record<string, SoundConfig>;
}

interface SoundConfig
{
    url : string;
    volume ?: number;
    loop ?: boolean;
    spatial ?: boolean;
    maxDistance ?: number;
    channel ?: string;
}
FieldDefaultMeaning
urlrequiredPath to the audio file
volume1Volume, 0 to 1
loopfalseLoop, and start playing as soon as the sound is built
spatialfalseAttach to the entity's node and follow it
maxDistance100Audible distance for a spatial sound
channelnoneThe 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:

typescript
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().

typescript
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.audioEngine is undefined
  • engine.managers.audioManager is undefined
  • NodeEntity.audio answers with undefined
  • SoundBehavior builds no sounds, and play and stop throw, naming the entity
  • The Blender sound handler logs a warning and creates no sounds

Released under the MIT License.