Concept

Sound

A loaded sample. Owns one decoded AudioBuffer and spawns Voices on play().

Fire the same coin sound eight times in one cascade and you want eight independent playbacks, each with its own volume, pitch and stop time, from one download.

A Sound is the decoded, immutable representation of an audio asset. Every call to sound.play() spawns a fresh Voice on the sound's default bus, or on one you name. The buffer is shared across all of them; the voices are not.

API surface

Sound + PlayOptions ts
class Sound {
  readonly name: string;
  readonly duration: number;          // seconds

  play(options?: PlayOptions): Voice;
}

interface PlayOptions {
  volume?: number | { jitter?: number };
  pitch?:  number | { jitter?: number };
  loop?:   boolean;
  bus?:    string;
  priority?: number;
  signal?: AbortSignal;
  spatializer?: { pan?: number; position?: [number, number, number] };
}

Live demo

engine.sound("hit").play()

Recipes

Load + play

ts ts
const sword = await engine.loadSound('sword', '/sfx/sword.webm', { bus: 'sfx' });
console.log(sword.duration);   // seconds

Codec ladder for cross-browser

ts ts
// Codec ladder — first decodable wins. Opus everywhere except old iOS, AAC there.
await engine.loadSound('coin', [
  '/sfx/coin.webm',
  '/sfx/coin.m4a',
], { bus: 'sfx' });

See the asset-formats guide for the encoding pipeline.

Spawn many voices from one sound

ts ts
// One Sound, many Voices — buffer is shared, each play() is independent.
const coin = engine.sound('coin');
for (let i = 0; i < 8; i++) coin.play({ pitch: { jitter: 0.05 } });

Tie playback to an AbortSignal

ts ts
const ac = new AbortController();
const v = engine.sound('coin').play({ signal: ac.signal });
// later, e.g. on unmount:
ac.abort();

Loudness-normalize on load

RMS-based normalization runs once per buffer at decode time. It takes the edge off a library where every asset was mastered by a different person at a different level, and it never touches the file on disk.

ts ts
// Pass { normalize: true } to apply RMS-target loudness at decode time.
// All normalized sounds will sit at the same perceived loudness.
await engine.loadSound('crowd', '/sfx/crowd.webm', {
  bus: 'sfx',
  normalize: true,
});

// Or tune the target — defaults are RMS 0.1 (~ -20 dBFS), peak ceiling 0.99.
await engine.loadSound('alert', '/sfx/alert.webm', {
  normalize: { targetRms: 0.15, peakCeiling: 0.95 },
});

Audio sprites

Engine has the full sprite story. Briefly, loadSprite shares the same fetch, decode and cache path as loadSound, then maps named regions onto the result.

ts ts
// One buffer, many regions — drop in a sprite when you'd otherwise load
// 5+ tiny one-shots that share a session/scene.
await engine.loadSprite('ui', '/sfx/ui-strip.webm', {
  click:   { start: 0,    duration: 0.04 },
  hover:   { start: 0.1,  duration: 0.05 },
  error:   { start: 0.2,  duration: 0.18 },
  success: { start: 0.5,  duration: 0.3 },
}, { bus: 'sfx' });

engine.sprite('ui').play('click');

Pitfalls

Don't store the AudioBuffer yourself.
The Sound owns it. If you read raw bytes for visualization, copy them out and let the Sound stay the source of truth.
Don't await sound.play().
play() is synchronous and returns the Voice. Awaiting waits forever (it's not a Promise). Use v.ended to await completion.

Related