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 '@schmooky/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 offline renderer — call once per buffer/factor pair, cache the result. Use this when you know the factors at load time and don't need to ramp.

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' });

Realtime varispeed via AudioWorklet

For live ramps — boss intros that bend in real time, gameplay slow-mo — use the worklet node. The stretch param is a real AudioParam, so all the standard automation methods work.

The realtime worklet is varispeed, not pitch-preserving.
Unlike the offline StretchProcessor, this node resamples a ring buffer at a variable rate, so ramping stretch changes pitch and tempo together (tape-style). Use it when you want that effect; pre-render with StretchProcessor when you need the pitch held.
ts ts
import { ensureStretchWorklet, createStretchWorkletNode } from '@schmooky/zvuk';

await ensureStretchWorklet(engine.context);
const node = createStretchWorkletNode(engine.context, { stretchFactor: 1 });

// Insert into a bus's FX chain — the stretch param is automatable.
engine.bus('music').addFx({
  input: node,
  output: node,
  bypassed: false,
  dispose: () => node.dispose(),
});

// Live varispeed ramp over 2 seconds. NOTE: the realtime worklet is NOT
// pitch-preserving — stretch < 1 plays slower AND drops pitch (tape-style).
node.stretch.linearRampToValueAtTime(0.5, engine.context.currentTime + 2);

Voice-level rate ramp (cheap, alters pitch)

ts ts
// Voice-level rate control (alters pitch + tempo, not pitch-preserving).
// Cheaper than the worklet, fine when you don't care about pitch.
const v = engine.sound('boss-stinger').play();
v.setPlaybackRate(0.6, { duration: 0.8, curve: 'easeOut' });

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