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:
- Split the audio into tiny overlapping grains (e.g., 10–50 ms)
- Stretch or compress the grains in time
- Resample the grains to change pitch
- 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;
}
}
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;
}
}
The User Experience
The user interface is simple but effective:
- Semitone slider — from -12 to +12 semitones
- Real-time preview — hear changes instantly
- Format support — MP3, WAV, FLAC, M4A, AAC, OGG
- 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>
Real-World Usage
Since launching the pitch shifter, I've seen it used for:
- Music production — changing the key of samples
- Voice modulation — creating character voices
- Language learning — adjusting speech speed without pitch change
- 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)