Model size is the least predictive line on a local text-to-speech spec sheet, and the two numbers that decide whether a model belongs on your machine are wall time on the same input and the word error rate of a speech-to-text round trip. I swapped the TTS lane on my Mac mini on those two measurements on 2026-10-20, a 31.2M-parameter model took over from a 2.37B one, the smaller thing ran 2.6x faster on the wall clock, and prose came back at 0.0% WER from Whisper.
What the two lanes are
The old lane was a Kyutai Pocket TTS shim on :8310, torch runtime, ReLU-2.37B weights, 283 MB of cache. The challenger is gradium/phonon-31m-en, 31.2M parameters, 64.5 MB at q8, Rust runtime with no torch anywhere in the loop. I fed both shims the same 129-character line on the same box: the incumbent took 0.96s of wall time to produce 14.08s of audio, and its WAV came back compressed so Python's wave module rejected it with unknown format: 3 before I could count a single frame. Phonon took 0.37s to produce 6.64s of audio with 76% voiced frames, 2.6x faster on the wall clock, roughly 2x tighter output, from weights 76 times smaller.
The swap moved the local lane and left my calls alone. The old shim's only caller was the legacy chained loop, speech-to-text into a model into synthesis, while the live call lane runs a full-duplex cloud voice, so this was never a question of whether the calls got faster. It was a question of what answers the text box.
What the model is
Phonon S 31M is Apache-2.0, the 40M and multilingual builds sit behind CC BY-NC and gating, so the license split matters the moment the audio goes anywhere commercial. The ptts 0.4.0 wheel ships the xn engine and the Mimi decoder as one Python extension, the core is Rust, output is 24 kHz mono 16-bit. Same codec family the Pocket TTS lane was already built on, which is why the size difference lives in the weights and the runtime, the transport is shared.
The numbers on the box
All of it CPU-only on a 16 GB Mac mini at q8: RTF 0.027 to 0.036 steady, 4.4s of audio in 0.16s of wall time, streaming first chunk at 0.20s with 9 chunks covering those 4.4s, fresh-process load 0.17 to 0.19s warm against 4.5s cold, time to first chunk 8 to 23 ms, full CLI call 1.07 to 1.46s. I re-ran the battery today, 2026-10-22, RTF 0.034 and 13 ms to first chunk, same box, nothing tuned between runs.
The check that makes it trustworthy
Synthesized audio is only as good as the transcript that comes back out of it, so every sample went through whisper-large-v3-turbo and got scored. Plain prose: 0.0% WER on Easton, Miles and Preston, verbatim. A line of spoken numbers: 15%, and the misses were normalization, a spoken figure against digits. A line of shell: 50%, launchctl came out launchtel, gui/1000 came out gkg slash 1000.
That last line is the guard. A small model is trustworthy for the domain you tested, and code plus paths are the domain that fails, so the rule shipped with the lane is don't feed it code, or run --lang none when you have to. The WAVs themselves are honest, 68% to 87% voiced frames, max amplitude 14k to 18k, real speech in every one of them.
Where the smaller model loses
What doesn't carry over is cloned voices. The old shim's voices were derived from audio prompts through get_state_for_audio_prompt, Phonon 0.4.0 has no audio-prompt path, add_voice wants .safetensors from Gradium Studio, the HF checkpoints ship no speaker encoder, cloning needs scripts/clone-voice.py against a sample recording. Day-one capability is the 37 English voices in the catalog, 13 female, 12 male, 12 characters, out of 102 total. Speed wins there and parity doesn't come free, it waits on a re-clone from a sample recording.
Three traps ate the week
The streamed WAV from the vendor server claims a data chunk length of 24932 against a file that's larger, strict parsers reject it outright, mp3 or pcm survive, pcm being headerless 16-bit mono at 24 kHz. hermes config set refused the tts.providers.* dot-paths, suggesting providers.phonon.type, and --force wrote them correctly, verified with yaml.safe_load. The pinned streaming provider pointed at an endpoint that had died, curl returned 000 against it, and resolve_streaming_provider never swaps providers to get streaming, so the pin kept hammering the corpse on every call until I removed it, then the dispatcher speaks per-sentence through the sync path.
Before wiring any of that I ran the vendor's stock server, a docker run of ptts-openai-server, and /health returned {"status":"ok"}, /v1/models named gradium/phonon-31m-en, /v1/audio/voices listed the 37 English voices, and a POST to /v1/audio/speech with voice Easton returned 318,764 bytes in 0.67s of wall time at 0.0% WER. That server speaks the exact API the old shim served, so the shim's eventual shape is near zero code.
What shipped
tts.provider: phonon on all three of my configs, the worker a CLI honoring the command-provider contract, text arrives in a temp file, the output extension wins, nonzero exit plus stderr is the failure detail. Default voice Easton, male American, punchy with a crisp attack. Unknown voice returns the clean OpenAI error shape, unknown voice 'bender'; this server has Alfie, ..., so the alias table lives in one place.
Two gates carried the decision, 2.6x on the wall clock and 0.0% WER on prose, and both of them are free to measure before you commit a lane. What's your bar for swapping a local TTS model, and does anyone run the round-trip check before committing?














