# Pitch & time-stretch · zvuk

zvuk v1.14.1 · MIT · https://zvuk.schmooky.dev/fx/pitch/

Two ways to change speed. Cheap playback-rate moves pitch and tempo together; granular time-stretch holds the pitch.

FX

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

A/B your own sound

bus outputidle

1\. playbackRate (pitch + tempo)

Cheap. The chipmunk effect.

rate1.00×

play at 1.00×

2\. StretchProcessor (preserves pitch)

Offline render. Tempo changes; pitch stays.

factor1.00×

render & play at 1.00×

Unlock & load

## Pitch via `play()`, which moves tempo too

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

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

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

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.

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

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

Don't time-stretch in a hot loop.

The processor is offline but synchronous. A one-second clip at 2× costs around 30 ms on a fast laptop, which is two dropped frames if you do it during gameplay. Pre-render at load time.

Don't stretch SFX.

Transients under about 100 ms come out granular and strange. Use `play({ pitch })` for SFX and `StretchProcessor` for sustained sources.

## Related

-   [Voice](/concepts/voice/) covers pitch jitter at play time.
-   [FX index](/fx/)
