Time-Stretching Audio Without Changing Its Pitch
A code-grounded explainer of how loopaloo's Audio Speed Adjuster and Pitch Shifter separate (or deliberately fuse) duration and pitch: resampling with cubic interpolation, Hann-windowed overlap-add time-stretching, and per-grain resampling for pitch shift.
If you speed up a tape recorder, two things happen at once: the music finishes sooner, and everyone sounds like a chipmunk. The duration and the pitch are welded together because you are literally pushing the same waveform past the playhead faster. Every cycle of every note completes in less time, so its frequency goes up. That is resampling, and it is the simplest, cheapest way to change the speed of audio.
But most of the time when we say "speed this podcast up to 1.5x" we do not want the chipmunk. We want the talking to go faster while the voice stays recognizably the same. That requires a fundamentally different operation: changing how long the audio lasts without changing the frequencies inside it. The two operations look similar in a UI (both have a speed slider) but the signal processing underneath has almost nothing in common.
This post walks through both, grounded in the actual code that runs in loopaloo's audio tools. The resampling path and the pitch-preserving path live side by side in the same component, which makes the contrast easy to see. We will also cover the inverse problem (changing pitch without changing duration) because once you understand one, the other is the same trick run in the opposite direction.
Resampling: the tape effect
Resampling is index arithmetic. To play audio at speed times the original rate, you produce an output buffer that is 1/speed as long, and for each output sample you read from a position in the source that advances by speed samples per output sample.
Here is the core of the speed adjuster's default (pitch-not-preserved) path:
const newLength = Math.floor(audioBuffer.length / speed);
// ...
for (let i = 0; i < newLength; i++) {
const sourceIndex = i * speed; // walk the source faster than the output
const index0 = Math.floor(sourceIndex);
const index1 = Math.min(index0 + 1, sourceData.length - 1);
const fraction = sourceIndex - index0;
// cubic interpolation across 4 neighbouring samples
adjustedData[i] = cubicInterpolate(y0, y1, y2, y3, fraction);
}
At speed = 2, sourceIndex lands on 0, 2, 4, 6... so you skip every other sample and the output buffer is half as long. Because sourceIndex is usually fractional (at speed = 1.5 you need sample 1.5, 3.0, 4.5...), you cannot just read an integer index. The code interpolates. A naive version would do linear interpolation between the two nearest samples; this one uses cubic interpolation across four neighbours (y0..y3), which produces a smoother curve and reduces the high-frequency grit that linear interpolation introduces when you resample.
The crucial consequence is that every frequency in the signal scales by speed. A 200 Hz note sampled half as densely now completes its cycle in half the time, so it reads back as 400 Hz. That is exactly the tape effect, and for some uses it is exactly what you want: pitch up a drum loop, drop a vocal an octave for a spooky effect, or just go fast for comedic value.
The live preview takes an even cheaper shortcut. Instead of resampling the buffer in JavaScript, it hands the work to the Web Audio API:
source.playbackRate.value = speed;
AudioBufferSourceNode.playbackRate is resampling done by the browser's own audio engine. It is the same operation conceptually (pitch rides along with speed), which is why the preview and the downloaded file behave the same way when "preserve pitch" is off. When you change the slider mid-playback, the code just updates playbackRate.value and the running source follows.
Why you cannot keep pitch by resampling
The reason resampling can never preserve pitch is worth stating plainly, because it explains why the second algorithm has to exist. Pitch is frequency. Frequency is cycles per second. If you keep all the samples but play them out over a shorter wall-clock time, you have packed more cycles into each second by definition. There is no resampling ratio that shortens the audio without raising frequency, because those are the same number.
To decouple them you have to do something that feels almost like cheating: keep the local shape of the waveform intact (so frequencies stay put) while changing how many copies of those local shapes you lay down end to end (so duration changes). That is what overlap-add time-stretching does.
Granular overlap-add time-stretching
Turn on "Preserve Pitch" in the speed adjuster and a completely different function runs. The idea: chop the source into short overlapping grains, then place those grains onto the output timeline at a different spacing than you read them from the source. If you read grains close together in the source but write them farther apart in the output, the audio gets longer. The grains themselves are never resampled, so the waveform inside each grain keeps its original frequencies.
The real implementation:
const windowSize = 2048;
const hopSize = Math.floor(windowSize / 4); // 512 samples
while (targetPos < newLength - windowSize) {
const sourceIndex = Math.min(Math.floor(sourcePos), sourceData.length - windowSize);
for (let i = 0; i < windowSize && targetPos + i < newLength; i++) {
const hannWindow = 0.5 * (1 - Math.cos(2 * Math.PI * i / windowSize));
targetData[targetPos + i] += sourceData[sourceIndex + i] * hannWindow;
}
sourcePos += hopSize * rate; // read pointer moves at playback rate
targetPos += hopSize; // write pointer moves at a fixed rate
}
Two things make this work, and they are both in those last two lines.
First, the write pointer always advances by hopSize (512 samples) while the read pointer advances by hopSize * rate. That decoupling is the whole game. At rate = 2 (twice as fast), the read pointer jumps 1024 source samples each iteration while the write pointer only advances 512 output samples, so you consume the source twice as fast as you fill the output, and the output ends up half as long. At rate = 0.5, you barely move through the source while filling output normally, so the output is twice as long. The output buffer length is floor(buffer.length / rate), matching the resampling path so the duration math is identical even though the mechanism is not.
Second, the Hann window (0.5 * (1 - cos(2*pi*i/windowSize))) tapers each 2048-sample grain to zero at both edges. This is essential. If you just chopped the audio into rectangular blocks and laid them down at new positions, every grain boundary would be a discontinuity, and discontinuities in a waveform are clicks. The Hann window fades each grain in and out, and because consecutive grains overlap (the hop is 512 but the window is 2048, so four grains overlap at any point), the faded-out tail of one grain is filled in by the faded-in head of the next. The grains are summed into the output (targetData[...] += ...), which is the "add" in overlap-add.
The overlap factor here is windowSize / hopSize = 2048 / 512 = 4. Four overlapping Hann windows summed together produce close to constant gain, which is why this scheme keeps the volume roughly even instead of pulsing.
One honest detail: summing four windowed grains can push samples above 1.0, so after processing each channel the code scans for the peak and divides everything down if it clipped:
let maxVal = 0;
for (let i = 0; i < newLength; i++) maxVal = Math.max(maxVal, Math.abs(targetData[i]));
if (maxVal > 1) {
for (let i = 0; i < newLength; i++) targetData[i] /= maxVal;
}
That is peak normalization, not a limiter; it scales the whole channel uniformly so nothing clips.
Resampling vs overlap-add at a glance
| Property | Resampling (tape) | Overlap-add stretch |
|---|---|---|
| Changes duration | Yes | Yes |
| Changes pitch | Yes (scales with speed) | No (preserved) |
| Cost | Very cheap (one pass, interpolation) | Higher (overlapping grains, windowing) |
| Read vs write pointer | Move together at speed | Decoupled: read at hop*rate, write at hop |
| Main artifact | High-freq grit if interpolation is crude | Transient smearing, phasiness at extremes |
| Good for | Effects, intentional pitch change | Podcasts, speech, keeping a voice natural |
Where overlap-add breaks down
This kind of grain-based stretch (a synchronous overlap-add, and a close cousin of WSOLA and the phase vocoder) is not artifact-free, and the artifacts get worse as the stretch ratio moves away from 1.0.
The most audible problem is transient smearing. Sharp attacks (a snare hit, a consonant like "t" or "k", a plucked string) are short events. When you stretch time, you are duplicating and overlapping grains, and a transient that fell inside one grain gets spread across the overlap region of several. The attack softens and can even double. Drums stretched slowly take on a flammed, mushy quality.
The second problem is phasiness, a hollow, slightly metallic or "underwater" coloration. This implementation copies grains directly without aligning the phase of the sinusoids inside them. When overlapping grains' waveforms do not line up, partial cancellation tints the sound. A full phase vocoder fixes this by tracking and correcting the phase of each frequency bin across grains; WSOLA fixes it by sliding each grain to the position of best correlation with what is already written. The version here does neither, which keeps it simple and fast but means the coloration grows at extreme ratios. This is why the tool's own code comment notes that for production-grade results you would reach for a dedicated library like SoundTouchJS.
In practice, gentle ratios near 1.0 on speech hold together well, and the smearing and phasiness become progressively more obvious the further you push toward an octave of stretch (roughly 0.5x or 2x), especially on music with strong transients. Speech and podcasts tolerate stretching far better than percussive music because the ear is forgiving about the exact attack shape of a spoken consonant but very sensitive to a smeared drum.
The inverse operation: pitch shift without duration change
Pitch shifting is time-stretching run backwards. The insight: if you can change duration without touching pitch, you can change pitch without touching duration by combining a stretch with a resample.
To raise pitch by n semitones, you want a frequency multiplier of 2^(n/12) (twelve semitones doubles the frequency, an octave). One way to get there: resample the audio by that ratio (which raises pitch and shortens it, the tape effect), then time-stretch it back to the original length (restoring duration without touching the now-higher pitch). The pitch shifter folds both steps into a single grain loop:
const pitchRatio = Math.pow(2, semitoneShift / 12);
const grainSize = 2048;
const hopSize = 512;
const overlapFactor = grainSize / hopSize; // 4
const newLength = buffer.length; // duration preserved
// Hann window
for (let i = 0; i < grainSize; i++)
envelope[i] = 0.5 * (1 - Math.cos(2 * Math.PI * i / (grainSize - 1)));
for (let grainIdx = 0; grainIdx < numGrains; grainIdx++) {
const sourcePos = grainIdx * hopSize;
const outputPos = grainIdx * hopSize; // read and write hops match -> duration unchanged
for (let i = 0; i < grainSize && outputPos + i < newLength; i++) {
const sourceIdx = sourcePos + i * pitchRatio; // resample WITHIN the grain
const intIdx = Math.floor(sourceIdx);
const frac = sourceIdx - intIdx;
const sample = sourceData[intIdx] * (1 - frac) + sourceData[intIdx + 1] * frac; // linear
outputData[outputPos + i] += sample * envelope[i] / overlapFactor;
}
}
Notice the difference from the time-stretcher. There, the read and write pointers advanced at different rates and the grain content was copied 1:1. Here, the grain positions advance at the same rate (grainIdx * hopSize for both), so the overall duration is unchanged, but inside each grain the read index advances by pitchRatio (sourcePos + i * pitchRatio). That per-grain resampling is what compresses or expands the waveform locally to shift the pitch, while the matched grain spacing keeps the total length fixed. The / overlapFactor divides by 4 to compensate for the four overlapping Hann windows, taking the place of the peak normalization used in the stretcher.
The interpolation here is linear, not cubic, which is one reason aggressive pitch shifts can sound a little rougher than gentle ones. And it shares the same family of artifacts as the stretcher: at an octave up or down (pitchRatio of 2 or 0.5) you are resampling each grain hard, and transient smearing plus phasiness become obvious. Within a few semitones the result holds together well, which is why the cents control (1/100 of a semitone, handy for nudging a track toward A440) is the most artifact-safe way to use it. The cents slider in the tool runs from -50 to +50, so fine-tuning stays inside half a semitone in either direction.
For comparison, the pitch shifter also offers a non-preserving mode that just rewrites the buffer's sample rate (Math.floor(sampleRate * pitchRatio)) and copies samples unchanged. That is the pure tape effect again: pitch and duration move together, zero grain artifacts, but you cannot keep the length.
Putting it together
The mental model that ties all of this together is two independent pointers and a windowed grain.
- Move read and write pointers together, scaling the read step: resampling. Cheap, pitch and duration locked.
- Move them at different rates, copy grains 1:1: time-stretch. Duration changes, pitch held.
- Move them together but resample inside each grain: pitch shift. Pitch changes, duration held.
All three share the same Hann window family, the same 2048-sample grain, the same 512-sample hop, and the same overlap-add summation; they differ mainly in which pointer moves how fast (and the stretcher uses cubic interpolation on its resampling path, while the pitch shifter uses linear interpolation inside grains). That is genuinely most of what production time and pitch engines do, with the remaining sophistication going into transient detection and phase alignment to clean up the artifacts described above.
If you want to hear the difference yourself, loopaloo's Audio Speed Adjuster runs both paths: leave "Preserve Pitch" off for the tape effect, turn it on for the overlap-add stretch, and the duration comparison updates either way. The Pitch Shifter runs the inverse operation, letting you move by musical intervals or fine-tune in cents while keeping the original length. Both decode and process entirely in your browser with decodeAudioData and export an MP3, so nothing leaves your machine.
Related Tools
Audio Speed Changer
Speed up or slow down audio playback from 0.25x to 4x without changing pitch. Perfect for transcription, music practice, podcast speed adjustment, and audiobook listening.
Audio Pitch Shifter
Shift audio pitch up or down by semitones with musical interval names (octave, perfect fifth, major third). Fine-tune with cents adjustment. Option to preserve original duration using granular synthesis.
Related Articles
Try Our Free Tools
200+ browser-based tools for developers and creators. No uploads, complete privacy.
Explore All Tools