Z zvuk
FX

Pitch & time-stretch

Two ways to change 'speed': cheap playback-rate (alters pitch + tempo) and granular time-stretch (preserves pitch).

TL;DR

The Web Audio API gives you a free way to change playback rate via AudioBufferSourceNode.playbackRate — 1.5× makes the buffer finish 50% faster and raises the pitch by a fifth. zvuk exposes this as play({ pitch }).

For pitch-preserving tempo changes (slow it down without making it sound underwater), use StretchProcessor — an offline granular SOLA-style renderer that takes one AudioBuffer and returns a stretched copy at the same pitch.

Live demo — A/B

Both modes on the same source. Twiddle and click both buttons; you should clearly hear the difference between "chipmunk" and "tape slowdown."

Pitch via play() — cheap, pitch + tempo

ts ts
engine.sound('coin').play({
  pitch: 1.5,                         // 1.5× = chipmunk pitch + faster
});

// With jitter — varies per voice, makes stacked SFX feel organic:
engine.sound('hit').play({
  pitch: { jitter: 0.08 },           // ±8% playback rate
});

Use this for: SFX that should feel "smaller" or "bigger", random pitch variation on stacked one-shots, retro arcade effects.

Time-stretch via StretchProcessor — preserves pitch

ts ts
import { StretchProcessor } from 'zvuk';

const original = await fetch('/music/bg.webm')
  .then((r) => r.arrayBuffer())
  .then((ab) => engine.context.decodeAudioData(ab));

// Render a slower copy at half-tempo, pitch unchanged.
const slowMo = StretchProcessor.stretchBuffer(engine.context, original, 2);

engine.createSound('bg-half', slowMo, { bus: 'music' });
engine.sound('bg-half').play({ loop: true });

Use this for: slowed-down music for boss intros, doubled-tempo loops for chase scenes, normalising vocal cadence across recordings.

Pre-render multiple speeds

The renderer is offline — call it once per buffer/factor pair, cache the result. Realtime tempo control needs an AudioWorklet implementation (planned).

ts ts
// Wrap it for re-use.
const stretched = (factor: number) =>
  StretchProcessor.stretchBuffer(engine.context, original, factor);

engine.createSound('bg-1.25', stretched(1.25), { bus: 'music' });
engine.createSound('bg-1.5',  stretched(1.5),  { bus: 'music' });
engine.createSound('bg-2.0',  stretched(2.0),  { bus: 'music' });

Quality notes

  • StretchProcessor uses overlap-add granular synthesis with cross-correlation alignment for stretch factors below 2.5×, plus a high-frequency-restore filter to compensate for the comb filtering granular methods inevitably introduce. It's good for music and dialogue.
  • For factors > 3× you'll hear granular artifacts. Pitch-shifting impressionistic ambience can hide them; clean dialogue can't.
  • Factor < 1 (i.e. play faster while preserving pitch) is not currently supported. Use play({ pitch }) instead and accept the pitch shift, or pre-render shorter versions.

Pitfalls

Don't time-stretch in a hot loop.
The processor is offline but synchronous; a 1-second clip at 2× costs ~30 ms on a fast machine. Pre-render at load time, not on every play.
Don't stretch SFX.
Short transients (under 100 ms) sound granular and weird stretched. Use play({ pitch }) for SFX, StretchProcessor for sustained sources.

Related