Pitch & time-stretch
Two ways to change speed. Cheap playback-rate moves pitch and tempo together; granular time-stretch holds the pitch.
"Slow this down" means two completely different things depending on whether you also wanted it to drop a fifth in pitch. Tape does one. A DAW does the other. They cost about a thousand times different amounts.
The cheap one is free: AudioBufferSourceNode.playbackRate
finishes the buffer faster and raises the pitch to match, exactly like
winding tape. At 1.5× a sample ends 50% sooner and sits a fifth higher.
zvuk exposes it as play({ pitch }).
The expensive one holds pitch while moving tempo.
StretchProcessor is an
offline granular renderer, SOLA-style, that takes one AudioBuffer and
returns a stretched copy at the original pitch.
Live demo
Both modes on the same source. Move the slider and press both buttons. One sounds like a chipmunk. The other sounds like the same performance, slower.
Pitch via play(), which moves tempo too
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
}); Reach for this when a sound should feel smaller or bigger, when stacked one-shots need random variation so they don't machine-gun, or when you want an outright retro arcade effect.
Time-stretch via StretchProcessor, which holds 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 }); Reach for this when music has to slow for a boss intro, when a loop needs doubling for a chase, or when vocal cadence has to match across takes recorded on different days.
Pre-render multiple speeds
The renderer is offline, so call it once per buffer and factor pair and cache what comes back. This is the right shape when you know the factors at load time and never need to ramp between them.
// 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 a ramp that happens while audio is playing, use the worklet node
instead. Boss intros that bend, gameplay slow motion. Its
stretch parameter is a real AudioParam, so every
standard automation method works on it.
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 that compensates for the comb filtering granular methods introduce. Good for music and dialogue.
- Past about 3× the granular artifacts are audible. Impressionistic ambience hides them. Clean dialogue does not.
- 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 and
StretchProcessor for sustained sources.