DEV Community

Kaleem Ullah
Kaleem Ullah

Posted on

Building a Real-Time Pitch Shifter with the Web Audio API

Building a Real-Time Pitch Shifter with the Web Audio API

Introduction

Pitch shifting is one of the most requested audio effects—musicians use it to change the key of a song without affecting tempo, and content creators use it for voice modulation.

I built SonicLab's Voice Pitch Shifter to do exactly that, entirely in the browser. In this post, I'll explain how it works, the challenges I faced, and how you can build one too.


The Approach: Granular Pitch Shifting
The granular approach works like this:

  1. Split the audio into tiny overlapping grains (e.g., 10–50 ms)
  2. Stretch or compress the grains in time
  3. Resample the grains to change pitch
  4. Crossfade the grains to avoid artifacts

Here's a simplified implementation:

class PitchShifterEngine {
  constructor(audioContext) {
    this.ctx = audioContext;
    this.grainSize = 0.1; // 100ms grains
    this.overlap = 0.5;    // 50% overlap
  }

  async shiftPitch(audioBuffer, semitones) {
    const pitchRatio = Math.pow(2, semitones / 12);
    const sampleRate = audioBuffer.sampleRate;
    const channelData = audioBuffer.getChannelData(0); // Mono for simplicity

    // Calculate grain parameters
    const grainLength = Math.floor(this.grainSize * sampleRate);
    const hopSize = Math.floor(grainLength * (1 - this.overlap));
    const outputLength = Math.floor(channelData.length / pitchRatio);

    // Create output buffer
    const outputData = new Float32Array(outputLength);

    // Granular processing loop
    let readIndex = 0;
    let writeIndex = 0;

    while (readIndex < channelData.length - grainLength && writeIndex < outputLength) {
      // Extract a grain
      const grain = channelData.slice(readIndex, readIndex + grainLength);

      // Apply window (Hanning window to reduce artifacts)
      const windowedGrain = applyWindow(grain);

      // Resample the grain (pitch shift)
      const resampledGrain = resampleAudio(windowedGrain, pitchRatio);

      // Add to output with crossfade
      for (let i = 0; i < resampledGrain.length && writeIndex + i < outputLength; i++) {
        outputData[writeIndex + i] += resampledGrain[i] * getCrossfadeFactor(i, resampledGrain.length);
      }

      // Move forward
      readIndex += hopSize * pitchRatio;
      writeIndex += hopSize;
    }

    // Create a new AudioBuffer from the output
    const outputBuffer = this.ctx.createBuffer(1, outputData.length, sampleRate);
    outputBuffer.copyToChannel(outputData, 0);
    return outputBuffer;
  }

  // Resample audio (change pitch)
  resampleAudio(data, ratio) {
    const outputLength = Math.floor(data.length / ratio);
    const output = new Float32Array(outputLength);

    for (let i = 0; i < outputLength; i++) {
      const srcIndex = i * ratio;
      const srcIndexFloor = Math.floor(srcIndex);
      const srcIndexCeil = Math.min(srcIndexFloor + 1, data.length - 1);
      const fraction = srcIndex - srcIndexFloor;

      // Linear interpolation
      output[i] = data[srcIndexFloor] * (1 - fraction) + data[srcIndexCeil] * fraction;
    }
    return output;
  }

  // Apply Hanning window
  applyWindow(data) {
    const output = new Float32Array(data.length);
    for (let i = 0; i < data.length; i++) {
      const windowValue = 0.5 * (1 - Math.cos((2 * Math.PI * i) / (data.length - 1)));
      output[i] = data[i] * windowValue;
    }
    return output;
  }
}
Enter fullscreen mode Exit fullscreen mode

Alternative Approach: Using an AudioWorklet

For production use, I moved the processing to an AudioWorklet for better performance and lower latency:

// pitch-shifter-worklet.js
class PitchShifterWorklet extends AudioWorkletProcessor {
  constructor() {
    super();
    this.grainSize = 0.1;
    this.overlap = 0.5;
    this.pitchRatio = 1.0;
    this.buffer = [];
  }

  process(inputs, outputs) {
    const input = inputs[0];
    const output = outputs[0];

    if (input.length === 0) return true;

    // Process audio in real-time
    const inputData = input[0];
    // ... granular processing logic

    return true;
  }
}
Enter fullscreen mode Exit fullscreen mode

The User Experience

The user interface is simple but effective:

  1. Semitone slider — from -12 to +12 semitones
  2. Real-time preview — hear changes instantly
  3. Format support — MP3, WAV, FLAC, M4A, AAC, OGG
  4. Download — export the shifted audio
<div className="pitch-controls">
  <label>
    Pitch Shift: {semitoneValue} semitones
    <input
      type="range"
      min="-12"
      max="12"
      value={semitoneValue}
      onChange={(e) => handlePitchChange(parseFloat(e.target.value))}
    />
  </label>
  <button onClick={playPreview}>▶ Preview</button>
  <button onClick={downloadAudio}>⬇ Download</button>
</div>
Enter fullscreen mode Exit fullscreen mode

Real-World Usage

Since launching the pitch shifter, I've seen it used for:

  1. Music production — changing the key of samples
  2. Voice modulation — creating character voices
  3. Language learning — adjusting speech speed without pitch change
  4. Content creation — adding vocal effects to videos

Try It Yourself

You can test the live pitch shifter here: SonicLab Voice Pitch Shifter

Top comments (0)