Two small open text-to-speech engines that both run as plain ONNX: how each one works, runnable examples side by side, and a measured comparison of speed, memory and intelligibility.
Author
Benedict Thekkel
Short answer
Use Kokoro when you have a GPU or can spare a few CPU cores. Use Piper medium on one or two CPU cores, for a language Kokoro lacks, or when you must train your own voice.
Question
Piper medium (lessac)
Piper high (ljspeech)
Kokoro v1.0 (fp32)
Real-time factor, CPU 1 thread
0.054
0.433
0.476
Real-time factor, CPU 4 threads
0.032
0.269
0.267
Real-time factor, CUDA
0.028
0.031
0.029
First audio, CPU 1 thread
0.14 s
1.22 s
1.45 s
Round-trip WER, 20 sentences x 3 passes
3.07%
3.27%
1.23%
Disk
63 MB
114 MB
326 MB
Extra process RAM, CPU
about 200 MB
about 270 MB
500 to 600 MB
Parameters
15.7M
28.3M
81.2M
Voices
178 voices, one file each
same
54 voices in one model
Languages
53 language families
same
8
Train your own voice
Yes
Yes
No trainer released
Real-time factor (RTF) is synthesis time divided by audio length, so 0.1 means ten times faster than real time. Word error rate (WER) here means: synthesise a sentence, transcribe it with Whisper, and count the wrong words.
Three results are worth keeping:
The speed gap belongs to Piper medium, not to Piper. Piper high is as slow as Kokoro on CPU. On CUDA all three are about equal.
The text front end decides more of the hard cases than the voice model does. Section 9 shows this. It is also where the two engines are hardest to compare fairly.
Check the licence of each Piper voice. The engine licence and the voice dataset licence are separate. Section 5 has the details.
What this page does not measure: how natural the voices sound. The players below exist so you can judge that yourself. WER measures whether a machine can follow the words, not whether a person enjoys listening.
1. What are Piper and Kokoro?
Both map written text to a speech waveform. Both run locally with no API, and both are small enough for a CPU.
Input. A text string, a chosen voice, and a speed setting.
Output. Mono audio at a fixed rate: 22,050 Hz for the Piper voices used here, 24,000 Hz for Kokoro.
What a benchmark number hides. Most production failures in small TTS come from text, not from the model. A voice that reads $3.50 as “dollar three point five zero” sounds broken however good the waveform is. Streaming matters too: both engines produce a whole sentence at a time, so first-audio delay depends on the length of the first sentence. Neither engine streams inside a sentence in the path used here.
3. How They Work
Both engines share the same outline. They differ inside the last box, and in who makes the phonemes.
text
|
v
text normalisation $3.50 -> three dollars and fifty cents (often skipped, see section 9)
|
v
grapheme to phoneme espeak-ng (Piper) | misaki, with espeak-ng as fallback (Kokoro)
|
v
phoneme ids Piper: table in the voice's .onnx.json | Kokoro: tokenizer.json
|
v
ONNX model Piper: VITS | Kokoro: StyleTTS 2 decoder + ISTFTNet
|
v
waveform
A phoneme is one unit of speech sound. Grapheme to phoneme (G2P) converts spelling into those sounds. Neither model reads letters, so a wrong phoneme becomes a wrong word no matter how good the model is.
Piper: VITS (2021, paper). VITS trains an end-to-end model from phonemes to waveform. A variational autoencoder, a normalising flow and an adversarial loss replace the older two-stage acoustic model plus vocoder. It samples random noise during synthesis, so Piper’s output changes on every run. The inputs noise_scale and noise_w control that sampling, and length_scale controls speed. The project started in January 2023 at rhasspy/piper. That repository is now archived, and development moved to OHF-Voice/piper1-gpl, which also changed the licence from MIT to GPL-3.0. Its README says the project is looking for maintainers.
Kokoro: StyleTTS 2 decoder (v1.0, January 2025).StyleTTS 2 separates what is said from how it is said. A 256-number style vector carries the voice, and an ISTFTNet decoder makes the waveform. The model card says “decoder only: no diffusion, no encoder release”. That has three effects. Output is deterministic. A voice is a small array, so 54 voices share one model and you can blend them by adding arrays. But no encoder means there is no released way to make a new voice from recordings.
4. Evaluation Metrics
Real-time factor (RTF). Synthesis time divided by audio duration. Under 1.0 keeps up with playback. The margin is what lets one machine serve several streams.
Time to first audio. Time from text to the first playable sentence. This is the delay a person notices in a conversation. This page measures it warm and cold.
Round-trip WER. Transcribe the output with an ASR model and compute word error rate against the input. It is cheap and automatic. It has two blind spots. Whisper rewrites spoken numbers as digits, so “dollar three point five zero” and “three dollars and fifty cents” both come back as $3.50. It also says nothing about how natural the voice is.
Mean opinion score (MOS). People rate samples from 1 to 5. This is the real measure of naturalness, and this page does not have one. The TTS Arena collects crowd votes.
Footprint. Disk, RAM and VRAM. These often decide the choice on a small device.
import jiwernorm = jiwer.Compose([jiwer.ToLowerCase(), jiwer.RemovePunctuation(), jiwer.RemoveMultipleSpaces(), jiwer.Strip()])# A synthetic engine said this sentence, and an ASR model wrote down what it heardreference ="The birch canoe slid on the smooth planks."heard ="the birch canoe slid on the smooth planks"print("round-trip WER, perfect :", jiwer.wer(norm(reference), norm(heard)))print("round-trip WER, one miss:", round(jiwer.wer(norm(reference), norm("the birch canoe slid on the smooth banks")), 3))synthesis_seconds, audio_seconds =0.5, 5.0print("RTF:", synthesis_seconds / audio_seconds, "(under 1.0 is faster than real time)")
round-trip WER, perfect : 0.0
round-trip WER, one miss: 0.125
RTF: 0.1 (under 1.0 is faster than real time)
5. Datasets, and Why Voice Licences Differ
Each Piper voice is trained from its own dataset, so the licence of the voice’s data can be stricter than the MIT tag on the Hub repository. Read the MODEL_CARD file beside each voice.
The model card says permissive or non-copyrighted audio, and lists CC BY sources
Weights are Apache-2.0
Harvard sentences (IEEE, 1969)
Evaluation on this page
Phonetically balanced English sentences
Published test material
Not relevant, used for scoring only
This is an engineer’s reading of those pages, not legal advice.
The evaluation text is written inside the notebook, so no dataset download is needed. Only models land in DL_tasks/datasets/hf_cache (gitignored).
6. The Model Landscape
Parameter counts below come from summing the initializers in each ONNX file. Voice and language counts come from Piper’s voices.json and Kokoro’s VOICES.md.
Model
Params
ONNX size
Rate
Scope
Licence
Best for
Piper medium (lessac)
15.7M
63 MB
22,050 Hz
English voice
see section 5
CPU speed and size
Piper high (ljspeech)
28.3M
114 MB
22,050 Hz
English voice
Public domain data
A public domain voice, if a GPU or spare CPU is available
Kokoro fp32 (model.onnx)
81.2M
326 MB
24,000 Hz
54 voices, 8 languages
Apache-2.0
Best WER here, and CUDA
Kokoro q8 (model_quantized.onnx)
not counted
92 MB
24,000 Hz
same
Apache-2.0
Small download on CPU only
Piper’s low quality lessac voice has the same 15.7M parameters as medium. Piper has 178 voices in total: 124 medium, 26 low, 14 x_low and 14 high, across 53 language families and 58 locales.
How this ties back to section 2. Piper medium wins the CPU budget cases. Kokoro wins where a GPU exists, because the speed gap disappears there and the licence is simpler. For broad language coverage or a custom voice, Piper is the only option of the two.
7. Setup
Neither vendor package can run here, and neither is needed. This repo is on Python 3.14. kokoro and misaki 0.9.4 require Python below 3.13, and kokoro-onnx 0.6.1 requires below 3.14. So this page drives both models with ONNX Runtime directly, in about 40 lines each. It also shows how little there is inside either engine.
Package
Role
onnxruntime-gpu
Runs both models. Without a GPU it falls back to CPU
phonemizer
Python wrapper around espeak-ng, for G2P
espeakng-loader
Ships the espeak-ng library and data as a wheel, so no apt install or sudo
huggingface_hub
Downloads the model files
transformers, torch
Whisper, for round-trip WER only
onnxruntime-gpu installs without its [cuda] extra, because it can reuse the CUDA 13 libraries torch already brought. That costs 335 MB rather than 2.5 GB. Import torch first.
Rerunning this page. Run it in JupyterLab on a machine with these packages. The title cell carries skip_exec: true like every other page here, because the submodule’s CI installs no model dependencies. So nbdev-test does not run this page, and the stored outputs are what the site shows.
How this was checked against the official package. On a test sentence, piper-tts 1.8.0 and the Piper class below produced the same phonemes for all three voices, apart from the space between sentences. Audio cannot be compared byte for byte, because Piper is random.
import base64import ctypesimport ctypes.utilimport gcimport ioimport jsonimport reimport statisticsimport subprocessimport timefrom pathlib import Pathimport numpy as npimport pandas as pdimport psutilimport soundfile as sfimport torch # import before onnxruntime so its CUDA libraries are already on handimport onnxruntime as ortimport espeakng_loaderfrom dotenv import find_dotenv, load_dotenvfrom huggingface_hub import hf_hub_downloadfrom huggingface_hub.utils import disable_progress_barsfrom IPython.display import HTML, displayfrom phonemizer.backend import EspeakBackendfrom phonemizer.backend.espeak.wrapper import EspeakWrapper# Knowledge/.env sets HF_TOKEN - authenticated HF Hub requests get higher rate limitsload_dotenv(find_dotenv(usecwd=True))device ="cuda:0"if torch.cuda.is_available() else"cpu"ort.preload_dlls()ort.set_default_logger_severity(3)disable_progress_bars()CPU = ["CPUExecutionProvider"]CUDA = ["CUDAExecutionProvider", "CPUExecutionProvider"]has_cuda = device !="cpu"and"CUDAExecutionProvider"in ort.get_available_providers()PROVIDERS = CUDA if has_cuda else CPUprint("device:", device, "| onnxruntime", ort.__version__, "|", PROVIDERS[0])# All downloads go to DL_tasks/datasets/ (gitignored)DATA_DIR = Path("../../datasets")HF_CACHE = DATA_DIR /"hf_cache"HF_CACHE.mkdir(parents=True, exist_ok=True)# The pip-installed espeak-ng library and data, so no apt install or sudo is neededEspeakWrapper.set_library(espeakng_loader.get_library_path())EspeakWrapper.set_data_path(espeakng_loader.get_data_path())def gpu_mb():"Whole-GPU memory in MB. torch.cuda counters cannot see ONNX Runtime, so ask the driver."try: out = subprocess.run(["nvidia-smi", "--query-gpu=memory.used", "--format=csv,noheader,nounits"], capture_output=True, text=True, check=True).stdoutexcept (OSError, subprocess.CalledProcessError):return0returnint(out.split()[0])def rss_mb():return psutil.Process().memory_info().rss /1e6def vram(tag=""):print(f"{tag:24s} GPU {gpu_mb():6d} MB | process RSS {rss_mb():6.0f} MB")def free_memory():"GC, then hand freed VRAM and RAM back. Call right after `del session`." gc.collect()if torch.cuda.is_available(): torch.cuda.empty_cache() torch.cuda.ipc_collect()try: ctypes.CDLL(ctypes.util.find_library("c") or"libc.so.6").malloc_trim(0)exceptException: # non-glibc - nothing to trimpassdef session(path, threads=4, providers=PROVIDERS):"An ONNX Runtime session with thread counts set explicitly." opts = ort.SessionOptions()# Left unset, ORT pins threads to cores and logs an error per thread inside this LXC opts.intra_op_num_threads = threads opts.inter_op_num_threads =1return ort.InferenceSession(str(path), opts, providers=providers)def player(audio, sr):"An <audio> tag holding the clip as MP3, about a tenth of the size of the WAV." buf = io.BytesIO() sf.write(buf, np.clip(audio, -1, 1), sr, format="MP3") b64 = base64.b64encode(buf.getvalue()).decode()returnf'<audio controls preload="none" style="width:220px" src="data:audio/mpeg;base64,{b64}"></audio>'def player_table(columns, rows):"Show `rows` of (label, [(audio, sr), ...]) as a table of players under `columns`." head ="".join(f"<th>{c}</th>"for c in ["Text", *columns]) body ="".join(f"<tr><td>{label}</td>"+"".join(f"<td>{player(a, sr)}</td>"for a, sr in clips) +"</tr>"for label, clips in rows ) display(HTML(f"<table><thead><tr>{head}</tr></thead><tbody>{body}</tbody></table>"))# Split on sentence ends, but not after the abbreviations used on this pageSENTENCE_END = re.compile(r"(?<!Dr\.)(?<!St\.)(?<=[.!?])\s+(?=[A-Z])")def sentences(text):return SENTENCE_END.split(text.strip())def speak(engine, text, **kwargs):"Synthesise sentence by sentence, the way both engines stream, and join the audio."return np.concatenate([engine.synth(s, **kwargs) for s in sentences(text)])
A voice is two files: the .onnx model and a .onnx.json config holding the sample rate, the espeak voice, the inference defaults and the phoneme id table. Synthesis has three steps: phonemise with espeak-ng, map each phoneme to an id with a pad id after it, and run the model.
import unicodedataPIPER_REPO ="rhasspy/piper-voices"# espeak-ng resolves a bare "en" to British English, and phonemizer refuses the bare nameESPEAK_ALIASES = {"en": "en-gb"}class Piper:"A Piper voice on plain ONNX Runtime: espeak-ng phonemes -> ids -> VITS -> waveform."def__init__(self, stem, threads=4, providers=PROVIDERS):self.path = Path(hf_hub_download(PIPER_REPO, f"{stem}.onnx", cache_dir=HF_CACHE))self.config = json.loads(Path(hf_hub_download(PIPER_REPO, f"{stem}.onnx.json", cache_dir=HF_CACHE)).read_text())self.sr =self.config["audio"]["sample_rate"] voice =self.config["espeak"]["voice"]self.g2p = EspeakBackend(ESPEAK_ALIASES.get(voice, voice), preserve_punctuation=True, with_stress=True)self.session = session(self.path, threads, providers)def phonemes(self, text):returnself.g2p.phonemize([text], strip=True)[0]def ids(self, phonemes):"Start marker, then each phoneme followed by a pad id, then an end marker." table =self.config["phoneme_id_map"] out = [*table["^"], *table["_"]]for ch in unicodedata.normalize("NFD", phonemes):if ch in table: out += [*table[ch], *table["_"]]return out + table["$"]def synth(self, text, length_scale=None, noise_scale=None, noise_w=None): inf =self.config["inference"] scales = [noise_scale or inf["noise_scale"], length_scale or inf["length_scale"], noise_w or inf["noise_w"]] ids =self.ids(self.phonemes(text))returnself.session.run(None, {"input": np.array([ids], dtype=np.int64),"input_lengths": np.array([len(ids)], dtype=np.int64),"scales": np.array(scales, dtype=np.float32), })[0].squeeze()TEXT ="The birch canoe slid on the smooth planks."LESSAC ="en/en_US/lessac/medium/en_US-lessac-medium"t0 = time.perf_counter()piper = Piper(LESSAC)print(f"load: {time.perf_counter() - t0:.2f}s, {piper.path.stat().st_size /1e6:.0f} MB on disk, running on {piper.session.get_providers()[0]}")print("inputs:", [(i.name, i.type) for i in piper.session.get_inputs()])print("inference defaults:", piper.config["inference"])print("phonemes:", piper.phonemes(TEXT))print("first ids:", piper.ids(piper.phonemes(TEXT))[:16])t0 = time.perf_counter()audio = piper.synth(TEXT)seconds = time.perf_counter() - t0print(f"{len(audio) / piper.sr:.2f}s of audio at {piper.sr} Hz in {seconds:.3f}s")player_table(["Piper lessac medium"], [(TEXT, [(audio, piper.sr)])])del piperfree_memory()vram("after piper")
load: 1.61s, 63 MB on disk, running on CUDAExecutionProvider
inputs: [('input', 'tensor(int64)'), ('input_lengths', 'tensor(int64)'), ('scales', 'tensor(float)')]
inference defaults: {'noise_scale': 0.667, 'length_scale': 1, 'noise_w': 0.8}
phonemes: ðə bˈɜːtʃ kənˈuː slˈɪd ɔnðə smˈuːð plˈæŋks.
first ids: [1, 0, 41, 0, 59, 0, 3, 0, 15, 0, 120, 0, 62, 0, 122, 0]
2.25s of audio at 22050 Hz in 0.411s
Text
Piper lessac medium
The birch canoe slid on the smooth planks.
after piper GPU 120 MB | process RSS 1378 MB
Kokoro
Kokoro takes phoneme ids, a style vector and a speed. The voice file holds one style vector for each possible input length, so the phoneme count selects the row. Input is limited to 510 tokens, so long text must be split first.
The model expects misaki’s phoneme alphabet. This class runs espeak-ng and converts the output with the table from misaki’s own espeak fallback. That is the fallback route, not the main misaki route, and section 9 measures the difference.
KOKORO_REPO ="onnx-community/Kokoro-82M-v1.0-ONNX"# espeak-ng spells a diphthong as two tied symbols and Kokoro's vocabulary holds one# symbol for it. This is the US English table from misaki/espeak.py (Apache-2.0),# longest match first. \u escapes keep the source ASCII.E2M = [ ("\u0294\u02ccn\u0329", "\u0294n"), ("\u0294n\u0329", "\u0294n"), ("a^\u026a", "I"), ("a^\u028a", "W"), ("d^\u0292", "\u02a4"), ("e^\u026a", "A"), ("t^\u0283", "\u02a7"), ("\u0254^\u026a", "Y"), ("\u0259^l", "\u1d4al"), ("\u02b2o", "jo"), ("\u02b2\u0259", "j\u0259"), ("e", "A"), ("\u02b2", ""), ("\u025a", "\u0259\u0279"), ("r", "\u0279"), ("x", "k"), ("\u00e7", "k"), ("\u0250", "\u0259"), ("\u026c", "l"), ("\u0303", ""),]US_FIXES = [ ("o^\u028a", "O"), ("\u025c\u02d0\u0279", "\u025c\u0279"), ("\u025c\u02d0", "\u025c\u0279"), ("\u026a\u0259", "i\u0259"), ("\u02d0", ""), ("o", "\u0254"), ("\u027e", "T"), ("\u0294", "t"), ("^", ""),]class Kokoro:"Kokoro on plain ONNX Runtime: phonemes -> ids -> StyleTTS 2 decoder -> waveform at 24 kHz." sr =24000 max_tokens =510def__init__(self, filename="model.onnx", voice="af_heart", threads=4, providers=PROVIDERS):self.path = Path(hf_hub_download(KOKORO_REPO, f"onnx/{filename}", cache_dir=HF_CACHE)) tokenizer = json.loads(Path(hf_hub_download(KOKORO_REPO, "tokenizer.json", cache_dir=HF_CACHE)).read_text())self.vocab = tokenizer["model"]["vocab"]self.g2p = EspeakBackend("en-us", preserve_punctuation=True, with_stress=True, tie="^")self.session = session(self.path, threads, providers)self.voice =self.load_voice(voice)@staticmethoddef load_voice(name):"One 256-dim style vector per input length, 510 of them." path = hf_hub_download(KOKORO_REPO, f"voices/{name}.bin", cache_dir=HF_CACHE)return np.fromfile(path, dtype=np.float32).reshape(-1, 1, 256)def phonemes(self, text): out =self.g2p.phonemize([text], strip=True)[0]for old, new in E2M: out = out.replace(old, new) out = re.sub(r"(\S)\u0329", "\u1d4a\\1", out)for old, new in US_FIXES: out = out.replace(old, new)return outdef from_phonemes(self, phonemes, speed=1.0, voice=None): ids = [self.vocab[ch] for ch in phonemes if ch inself.vocab]iflen(ids) >self.max_tokens:raiseValueError(f"{len(ids)} tokens is over the {self.max_tokens} limit, so split the text first") style = (self.voice if voice isNoneelse voice)[len(ids)]returnself.session.run(None, {"input_ids": np.array([[0, *ids, 0]], dtype=np.int64),"style": style,"speed": np.array([speed], dtype=np.float32), })[0].squeeze()def synth(self, text, speed=1.0, voice=None):returnself.from_phonemes(self.phonemes(text), speed, voice)t0 = time.perf_counter()kokoro = Kokoro()print(f"load: {time.perf_counter() - t0:.2f}s, {kokoro.path.stat().st_size /1e6:.0f} MB on disk, running on {kokoro.session.get_providers()[0]}")print("inputs:", [(i.name, i.type) for i in kokoro.session.get_inputs()])print("voice shape:", kokoro.voice.shape)print("phonemes:", kokoro.phonemes(TEXT))t0 = time.perf_counter()audio = kokoro.synth(TEXT)seconds = time.perf_counter() - t0print(f"{len(audio) / kokoro.sr:.2f}s of audio at {kokoro.sr} Hz in {seconds:.3f}s")player_table(["Kokoro af_heart"], [(TEXT, [(audio, kokoro.sr)])])del kokorofree_memory()vram("after kokoro")
load: 1.51s, 326 MB on disk, running on CUDAExecutionProvider
inputs: [('input_ids', 'tensor(int64)'), ('style', 'tensor(float)'), ('speed', 'tensor(float)')]
voice shape: (510, 1, 256)
phonemes: ðə bˈɜɹʧ kənˈu slˈɪd ɔnðə smˈuð plˈæŋks.
2.75s of audio at 24000 Hz in 0.115s
Text
Kokoro af_heart
The birch canoe slid on the smooth planks.
after kokoro GPU 122 MB | process RSS 1506 MB
9. Examples
Everyday sentences
The same text through both engines. Listen for rhythm, and for how the question ends. Whisper and the metrics cannot judge this part.
PLAIN = ["Your meeting with the design team has moved to the afternoon, so you have about an hour free before lunch.","Are you coming to the meeting, or should we start without you?","Turn left at the next junction, then keep right and follow the signs to the airport.","The old lighthouse stood alone at the edge of the cliff, and nobody had climbed its stairs for years.",]piper, kokoro = Piper(LESSAC), Kokoro()player_table(["Piper lessac medium", "Kokoro af_heart"], [(t, [(speak(piper, t), piper.sr), (speak(kokoro, t), kokoro.sr)]) for t in PLAIN])
Text
Piper lessac medium
Kokoro af_heart
Your meeting with the design team has moved to the afternoon, so you have about an hour free before lunch.
Are you coming to the meeting, or should we start without you?
Turn left at the next junction, then keep right and follow the signs to the airport.
The old lighthouse stood alone at the edge of the cliff, and nobody had climbed its stairs for years.
Hard text: numbers, abbreviations, homographs and product names
This is the most useful comparison on the page. Each row plays three versions: Piper, Kokoro with espeak-ng phonemes, and Kokoro with the phonemes misaki 0.9.4 produced on Python 3.12, stored in the cell because misaki cannot install here. Kokoro through its official kokoro package uses misaki, so the third column is the fair one for Kokoro. The second column is what kokoro-onnx-style setups get when they use espeak.
from num2words import num2wordsfrom transformers import logging as transformers_loggingfrom transformers import pipelinetransformers_logging.set_verbosity_error()transformers_logging.disable_progress_bar()# Kokoro's own text front end is misaki, which pins Python below 3.13 and so cannot be# installed in this 3.14 venv. These strings are what misaki 0.9.4 printed on Python 3.12# for the sentences in HARD, kept so this page can play its output without importing it.HARD = {'money': 'The invoice for $3.50 is due on March 3rd, 2024, at 10:30 am.', 'abbrev': 'Dr. Smith lives at 12 St. James St. near the NASA office.', 'homograph': 'I read the book yesterday, and I will lead the team that reads the lead report.', 'tech': 'Kubernetes pods talk to PostgreSQL through PgBouncer on Ubuntu.', 'question': 'Are you coming to the meeting, or should we start without you?'}MISAKI = {'money': '\xf0i \u02c8\u026anv\u02ccYs f\u0254\u0279\u03b8\u0279\u02c8i d\u02c8\u0251l\u0259\u0279z \xe6nd f\u02c8\u026afti s\u02c8\u025bnts \u026az d\u02c8u \u02cc\u0254n m\u02c8\u0251\u0279\u02a7\u03b8\u02c8\u025c\u0279d, tw\u02c8\u025bnti tw\u02c8\u025bnti f\u02c8\u0254\u0279, \xe6t t\u02c8\u025bn:\u03b8\u02c8\u025c\u0279Ti \u02ccA\u02c8\u025bm.', 'abbrev': 'd\u02c8\u0251kt\u0259\u0279 sm\u02c8\u026a\u03b8 l\u02c8\u026avz \xe6t tw\u02c8\u025blv s\u0259nt. \u02a4\u02c8Amz s\u0259nt. n\u02cc\u026a\u0279\xf0\u0259 n\u02c8\xe6s\u0259\u02c8\u0254f\u0259s.', 'homograph': '\u02ccI \u0279\u02c8\u025bd \xf0\u0259 b\u02c8\u028ak j\u02c8\u025bst\u0259\u0279d\u02ccA, \xe6nd \u02ccI w\u026al l\u02c8id \xf0\u0259 t\u02c8im \xf0\xe6t \u0279\u02c8idz \xf0\u0259 l\u02c8id \u0279\u0259p\u02c8\u0254\u0279t.', 'tech': 'k\u02ccub\u0259\u0279n\u02c8\u025bTiz p\u02c8\u0251dz t\u02c8\u0254k t\u0259 p\u02c8Ost\u0261\u0259\u0279\u0279\u02cc\u025bskj\u02ccu\u02c8\u025bl \u03b8\u0279u p\u02cci\u02a4\u02c8i b\u02c8Wns\u0259\u0279\u02cc\u0254n ub\u02c8untu.', 'question': '\u02cc\u0251\u0279 ju k\u02c8\u028cm\u026a\u014b t\u0259\xf0\u0259 m\u02c8iT\u026a\u014b, \u0254\u0279\u0283\u02cc\u028ad wi st\u02c8\u0251\u0279t w\u026a\xf0\u02c8Wt ju?'}def spell_money(text):"A one-rule text normaliser: $3.50 -> three dollars and fifty cents."return re.sub(r"\$(\d+)\.(\d\d)",lambda m: f"{num2words(int(m[1]))} dollars and {num2words(int(m[2]))} cents", text)def load_asr():return pipeline("automatic-speech-recognition", model="openai/whisper-small", device=device, dtype=torch.float16 if device !="cpu"else torch.float32, model_kwargs={"cache_dir": HF_CACHE})def transcribe(asr, audio, sr):import librosa # whisper wants 16 kHz clip = librosa.resample(np.asarray(audio, dtype=np.float32), orig_sr=sr, target_sr=16000)return asr({"array": clip, "sampling_rate": 16000}, generate_kwargs={"language": "en", "task": "transcribe"})["text"].strip()rows, heard = [], []for key, text in HARD.items(): clips = {"Piper": (speak(piper, text), piper.sr),"Kokoro, espeak": (speak(kokoro, text), kokoro.sr),"Kokoro, misaki": (kokoro.from_phonemes(MISAKI[key]), kokoro.sr), } rows.append((text, list(clips.values()))) heard.append((key, clips))print(f"{key}\n espeak: {piper.phonemes(text)}\n misaki: {MISAKI[key]}")player_table(["Piper", "Kokoro, espeak", "Kokoro, misaki"], rows)asr = load_asr()print("\nWhat Whisper heard:")for key, clips in heard:print(key)for name, (clip, sr) in clips.items():print(f" {name:16s}: {transcribe(asr, clip, sr)}")del asr, rows, heard, clips, clipfree_memory()
The invoice for $3.50 is due on March 3rd, 2024, at 10:30 am.
Dr. Smith lives at 12 St. James St. near the NASA office.
I read the book yesterday, and I will lead the team that reads the lead report.
Kubernetes pods talk to PostgreSQL through PgBouncer on Ubuntu.
Are you coming to the meeting, or should we start without you?
What Whisper heard:
money
Piper : The invoice for $3.50 is due on March 3, 2024, at 10.30 am.
Kokoro, espeak : The invoice for $3.50 is due on March 3, 2024 at 10.30 a.m.
Kokoro, misaki : The invoice for $3.50 is due on March 3, 2024, at 10.30 a.m.
abbrev
Piper : Dr. Smith lives at Twelfth St. James St. near the Nass Office.
Kokoro, espeak : Dr. Smith lives at 12-cent James-cent, near the NASA office.
Kokoro, misaki : Dr. Smith lives at 12-cent James-cent, near the NASA office.
homograph
Piper : I read the book yesterday, and I will lead the team that reads the lead report.
Kokoro, espeak : Read the book yesterday, and we'll lead the team that reads the lead report.
Kokoro, misaki : I read the book yesterday, and I will lead the team that reads the lead report.
tech
Piper : Kubernetes pods talk to PostgreSQL through PG Bouncer on Ubuntu.
Kokoro, espeak : Kubernetes pods talk to Postgre SQL through PG Bouncer on Ubuntu.
Kokoro, misaki : Kubernetes pods talk to Postgre SQL through PG Bouncer on Ubuntu.
question
Piper : Are you coming to the meeting, or should we start without you?
Kokoro, espeak : Are you coming to the meeting, or should we start without you?
Kokoro, misaki : Are you coming to the meeting, or should we start without you?
What the phonemes above show. Whisper hides several of these differences, so read the phonemes as well as the transcripts.
Case
espeak-ng (Piper, and Kokoro’s fallback)
misaki (Kokoro’s official path)
$3.50
“dollar three point five zero”: wrong
“three dollars and fifty cents”: right
2024
“two thousand twenty four”
“twenty twenty four”: better for a year
lives (verb)
Rhymes with “knives”: wrong
Rhymes with “gives”: right
read (past tense)
“reed”: wrong
“red”: right
lead the team, lead report
“leed”, both correct
“leed”, both correct
St. James St.
“Saint James Saint”: second one wrong
Same mistake
PostgreSQL, PgBouncer
Spelled badly
Same mistake
So misaki is ahead on ordinary English, and both fail on domain words. Whisper also dropped the opening “I” in the “Kokoro, espeak” clip of the homograph sentence, but not in the misaki clip. That is one clip, so treat it as a lead and not a finding.
Piper has no better G2P to switch to, but its error list can be cut with a normaliser. The next cell shows one rule fixing the money case. A real product needs a proper normaliser plus a pronunciation table for its own words.
Speed and voice controls
Piper changes speed through length_scale, where larger is slower. Kokoro uses speed, where larger is faster.
Voices are where the designs differ most. A Kokoro voice is an array, so blending two is a line of arithmetic and costs nothing. A Piper voice is a whole model file, so each one costs a download and a session. The third table plays the three Piper voices. Remember that ryan is non-commercial.
# The one-rule normaliser fixes the money reading for every espeak pathprint("before:", piper.phonemes(HARD["money"]))print("after :", piper.phonemes(spell_money(HARD["money"])))player_table(["Piper, money spelled", "Kokoro, money spelled"], [(spell_money(HARD["money"]), [(speak(piper, spell_money(HARD["money"])), piper.sr), (speak(kokoro, spell_money(HARD["money"])), kokoro.sr)])])SPEECH ="Speech rate and voice are the two controls most products expose first."slow, fast = speak(piper, SPEECH, length_scale=1.25), speak(piper, SPEECH, length_scale=0.8)kslow, kfast = speak(kokoro, SPEECH, speed=0.8), speak(kokoro, SPEECH, speed=1.25)player_table(["Piper slow", "Piper fast", "Kokoro slow", "Kokoro fast"], [(SPEECH, [(slow, piper.sr), (fast, piper.sr), (kslow, kokoro.sr), (kfast, kokoro.sr)])])# Kokoro: voices are arrays, so a new voice is one line of arithmeticbella, michael = Kokoro.load_voice("af_bella"), Kokoro.load_voice("am_michael")blend =0.5* bella +0.5* michaelplayer_table(["af_heart", "af_bella", "am_michael", "50/50 bella + michael"], [(SPEECH, [(speak(kokoro, SPEECH), kokoro.sr), (speak(kokoro, SPEECH, voice=bella), kokoro.sr), (speak(kokoro, SPEECH, voice=michael), kokoro.sr), (speak(kokoro, SPEECH, voice=blend), kokoro.sr)])])del piper, kokoro, slow, fast, kslow, kfast, bella, michael, blendfree_memory()# Piper: a voice is a whole model file, so each one costs a download and a sessionPIPER_VOICES = {"lessac medium": LESSAC,"ljspeech high": "en/en_US/ljspeech/high/en_US-ljspeech-high","ryan high": "en/en_US/ryan/high/en_US-ryan-high"}clips = []for name, stem in PIPER_VOICES.items(): voice = Piper(stem) clips.append((speak(voice, SPEECH), voice.sr))del voice free_memory()player_table(list(PIPER_VOICES), [(SPEECH, clips)])del clipsvram("after voices")
The invoice for three dollars and fifty cents is due on March 3rd, 2024, at 10:30 am.
Text
Piper slow
Piper fast
Kokoro slow
Kokoro fast
Speech rate and voice are the two controls most products expose first.
Text
af_heart
af_bella
am_michael
50/50 bella + michael
Speech rate and voice are the two controls most products expose first.
Text
lessac medium
ljspeech high
ryan high
Speech rate and voice are the two controls most products expose first.
after voices GPU 182 MB | process RSS 2529 MB
10. Head-to-Head Benchmark
Method. Each configuration gets the same five-sentence paragraph, synthesised sentence by sentence as a streaming system would. One model is live at a time. RTF and first-audio time are the median of three runs after a warm-up call. cold_first_s is the warm-up call itself.
Hardware. Proxmox LXC with 4 vCPU and an RTX 3060 12 GB, ONNX Runtime 1.31.1, measured on 2026-11-04. The box was running other work, so read differences under about 20% as noise. This is a smoke test, not a leaderboard. One thread on this x86 CPU is not a Raspberry Pi, and no Pi was measured.
PARAGRAPH = ("The storm reached the coast shortly after midnight. Power lines came down across the valley, ""and most of the town woke up without electricity. Crews worked through the morning to clear ""the roads and repair the damage. By early afternoon, the first homes had their lights back on. ""Officials asked people to stay indoors until the wind dropped.")ENGINES = {"Piper medium (lessac)": lambda t, p: Piper(LESSAC, t, p),"Piper high (ljspeech)": lambda t, p: Piper(PIPER_VOICES["ljspeech high"], t, p),"Kokoro fp32": lambda t, p: Kokoro("model.onnx", "af_heart", t, p),"Kokoro q8": lambda t, p: Kokoro("model_quantized.onnx", "af_heart", t, p),}RUNS = {"CPU 1 thread": (1, CPU), "CPU 4 threads": (4, CPU)}if has_cuda: RUNS["CUDA"] = (4, CUDA)parts = sentences(PARAGRAPH)results = []for engine_name, build in ENGINES.items():for run_name, (threads, providers) in RUNS.items(): free_memory() rss0, gpu0 = rss_mb(), gpu_mb() t0 = time.perf_counter() engine = build(threads, providers) load_s = time.perf_counter() - t0 t0 = time.perf_counter() engine.synth(parts[0]) cold_s = time.perf_counter() - t0 first, total = [], []for _ inrange(3): t0 = time.perf_counter() clip = engine.synth(parts[0]) first.append(time.perf_counter() - t0)for part in parts[1:]: clip = np.concatenate([clip, engine.synth(part)]) total.append(time.perf_counter() - t0) audio_s =len(clip) / engine.sr results.append({"engine": engine_name, "run": run_name,"rtf": round(statistics.median(total) / audio_s, 3),"first_audio_s": round(statistics.median(first), 3),"cold_first_s": round(cold_s, 3),"load_s": round(load_s, 2),"audio_s": round(audio_s, 1),"rss_mb": round(rss_mb() - rss0),"gpu_mb": gpu_mb() - gpu0,"disk_mb": round(engine.path.stat().st_size /1e6), })del engine, clip free_memory()bench = pd.DataFrame(results)display(bench)
engine
run
rtf
first_audio_s
cold_first_s
load_s
audio_s
rss_mb
gpu_mb
disk_mb
0
Piper medium (lessac)
CPU 1 thread
0.054
0.144
0.157
1.39
18.0
220
0
63
1
Piper medium (lessac)
CPU 4 threads
0.032
0.079
0.163
1.43
18.0
200
0
63
2
Piper medium (lessac)
CUDA
0.028
0.097
0.121
1.48
17.6
170
526
63
3
Piper high (ljspeech)
CPU 1 thread
0.433
1.218
1.147
1.63
19.7
266
0
114
4
Piper high (ljspeech)
CPU 4 threads
0.269
0.742
0.837
1.88
19.7
274
0
114
5
Piper high (ljspeech)
CUDA
0.031
0.131
0.156
1.58
20.1
247
526
114
6
Kokoro fp32
CPU 1 thread
0.476
1.449
1.547
1.89
21.4
503
0
326
7
Kokoro fp32
CPU 4 threads
0.267
0.805
0.817
1.62
21.4
596
0
326
8
Kokoro fp32
CUDA
0.029
0.104
0.122
1.36
21.4
380
1040
326
9
Kokoro q8
CPU 1 thread
0.359
1.060
1.054
1.29
21.3
356
0
92
10
Kokoro q8
CPU 4 threads
0.234
0.707
0.749
1.40
21.3
357
0
92
11
Kokoro q8
CUDA
0.176
0.544
0.590
1.43
21.4
250
274
92
from pyecharts import options as optsfrom pyecharts.charts import Bardef bar_chart(title, frame, value, y_name):"One bar series per engine, grouped by run." chart = Bar().add_xaxis(list(RUNS))for name, group in frame.groupby("engine", sort=False): chart.add_yaxis(name, [float(v) for v in group[value]], label_opts=opts.LabelOpts(position="top", font_size=10))return chart.set_global_opts( title_opts=opts.TitleOpts(title=title), legend_opts=opts.LegendOpts(pos_top="8%"), yaxis_opts=opts.AxisOpts(name=y_name), tooltip_opts=opts.TooltipOpts(trigger="axis"), )bar_chart("Real-time factor (lower is faster)", bench, "rtf", "RTF").render_notebook()
bar_chart("Time to first audio, warm (lower is better)", bench, "first_audio_s", "seconds").render_notebook()
Reading the speed results.
On CPU, Piper medium is about 8 times faster than Kokoro fp32 at 4 threads (0.032 vs 0.267), and its first audio arrives 10 times sooner at 1 thread (0.14 s vs 1.45 s).
Piper high costs almost as much as Kokoro on CPU (0.269 vs 0.267), so choosing it gives up Piper’s main advantage. Its parameter count is under twice medium’s, yet it runs about 8 times slower.
On CUDA the gap closes. All three fp32 models sit near RTF 0.03, about 30 times real time.
Do not put Kokoro q8 on CUDA. It runs at 0.176, about 6 times slower than fp32 on the same GPU. The likely cause is that its quantised operations fall back to CPU. That cause was not checked.
Kokoro q8 is a size choice on CPU. It saves 234 MB of disk and about 200 MB of RAM. Its speed gain over fp32 at 4 threads (0.234 vs 0.267) is within noise.
VRAM. The change in GPU memory was 526 MB for Piper and 1,040 MB for Kokoro fp32. Both include ONNX Runtime’s memory arena, so they are not the size of the weights.
Round-trip WER
Twenty Harvard sentences go through each engine three times, because Piper is random. Whisper small transcribes them with the same normalisation. Numbers and words with two common spellings were avoided, because they count as errors for every engine.
HARVARD = ["The birch canoe slid on the smooth planks.", "Glue the sheet to the dark blue background.","It's easy to tell the depth of a well.", "These days a chicken leg is a rare dish.","Rice is often served in round bowls.", "The juice of lemons makes fine punch.","The box was thrown beside the parked truck.", "The hogs were fed chopped corn and garbage.","Four hours of steady work faced us.", "A large size in stockings is hard to sell.","The boy was there when the sun rose.", "A rod is used to catch pink salmon.","The source of the huge river is the clear spring.", "Kick the ball straight and follow through.","Help the woman get back to her feet.", "A pot of tea helps to pass the evening.","The fish twisted and turned on the bent hook.", "The soft cushion broke the man's fall.","The salt breeze came across from the sea.", "The small pup gnawed a hole in the sock.",]refs = [norm(t) for t in HARVARD]PASSES =3# Piper samples noise, so its output and its score change from run to runasr = load_asr()wers, misses = {}, {}for name, build in {"Piper medium (lessac)": ENGINES["Piper medium (lessac)"],"Piper high (ljspeech)": ENGINES["Piper high (ljspeech)"],"Kokoro fp32 (af_heart)": ENGINES["Kokoro fp32"]}.items(): engine = build(4, PROVIDERS) hyps = [norm(transcribe(asr, engine.synth(t), engine.sr)) for _ inrange(PASSES) for t in HARVARD] wers[name] = jiwer.wer(refs * PASSES, hyps) misses[name] =sorted({(r, h) for r, h inzip(refs * PASSES, hyps) if r != h})del engine free_memory()del asrfree_memory()vram("after benchmark")for name, value in wers.items():print(f"{name:24s} WER {value:6.2%} ({len(misses[name])} distinct misheard sentences over {PASSES} passes)")for ref, hyp in misses[name]:print(f" said : {ref}\n heard: {hyp}")( Bar() .add_xaxis(list(wers)) .add_yaxis("round-trip WER %", [round(v *100, 2) for v in wers.values()]) .set_global_opts(title_opts=opts.TitleOpts(title="Round-trip WER, 20 Harvard sentences (lower is better)"), yaxis_opts=opts.AxisOpts(name="WER %")) .render_notebook())
after benchmark GPU 182 MB | process RSS 2701 MB
Piper medium (lessac) WER 3.07% (7 distinct misheard sentences over 3 passes)
said : glue the sheet to the dark blue background
heard: blue the sheet to the dark blue background
said : its easy to tell the depth of a well
heard: its easy to tell the depth of a will
said : rice is often served in round bowls
heard: rices often served in round bowls
said : the birch canoe slid on the smooth planks
heard: the birch can use slid on the smooth planks
said : the box was thrown beside the parked truck
heard: the box was thrown beside the park truck
said : the small pup gnawed a hole in the sock
heard: the small pup not a hole in the sock
said : the source of the huge river is the clear spring
heard: the source of a huge river is the clear spring
Piper high (ljspeech) WER 3.27% (10 distinct misheard sentences over 3 passes)
said : four hours of steady work faced us
heard: four hours of steady work face stars
said : glue the sheet to the dark blue background
heard: the sheet to the dark blue background
said : the fish twisted and turned on the bent hook
heard: a fish twisted and turned on the bent hook
said : the hogs were fed chopped corn and garbage
heard: the hubs were fed chopped corn and garbage
said : the hogs were fed chopped corn and garbage
heard: the hulls were fed chopped corn and garbage
said : the hogs were fed chopped corn and garbage
heard: the hums were fed chopped corn and garbage
said : the juice of lemons makes fine punch
heard: juice of lemons makes fine punch
said : the small pup gnawed a hole in the sock
heard: a small pup nord a hole in the sock
said : the small pup gnawed a hole in the sock
heard: the small pup nord a hole in the sock
said : the small pup gnawed a hole in the sock
heard: the small pup nortahole in the sock
Kokoro fp32 (af_heart) WER 1.23% (2 distinct misheard sentences over 3 passes)
said : the box was thrown beside the parked truck
heard: the box was thrown beside the park truck
said : the small pup gnawed a hole in the sock
heard: the small pup not a hole in the sock
Reading the WER result. Kokoro scores 1.23%, against 3.07% and 3.27% for Piper. Kokoro is deterministic, so its three passes are one sample repeated: two misheard sentences out of twenty. Both of its misses also appear for Piper medium, which suggests the sentences are hard for Whisper. The Piper errors change between passes, which fits its random sampling.
The direction is plausible, but twenty sentences and one voice per engine cannot prove a ranking. Kokoro ran here on the espeak route, so this result does not include any benefit from misaki.
11. Common Frameworks
These are the packages you meet when you use either engine in production. The licence column matters here, because the weights are not the whole stack.
A real-time voice agent pipeline, with service modules for both Piper and Kokoro
BSD-2-Clause
The TTS is one stage in a conversation
Default stack. For a server with a GPU, use Kokoro through its official package on a Python 3.12 environment, behind Kokoro-FastAPI or Pipecat. For a CPU-only device, use piper-tts with a medium voice whose data licence you have read. For an app on mobile or embedded targets, use sherpa-onnx.
The most common wrong turn is to choose on voice demos and ship with no text normaliser. The demo text has no prices, dates or product names, and your users’ text does.
12. Going Further
Gotchas found while building this page:
Symptom
Cause
Fix
ONNX Runtime logs pthread_setaffinity_np failed per thread
It pins threads to cores, and this LXC refuses
Set intra_op_num_threads and inter_op_num_threads
language "en" is not supported by the espeak backend
The en_US-ljspeech-high config says en
Map en to en-gb, which matches official Piper
An en_US voice speaks with British vowels
Same cause: espeak treats en as British
Prefer a voice whose config says en-us, or override it and listen
Segfault on load or first run
model_q8f16.onnx on ONNX Runtime 1.31.1 here
Use model.onnx or model_quantized.onnx
torch.cuda.memory_allocated() reads 0 during synthesis
PyTorch cannot see ONNX Runtime’s allocations
Ask nvidia-smi, as gpu_mb() does
empty_cache() does not free the VRAM
The memory belongs to the ONNX Runtime session
del the session
Kokoro raises on long text
The 510-token limit
Split into sentences first
Fine-tuning and new voices. Piper’s training guide covers training with PyTorch Lightning. Kokoro has no released trainer, so voice blending is the supported way to get something new.
Pronunciation control. Both classes on this page accept phonemes you write yourself, which is the simplest route to fixing a product name. Kokoro.from_phonemes takes them directly, and Piper.ids does the same for Piper. The kokoro package also accepts inline markup such as [Kokoro](/phonemes/).
Not covered here:
A listening test or MOS. The players are here, but no scores were collected.
A measured run on a Raspberry Pi 5. The 1-thread column is not a stand-in for one.
Any language other than English, on either engine.
Kokoro through the official kokoro PyTorch package. This page only reuses misaki’s phonemes, with the ONNX export.