Guide

Guide Audio Audio basics

Audio basics

Play sounds and music with reliable runtime controls.

Intro ~5 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

SoundAudioStream
BackingDecoded AudioBufferHTMLAudioElement (streamed)
Best forShort SFX, UI sounds, footstep poolsBackground tracks, ambient loops, radio, long audio
Seekable voiceNoYes — voice.seek(t) / voice.time
Concurrent voicesYes — poolSize controls the poolNo — one playhead, one active voice
Default busapp.audio.soundapp.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:

import { AudioStream, Scene, Sound } from '@codexo/exojs';

class AudioScene extends Scene {
    async load(loader) {
        await loader.load(Sound, { laser: 'audio/laser.ogg' });
        await loader.load(AudioStream, { theme: 'audio/theme.ogg' });
    }

    init(loader) {
        this.laser = loader.get(Sound, 'laser');
        this.theme = loader.get(AudioStream, 'theme');

        // 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. Stream and sound playback is deferred and starts automatically on that first gesture, so you can call play() in init and keep the returned voice — there is no separate “start on click” step beyond the gesture the browser already requires. Check app.audio.locked to know whether the gesture has happened yet; app.audio.onUnlock fires once when it does.

if (this.app.audio.locked) {
    // still waiting for the first user gesture; the queued voice will auto-start
}

Playback controls

Per-play overrides are passed to play(); everything afterwards is controlled on the returned Voice:

// Per-play overrides (bus, volume, loop, playbackRate, detune, time, muted)
const voice = this.app.audio.play(sound, { volume: 0.5, loop: true, playbackRate: 1.2 });

voice.volume = 0.8;     // live volume, range [0, 1]
voice.fade(0.2, 500);   // ramp volume to 0.2 over 500ms (no stop)
voice.stop();           // stop now and release the voice
voice.stop(800);        // fade out over 800ms, then stop

Every Voice carries volume (get/set), fade(to, ms), stop(fadeMs?), 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 { Pausable, Seekable } from '@codexo/exojs';

// A SoundVoice is Seekable + Loopable + RatePitched + Spatializable.
// An AudioStreamVoice adds Pausable on top of those.
const streamVoice = this.app.audio.play(stream, { loop: true });

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 (streams only)
streamVoice.resume();
streamVoice.paused;          // boolean

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, ms) ramps a voice’s volume without stopping it; stop(fadeMs) ramps to zero and then releases the voice:

// Fade out over 800ms, then stop
voice.stop(800);

// Fade up to 0.7 over 500ms, keep playing
voice.fade(0.7, 500);

The crossFade utility fades one playing voice down while fading another up, in parallel:

import { crossFade } from '@codexo/exojs';

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, 2000);
// 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:

const gunshot = loader.get(Sound, 'gunshot');
gunshot.poolSize = 24;

// ... hold spacebar to fire rapidly ...
this.app.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:

const cents = Math.random() * 300 - 150; // -150 to +150 cents
this.app.audio.play(sound, { playbackRate: Math.pow(2, cents / 1200) });

Buses

The audio manager 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 { AudioBus } from '@codexo/exojs';

const voiceBus = new AudioBus('voice-over', { parent: app.audio.master });
app.audio.registerBus(voiceBus);

// Per-play:
const line = this.app.audio.play(sound, { bus: voiceBus });
// Or reroute the live voice:
line.bus = app.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:

sound.defineSprite('impact', { start: 0.5, end: 0.8 });
sound.defineSprite('whoosh', { start: 1.2, end: 1.6 });

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.

Examples

Play Sound Pointer Audio Open in Playground View source

Preview is paused until you click Play.

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

const app = new Application({
    canvas: {
        width: 1280,
        height: 720,
        mount: document.body,
        sizingMode: 'fit',
    },
    clearColor: Color.black,
});

class PlaySoundScene extends Scene {
    private sound!: Sound;
    private text!: Text;

    override async load(loader): Promise<void> {
        await loader.load(Sound, { click: assets.demo.audio.uiClick });
    }

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

        this.sound = loader.get(Sound, 'click');
        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);
        this.app.input.onPointerTap.add(() => {
            this.app.audio.play(this.sound);
        });
    }

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

app.start(new PlaySoundScene());

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

Crossfade Tracks Pointer Audio Open in Playground View source

Preview is paused until you click Play.

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

const app = new Application({
    canvas: {
        width: 1280,
        height: 720,
        mount: document.body,
        sizingMode: 'fit',
    },
    clearColor: Color.black,
});

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(loader): Promise<void> {
        await loader.load(AudioStream, { a: assets.demo.audio.musicA, b: assets.demo.audio.musicB });
    }

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

        // 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;

        // Both tracks loop; the crossfade only swaps which one is audible.
        this.trackA = loader.get(AudioStream, 'a');
        this.trackB = loader.get(AudioStream, 'b');

        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.',
        });

        this.app.input.onPointerTap.add(() => {
            // stopAfter: false keeps both loops alive so we can crossfade back.
            if (this.toB) {
                void crossFade(this.trackAVoice, this.trackBVoice, 2000, { toVolume: PEAK, stopAfter: false });
                this.hud.setStatus('Crossfading to Track B…');
            } else {
                void crossFade(this.trackBVoice, this.trackAVoice, 2000, { 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 = this.app.audio.play(this.trackA, { loop: true, volume: PEAK });
        this.trackBVoice = this.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): void {
        context.backend.clear();
        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 (this.app.audio.locked) {
            context.render(this.tapPrompt);
        }
    }
}

app.start(new 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.