Piper is a fast, fully local neural text-to-speech system from the Rhasspy project, the same community behind popular offline voice tooling for home automation. You hand it a sentence on standard input, it hands you a WAV file a fraction of a second later, and nothing ever leaves the machine. Voices are built on the VITS architecture, exported to ONNX, and shipped as a single .onnx file plus a small JSON sidecar. That combination is why Piper feels at home on a Raspberry Pi 4: no cloud call, no account, no GPU, and still real-time or better speech for dozens of languages.
Why read the C++ source of a TTS engine? Because Piper is one of the smallest end-to-end neural inference engines you will ever get to read in one sitting. The whole engine lives in a single piper.cpp of roughly 630 lines and a piper.hpp of about 130, plus a vendored JSON header and a 40-line WAV writer. It shows, in miniature, the same anatomy as the much larger runtimes we have toured on this blog: an ONNX Runtime session wrapped in a thin native layer, tensors assembled by hand, and fixed-point audio squeezed out at the end. If llama.cpp is the reference example of a lean native LLM runner, Piper is the equivalent for speech.
This post walks the repository through two generated architecture diagrams. Every box in the diagrams points at a real file on the master branch, and every line anchor quoted below was verified against the actual tree, so the links should survive as more than decoration.
The five neighborhoods of Piper: a CLI shell, a compact C++ core, a WAV writer, a Python runtime package, and the training side that exports voices.
Reading the overview from left to right:
The overview is best read as the life of one request, from your terminal to a speaker. Each bullet below names the exact function that carries the torch, with a verified line anchor:
main, L100 is the CLI shell. It reads lines from stdin forever, one utterance per line, and prints the path of every WAV it writes.initialize, L216 boots espeak-ng once per process so the engine can convert raw text into IPA phonemes, and optionally loads the libtashkeel model used to diacritize Arabic.loadVoice, L309 parses the JSON sidecar that ships next to every.onnxvoice, filling the config structs declared inpiper.hpp, L28.loadModel, L262 creates theOrt::Sessionat L301 with deliberately austere session options tuned for fast startup rather than peak throughput.synthesize, L337 packs phoneme ids, their lengths, and three scale floats into input tensors, then invokes the model once per phrase.textToAudio, L446 orchestrates the speech flow: sentence splitting, phonemization viaphonemize_eSpeak, L474, id mapping throughphonemes_to_ids, L555, and silence insertion.textToWavFile, L619 buffers the int16 samples and writes them behind a RIFF header fromwriteWavHeader, L27.PiperVoice.load, L25 is the Python twin, a dataclass that wrapsonnxruntime.InferenceSessionforpip install piper-ttsusers.export_onnx.main, L16 sits upstream of everything: it turns a PyTorch training checkpoint into the very.onnxvoice the C++ side consumes.
From Install to Your First Model
The fastest route is a release tarball. Piper ships prebuilt binaries for Windows, macOS, Linux on x86_64 and armhf, and each archive carries an espeak-ng-data directory next to the executable, which is exactly where main.cpp looks for it, L156. Download a voice pair from the HuggingFace model hub, for example en_US-amy-medium.onnx and its .onnx.json sidecar, then:
echo 'Welcome to local text to speech.' | piper -m en_US-amy-medium.onnx -f welcome.wav
Behind that one-liner, three things happen in order. First, parseArgs in main.cpp, L457 validates that both the model and its sidecar exist, defaulting the config path to the model path plus .json. Second, loadVoice reads the sidecar with the vendored json.hpp parser and extracts the phoneme_id_map, the speaker count, the sample rate, and three synthesis knobs. Third, espeak-ng is initialized and the ONNX model is memory-mapped into a session. The stdin loop then synthesizes your line and reports a real-time factor, the ratio of inference seconds to audio seconds, at main.cpp, L355. On a Raspberry Pi 4 that factor hovers around 1 to 2 for medium voices, which is why the project markets itself as real-time on the Pi.
The defaults are worth a glance because they are the knobs you will tweak first. SynthesisConfig, L52 fixes noiseScale at 0.667, lengthScale at 1.0, and noiseW at 0.8, with a 22050 Hz mono 16-bit output shape. lengthScale above 1.0 slows the delivery down, and --sentence_silence pads pauses between sentences. Multi-speaker voices resolve --speaker names through a speaker_id_map, and the --json-input mode lets a caller pass text, speaker, and output path as one JSON object per line, which is how Home Assistant and similar integrations drive the binary.
Python developers get the same engine through the wheel. The src/python_run package exposes exactly one symbol, PiperVoice, re-exported from __init__.py. PiperVoice.load builds an onnxruntime.InferenceSession with the CPU provider by default and a CUDA provider when asked, and synthesize_stream_raw yields raw PCM bytes sentence by sentence, so you can wrap it in wave or push it straight to an audio device. A companion downloader in download.py fetches voices and verifies their hashes, and an optional HTTP server wraps the whole thing for networked setups.
Before committing to a voice, you can measure rather than guess. The src/benchmark folder contains small scripts, including benchmark_onnx.py and benchmark_torchscript.py, that replay the same test sentences against an exported model and report inference timing per utterance. They use the identical four-name tensor contract as the shipped runtimes, so their numbers translate directly to what the C++ binary will experience on your hardware. For a median voice and a mid-range laptop CPU, expect a real-time factor comfortably above 1, and on the Pi treat medium-quality voices as the sweet spot between size and clarity.
Inside the Detail Diagram
The detail diagram zooms into the two bodies of code that matter: the C++ engine on one side, and the Python package pair on the other.
The synthesis chain in close-up: config structs feed a session-bearing ModelSession, synthesize builds tensors for one Run call, and the Python runtime mirrors the same tensor contract.
Follow the C++ column downward. initialize calls espeak_Initialize, L221 in synchronous mode, meaning espeak never writes audio, it only answers phoneme queries. loadVoice splits the sidecar parse across three functions, parsePhonemizeConfig, L47, parseSynthesisConfig, L135, and parseModelConfig, L197, each tolerant of missing keys. loadModel then disables graph optimizations, the CPU memory arena, memory patterns, and profiling, a tradeoff documented in comments at L275 as roughly halving model load time with no visible inference benefit. That choice is very Piper: optimize for the second a user waits, not the millisecond a server saves.
synthesize is the heart. It allocates three or four tensors matching the interface fixed by the exporter, names hard-coded at L380 as input, input_lengths, scales, and the optional sid, then fires one session.onnx.Run, L386 call. The output float tensor is peak-normalized and clamped into int16 samples at L411, which keeps the audio path allocation-light and the WAV format trivial. textToWavFile, L619 then writes the header through wavfile.hpp, L6 and streams the buffer to any std::ostream, which is how --output_file - can dump sound straight to stdout.
The Python column is a faithful mirror, not a wrapper. PiperVoice.phonemize, L57 calls the same piper-phonemize routines the C++ build links against, phonemes_to_ids, L72 frames each sentence with the BOS, EOS, and PAD sentinels defined in const.py, L3, and synthesize_ids_to_raw, L140 feeds the identical four-name tensor contract, culminating in the run call at L183. If you have read our ONNX primer, the input names will look familiar, because exported VITS graphs pin their interface at export time.
Why the Two Implementations Stay in Sync
The trick that keeps C++ and Python agreeing is that neither owns the speech flow’s contract. The exporter owns it. During training, the VITS model in vits/models.py, L522 exposes an infer entry point at L681, and the exporter traces through it so the ONNX graph expects exactly the tensors the C++ side later builds. The PyTorch Lightning shell in vits/lightning.py, L20 handles optimization and epochs, then hands the weights over. A reference consumer in infer_onnx.py, L19 exercises the exported graph with the same input names, closing the loop before any release. This division, one exporter defining the interface and many thin runtimes consuming it, is a pattern you can lift into your own projects, and it is the same idea that lets projects like Ollama and MediaPipe serve one model format from many bindings.
The voice ecosystem completes the picture. Training itself is driven through the notebooks at the repo root, where a multilingual training notebook and an ONNX inference notebook walk from raw audio clips to a finished .onnx voice, and the preprocessing step in preprocess.py, L43 turns transcripts into the phoneme-id datasets the trainer consumes. Everything downstream of that point is deliberately frozen: once export_onnx.py writes the graph, the C++ core, the Python wheel, and the benchmark scripts all treat it as an immutable artifact with a published interface. That freeze is what allows a voice trained on a GPU workstation in PyTorch to speak on a Pi through six hundred lines of C++ it has never seen.
Honest Limits
No source tour is complete without the caveats, and Piper wears its constraints openly in the code.
- The audio format is hard-wired. The WAV writer assumes 16-bit samples, with
blockAlignandbitsPerSamplefixed atwavfile.hpp, L18, and the C++ path rescales every output to int16. If your voice exports a different sample width, the C++ runner will not follow you. - There is no token streaming in the C++ binary.
--output-rawstreams finished phrase buffers through a background thread atrawOutputProc, L371, but latency is per phrase, not per sample. True incremental synthesis would require the streaming export variants that live, experimentally, in the training tree. - Compute defaults to CPU. A
--use-cudaflag appends the CUDA execution provider at L268, but there is no DirectML, Core ML, or Vulkan path in the C++ core, so exotic accelerators are out of scope by design. - Phoneme coverage is only as good as espeak-ng. Unknown phonemes are counted and warned about at L600, not invented, which is honest but means unusual scripts can degrade to silences or approximations.
- It is not a training framework.
src/python/piper_trainis a separate heavyweight dependency tree for making voices; the C++ andpython_runpackages are consumers only. - The repo ships a voice config and a test model under
etc/, but all real voices live on HuggingFace, so first use requires a download, and the--espeak_datapath matters when you relocate the binary.
None of these limits are hidden, and most are direct consequences of the design goal: a TTS engine small enough to start instantly on a $50 computer, with the entire speech flow legible in under a thousand lines. That legibility is the real gift of the codebase. Between the overview and the detail diagram you have now seen where text becomes phonemes, where phonemes become ids, where ids become floats, and where floats become the bytes your speaker plays, all anchored to lines you can open in one click. Enjoyed this post? Never miss out on future posts by following us