Audio basics
Play sounds and music with reliable runtime controls.
Audio basics
ExoJS audio splits cleanly into two halves: assets are pure data descriptors — Sound for short, pooled, decoded-buffer clips and AudioStream for long, streamed, seekable tracks — and a Voice is the live, controllable playback instance you get back when you play one. You never call playback methods on the asset itself. Instead you call app.audio.play(asset, options), which returns a Voice, and all live control (volume, fade, seek, loop, rate, pause) lives on that Voice.
Sound vs. AudioStream
| Sound | AudioStream | |
|---|---|---|
| Backing | Decoded AudioBuffer |
HTMLAudioElement (streamed) |
| Best for | Short SFX, UI sounds, footstep pools | Background tracks, ambient loops, radio, long audio |
| Seekable voice | No | Yes — voice.seek(t) / voice.time |
| Concurrent voices | Yes — poolSize controls the pool |
No — one playhead, one active voice |
| Default bus | app.audio.sound |
app.audio.music |
Use Sound when you need many overlapping instances of the same short clip. Use AudioStream for long, seekable content that you want the browser to stream rather than decode entirely into memory. (A third descriptor, AudioGenerator, synthesises tones from an oscillator — covered in the Audio effects chapter.)
Loading and playing
Both types are loaded through the Loader, like textures, then played through app.audio:
class AudioScene extends Scene {
private laser!: Sound;
private theme!: AudioStream;
private themeVoice!: Voice;
async load() {
const [laser, theme] = await Promise.all([this.loader.load('audio/laser.ogg'), this.loader.load(Asset.type('music', 'audio/theme.ogg'))]);
this.laser = laser;
this.theme = theme;
}
init() {
// Playing returns a live Voice - keep it to control this instance.
this.themeVoice = this.app.audio.play(this.theme, { loop: true, volume: 0.6 });
}
}A Sound is a data descriptor that holds a decoded buffer; each app.audio.play(sound) creates an independent pooled Voice, so overlapping concurrent playback is just multiple voices. An AudioStream has one HTMLAudioElement and therefore one playhead — playing it again stops the previous voice and returns a fresh one.
AudioContext auto-unlock
Browsers require a user gesture before Web Audio starts. ExoJS handles this automatically: the AudioContext is created on first use and resumed (un-suspended) on the first mousedown, touchstart, or touchend event observed on document.
app.audio.play(asset, ...) always returns a Voice immediately, even while audio is still locked — but what that voice does depends on the asset:
| Asset | Played while locked |
|---|---|
AudioStream |
Deferred. The media element owns its own playhead, so it is simply told to play on the unlock gesture. Call play() in init and keep the voice. |
Sound |
Skipped. Returns an already-ended voice; nothing is heard. |
AudioGenerator |
Skipped. Same. |
A Sound or generator cannot be deferred honestly: a suspended AudioContext’s clock stands still, so every source scheduled while locked lands on the same instant and the whole backlog would fire simultaneously the moment audio unlocks. Skipping is the only behaviour that never produces a burst of stacked sound effects.
So start such playback from the unlock gesture — this is the canonical pattern:
import { type Application, Sound } from '@codexo/exojs';
declare const application: Application;
declare const music: Sound;
application.audio.onUnlock.add(() => application.audio.play(music, { loop: true }));
onUnlock runs every handler exactly once, as soon as audio is usable — it is a “run this when you can” hook, not a plain one-shot event, so when you subscribe does not matter:
- Subscribe while audio is already unlocked and the handler runs right away (on a microtask). A scene loaded mid-session behaves like one loaded at startup.
- Subscribe while audio is locked and it waits for the next unlock. That includes a re-lock: an iOS audio-session interruption or a bfcache restore drops the context back to
suspended, and a handler registered inside that window still fires when audio returns. - A handler that has already run is never fired again by a later unlock — looping music started here does not stack a second copy after every interruption.
remove()cancels a pending handler either way, so a scene can subscribe ininitand clean up inunload. Nothing fires once the owningApplicationis destroyed.
Check app.audio.locked to know whether the gesture has happened yet, and gate one-shot effects on it:
if (this.app.audio.locked) {
return; // still waiting for the first user gesture — playing a Sound here does nothing
}
The first skipped play logs one warning per AudioSystem; further ones stay quiet until audio unlocks, so a menu full of click sounds cannot flood the console.
Audio needs a user gesture
Browsers won’t start audio until the user interacts with the page. Trigger the first sound or music from a click, tap, or key press — the embedded examples below ask for a click for exactly this reason.
Playback controls
Per-play overrides are passed to play(); everything afterwards is controlled on the returned Voice:
import { type Application, Sound, Time } from '@codexo/exojs';
declare const application: Application;
declare const sound: Sound;
// Per-play overrides (bus, volume, loop, playbackRate, detune, time, muted)
const voice = application.audio.play(sound, { volume: 0.5, loop: true, playbackRate: 1.2 });
voice.volume = 0.8; // live volume, range [0, 1]
voice.fade(0.2, Time.seconds(0.5)); // ramp volume to 0.2 over 0.5s (no stop)
voice.stop(); // stop now and release the voice
voice.stop(Time.seconds(0.8)); // fade out over 0.8s, then stop
Every Voice carries volume (get/set), fade(to, duration), stop(fade?), an ended flag, an onEnd signal, the bus it routes through (voice.bus), and an output node you can tap for analysis. Beyond that base, a voice mixes in only the capabilities its backing node actually supports — narrow with a cast or an 'x' in voice check:
import {
type Application,
AudioStream,
type Loopable,
type Pausable,
type RatePitched,
type Seekable,
type Voice,
} from '@codexo/exojs';
declare const application: Application;
declare const stream: AudioStream;
type StreamVoice = Voice & Seekable & Pausable & Loopable & RatePitched;
// Both a SoundVoice and an AudioStreamVoice are
// Seekable + Pausable + Loopable + RatePitched + Spatializable.
const streamVoice = application.audio.play(stream, { loop: true }) as StreamVoice;
streamVoice.seek(10); // Seekable: jump to 10s
streamVoice.time; // current position in seconds
streamVoice.duration; // total length in seconds
streamVoice.loop = false; // Loopable
streamVoice.playbackRate = 1.5; // RatePitched: 0.1..20
streamVoice.detune = 1200; // RatePitched: cents (one octave up)
streamVoice.pause(); // Pausable
streamVoice.resume();
streamVoice.paused; // boolean
Pausing a Sound is a restart, not a freeze
A Sound plays through an AudioBufferSourceNode, which can be neither repositioned nor halted in place. pause() therefore reads the playhead and throws the source away; resume() starts a fresh one at exactly that offset. The offset is sample-exact, but the restart is not phase-continuous — on sustained tonal material (a held pad, a synth drone) the seam can be audible as a small click. Percussive and ambient material hides it. An AudioStream has no such caveat: the media element really does freeze.
Because SoundVoice is Pausable, scene.audio freezes buffer sounds along with everything else: app.scenes.pause() pauses voices registered with when: 'active', and scene retention (suspend()/restore()) pauses and reinstates whatever was playing.
For “one voice at a time” scenarios like voice-over lines, Sound’s play options also accept a replace flag (app.audio.play(sound, { replace: true })) that stops all other pooled voices of that sound before starting this one.
Volume and fading
Voice volume is linear gain in the range [0, 1], where 1 is “as authored”. The bus the voice routes through can amplify beyond that (bus volume is 0..2), so a quiet voice on a hot bus can still be loud. dB conversion is up to you.
fade(to, duration) ramps a voice’s volume without stopping it; stop(fade) ramps to zero and then releases the voice:
import { type Voice, Time } from '@codexo/exojs';
declare const voice: Voice;
// Fade out over 0.8s, then stop
voice.stop(Time.seconds(0.8));
// Fade up to 0.7 over 0.5s, keep playing
voice.fade(0.7, Time.seconds(0.5));
The crossFade utility fades one playing voice down while fading another up, in parallel:
const current = this.app.audio.play(this.trackA, { volume: 0.7 });
const next = this.app.audio.play(this.trackB, { volume: 0 });
await crossFade(current, next, Time.seconds(2));
// next is at full volume; current has faded out and stopped (stopAfter defaults to true)Pass { toVolume } to fade the incoming voice to something other than full, and { stopAfter: false } to keep the outgoing voice alive at volume 0 — the right choice when you crossfade back and forth between two looping tracks.
The sound pool
Sound instances are pooled. poolSize (default 8) controls the maximum number of simultaneous AudioBufferSourceNode instances. When the pool is full and you call play() again, the oldest active source is evicted based on poolStrategy:
'fifo'(default) — first-in, first-out. Steady-state playback.'lru'— evicts the source closest to its natural end.'priority'— usesSound.priority(current single-sound behavior is equivalent to FIFO).
For rapid-fire SFX (gunshots, footsteps, UI clicks), set poolSize higher and use the default FIFO strategy:
import { type Application, Sound } from '@codexo/exojs';
declare const application: Application;
declare const gunshot: Sound;
gunshot.poolSize = 24;
// ... hold spacebar to fire rapidly ...
application.audio.play(gunshot); // oldest voice gets evicted when the pool is full
Pitch variation for a richer sound is one line — randomise playbackRate per play:
import { type Application, Sound } from '@codexo/exojs';
declare const application: Application;
declare const sound: Sound;
const cents = Math.random() * 300 - 150; // -150 to +150 cents
application.audio.play(sound, { playbackRate: Math.pow(2, cents / 1200) });
Buses
The audio system exposes three built-in buses: app.audio.master, app.audio.music, and app.audio.sound. Each play routes to a default bus (music for AudioStream, sound for Sound and AudioGenerator). Buses form a tree — music and sound are children of master, and master connects to the audio destination.
Route a play through a specific bus with the bus option, or reassign a live voice’s bus:
import { type Application, AudioBus, Sound } from '@codexo/exojs';
declare const application: Application;
declare const sound: Sound;
const voiceBus = new AudioBus('voice-over', { parent: application.audio.master });
application.audio.registerBus(voiceBus);
// Per-play:
const line = application.audio.play(sound, { bus: voiceBus });
// Or reroute the live voice:
line.bus = application.audio.master;
Buses have independent volume (0..2), muted, and pan controls, plus a filter chain. The Audio effects chapter covers bus filters in detail.
Audio sprites
A Sound can define named sub-regions (“sprites”) on the descriptor — useful when you bake several effects into a single file and want to address them by name rather than by offset:
import { type Application, Sound } from '@codexo/exojs';
declare const application: Application;
declare const sound: Sound;
sound.addSprite('impact', { start: 0.5, end: 0.8 });
sound.addSprite('whoosh', { start: 1.2, end: 1.6, loop: true });
// `sprite(name)` is the playback side: it returns a `Sound` over that window.
application.audio.play(sound.sprite('impact'));
Sprites can also be declared up front via the sprites constructor option. They are part of the Sound descriptor’s data; clip ranges are validated against the buffer duration when defined.
Sprites from a sidecar file
Writing the clip table in code stops scaling once a tool produces it. A sound asset therefore accepts a source string for sprites, naming a JSON sidecar that holds the same map:
{
"impact": { "start": 0.5, "end": 0.8 },
"whoosh": { "start": 1.2, "end": 1.6, "loop": true }
}
import { Assets } from '@codexo/exojs';
const assets = Assets.from({
sfx: { type: 'sound', source: 'sfx.ogg', sprites: 'sfx.sprites.json' },
});
The sidecar is loaded through the sound’s own asset scope, so it is claimed and released with the sound — one asset to configure, not two. Its source is resolved against the loader’s base path exactly like the audio file, so it is not a path relative to sfx.ogg.
A malformed sheet — not an object, a non-clip entry, a non-finite time, or a window that runs past the buffer — fails the load with an AssetDecodeError naming the sidecar. This is ExoJS’s own descriptor and the only sprite format the loader reads: audiosprite and Howler atlases store [offsetMs, durationMs] tuples, which you convert once at build time rather than on every load.
sound.sprite(name) is the named counterpart of sound.clip(offset, duration) — both return a Sound sharing the parent’s decoded buffer, so they play through app.audio.play() like any other sound and carry their own voice pool and playback defaults (a sprite’s loop flag becomes the sub-sound’s loop). The sub-sound is memoized per name, so the pool really is shared across every play of that sprite; it is discarded when the name is redefined or removed. Looking up a name that was never defined throws.
Examples
Click the canvas to play a loaded Sound — the minimal audio example.
Two looping AudioStream tracks crossfading back and forth with crossFade().
Try it
Where to go next
The next chapter, Spatial audio, covers 2D positional audio — how to place sounds in world space so they pan and attenuate based on the listener’s position.

