Introduction

Play a recording at half speed and the voice drops an octave — that eerie, slowed-down growl beloved by horror films. Speed it up and everyone sounds like a chipmunk. For decades, changing duration and changing pitch were inseparably coupled: sample rate was destiny.

The phase vocoder breaks that link. By transforming a signal into the frequency domain using the Fast Fourier Transform, it separates when each frequency happens from what that frequency is. You can then resynthesise the signal at any speed you choose, keeping every pitch exactly where it was.

The trick is subtle and elegant: instead of repeating or dropping raw audio samples, the algorithm tracks the instantaneous phase of each frequency component across successive analysis frames and advances it at exactly the right rate for the new tempo. The result is smooth, natural-sounding time-stretched audio — the same technology in every professional DAW, music-education app, and video-game engine that needs to play back audio at variable speeds.

Try It: Stretch a Tone

The demo below synthesises a short chord, runs it through a simplified phase vocoder, and lets you choose how much to stretch or compress it. The waveform display shows both the original and the time-scaled output.

<!-- Demo HTML layout for the phase vocoder interactive widget -->
<p class="hint">Adjust the stretch factor, then click <b>Run Phase Vocoder</b> to process a synthesised C-major chord (C4-E4-G4). The blue waveform is the original (1 second); the red is the time-scaled output. Click <b>Play output</b> to hear it — pitch stays the same even as duration changes.</p>
<div class="controls">
  <label>Stretch / compress factor: <strong id="stretchVal">1.0×</strong>
    <input type="range" id="stretchSlider" min="0.25" max="2.0" step="0.05" value="1.0">
  </label>
</div>
<div class="canvas-wrap">
  <div class="canvas-label">Original signal (1 s)</div>
  <canvas id="origCanvas" width="560" height="100"></canvas>
  <div class="canvas-label">Output signal (time-scaled)</div>
  <canvas id="outCanvas" width="560" height="100"></canvas>
</div>
<div class="status" id="status">Adjust the slider and click Run.</div>
<div class="btns">
  <button id="btnRun" type="button">Run Phase Vocoder</button>
  <button id="btnPlay" type="button" class="ghost" disabled>Play output</button>
  <button id="btnReset" type="button" class="ghost">Reset</button>
</div>
/* Styles for the phase vocoder demo widget */
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; padding: .5rem; }
.hint { font-size: .88rem; color: #444; margin: 0 0 .7rem; line-height: 1.45; }
.controls { margin-bottom: .6rem; }
label { display: flex; flex-direction: column; gap: .25rem; font-size: .9rem; font-weight: 600; }
input[type=range] { width: 100%; max-width: 400px; cursor: pointer; }
.canvas-wrap { display: flex; flex-direction: column; gap: .25rem; margin: .5rem 0; }
.canvas-label { font-size: .78rem; font-weight: 700; color: #1d3557; text-transform: uppercase; letter-spacing: .04em; }
canvas { width: 100%; height: 100px; border-radius: 8px; background: #e8eef3; display: block; }
.status { font-size: .95rem; font-weight: 600; min-height: 1.4em; margin: .4rem 0; color: #1d3557; }
.status.ok { color: #0a7d33; }
.status.bad { color: #c92f3c; }
.btns { display: flex; gap: .5rem; flex-wrap: wrap; }
button { font: 600 14px system-ui, sans-serif; padding: .45rem .9rem;
         border: 1px solid #1d3557; background: #1d3557; color: #fff;
         border-radius: 8px; cursor: pointer; }
button.ghost { background: #fff; color: #1d3557; }
button:disabled { opacity: .45; cursor: not-allowed; }
const i18nFmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));

// Phase vocoder parameters
const SR = 22050;       // sample rate (Hz)
const DURATION = 1.0;   // source duration (seconds)
const N = 1024;         // DFT frame size (kept small so the browser does not freeze)
const HOP_A = N / 4;    // analysis hop = N/4 (75% overlap)

// Synthesise a C-major chord: C4, E4, G4
// frequencies in Hz
const FREQS = [261.63, 329.63, 392.00];

function synthesise() {
  // Mix three sine waves at equal amplitude
  const len = Math.round(SR * DURATION);
  const buf = new Float32Array(len);
  for (let i = 0; i < len; i++) {
    const t = i / SR;
    for (const f of FREQS) buf[i] += (1 / FREQS.length) * Math.sin(2 * Math.PI * f * t);
  }
  return buf;
}

// Hann window (reduces spectral leakage at frame edges)
function hann(n) {
  const w = new Float32Array(n);
  for (let i = 0; i < n; i++) w[i] = 0.5 * (1 - Math.cos(2 * Math.PI * i / (n - 1)));
  return w;
}

// Naive DFT — O(N^2) but fine for the small N=1024 used here
function dft(real, imag) {
  const n = real.length;
  const re = new Float32Array(n), im = new Float32Array(n);
  for (let k = 0; k < n; k++) {
    for (let t = 0; t < n; t++) {
      const ang = -2 * Math.PI * k * t / n;
      re[k] += real[t] * Math.cos(ang) - imag[t] * Math.sin(ang);
      im[k] += real[t] * Math.sin(ang) + imag[t] * Math.cos(ang);
    }
  }
  return { re, im };
}

// Inverse DFT
function idft(re, im) {
  const n = re.length;
  const real = new Float32Array(n), imag = new Float32Array(n);
  for (let t = 0; t < n; t++) {
    for (let k = 0; k < n; k++) {
      const ang = 2 * Math.PI * k * t / n;
      real[t] += re[k] * Math.cos(ang) - im[k] * Math.sin(ang);
      imag[t] += re[k] * Math.sin(ang) + im[k] * Math.cos(ang);
    }
    real[t] /= n;
    imag[t] /= n;
  }
  return real;
}

// Wrap angle to the interval (-pi, pi]
function principalAngle(a) {
  return ((a % (2 * Math.PI)) + 3 * Math.PI) % (2 * Math.PI) - Math.PI;
}

// Phase vocoder time-scale modification
function phaseVocoder(input, alpha) {
  const W = hann(N);
  const hopA = HOP_A;
  const hopS = Math.round(hopA * alpha);
  const numFrames = Math.floor((input.length - N) / hopA);
  const outLen = (numFrames * hopS) + N;
  const output = new Float32Array(outLen);
  const phaseAcc = new Float32Array(N);
  let prevPhase = null;

  for (let frame = 0; frame < numFrames; frame++) {
    // Extract and window one analysis frame
    const inOffset = frame * hopA;
    const re = new Float32Array(N), im = new Float32Array(N);
    for (let i = 0; i < N; i++) re[i] = (input[inOffset + i] || 0) * W[i];

    // Transform to frequency domain
    const spec = dft(re, im);
    const mag = new Float32Array(N);
    const phase = new Float32Array(N);
    for (let k = 0; k < N; k++) {
      mag[k] = Math.sqrt(spec.re[k] * spec.re[k] + spec.im[k] * spec.im[k]);
      phase[k] = Math.atan2(spec.im[k], spec.re[k]);
    }

    if (frame === 0) {
      // First frame — seed the phase accumulator directly
      for (let k = 0; k < N; k++) phaseAcc[k] = phase[k];
      prevPhase = phase.slice();
    } else {
      // Compute instantaneous frequency and advance synthesis phase
      for (let k = 0; k < N; k++) {
        const expectedAdv = 2 * Math.PI * k * hopA / N;
        const delta = principalAngle(phase[k] - prevPhase[k] - expectedAdv);
        const instFreq = 2 * Math.PI * k / N + delta / hopA;
        phaseAcc[k] += instFreq * hopS;
      }
      prevPhase = phase.slice();
    }

    // Reconstruct from magnitudes + accumulated phase
    const outRe = new Float32Array(N), outIm = new Float32Array(N);
    for (let k = 0; k < N; k++) {
      outRe[k] = mag[k] * Math.cos(phaseAcc[k]);
      outIm[k] = mag[k] * Math.sin(phaseAcc[k]);
    }

    // Convert back to time domain and overlap-add
    const rebuilt = idft(outRe, outIm);
    const outOffset = frame * hopS;
    const norm = 1.5;
    for (let i = 0; i < N; i++) {
      output[outOffset + i] += rebuilt[i] * W[i] / norm;
    }
  }
  return output;
}

// Draw a waveform buffer onto a canvas element
function drawWave(canvas, buf, color) {
  const ctx = canvas.getContext('2d');
  const W = canvas.width, H = canvas.height;
  ctx.clearRect(0, 0, W, H);
  ctx.fillStyle = '#e8eef3';
  ctx.fillRect(0, 0, W, H);
  ctx.beginPath();
  ctx.strokeStyle = color;
  ctx.lineWidth = 1.2;
  const step = buf.length / W;
  let maxAmp = 0;
  for (let i = 0; i < buf.length; i++) if (Math.abs(buf[i]) > maxAmp) maxAmp = Math.abs(buf[i]);
  if (maxAmp < 0.001) maxAmp = 1;
  for (let x = 0; x < W; x++) {
    const idx = Math.floor(x * step);
    const y = H / 2 - (buf[idx] / maxAmp) * (H / 2 - 4);
    x === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
  }
  ctx.stroke();
}

let origBuf = null, outBuf = null;

// Play a Float32Array through the Web Audio API
function playBuffer(floatBuf) {
  const ac = new (window.AudioContext || window.webkitAudioContext)({ sampleRate: SR });
  const ab = ac.createBuffer(1, floatBuf.length, SR);
  ab.copyToChannel(floatBuf, 0);
  const src = ac.createBufferSource();
  src.buffer = ab;
  src.connect(ac.destination);
  src.start();
}

const stretchSlider = document.getElementById('stretchSlider');
const stretchValEl = document.getElementById('stretchVal');
const statusEl = document.getElementById('status');
const btnRun = document.getElementById('btnRun');
const btnPlay = document.getElementById('btnPlay');
const btnReset = document.getElementById('btnReset');
const origCanvas = document.getElementById('origCanvas');
const outCanvas = document.getElementById('outCanvas');

stretchSlider.addEventListener('input', () => {
  stretchValEl.textContent = parseFloat(stretchSlider.value).toFixed(2) + '×';
});

btnRun.addEventListener('click', () => {
  const alpha = parseFloat(stretchSlider.value);
  statusEl.textContent = i18nFmt('Running phase vocoder at %alpha%× …', { alpha: alpha.toFixed(2) });
  statusEl.className = 'status';
  btnRun.disabled = true;
  btnPlay.disabled = true;

  // Defer heavy work so the browser can repaint the status first
  setTimeout(() => {
    try {
      origBuf = synthesise();
      drawWave(origCanvas, origBuf, '#1d3557');
      outBuf = phaseVocoder(origBuf, alpha);
      drawWave(outCanvas, outBuf, '#e63946');
      const ratio = (outBuf.length / origBuf.length).toFixed(2);
      statusEl.textContent = i18nFmt('Done — stretch %alpha%× · output length ratio: %ratio%', { alpha: alpha.toFixed(2), ratio });
      statusEl.className = 'status ok';
      btnPlay.disabled = false;
    } catch (e) {
      statusEl.textContent = 'Something went wrong. Try again.';
      statusEl.className = 'status bad';
    }
    btnRun.disabled = false;
  }, 30);
});

btnPlay.addEventListener('click', () => {
  if (outBuf) playBuffer(outBuf);
});

btnReset.addEventListener('click', () => {
  origBuf = null; outBuf = null;
  const ctx1 = origCanvas.getContext('2d'); ctx1.clearRect(0, 0, origCanvas.width, origCanvas.height);
  ctx1.fillStyle = '#e8eef3'; ctx1.fillRect(0, 0, origCanvas.width, origCanvas.height);
  const ctx2 = outCanvas.getContext('2d'); ctx2.clearRect(0, 0, outCanvas.width, outCanvas.height);
  ctx2.fillStyle = '#e8eef3'; ctx2.fillRect(0, 0, outCanvas.width, outCanvas.height);
  statusEl.textContent = 'Adjust the slider and click Run.';
  statusEl.className = 'status';
  btnPlay.disabled = true;
  stretchSlider.value = '1.0';
  stretchValEl.textContent = '1.00×';
});

// Paint blank canvases on load
(function init() {
  const ctx1 = origCanvas.getContext('2d');
  ctx1.fillStyle = '#e8eef3'; ctx1.fillRect(0, 0, origCanvas.width, origCanvas.height);
  const ctx2 = outCanvas.getContext('2d');
  ctx2.fillStyle = '#e8eef3'; ctx2.fillRect(0, 0, outCanvas.width, outCanvas.height);
})();
Result
Console

Notice that the pitch stays constant even when the duration changes dramatically. The phase vocoder achieves this by advancing each frequency bin's phase by exactly the amount needed to maintain phase coherence — no more, no less. Compare this with what a naive approach (simply repeating chunks) would sound like: you would hear audible clicks at the boundaries between repeated blocks.

How It Works

The phase vocoder has three stages: analysis, manipulation, and synthesis.

Analysis — slicing into frames

The input signal x[n]x[n] is windowed into overlapping frames of length NN with a hop size HaH_a (analysis hop). Each frame is transformed by the DFT to give a complex spectrum XkX_k with magnitude ∣Xk∣|X_k| and phase ϕk\phi_k.

Phase advance — the core trick

Between two successive analysis frames separated by HaH_a samples, the expected phase advance for bin kk is

Δϕk=2πkHaN\Delta\phi_k = \frac{2\pi k H_a}{N}

The actual measured advance Δϕ^k\Delta\hat{\phi}_k will differ by a small deviation δk\delta_k (the "instantaneous frequency error"). The instantaneous frequency of bin kk is

ωk=2πkN+δkHa\omega_k = \frac{2\pi k}{N} + \frac{\delta_k}{H_a}

To synthesise at a stretch factor α\alpha (e.g.\ α=0.5\alpha = 0.5 for half-speed), the synthesis hop is Hs=αHaH_s = \alpha H_a. The output phase for each bin is accumulated as

ψk←ψk+ωk⋅Hs\psi_k \leftarrow \psi_k + \omega_k \cdot H_s

The output frame is reconstructed by inverse DFT using the original magnitudes ∣Xk∣|X_k| but the new accumulated phase ψk\psi_k. Overlap-add reassembles the frames into the final signal.

Why this preserves pitch

Pitch depends only on frequency, and the frequencies ωk\omega_k are unchanged — only the rate at which frames are spaced changes. Doubling α\alpha doubles the duration while the signal's spectral content remains identical.

Artefacts and improvements

The basic algorithm introduces phasiness on transients and harmonic sounds because different partials of the same harmonic are advanced independently, scrambling their relative phases. Refinements such as phase locking (Laroche & Dolson, 1999) and transient detection (mark transients, skip phase accumulation, copy frames verbatim) produce the clean results heard in professional tools.

Where It Matters

The ability to decouple duration from pitch turns up in almost every corner of modern audio:

  • Music production: DJ software, DAWs (Ableton, Logic, Pro Tools) use phase vocoders to synchronise loops to the project tempo without retiming the original recording.
  • Film and podcast post-production: dialogue replacement, ADR, and podcast speed-up (listen at 1.5× without the chipmunk effect) all rely on time-scale modification.
  • Music education and transcription apps: slow a guitar solo to 50% speed so a student can follow each note while the pitch stays in the original key.
  • Game engines: adaptive music systems stretch cues to fill scenes of variable length; speech in cut-scenes can be stretched to match dubbed-language timing.
  • Hearing aids and accessibility tools: real-time time-dilation lets wearers slow incoming speech without pitch distortion.
  • Scientific analysis: bioacoustics researchers slow ultrasonic bat calls into the audible range — the phase vocoder keeps harmonic relationships intact for species identification.

The same STFT machinery also underlies pitch shifting (change the bin indices without changing hop size), robot-voice effects, and the auto-tune family of algorithms that correct vocal pitch after recording.

Conclusion

The phase vocoder is a beautiful illustration of what happens when you take a signal apart into its frequency components: dimensions that seem inseparable in the time domain — how long a sound lasts versus how high it is — become fully independent handles you can turn at will.

Next time you listen to a slowed-down guitar solo on a music app, or a podcast at 1.5× without anyone sounding like a chipmunk, you are hearing the phase vocoder at work: carefully accumulating phase bin by bin so that every frequency arrives at exactly the right moment, stretched smoothly across whatever duration you choose. It is the Fast Fourier Transform doing its most musical trick.

Share this article

Pick a channel — or use your device's native share sheet.

Comments

Loading comments...

https://www.kipuhub.com/en/article/phase-vocoder/Content licensed under CC BY-NC 4.0.