Guide

GuideAudioAudio basics

Audio basics

Play sounds and music with reliable runtime controls.

Intro~8 min read

What you'll learn

  • load and play Sound and AudioStream
  • control volume, looping, and fades
  • handle the browser autoplay gesture

Before you start

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:

examples/guides/audio-basics/audio-scene.ts
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 in init and clean up in unload. Nothing fires once the owning Application is 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.

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

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:

examples/guides/audio-basics/cross-fade.ts
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' — uses Sound.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

Play SoundPointerAudioOpen in PlaygroundView source

Preview is paused until you click Play.

import { Application, Color, FixedResolutionCanvasSizing, type RenderingContext, Scene, Sound, Text } from '@codexo/exojs';

// A small pool of different UI sounds so repeated taps stay interesting.
const SOUND_KEYS = ['uiClick', 'uiConfirm', 'uiBong', 'impactLight', 'impactHeavy'] as const;

class PlaySoundScene extends Scene {
  private sounds!: Sound[];
  private text!: Text;
  private index = 0;

  override init(): void {
    const app = this.app;
    const { width, height } = app;

    // Keep example SFX comfortable - full volume is jarring in the docs.
    app.audio.sound.volume = 0.5;

    // Path-only get() infers Sound from the .ogg extension - sidesteps a
    // compile-time overload ambiguity between Sound and the Json token form
    // when passing the Sound token explicitly.
    this.sounds = SOUND_KEYS.map(key => this.loader.get(assets.demo.audio[key]));
    this.text = new Text('Click anywhere to play SFX', { fillColor: Color.white, fontSize: 24, align: 'center' })
      .setAnchor(0.5, 0.5)
      .setPosition(width / 2, height / 2);
    app.input.onPointerTap.add(() => {
      // Cycle through the pool so each tap plays a different sound.
      const sound = this.sounds[this.index];
      this.index = (this.index + 1) % this.sounds.length;
      app.audio.play(sound);
      this.text.text = `Playing: ${SOUND_KEYS[(this.index + this.sounds.length - 1) % this.sounds.length]}`;
    });
  }

  override draw(context: RenderingContext): void {
    context.render(this.text);
  }
}

const app = new Application({
  scenes: { PlaySoundScene },
  canvas: {
    width: 1280,
    height: 720,
    mount: document.body,
    sizing: new FixedResolutionCanvasSizing(),
  },
  clearColor: Color.black,
});

await app.start(PlaySoundScene);

Click the canvas to play a loaded Sound — the minimal audio example.

Crossfade TracksPointerAudioOpen in PlaygroundView source

Preview is paused until you click Play.

import { Application, Asset, AudioStream, Color, crossFade, FixedResolutionCanvasSizing, Graphics, type RenderingContext, Scene, Text, Time, type Voice } from '@codexo/exojs';
import { mountControls } from '@examples/runtime';

const PEAK = 0.7;
const COLOR_A = new Color(120, 200, 255);
const COLOR_B = new Color(255, 160, 120);

const METER_W = 120;
const METER_H = 320;

class CrossfadeTracksScene extends Scene {
  private trackA!: AudioStream;
  private trackB!: AudioStream;
  private trackAVoice!: Voice;
  private trackBVoice!: Voice;
  private toB = true;
  // Displayed meter levels, eased toward each voice's target volume.
  private dispA = PEAK;
  private dispB = 0;
  private graphics!: Graphics;
  private labelA!: Text;
  private labelB!: Text;
  private nowPlaying!: Text;
  private tapPrompt!: Text;
  // Canvas-relative layout computed in init().
  private meterAX = 0;
  private meterBX = 0;
  private meterBaseY = 0;
  private hud!: ReturnType<typeof mountControls>;

  override async load(): Promise<void> {
    const app = this.app;
    const { width, height } = app;

    // Spread the two meters across the wide canvas: each sits a third of the
    // way in from its side, centred on the meter width.
    this.meterAX = width * 0.33 - METER_W / 2;
    this.meterBX = width * 0.67 - METER_W / 2;
    this.meterBaseY = height * 0.82;

    // AudioStream is a non-leaf resource kind (no seamless placeholder), so each
    // track is loaded directly through `Asset.type('music', ...)` and awaited. Both
    // tracks loop; the crossfade only swaps which one is audible.
    [this.trackA, this.trackB] = await Promise.all([
      this.loader.load(Asset.type('music', assets.demo.audio.musicA)),
      this.loader.load(Asset.type('music', assets.demo.audio.musicB)),
    ]);

    this.graphics = new Graphics();
    this.labelA = new Text('Track A', { fillColor: Color.white, fontSize: 22, align: 'center' })
      .setAnchor(0.5, 0.5)
      .setPosition(this.meterAX + METER_W / 2, height * 0.26);
    this.labelB = new Text('Track B', { fillColor: Color.white, fontSize: 22, align: 'center' })
      .setAnchor(0.5, 0.5)
      .setPosition(this.meterBX + METER_W / 2, height * 0.26);
    this.nowPlaying = new Text('', { fillColor: Color.white, fontSize: 20, align: 'center' }).setAnchor(0.5, 0.5).setPosition(width / 2, height * 0.15);

    // Shown while the browser still blocks audio (`app.audio.locked`); the
    // first click or keypress unlocks it and the queued music starts.
    this.tapPrompt = new Text('Click or press any key to start audio', { fillColor: Color.white, fontSize: 22, align: 'center' })
      .setAnchor(0.5, 0.5)
      .setPosition(width / 2, height - 48);

    this.hud = mountControls({
      title: 'Crossfade Tracks',
      controls: [{ keys: 'Click', action: 'crossfade between Track A and Track B (2s)' }],
      status: 'Click or press any key to start…',
      hint: 'The brighter meter with the bar above it is the active track; both loop continuously while their volumes ramp.',
    });

    app.input.onPointerTap.add(() => {
      // stopAfter: false keeps both loops alive so we can crossfade back.
      if (this.toB) {
        void crossFade(this.trackAVoice, this.trackBVoice, Time.seconds(2), { toVolume: PEAK, stopAfter: false });
        this.hud.setStatus('Crossfading to Track B…');
      } else {
        void crossFade(this.trackBVoice, this.trackAVoice, Time.seconds(2), { toVolume: PEAK, stopAfter: false });
        this.hud.setStatus('Crossfading to Track A…');
      }
      this.toB = !this.toB;
    });

    // Core defers playback until the AudioContext unlocks on the first
    // gesture, then starts automatically - start both loops (B silent) so
    // crossFade only has to ramp gains rather than start playback mid-fade.
    this.trackAVoice = app.audio.play(this.trackA, { loop: true, volume: PEAK });
    this.trackBVoice = app.audio.play(this.trackB, { loop: true, volume: 0 });
    this.hud.setStatus('Track A active — click to crossfade.');
  }

  private drawMeter(x: number, level: number, active: boolean, color: Color): void {
    const height = METER_H;
    const baseY = this.meterBaseY;
    const width = METER_W;

    // Background trough.
    this.graphics.fillColor = new Color(45, 45, 45);
    this.graphics.drawRectangle(x, baseY - height, width, height);

    // Filled level (volume 0..PEAK mapped to full height). The inactive
    // track dims to ~45% so the active one reads as the bright one.
    const fill = Math.max(0, Math.min(1, level / PEAK));
    const lit = active ? color : new Color(color.r * 0.45, color.g * 0.45, color.b * 0.45);
    this.graphics.fillColor = lit;
    this.graphics.drawRectangle(x, baseY - height * fill, width, height * fill);

    // Active-track marker bar above the meter.
    if (active) {
      this.graphics.fillColor = new Color(255, 255, 255);
      this.graphics.drawRectangle(x, baseY - height - 12, width, 5);
    }
  }

  override draw(context: RenderingContext): void {
    const app = this.app;
    this.graphics.clear();

    // voice.volume returns the fade TARGET immediately, so ease the
    // displayed level toward it for a smooth meter during the 2s ramp.
    this.dispA += (this.trackAVoice.volume - this.dispA) * 0.06;
    this.dispB += (this.trackBVoice.volume - this.dispB) * 0.06;

    const aLevel = this.dispA;
    const bLevel = this.dispB;
    const aActive = aLevel >= bLevel;

    this.drawMeter(this.meterAX, aLevel, aActive, COLOR_A);
    this.drawMeter(this.meterBX, bLevel, !aActive, COLOR_B);

    this.labelA.text = `Track A  ${Math.round((aLevel / PEAK) * 100)}%`;
    this.labelB.text = `Track B  ${Math.round((bLevel / PEAK) * 100)}%`;
    this.nowPlaying.text = `Active: Track ${aActive ? 'A' : 'B'}`;

    context.render(this.graphics);
    context.render(this.labelA);
    context.render(this.labelB);
    context.render(this.nowPlaying);

    if (app.audio.locked) {
      context.render(this.tapPrompt);
    }
  }
}

const app = new Application({
  scenes: { CrossfadeTracksScene },
  canvas: {
    width: 1280,
    height: 720,
    mount: document.body,
    sizing: new FixedResolutionCanvasSizing(),
  },
  clearColor: Color.black,
});

await app.start(CrossfadeTracksScene);

Two looping AudioStream tracks crossfading back and forth with crossFade().

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.