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
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
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.
// 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.
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.
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)
// 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
play({ pitch }) for SFX, StretchProcessor
for sustained sources.