Speaking Cleanly
The model writes for the screen, not for the ear
Her words come from a language model, and language models are trained on text meant
to be read. So llama3.2:3b hands you **Wrong.** for emphasis, backticks
around anything technical, ALL CAPS when the personality prompt puts her in a mood,
and ellipses glued straight onto the next word. On screen, all of it reads fine.
Spoken, it falls apart: Piper spells an all-caps word letter by letter
("aitch-oh-double-u"), stumbles through asterisks as noise or dead air, and rushes an
ellipsis that deserved a beat of silence. The assistant you have been polishing for
twenty-two chapters suddenly sounds like a screen reader from 1988.
The tempting fix is to patch each problem the moment you hear it: a quick
.replace("...", "") here, a special case for one acronym there, sprinkled
wherever synthesis gets called. That path is a dead end. The patches pile up, they
fire in whatever order they were pasted, and you debug them by listening to audio,
which is slow, imprecise, and impossible to put in a test. You end up tuning a
synthesizer when the actual bug is three characters of punctuation.
So the design in one line: normalize first, synthesize second: one ordered list of (pattern, replacement) rules, applied by one pure function you can test without ever loading a voice model. Every delivery problem becomes a string problem, and string problems are cheap: you print them, diff them, and assert on them. Nothing reaches Piper that your own eyes have not already approved.
Piper takes plain text. There is no markup channel for "pause here" or "soften this"; the model infers all of its delivery from the words and the punctuation around them. That constraint promotes this chapter's function from cleanup to control surface: commas, periods, and ellipses are the levers you have, and the rules list is the one place in the project where a lever gets pulled. When a line sounds wrong from now on, you fix a rule, not a call site.
Rules, then proof, then sound
# labs/speak_clean.py
import re
from collections.abc import Callable
Rule = tuple[str, str | Callable[[re.Match[str]], str]]
RULES: list[Rule] = [
(r"\*\*(.+?)\*\*", r"\1"), # **bold** -> keep the words, drop the stars
(r"`(.+?)`", r"\1"), # `code` -> plain text
(r"\*(.+?)\*", r"\1"), # *italic* -> plain text
(r"\.\.\.", "... "), # give the next word room after an ellipsis
(r"\b[A-Z]{2,}\b", lambda m: m.group(0).lower()), # SHOUTING -> shouting
]
def normalize(text: str) -> str:
for pattern, repl in RULES:
text = re.sub(pattern, repl, text)
return text.strip()
if __name__ == "__main__":
raw = "*Oh.* It's you... HOW disappointing. Still, `well done`."
print("raw: ", raw)
print("clean:", normalize(raw))
$ uv run python labs/speak_clean.py
raw: *Oh.* It's you... HOW disappointing. Still, `well done`.
clean: Oh. It's you... how disappointing. Still, well done.
Three decisions in fourteen lines. The rules live in a list because a list has an
order, and order will turn out to be part of the behavior: the markdown rules sit
first so no later rule ever sees a stray asterisk. The caps rule is anchored with
\b word boundaries so it only matches whole uppercase words; without
them it also matches the capital runs inside mixed-case words, and the
first casualty is her own name, which comes out as "glados". And
normalize is pure (string in, string out; no file, no audio, no
model), so its whole behavior is verifiable by reading its output. Which is good,
because the output above has a flaw: look closely after the ellipsis. Two spaces.
We ship that bug to the next stage on purpose.
CASES: list[tuple[str, str]] = [
("**Wrong.** Try again.", "Wrong. Try again."),
("You survived... again. HOW disappointing.", "You survived... again. how disappointing."),
("Run `diagnostics`, please.", "Run diagnostics, please."),
]
def run_cases() -> int:
failures = 0
for raw, expected in CASES:
got = normalize(raw)
status = "PASS" if got == expected else "FAIL"
if got != expected:
failures += 1
print(f"{status}: {raw!r} -> {got!r}")
return failures
if __name__ == "__main__":
print(f"{run_cases()} failing")
$ uv run python labs/speak_clean.py
PASS: '**Wrong.** Try again.' -> 'Wrong. Try again.'
FAIL: 'You survived... again. HOW disappointing.' -> 'You survived... again. how disappointing.'
PASS: 'Run `diagnostics`, please.' -> 'Run diagnostics, please.'
1 failing
Each case is a raw line and the exact string you expect back, and the loop compares
them with ==: no audio, no judgment calls, a millisecond per case. The
table immediately convicts stage 1's flaw: the ellipsis rule appends a space without
checking whether one already follows, so "... again" becomes "... again",
and the FAIL line shows the exact bytes of the disagreement. The alternative was
playing the clip five times, wondering whether the pause after "you" ran slightly
long. This is what verifying fixes as strings means.
RULES: list[Rule] = [
(r"\*\*(.+?)\*\*", r"\1"),
(r"`(.+?)`", r"\1"),
(r"\*(.+?)\*", r"\1"),
(r"\.\.\.", "... "),
(r"\b[A-Z]{2,}\b", lambda m: m.group(0).lower()),
(r"\s+([.,!?;:])", r"\1"), # sweep: no space stranded before punctuation
(r"[ \t]{2,}", " "), # sweep: collapse doubled spaces
]
$ uv run python labs/speak_clean.py
PASS: '**Wrong.** Try again.' -> 'Wrong. Try again.'
PASS: 'You survived... again. HOW disappointing.' -> 'You survived... again. how disappointing.'
PASS: 'Run `diagnostics`, please.' -> 'Run diagnostics, please.'
0 failing
Notice what the fix is not: the ellipsis rule is untouched. Instead, two sweep rules close the list and clean up after everything above them: stranded spaces before punctuation, doubled spaces from any rule that padded too eagerly. That is the ordering convention the pipeline keeps from here on: destructive rules first, transformations in the middle, cosmetic sweeps last. Any future rule can be a little sloppy about spacing, because the sweeps have its back, and the table proves the whole stack still agrees with you.
import wave
from pathlib import Path
import numpy as np
import sounddevice as sd
from piper.voice import PiperVoice
MODEL = Path("configs/en_US-lessac-medium.onnx")
OUTPUT = Path("glados/data/she_says.wav")
def speak_clean(text: str, voice: PiperVoice, output: Path = OUTPUT) -> str:
clean = normalize(text)
chunks = list(voice.synthesize(clean))
audio_data = b"".join(c.audio_int16_bytes for c in chunks)
rate = chunks[0].sample_rate
with wave.open(str(output), "wb") as f:
f.setnchannels(chunks[0].sample_channels)
f.setsampwidth(chunks[0].sample_width)
f.setframerate(rate)
f.writeframes(audio_data)
audio = np.frombuffer(audio_data, dtype=np.int16).astype(np.float32) / 32768.0
sd.play(audio, rate)
sd.wait()
return clean
if __name__ == "__main__":
if (failing := run_cases()):
raise SystemExit(f"{failing} normalization case(s) failing; refusing to speak")
voice = PiperVoice.load(MODEL)
raw = "*Oh.* It's you... HOW disappointing. Still, `well done`."
print("spoke:", speak_clean(raw, voice))
$ uv run python labs/speak_clean.py
PASS: '**Wrong.** Try again.' -> 'Wrong. Try again.'
PASS: 'You survived... again. HOW disappointing.' -> 'You survived... again. how disappointing.'
PASS: 'Run `diagnostics`, please.' -> 'Run diagnostics, please.'
spoke: Oh. It's you... how disappointing. Still, well done.
The synthesis half is chapter 3's plumbing wearing a new front door: chunks
collected, format read off the data instead of hardcoded, WAV written as the
debugging artifact, float32 for the speaker. What is new is the gate. The demo runs
the test table before loading any model, and a failing table refuses to speak at
all: the same preflight discipline the voice-cloning work drilled in, applied to
text. speak_clean returns the clean string so the caller can log
exactly what she said, and the pause you hear after "you..." is the ellipsis rule,
audible at last. The WAV lands next to her other artifacts, ready for a waveform
editor whenever your ears and your eyes disagree.
Why this works: order is behavior, and purity is leverage
The engine underneath is almost embarrassingly small: a loop that rewrites one string
once per rule, in list order. But each rule receives the previous rule's output, so
the list is not a bag of independent fixes; it is a pipeline, and position in the
list is part of a rule's meaning. Strip **bold** after the
single-asterisk rule has run and the italic rule eats one star from each pair,
leaving debris for every rule downstream. The destructive-first, sweep-last
convention is not tidiness; it is the discipline that keeps seven rules from having
forty-nine interactions.
The second argument of re.sub splits into two kinds, and the split
decides what a rule can do. A string replacement is a template stamped in at match
time: it can echo captured groups with \1, but it cannot compute anything
from them. A function replacement is called once per match, receives the match object,
and returns whatever it likes; that is the only way to lowercase a word you have not
seen yet. Templates for rearranging text, functions for transforming it. And because
the whole pipeline stays pure, the leverage compounds: a text-in, text-out function
needs no fixtures, no mocks, no hardware, so its entire behavior fits in a table of
pairs. Any stage of any pipeline you can keep pure, you can test this cheaply, forever.
You know all-caps needs lowercasing, and the lambda feels heavy, so you reach for a
backreference and lowercase it up front. Surely r"\1".lower() means "the
match, lowercased":
RULES: list[Rule] = [
# ...
(r"\b([A-Z]{2,})\b", r"\1".lower()), # "lowercase the match"... it does not
# ...
]
$ uv run python labs/speak_clean.py
PASS: '**Wrong.** Try again.' -> 'Wrong. Try again.'
FAIL: 'You survived... again. HOW disappointing.' -> 'You survived... again. HOW disappointing.'
PASS: 'Run `diagnostics`, please.' -> 'Run diagnostics, please.'
1 failing
No crash, and the table catches it anyway: the caps sailed through untouched.
Reason from when things run. .lower() executes while the rules list is
being built, before any text exists, on the literal two characters backslash-one,
which contain no letters to lowercase. The expression evaluates to
"\1", exactly what you started with, so re.sub dutifully
inserts each match unchanged. One probe settles it:
print(repr(r"\1".lower())) prints '\\1', and the mystery
is over. Only a function replacement runs at substitution time on real matched
text, so only a function can transform what it matched. The lambda was never heavy;
it was load-bearing.
Checkpoint, in a clear voice
- I can name the screen-text habits that wreck synthesis (markdown emphasis, backticks, all-caps, glued ellipses) and say what each one sounds like when it reaches Piper raw.
- I can explain why rule order is part of the pipeline's behavior and apply the convention: destructive first, transforms in the middle, sweeps last.
- I know which
re.subreplacements can compute from the match and which can only echo it, and I can say when each kind's code actually runs. - I can explain the
\banchors in the caps rule and what happens to mixed-case names like hers without them. - I can defend testing a normalizer as strings: what a table of pairs localizes in a millisecond that listening to audio never will.
- I can gate a side effect behind a passing table, so a broken transform refuses to reach the speaker at all.
Exercise 1 — teach her the abbreviations. Piper reads
"e.g." as a fumble. Add expansions for e.g., i.e., and
etc. to the pipeline, decide where in the list they belong, and prove
them with new table cases before you listen to anything.
Three string-replacement rules, anchored with \b and with the dots
escaped: (r"\be\.g\.", "for example"), and likewise "that is" and
"and so on". They belong with the destructive rules near the top: expansions
change words, and every later rule should see the final words. Add a case like
("Check the basics, e.g. the microphone.", "Check the basics, for example
the microphone.") and watch it pass; then listen once, as confirmation,
not as the test.
Exercise 2 — break the order on purpose. Move the single-asterisk italic rule above the double-asterisk bold rule. Predict which table case fails and what the output string looks like, then run the table and check yourself.
The bold case fails with '*Wrong.* Try again.' — stars on the
outside, which is the giveaway. The italic rule ran first, its lazy
(.+?) matched the inner star as content, and it consumed one star
from each side of the pair, leaving a decoy italic no later rule strips. Same
rules, same text, different order, different output: list order is behavior,
and this one-line experiment is the fastest proof of it you will ever run.
Exercise 3 — feed her real lines. Wire
normalize into the conversation loop just ahead of synthesis, log
every raw/clean pair as you chat, and read the log after a day: what does your
model emit that the seven rules miss?
Your transcript will read differently from anyone else's, but the usual next
offenders are structural: numbered lists ("1. First, ..."), bullet dashes at
line starts, and heading marks like ###, all of which Piper reads
in full. Each becomes a rule (r"(?m)^#{1,6}\s*" is a start), each
rule becomes a table case first, and the rules list grows the only way it
should: from evidence, one observed failure at a time, never from guesses.
She now speaks in clean sentences no matter how the model formats its thoughts. But she speaks them all in the same temper: thank her or insult her, and the reply arrives in an identical tone. Next chapter gives her a mood — a small state machine that reads the feel of what you say and lets it color what she says back.