GLaDOS Vol 3 · Her Craft
ch 23 / 99
Chapter 23

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.

◆ Note — punctuation is the only prosody dial you get

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

▣ Build · stage 1 — an ordered list of rules and a pure function
# 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.

▣ Build · stage 2 — a test table, because pure functions can have one
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.

▣ Build · stage 3 — sweep rules at the end of the list
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.

▣ Build · stage 4 — only clean text reaches the synthesizer
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.

⚠ Worked failure — I lowercased the replacement, so why is she still shouting?

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

✓ Checkpoint — what you can now do
  • 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.sub replacements 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 \b anchors 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.
⚡ Exercises — try first, then reveal
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.