# Voice · zvuk

zvuk v1.14.1 · MIT · https://zvuk.schmooky.dev/concepts/voice/

One playback instance. What sound.play() returns.

Concept

# 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`](/api/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

The Voice interface ts

```
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.

Use your own sound

sfx busidle

pitch jitter±0.08volume jitter±0.10

Hit me

0 voices spawned

Unlock & load

## 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
  });
}
```

### 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.

ts ts

```
// "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

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

### 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.

ts ts

```
// 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.

ts ts

```
// 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](/fx/pitch/).

ts ts

```
// 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](https://github.com/schmooky/zvuk/tree/main/examples/rhythm-metronome) drives one.

ts ts

```
// 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.

ts ts

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

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.

`pause()` rebuilds the source on `resume()`.

The Web Audio API doesn't expose pause on a buffer source, so we stop + re-spawn at the captured offset. For very tight cue chains, prefer `fade({ to: 0 })` over `pause()`.

## Related

-   [Sound](/concepts/sound/) is what spawned this voice.
-   [Concurrency](/concepts/concurrency/) covers voice limits and stealing.
-   [Spatializer](/concepts/spatializer/) covers pan and 3D position.
