Z zvuk
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

Voice — public interface ts
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

ts ts
// 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

ts ts
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

ts ts
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 ended.
Once ended resolves, the source/gain are disconnected. Calling stop() is a no-op; fade() resolves immediately.
Don't expect cues() to fire paused in v0.
The current implementation emits started and ended; paused is reserved for the upcoming pause/resume API.

Related