Guide

Guide Audio Audio basics

Audio basics

Play sounds and music with reliable runtime controls.

Intro ~4 min read

What you'll learn

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

Before you start

Audio basics

ExoJS audio is built on two media types: Sound for short, pooled, decoded-buffer playback and Music for long, streamed, seekable tracks. Both extend AbstractMedia and share the same volume, loop, playback rate, mute, bus, and fade controls. The difference is how the browser handles the underlying data.

Sound vs. Music

SoundMusic
BackingDecoded AudioBufferHTMLAudioElement (streamed)
Best forShort SFX, UI sounds, footstep poolsBackground tracks, ambient loops, long audio
SeekableNo — getTime() always returns 0Yes — setTime() seeks to a position
PooledYes — poolSize controls concurrent sourcesNo — one source, one stream
Default busapp.audio.soundapp.audio.music

Use Sound when you need many overlapping instances of the same short clip. Use Music for long, seekable content that you want the browser to stream from disk rather than decode entirely into memory.

Loading and playing

Both types are loaded through the Loader, like textures:

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

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

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

        this.theme.setLoop(true).setVolume(0.6).play();
    }
}

A Sound instance is a single object that pools multiple underlying AudioBufferSourceNode instances internally. Call play() as many times as you need — the pool handles concurrent voices. A Music instance has one HTMLAudioElement; play() and pause() toggle that single stream.

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.

For Sound, play() requests queue until the context is ready. Music uses HTMLAudioElement playback and is still subject to browser autoplay policy, so starting music from a user gesture remains the safest pattern.

If you need to know when the context is ready, import isAudioContextReady from @codexo/exojs and poll it.

Playback controls

All media share the same control surface:

sound.setVolume(0.8);        // 0..2, default 1
sound.setLoop(true);         // loop the clip
sound.setPlaybackRate(1.5);  // 0.1..20, 2 = double speed / one octave up
sound.setMuted(true);        // mute without forgetting volume

sound.play();
sound.pause();
sound.stop();                // stop + reset time
sound.toggle();              // play if paused, pause if playing

All setters return this for chaining. play() accepts optional per-call overrides:

sound.play({ volume: 0.5, loop: true, playbackRate: 1.2 });

Sound.play() also accepts a replace flag ({ replace: true }) that stops all other pooled sources before playing this one — useful for “one voice at a time” scenarios like voice-over lines.

Volume and fading

The engine exposes linear gain (0..2, where 1 is “as authored”). dB conversion is up to you — the raw gain value is what the GainNode receives.

fadeIn(ms) and fadeOut(ms) ramp the output gain over a duration in milliseconds:

// Fade out over 800ms, then pause
music.fadeOut(800, { stopAfter: true });

// Fade in over 500ms
music.fadeIn(500);

The crossFade utility fades from one media instance to another in parallel:

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

await crossFade(this.trackA, this.trackB, 2000);
// trackA is now silent (and paused by default), trackB is at full volume

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 ...
gunshot.play(); // next oldest gets evicted when pool is full

Pitch variation for a richer sound is one line — randomise playbackRate:

const cents = Math.random() * 300 - 150; // -150 to +150 cents
sound.play({ 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 media instance routes to its default bus (music for Music, sound for Sound). Buses form a tree — music and sound are children of master, and master connects to the audio destination.

Set a media instance’s bus explicitly:

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

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

Buses have independent volume, 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”) for one-shot playback from specific offsets:

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

sound.playSprite('impact');
sound.playSprite('whoosh', { volume: 0.7 });

This is useful when you bake multiple sound effects into a single file and address them by name rather than by offset.

Examples

Play Sound Pointer Audio Open in Playground View source
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.sound.play();
        });
    }

    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
import { Application, Color, crossFade, Graphics, Music, Scene, Text } 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!: Music;
    private trackB!: Music;
    private toB = true;
    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(Music, { 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;

        // Track A starts at full volume, Track B silent — both loop so the
        // crossfade only swaps which one is audible, never restarting either.
        this.trackA = loader.get(Music, 'a').setLoop(true).setVolume(PEAK);
        this.trackB = loader.get(Music, 'b').setLoop(true).setVolume(0);

        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(() => {
            if (this.toB) {
                void crossFade(this.trackA, this.trackB, 2000, { stopAfterFade: false });
                this.hud.setStatus('Crossfading to Track B…');
            } else {
                void crossFade(this.trackB, this.trackA, 2000, { stopAfterFade: 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 muted) so
        // crossFade only has to ramp gains rather than start playback mid-fade.
        this.trackA.play();
        this.trackB.play();
        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();

        const aLevel = this.trackA.volume;
        const bLevel = this.trackB.volume;
        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 music 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.