Voice
One playback instance. What sound.play() returns.
A sound is a file. A voice is one time you played it. The distinction only starts to matter when the eighth copy is still ringing and you need to stop the third.
A Voice is a single live playback.
It owns its own gain stage and source node, emits lifecycle cues, can be
faded or stopped at any point, and tears itself down when it ends. You
never construct one; sound.play() hands it to you.
API surface
class Voice {
readonly id: number;
readonly priority: number;
readonly bus: string | undefined;
readonly sourceName: string | undefined; // name of the Sound that spawned it
readonly startedAt: number; // engine.now at spawn
readonly playbackRate: number; // current rate
readonly isPaused: boolean;
readonly spatializer: Spatializer | undefined;
readonly ended: Promise<void>; // resolves on natural end, stop, or abort
fade(opts: { to: number; duration: number; curve?: FadeCurve }): Promise<void>;
pause(): void; // captures offset
resume(): void; // resumes from captured offset
setPlaybackRate(rate: number, opts?: { duration?: number; curve?: FadeCurve }): void;
stop(opts?: { fade?: number }): void; // ~8 ms click-free fade by default
cues(): AsyncIterableIterator<'started' | 'paused' | 'resumed' | 'ended'>;
level(): { rms: number; peak: number }; // live amplitude readout (lazy analyser tap)
} 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
});
} Fade-in on start
play({ fadeIn }) is the mirror of the click-free stop fade.
The voice ramps from 0 up to volume across the
window you give it. Reach for it on ambient layers that should drift in
rather than appear.
// "Drop in smoothly" — voice ramps from 0 → volume over fadeIn seconds.
// The dual of the click-free stop fade.
engine.sound('ambience').play({ loop: true, volume: 0.7, fadeIn: 0.5 }); 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 Pause / resume on blur
pause() tears down the current source node and remembers the
sample offset. resume() rebuilds the source and starts again
from there. A looping voice round-trips through its loop region on the
way.
// Pause-on-blur: keep the voice alive across menu/modal transitions.
const v = engine.sound('intro').play({ loop: true });
window.addEventListener('blur', () => v.pause());
window.addEventListener('focus', () => v.resume());
// resume() picks up at the offset captured on pause(). Loop crossfade (off by default)
AudioBufferSourceNode's native loop is a hard cut from
loopEnd back to loopStart. When those points
don't sit on a zero crossing you get a click every single loop. Setting
loopCrossfade spawns a parallel buffer source one crossfade
window before the boundary and equal-power ramps between them. It is the
same trick a sample editor offers at edit time, done at runtime
instead.
// Off by default. Set loopCrossfade > 0 to splice an equal-power
// crossfade at the loop boundary — masks the click from non-zero-crossing
// loop regions without needing to re-edit the asset.
const v = engine.sound('music-bed').play({
loop: true,
loopStart: 0.04, // skip a fade-in
loopEnd: 31.96, // and fade-out
loopCrossfade: 0.05, // 50 ms equal-power overlap
});
// Cost: one extra AudioBufferSourceNode + GainNode per loop iteration.
// Ignored when loop is false, or when the region is shorter than 2× the
// crossfade window (silent fallback to native hard-cut loop). Live playback-rate ramp
setPlaybackRate automates the underlying
playbackRate AudioParam, so pitch and tempo move together.
When you need one without the other, see
pitch and time-stretch.
// Slow-mo sting on a boss intro — ramp the rate over 800 ms.
const v = engine.sound('boss-stinger').play();
v.setPlaybackRate(0.6, { duration: 0.8, curve: 'easeOut' }); Per-voice level readout
voice.level() returns { rms, peak } as
linear values in [0..1]. The first call lazily attaches an
AnalyserNode to the voice's gain stage, so nothing is allocated until
something reads. Use it for per-voice meters and clip indicators. The
rhythm-metronome example
drives one.
// Per-voice peak meter — useful for "show the loudest voice" UI,
// or for driving custom voice-stealing rules beyond the built-in strategies.
function tick() {
for (const v of engine.activeVoices()) {
const lv = v.level();
if (lv.peak > 0.95) flashClipIndicator(v.id);
}
requestAnimationFrame(tick);
}
requestAnimationFrame(tick); Live spatial control
The spatializer is exposed on the voice. Hold the reference and call
setPan or setPosition from your render loop.
// Hold the voice and steer the spatializer as the source moves.
const v = engine.sound('engine').play({
loop: true,
spatializer: { position: [0, 0, 0] },
});
requestAnimationFrame(function tick() {
if (v.spatializer) v.spatializer.setPosition(player.x, 0, player.z);
requestAnimationFrame(tick);
}); Stop semantics
source.stop() cuts a buffer wherever it happens to be. If
that lands off a zero crossing, the speaker gets a step function and you
hear a click. voice.stop() ramps the gain stage down over
about 8 ms first, which is short enough that nobody reads it as a fade
and long enough that nobody hears the cut. Pass
{ fade: 0 } for a sample-accurate hard cut, or set
voice.stopFade on the engine to change the default. Every
stop path goes through it: an explicit stop(), an
AbortSignal abort, a region timer expiring, and a voice
stolen by a concurrency cap.
Pitfalls
ended.ended resolves, the source/gain are disconnected. Calling
stop() is a no-op; fade() resolves immediately.
pause() rebuilds the source on resume().fade({ to: 0 }) over pause().
Related
- Sound is what spawned this voice.
- Concurrency covers voice limits and stealing.
- Spatializer covers pan and 3D position.