Skip to content

Audio

Estella’s audio plays one-shot effects and looping music through the Audio resource, controls individual sounds with a handle, balances the mix with a bus hierarchy, and positions sounds in the world with spatial audio. It runs on the Web Audio backend in browsers and the native backend on WeChat.

Audio is a per-App resource, registered by default. Sound files are decoded into buffers that are cached on first use, then played on a bus (a named volume group). Each play returns an AudioHandle you can keep to adjust or stop that one sound. For sounds tied to a place or an object, an AudioSource component plays declaratively and (when spatial) attenuates by distance from the AudioListener.

Decoded buffers live in a byte-budgeted warm cache (default 32 MB, RuntimeConfig.audioCacheBudget / build-config audioCacheBudget), the audio mirror of the texture budget: a buffer released by asset lifetime stays instantly playable and is revived without a re-fetch + re-decode, while the oldest unused buffers are dropped past the budget. On OS memory warnings the engine trims the whole warm cache automatically — sounds that are playing keep playing.

import { defineSystem, Res, Audio } from 'esengine';
const play = defineSystem([Res(Audio)], (audio) => {
const sfx = audio.playSFX('assets/hit.wav', { volume: 1, pitch: 1, pan: 0 });
audio.playBGM('assets/theme.ogg', { fadeIn: 0.5, crossFade: 1.0 });
});

playSFX fires a one-shot and returns a handle; playBGM loops and replaces the current track, optionally cross-fading. Both auto-preload an uncached clip and play it once ready.

Method Description
playSFX(url, config?) Play a one-shot; returns an AudioHandle. Config: volume, pitch, pan, priority.
playBGM(url, config?) Loop music, replacing the current track. Config: volume, fadeIn, crossFade.
stopBGM(fadeOut?) Stop the music, optionally fading out over fadeOut seconds.
stopAll() Stop every playing sound.
setMasterVolume(v) Set the master bus volume (0..1).
setMusicVolume(v) Set the music bus volume.
setSFXVolume(v) Set the sfx bus volume.
setUIVolume(v) Set the ui bus volume.
muteBus(name, muted) Mute/unmute a bus by name.
preload(url) Decode and cache a clip ahead of time (async).
preloadAll(urls) Preload an array of clips (async).
getBufferHandle(url) The cached buffer for url, or undefined.
getSpectrum(out) Fill a Uint8Array with the master output’s frequency spectrum (0–255 per bin, low→high) for a visualizer. Returns false on backends without analysis (WeChat) — treat that as silence.
setBufferBudget(bytes) Override the warm-cache byte budget (null returns to the RuntimeConfig value; 0 disables the cache).
getBufferStats() Residency counters: bufferCount, bufferBytes, bufferBudget, evictableCount.
trimBufferCache() Free every unused cached buffer now (the engine calls this on OS memory warnings).

playSFX returns an AudioHandle — keep it to drive that specific sound:

const shot = audio.playSFX('assets/laser.wav');
shot.setVolume(0.6);
shot.setPan(-0.5); // -1 left … +1 right
shot.setPlaybackRate(1.2); // pitch / speed
shot.setLoop(true);
shot.pause(); shot.resume(); shot.stop();
shot.onEnd = () => { /* the sound finished */ };
Member Type Description
setVolume(v) method Volume, 0..1.
setPan(p) method Stereo pan, -1 (left) … +1 (right).
setPlaybackRate(r) method Playback speed / pitch (1 = normal).
setLoop(l) method Loop the sound.
pause() / resume() / stop() method Transport control.
onEnd callback Called once when the sound finishes.
isPlaying readonly Whether it’s currently playing.
currentTime readonly Playback position in seconds.
duration readonly Total length in seconds.

Attach an AudioSource to an entity to play without code — ideal for ambience, looping machines, or a sound anchored to an object. With playOnAwake + enabled the audio system starts it automatically.

Property Type Default Description
clip asset '' Audio asset to play.
bus string 'sfx' Mixer bus to route through.
volume number 1 Volume, 0..1.
pitch number 1 Playback speed / pitch.
loop boolean false Loop the clip.
playOnAwake boolean false Start automatically when the entity is enabled.
spatial boolean false Attenuate by distance from the listener.
minDistance number 100 Distance within which volume is full.
maxDistance number 1000 Distance beyond which the sound is silent.
attenuationModel AttenuationModel Inverse Falloff curve — Linear / Inverse / Exponential.
rolloff number 1 Falloff steepness multiplier.
priority number 0 Voice priority when the pool is full (higher wins).
enabled boolean true Disable to stop and silence without removing the component.

The mixer is hierarchical — master → { music, sfx, ui, voice }. Setting a bus volume scales everything routed under it:

audio.setMasterVolume(0.8);
audio.setMusicVolume(0.6);
audio.setSFXVolume(1.0);
audio.setUIVolume(0.9);
audio.muteBus('music', true);

Route a sound to a bus with the AudioSource.bus field, or leave playSFX defaulting to sfx. Volume setters exist for setMasterVolume (a global slider) and setMusicVolume / setSFXVolume / setUIVolume. The voice bus routes sounds but has no volume setter — toggle it with muteBus('voice', …).

Each bus carries a DSP insert chain, declared as data:

audio.setBusEffects('music', [
{ type: 'filter', filter: 'lowpass', frequency: 800, q: 1 }, // muffle (pause menu)
{ type: 'reverb', seconds: 1.5, wet: 0.3 }, // procedural room tail
{ type: 'compressor', thresholdDb: -24, ratio: 4 },
]);
audio.setBusEffects('music', []); // clear

Sidechain ducking is a rule, not code — duck music while voice lines play:

audio.setBusDucking('music', { trigger: 'voice', amount: 0.3, attack: 0.05, release: 0.4 });
audio.setBusDucking('music', null); // remove

While the trigger bus carries signal, the target’s duck stage ramps to amount; on silence it releases back to 1. Ducking is a separate gain stage, so it never fights the user’s volume setting. On backends without a WebAudio graph (WeChat) both APIs degrade to no-ops, like the volume calls.

The Audio Mixer bottom-dock panel edits all of this visually — one strip per bus with a volume fader, mute, the effect chain, a duck-by rule, and custom buses. Edits persist to project.esproject (features.audio) and apply live in the editor; Play and every export boot the identical mix.

Selecting an audio asset shows a decoded waveform with play/seek plus Import Settings: Compress and Bitrate control the cook’s WAV → MP3 transcode (enable Compress audio in the Package dialog). Already-compressed formats pass through untouched. MP3 has a small encoder delay — turn Compress off on clips that must loop seamlessly.

Audio is a facade over three exported classes the Web Audio backend assembles — AudioMixer, AudioBus, and AudioPool. You rarely construct them inside a game (on the WeChat backend they don’t exist at all, which is why the bus APIs degrade to no-ops there), but they’re public for two real jobs: custom buses beyond the built-in four, and standalone audio pipelines — an audition tool, a custom PlatformAudioBackend, tests — built on your own AudioContext.

Custom buses need no class at all — the facade covers them:

audio.ensureBus('ambience'); // create under master (idempotent)
audio.ensureBus('footsteps', 'sfx'); // or under an existing bus
audio.setBusVolume('ambience', 0.5);
// playSFX is always on the 'sfx' bus; playTrack (or an entity's AudioSource.bus) picks one.
audio.playTrack('assets/wind.ogg', { bus: 'ambience' });

ensureBus(name, parent?) creates the bus if it’s missing (under parent, defaulting to master) and returns false on backends without a mixer graph; every by-name facade call (setBusVolume, muteBus, setBusEffects, setBusDucking) then reaches it.

One mixer strip: input → [effect inserts…] → duck → gain(volume) → parent. Sources and child buses connect to input; node is the output gain — what volume/mute act on and where parents (or an AnalyserNode for a VU meter) tap. The duck stage is a separate gain, so sidechain ducking never fights the user’s volume setting. Constructed with an AudioBusConfig:

AudioBusConfig Type Default Description
name string Bus name.
volume number 1 Initial volume, clamped 0..1.
muted boolean false Start muted.
parent string 'master' Parent bus name (consumed by AudioMixer.createBus).
Member Description
input / node Entry GainNode / output GainNode (analyser taps connect from node).
volume 0..1; changes are smoothed over ~15 ms so they never click.
muted Ramps the output to 0 and back without losing volume.
effects / setEffects(defs) Read / idempotently replace the DSP insert chain (BusEffectDef[]).
duckTo(level, timeConstant) Ramp the duck stage toward level (the mixer’s ducking drives this).
connect(dest) / addChild(bus) Wire the output into a parent bus or a raw AudioNode.

Owns the bus tree. Constructing one builds master → { music, sfx, ui, voice } wired to context.destination, with volumes from AudioMixerConfig (masterVolume / musicVolume / sfxVolume / uiVolume / voiceVolume — music defaults to 0.8, the rest to 1):

import { AudioMixer } from 'esengine';
const mixer = new AudioMixer(new AudioContext(), { musicVolume: 0.6 });
const ambience = mixer.createBus({ name: 'ambience' }); // under master
Method Description
master / music / sfx / ui / voice The built-in buses, as readonly fields.
getBus(name) / busNames() Look up a bus / list every name (creation order, master first).
createBus(config) Create and wire a bus under config.parent (default master).
setDucking(target, rule | null) / getDucking(target) Install or clear a sidechain BusDuckRule (trigger, amount, attack, release, threshold).
updateDucking() Per-frame: measure each trigger’s RMS and ramp its target’s duck stage (the audio system calls this for the engine’s mixer).

The voice pool behind one-shots: pre-built gain → panner node pairs (PooledAudioNode) that are acquire()d per play and release()d when the sound ends, growing past the initial 16 on demand; activeCount and capacity report usage. Reach for it when you build custom playback on your own AudioContext and want to avoid per-play node churn.

Set AudioSource.spatial = true and give your listener entity (usually the camera or player) an enabled AudioListener. The source then attenuates by distance:

import { defineSystem, Commands, Transform, AudioSource, AudioListener,
AttenuationModel } from 'esengine';
const setupSpatial = defineSystem([Commands()], (cmds) => {
// A positioned, looping ambience that fades with distance.
cmds.spawn()
.insert(Transform, { position: { x: 600, y: 0, z: 0 } })
.insert(AudioSource, {
clip: 'assets/campfire.ogg', loop: true, playOnAwake: true,
spatial: true, minDistance: 80, maxDistance: 900,
attenuationModel: AttenuationModel.Inverse,
});
// The listener (attach to the camera/player).
cmds.entity(cameraEntity).insert(AudioListener, { enabled: true });
});
AttenuationModel Falloff
Linear Straight line from minDistance to maxDistance.
Inverse (default) Natural inverse-distance rolloff.
Exponential Steeper exponential rolloff.

AudioListener has a single field, enabled (default true) — keep exactly one enabled listener in the scene.

Estella’s spatial pipeline is honest 2D: each frame, the audio system takes the source’s and the listener’s world positions, computes the distance, and drives the playing handle with volume × attenuation plus an x-axis stereo pan ((sourceX − listenerX) / maxDistance, clamped to ±1). There is no HRTF, doppler, or occlusion. A spatial source playing with no enabled listener warns once and measures from the world origin.

The curve math is exported as pure functions for tools and tests — calculateAttenuation(distance, config) with a SpatialAudioConfig (model, refDistance, maxDistance, rolloff — the system fills it from AudioSource.minDistance/maxDistance/rolloff), and calculatePanning(sourceX, sourceY, listenerX, listenerY, maxDistance):

import { calculateAttenuation, calculatePanning, AttenuationModel } from 'esengine';
const gain = calculateAttenuation(250, {
model: AttenuationModel.Inverse, refDistance: 100, maxDistance: 1000, rolloff: 1,
}); // 0.4 — refDistance / distance
const pan = calculatePanning(600, 0, 0, 0, 1000); // 0.6 — to the right
  • Preload clips you’ll play in performance-critical moments (preload / preloadAll) so the first play isn’t silent while it decodes.
  • Start on a gesture — browsers block audio until the first user input.
  • Separate buses (music / sfx / ui / voice) so players can mix them, and drive everything from master.
  • Cap simultaneous sounds with priority on AudioSource so important sounds win when the voice pool is full.
  • Use AudioSource (declarative) for world/object sounds and playSFX for fire-and-forget UI/gameplay one-shots.
  • Assets — loading and bundling audio clips.
  • ScenesAudioSource state persists with the scene.