Concept
Voice
One playback instance. What sound.play() returns.
TL;DR
A Voice is a single, live playback. It owns its own gain stage
and source node, fires lifecycle cues, can be faded or stopped at any time,
and tears itself down on natural end. You don't construct it; you receive it
from sound.play().
API surface
class Voice {
readonly id: number;
readonly priority: number;
readonly bus: string | undefined;
readonly startedAt: number; // engine.now at spawn
readonly ended: Promise<void>; // resolves on natural end, stop, or abort
fade(opts: { to: number; ms: number; curve?: FadeCurve }): Promise<void>;
stop(): void;
cues(): AsyncIterableIterator<'started' | 'paused' | 'ended'>;
} Live demo
Hit the button repeatedly. Random pitch + volume jitter on every voice.
Recipes
Random pitch + volume jitter
// Stacked SFX with variation so they don't sound robotic.
for (let i = 0; i < 6; i++) {
engine.sound('hit').play({
pitch: { jitter: 0.08 }, // ±8% playback rate
volume: { jitter: 0.05 }, // ±5% gain
});
} Async iterator for cues
const v = engine.sound('intro').play();
for await (const cue of v.cues()) {
if (cue === 'started') analytics.send('intro:start');
if (cue === 'ended') ui.advance();
} AbortSignal cancellation
const ac = new AbortController();
const v = engine.sound('alert').play({ signal: ac.signal });
// Anywhere — close a modal, route change, etc.
ac.abort();
await v.ended; // resolves immediately Pitfalls
Don't hold a Voice ref past
Once ended.ended resolves, the source/gain are disconnected. Calling
stop() is a no-op; fade() resolves immediately.
Don't expect
The current implementation emits cues() to fire paused in v0.started and ended;
paused is reserved for the upcoming pause/resume API.
Related
- Sound — what spawned this voice.
- Concurrency — voice limits + stealing.
- Spatializer — pan / 3D position.