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.
How it works
Section titled “How it works”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.
Sound effects & music
Section titled “Sound effects & music”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.
Audio API reference
Section titled “Audio API reference”| 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). |
Controlling a playing sound
Section titled “Controlling a playing sound”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 rightshot.setPlaybackRate(1.2); // pitch / speedshot.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. |
Declarative playback — AudioSource
Section titled “Declarative playback — AudioSource”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. |
Mixer buses
Section titled “Mixer buses”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', …).
Bus effects & ducking
Section titled “Bus effects & ducking”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', []); // clearSidechain 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); // removeWhile 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 panel & project config
Section titled “The Audio Mixer panel & project config”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.
Import settings & cooked audio
Section titled “Import settings & cooked audio”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.
Under the facade
Section titled “Under the facade”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 busaudio.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.
AudioBus
Section titled “AudioBus”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. |
AudioMixer
Section titled “AudioMixer”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). |
AudioPool
Section titled “AudioPool”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.
Spatial audio
Section titled “Spatial audio”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 / distanceconst pan = calculatePanning(600, 0, 0, 0, 1000); // 0.6 — to the rightBest practices
Section titled “Best practices”- 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 frommaster. - Cap simultaneous sounds with
priorityonAudioSourceso important sounds win when the voice pool is full. - Use
AudioSource(declarative) for world/object sounds andplaySFXfor fire-and-forget UI/gameplay one-shots.