Her First Words
Speech is samples you can hold
Today the machine speaks. Not in her voice yet; that takes a dataset you collect in chapter 6 and a cloning model you meet in chapter 7. Today's voice is a stand-in, and the point of the chapter is everything underneath it, because the plumbing you build here carries every word she will ever say.
She needs the voice offline: no cloud API, no network round-trip, no per-character
billing, nothing a company can switch off. Piper gives you exactly that. It is a fast,
local text-to-speech engine that runs neural voice models on the CPU, and a voice is
just two files, a .onnx model and its sibling .onnx.json
config, downloaded once into configs/ and reused forever.
Here is what synthesis actually hands you, and it is the mental model for the whole chapter: a stream of raw audio samples. Each sample is one 16-bit signed integer, a single measurement of the waveform's amplitude, in the range −32768 to 32767. Twenty-two thousand of them per second. "Text-to-speech" means a neural network turns your sentence into that stream of numbers; your job is to catch the stream, wrap it in a file header, and push it at a speaker. Once you can do that with confidence, no audio bug in this book will stay mysterious for long.
Most Piper tutorials show voice.synthesize(text, wav_file): hand it a
file handle, let it write the WAV. That API no longer exists. Copy it and you get an
immediate TypeError. The current API returns the audio to you,
as an iterable of AudioChunk objects, each carrying raw bytes plus its
own sample rate, width and channel count. More work, and much better: you now own the
audio, and owning it is what lets chapter 10 route her words to a speaker, a file, or
a network socket without asking Piper's permission.
uv add piper-tts sounddevice numpy
uv run python -m piper.download_voices en_US-lessac-medium --data-dir configs
$ uv run python -m piper.download_voices en_US-lessac-medium --data-dir configs
Downloaded en_US-lessac-medium to configs/en_US-lessac-medium.onnx
Your first uv add creates uv.lock: open it and skim, once,
to see what "every version pinned" looks like in practice. The voice download lands
both files in configs/. Exact download output varies with your terminal
and Piper version; what matters is that ls configs/ shows the
.onnx and .onnx.json pair afterward.
Catch the stream
# labs/hello_glados_tts.py
from pathlib import Path
from piper.voice import PiperVoice
MODEL = Path("configs/en_US-lessac-medium.onnx")
TEXT = "Hello, test subject. The Aperture Science facility is now online."
voice = PiperVoice.load(MODEL)
chunks = list(voice.synthesize(TEXT))
print(f"chunks: {len(chunks)}")
print(f"first rate: {chunks[0].sample_rate} Hz")
$ uv run python labs/hello_glados_tts.py
chunks: 1
first rate: 22050 Hz
PiperVoice.load reads the model and automatically picks up the sibling
.onnx.json from the same directory. synthesize() is a
generator: it produces nothing until iterated, so list(...) forces every
chunk out at once. That costs memory and buys convenience, letting us inspect
chunks[0] and reuse the audio twice, for the file and the speaker,
without running inference again. A short sentence usually arrives as one chunk;
longer text arrives as several, and your chunk count may differ from the page's.
sample_rate = chunks[0].sample_rate
sample_width = chunks[0].sample_width
sample_channels = chunks[0].sample_channels
audio_data = b"".join(c.audio_int16_bytes for c in chunks)
print(f"rate={sample_rate} width={sample_width} channels={sample_channels}")
print(f"audio bytes: {len(audio_data)}")
$ uv run python labs/hello_glados_tts.py
rate=22050 width=2 channels=1
audio bytes: 187904
Why read the rate off the chunk instead of hardcoding 22050? Because different voice
models ship at different rates, and a script that asks the data plays any
Piper voice correctly. The join concatenates every chunk's raw PCM into one
contiguous byte string. Note carefully what is being joined: the
.audio_int16_bytes of each chunk, not the chunk objects themselves. That
one-keystroke difference is the failure box below. The byte count also tells a story:
187,904 bytes at 2 bytes per sample is 93,952 samples, which at 22,050 per second is
about 4.3 seconds of speech. Your count will differ a little; the arithmetic to check
it is the part that transfers.
import wave
OUTPUT = Path("glados_says.wav")
with wave.open(str(OUTPUT), "wb") as wav_file:
wav_file.setnchannels(sample_channels)
wav_file.setsampwidth(sample_width)
wav_file.setframerate(sample_rate)
wav_file.writeframes(audio_data)
with wave.open(str(OUTPUT), "rb") as r:
print(f"channels={r.getnchannels()} width={r.getsampwidth()} "
f"rate={r.getframerate()} frames={r.getnframes()}")
$ uv run python labs/hello_glados_tts.py
channels=1 width=2 rate=22050 frames=93952
A WAV file is the raw samples plus a 44-byte header stating rate, width and channel
count. All three matter: set the width or channels wrong and a player interprets the
bytes at the wrong stride, turning speech into noise or chipmunks. The
wave module is in the standard library, and the read-back at the end is a
habit from chapter 1's school of thought: do not trust that you wrote a valid file,
open it and ask.
import numpy as np
import sounddevice as sd
audio = np.frombuffer(audio_data, dtype=np.int16).astype(np.float32) / 32768.0
sd.play(audio, sample_rate)
sd.wait()
print(f"GLaDOS said: {TEXT}")
$ uv run python labs/hello_glados_tts.py
GLaDOS said: Hello, test subject. The Aperture Science facility is now online.
And the room has sound in it. sounddevice wants float samples between
−1.0 and 1.0; Piper gave us int16. Dividing by 32768.0 rescales the range, and the
asymmetry is correct rather than a bug: 32767 maps to 0.99997, because int16 has one
more negative value than positive. sd.wait() blocks until playback
finishes. Delete it and the script exits mid-sentence, cutting her off, which she
would certainly hold against you.
Why this works: one buffer, two views
Internally Piper converts your text to phonemes, runs the neural model, and yields 16-bit PCM in chunks so a long clip never has to exist in memory all at once before you can act on it. Everything after that is you re-presenting one byte buffer:
- The WAV file is the buffer plus a 44-byte header describing its format. No conversion, no resampling; the samples on disk are the samples Piper produced.
- The playback array is the same buffer rescaled to float32. Same samples, different unit.
Holding onto that identity is what makes audio debuggable. If playback is garbage but the WAV plays cleanly in another program, your bug lives in the normalization. If both are garbage, it lives in the assembly. If the WAV will not open at all, you wrote the header wrong. One buffer, two views, three distinguishable failures.
The most common slip in stage 2, and autocomplete practically begs you to make it: joining the chunk objects instead of their bytes.
chunks = list(voice.synthesize(TEXT))
audio_data = b"".join(chunks) # BUG: chunks, not c.audio_int16_bytes
$ uv run python labs/hello_glados_tts.py
Traceback (most recent call last):
File "labs/hello_glados_tts.py", line 10, in <module>
audio_data = b"".join(chunks)
TypeError: sequence item 0: expected a bytes-like object, AudioChunk found
The error names both sides of the mismatch: b"".join wants bytes-like
items and got an AudioChunk. The chunk is a container; the audio lives in
its .audio_int16_bytes attribute, alongside the format fields you read in
stage 2. When a join or a concatenation fails in this book, read the "expected X,
found Y" line first; it usually names the missing attribute access outright.
Checkpoint, and what she still cannot do
- I can say what an int16 PCM sample is, what its range is, and how many of them one second of Piper speech contains.
- Given a byte count, a sample width and a rate, I can compute a clip's duration, and I can run that arithmetic on my own output to sanity-check a capture.
- I can name the three WAV header fields and predict what playback does when each one is wrong.
- I can explain why 32767 maps to 0.99997 in the float view and why that is not a bug.
- Handed a broken audio pipeline, I can use the file view and the playback view to localize the failure to synthesis, assembly, or normalization.
Exercise 1 — make speak() a citizen. Refactor the script
into speak(text: str, voice: PiperVoice) -> bytes that returns the
joined PCM, plus a play(audio_data: bytes, rate: int) -> None that
handles the float conversion and playback. Which chapter-2 rule decides what goes
where?
Return-the-value: speak computes and returns bytes, touching no
speaker; play is honestly -> None, existing for its
side effect. Chapter 10 imports exactly this pair, which is why the refactor is
worth doing now, in the file you will actually keep.
Exercise 2 — misdeclare the rate on purpose. Write the WAV
with setframerate(sample_rate * 2), play the file in any media player,
and explain what you hear and why the file is exactly the same size.
Her line plays in half the time, one octave up: chipmunk GLaDOS. The player is pushing the same 93,952 samples at double speed because the header told it to. Size is unchanged since not one sample byte differs; only the 4-byte rate field in the header did. Format fields change interpretation, never data.
Exercise 3 — measure a longer line. Synthesize a
three-sentence paragraph, print the chunk count and total byte length, and predict
the duration before checking it against the WAV's getnframes() divided
by the rate.
Longer text yields several chunks; the byte total divided by 2 gives samples, and samples over 22,050 give seconds. Your numbers will differ from anyone else's (synthesis length varies with the text and model version), and the two ways of computing duration must still agree with each other. That habit, checking a number two independent ways, is one this book will not let go of.
The machine talks, in a pleasant, entirely wrong voice. Before we fix the voice we fix the other direction: chapter 4 gives her ears, and with them the first hard rule of audio capture — a recording you cannot verify is worse than no recording at all.