Concept
Sound
A loaded sample. Owns one decoded AudioBuffer and spawns Voices on play().
TL;DR
A Sound is the immutable, decoded representation of an audio
asset. Each call to sound.play() spawns a fresh Voice
attached to the sound's default bus (or an override). The buffer is shared;
voices are not.
API surface
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
const sword = await engine.loadSound('sword', '/sfx/sword.webm', { bus: 'sfx' });
console.log(sword.duration); // seconds Codec ladder for cross-browser
// 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
// 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
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. Removes the "every sound is mastered at a different level" workflow tax without affecting the original asset on disk.
// 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
See Engine for the full sprite story.
The short version: loadSprite shares the same fetch + decode
+ cache path as loadSound, then maps named regions onto it.
// 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
- Voice — what
play()returns. - Loading sounds — bulk load patterns.
- Asset formats — the codec ladder.