6 min read

Generate Slow, Don't Slow the Generation


It started as a friend’s idea: could you make a text-to-speech model slow a word right down — slow enough to hear every sound — without wrecking it? I liked the question enough to build a small thing around it: type a word, drag a slider, hear it slowly enough to catch each sound. Simple idea. The obvious way to build it is wrong in an interesting way.

The obvious way: generate the word once, then slow the audio down when someone asks for slow. That’s one line with any audio library. It also destroys the exact thing you’re trying to teach.

Slow a recording of “cat” down to a third of its speed and the crisp /t/ at the end — the little burst of air that is the T — smears into a soft wash. The beat of silence your mouth makes just before that burst fills up with a metallic, underwater warble. The learner drags the slider to hear the word more clearly, and the sounds get less clear — because the parts that carry the pronunciation are exactly the parts that don’t survive being stretched.

So the reframe: don’t slow the recording. Generate the word slow in the first place.

Two ways to make a word slower

A neural text-to-speech model doesn’t have to stretch existing audio. You can tell it, up front, “make each sound take longer,” and it synthesizes a fresh longer version — one where the /t/ burst is still a real burst, just arriving later. Same final duration as the stretched recording; completely different result.

The reason is in how each method treats time. The naive slowdown uses a phase vocoder, which stretches audio by interpolating between spectral snapshots. That interpolation is the smearing: a stop burst gets spread across frames until it softens, and a silent closure gets filled with phase artifacts, because the vocoder has no concept of “this part is supposed to be silent.” Fresh synthesis has no such problem — it regenerates each sound at its natural length, so a burst is re-created sharp and a silence stays silent. It isn’t stretching anything. It’s speaking slowly.

That’s a nice theory. I’ve been burned by enough nice theories to want a number.

The cheap test that decides everything

I set up the smallest experiment that could kill the idea. Ten words, each chosen to stress a specific failure mode — cat for a clean stop, strengths for the brutal four-consonant tail of /strɛŋkθs/, squirrel because it’s a well-known trap for learners, the for a function word with no hard sound to anchor on. Three slowdown factors each — 1.5×, 2×, 3× — for thirty word-speed pairs. For every pair I generated it both ways and measured how sharp the loudest onset stayed, as a stand-in for “did the transient survive.”

Fresh synthesis kept the sharper transient in 27 of the 30 pairs.

I want to be precise about what that number is, because a good-looking result on your own project is the one to distrust hardest: it’s a transient-sharpness metric, backed by my own ears on a side-by-side page — not a blind panel of real learners. But the metric and the ears agreed, and the spectrograms show the mechanism plainly — in the fresh version the silent gap before the /t/ in “cat” stays silent and the burst stays crisp; in the stretched version the gap fills in and the burst dissolves. The three pairs fresh didn’t win were all liquid, gliding words — through, squirrel — with no sharp burst to smear in the first place, which is exactly where the metric should come out a wash. The poster child was “cat” at 3×: the fresh /t/ landed about five times sharper than the stretched one.

The bet was real. Generate slow; don’t slow the generation.

The second lesson: you don’t get to stretch everything

Then I pushed it too far, which is the only way I ever learn the interesting half.

Fresh synthesis wins comfortably through about 3×. Past 4×, a new failure shows up that has nothing to do with phase vocoders — it’s in the neural model itself. Ask it for 8× and the vowels start to drone: the formants freeze into flat, static bands, and a drawn-out vowel stops sounding like slow speech and starts sounding like a stuck note. The stops get strange too — “cat” at 8× balloons toward five seconds, the model stretching even the silent closure of the /t/ into something that never happens in a real mouth. Uniform slowing was the wrong knob at the extreme. As I put it in my own notes at the time: 8× isn’t a slider problem, it’s a mechanism problem.

The fix is the actual phonics, and it’s the first lesson again one level down. A slow word is not a uniformly slow word. You stretch the vowels and the sustainable consonants, and you hold the stops — you keep the /t/ and /k/ at roughly their natural, short length — because a burst is not a thing you can sustain. Try to stretch a /t/ and you don’t get a slow /t/, you get a broken one. The sounds that carry the most information are precisely the ones you’re least allowed to slow down.

Which is the whole project in a sentence. Every naive way to make speech slower — stretch the recording, drag the one slider — fails in the same place: on the crisp, unstretchable consonants that were the entire reason for slowing the word down. You can’t slow a word by slowing all of it. You take it apart and slow only the parts that will hold still.

The knob you actually need

Slow the vowels, hold the stops — simple to say. Whether you can actually do it depends entirely on what knob the model hands you, and that ended up deciding which model to use.

I tried Kokoro first — a small, genuinely impressive open model. In fairness to it, my test wasn’t fair: Kokoro is a whole-utterance model that exposes one speed control for the entire clip, and I got per-phoneme control by reaching in and monkey-patching its internals, which it was never built for. It sounded bad in a way that says more about my hack than about the model. What it taught me was the real requirement — the per-phoneme knob has to be something the model exposes, not something you bolt on from the outside.

Piper is what the browser version runs, and Piper surprised me: a small VITS model, entirely client-side in WebAssembly, and good enough that the whole thing needs no server at all. But its exported model exposes exactly one timing control — a single global length_scale scalar. You can make the whole word slower. You cannot make the vowel slower and keep the /t/ crisp. That’s the wall from the last section, sitting right in the API: one scalar can’t do class-aware timing, so the browser version has to fake it — synthesize the sounds separately and stitch them back together.

FastPitch is the other end of the trade. It predicts a duration for every phoneme and takes a per-phoneme pace as an input, so “hold this vowel, keep this stop short” is one clean pass — the knob is built in. That’s the high-fidelity path, and it turns the class-aware idea that’s a hack in the browser into something almost trivial. The cost is that it wants a real GPU, so it’s the studio version, not the one that runs on your phone.

The catch applies to all of them, and it’s the model-agnostic version of the second lesson: no model, FastPitch included, gracefully renders a two-second held vowel, because that “teaching register” is barely present in anyone’s training data — past a point the sound decays into a frozen haze. So the answer was never a single best model. It’s a tiny scalar model that’s shockingly good in a browser for everyday slowdowns, a controllable model for the high-fidelity cases, and the discipline to stop before the register runs out.

(You can try the browser version — Piper, re-rendering the word live as you drag the slider, entirely in-browser, no server in the loop.)

(The spectrogram-and-onset side of this — checking whether a transient actually survived — grew into a separate set of in-browser audio tools.)


Companion code: ramwise-examples/fastpitch-per-phoneme — the per-phoneme pace approach in a runnable Colab notebook.