GLaDOS Vol 1 · Her Voice
ch 10 / 99
Chapter 10

Speech conversation loop

Four working parts are not a conversation

Nine chapters, four proven parts: the microphone records and verifies, Whisper transcribes, Ollama answers in character, F5-TTS speaks in her voice. None of that is a conversation. A conversation is a fifth thing, the cycle that runs them — listen, transcribe, think, speak, repeat — with each stage handing plain data to the next until you shut her down. Tonight you build the cycle, and the machine you have been assembling piece by piece finally turns and looks at you.

One architectural rule decides whether the loop is usable, and you already know it from chapter 5: load once, call many. Whisper takes seconds to load its weights; the cloning model takes longer. Put those constructors inside the loop body and she reboots her own brain before every reply, a compounding, infuriating pause. Setup lives above the loop; only cheap per-turn work lives inside it. Ollama is the one exception, and an instructive one: its daemon holds the model warm between calls, so the load-once bucket lives in someone else's process.

Assemble the cycle

▣ Build · stage 1 — prove the bookkeeping with a stub
# labs/voice_loop.py
def think(text: str, history: list[dict], reply: str) -> tuple[str, list[dict]]:
    history.append({"role": "user", "content": text})
    # (the real ollama.chat call goes here — it produces `reply`)
    history.append({"role": "assistant", "content": reply})
    return reply, history

if __name__ == "__main__":
    history: list[dict] = []
    _, history = think("Hello?", history, "Oh. It's you.")
    _, history = think("Miss me?", history, "I was doing fine without you.")
    print(f"history has {len(history)} messages")
    for m in history:
        print(f"  {m['role']}: {m['content']}")
$ uv run python labs/voice_loop.py
history has 4 messages
  user: Hello?
  assistant: Oh. It's you.
  user: Miss me?
  assistant: I was doing fine without you.

Start with the part that can run offline. The interesting logic in think() is the bookkeeping, two appends per turn, exactly as chapter 8 taught; the network call is a one-line swap later. Faking the reply lets you watch the history grow correctly before any model or microphone can confuse the diagnosis. When you assemble a system, wire the cheap parts first and prove the data flow; the expensive parts then drop into sockets you trust.

▣ Build · stage 2 — one function per stage
import json
from pathlib import Path
import sounddevice as sd
import soundfile as sf
from faster_whisper import WhisperModel
from f5_tts.api import F5TTS
import ollama

SAMPLE_RATE = 16000
CHANNELS = 1
LISTEN_DURATION = 5
AUDIO_IN = "glados/data/heard.wav"
AUDIO_OUT = "glados/data/glados_says.wav"
CONFIG_PATH = Path("configs/personality.json")
REF_FILE = "glados/data/voice/reference.wav"
REF_TEXT = "This is my reference recording for the local assistant."

def load_config() -> dict:
    if CONFIG_PATH.exists():
        with open(CONFIG_PATH) as f:
            return json.load(f)
    return {"name": "GLaDOS", "model": "llama3.2:3b",
            "system_prompt": "You are GLaDOS. Be sardonic, brief, and darkly witty."}

def listen() -> None:
    print(f"Listening for {LISTEN_DURATION}s...")
    audio = sd.rec(int(LISTEN_DURATION * SAMPLE_RATE), samplerate=SAMPLE_RATE,
                   channels=CHANNELS, dtype="float32")
    sd.wait()
    sf.write(AUDIO_IN, audio, SAMPLE_RATE)

def transcribe(stt_model: WhisperModel) -> str:
    segments, _ = stt_model.transcribe(AUDIO_IN, language="en")
    return " ".join(s.text.strip() for s in segments)

def think(text: str, history: list[dict], config: dict) -> tuple[str, list[dict]]:
    history.append({"role": "user", "content": text})
    response = ollama.chat(
        model=config["model"],
        messages=[{"role": "system", "content": config["system_prompt"]}] + history,
    )
    reply = response["message"]["content"]
    history.append({"role": "assistant", "content": reply})
    return reply, history

def speak(text: str, tts: F5TTS) -> None:
    tts.infer(ref_file=REF_FILE, ref_text=REF_TEXT, gen_text=text,
              file_wave=AUDIO_OUT, remove_silence=True)
    data, sample_rate = sf.read(AUDIO_OUT)
    sd.play(data, sample_rate)
    sd.wait()

Every function here is a chapter of this volume compressed to its final form, and each does exactly one job, passing plain data onward: a file path, a string, a list. Those boundaries are the volume's real product. Because transcribe() takes a model and returns a string, you can swap Whisper sizes without touching think(); because speak() takes text, the voice engine is replaceable without the loop noticing. Volume 4 will formalize this instinct into an architecture; you are already practicing it.

▣ Build · stage 3 — load above, loop below
if __name__ == "__main__":
    config = load_config()
    print("Loading Whisper...")
    stt_model = WhisperModel("base", device="cpu", compute_type="int8")
    print("Loading F5-TTS...")
    tts = F5TTS()
    history: list[dict] = []
    print("GLaDOS voice loop active. Press Ctrl+C to exit.\n")
    while True:
        try:
            listen()
            text = transcribe(stt_model)
            if not text.strip():
                print("(silence)")
                continue
            print(f"You: {text}")
            reply, history = think(text, history, config)
            print(f"GLaDOS: {reply}")
            speak(reply, tts)
        except KeyboardInterrupt:
            print("\nFacility shutting down.")
            break
$ uv run python labs/voice_loop.py
Loading Whisper...
Loading F5-TTS...
GLaDOS voice loop active. Press Ctrl+C to exit.

Listening for 5s...
You: Good evening, GLaDOS. How are you feeling?
GLaDOS: "Feeling" is a strong word for what I do. Operational. Mildly understimulated. And now, interrupted.
Listening for 5s...
(silence)
Listening for 5s...
You: Can you say something nice about me?
GLaDOS: You have excellent taste in artificial intelligences. That's the nicest true thing available.
^C
Facility shutting down.

Speak to your machine and it answers, aloud, in her voice, in her character, with memory across turns. Your transcript will read differently; the architecture of the moment is identical. Note the two guards earning their keep: the silence check skips the model calls entirely when Whisper hears nothing (a five-second window of room tone transcribes to an empty string), and the KeyboardInterrupt handler turns Ctrl+C into a shutdown line instead of a stack trace. She exits with dignity. It seemed important to her.

Why this works: where the time goes

Every operation in the loop belongs to one of two buckets, one-time setup or per-turn work, and the entire architecture is putting each operation in its right bucket. The model constructors are seconds-heavy and live above the loop, paid once at boot. The per-turn work is a recording (real time, five seconds), a transcription (a second or two on CPU), a model reply (about a second for a 3B model), and a synthesis (the slowest seat in the house on CPU). Misplace one constructor and every turn pays the boot cost again; the loop teaches you to read code by its buckets, and that reading never stops paying.

Time the seams yourself with time.perf_counter() around each stage and you will find, on a typical CPU-only machine, a turn takes somewhere in the region of ten seconds, dominated by synthesis, with recording a fixed five on top. That number is honest and it is not good, and the book knows it. The fixed listening window is wasteful (volume 2's wake word and volume 9's voice-activity detection attack it), the synthesis wants a GPU (volume 8's entire reason to exist), and the reply cannot start speaking until fully generated (volume 9 streams it). Write your measured numbers down, on paper, today. They are the "before" photo the rest of this book spends itself improving.

⚠ Worked failure — she pauses forever before every reply

The classic assembly mistake, made by collapsing the stages carelessly: the model loads drift inside the loop body.

while True:
    listen()
    stt_model = WhisperModel("base", device="cpu", compute_type="int8")  # BUG
    text = transcribe(stt_model)
    ...
$ uv run python labs/voice_loop.py   # constructor inside the loop
Listening for 5s...
[3.8s pause — weights loading, again]
You: Hello?
GLaDOS: Yes. Hello. As I said the last four times.
Listening for 5s...
[3.8s pause — the same weights, loading again]

Nothing errors, and every turn drags. The tell is that the pause is identical every time and disappears when you move one line above the loop. This bug's cousins run the industry's cloud bills: re-opened database connections, re-parsed configs, re-compiled regexes, all "working" and all paying setup cost in the hot path. You met the rule in chapter 5, applied it in chapter 7, and just debugged its violation. It is yours now.

What you built, and where she goes from here

Take inventory of the volume. A reproducible workspace that rebuilds anywhere. The returns-values discipline that let five files compose into one program. A speech engine you can drive at the sample level. Verified capture. Local transcription. A dataset of her actual lines, scraped politely and restartably. Her voice, cloned from one clip. A local brain with maintained memory. A personality that lives in an editable file. And a loop that makes them a single machine you can stand in a room and talk to, owing nothing to any cloud.

✓ Checkpoint — volume 1, closed out
  • I can draw the five-stage cycle from memory and name the data type passed across every seam.
  • I can sort every operation in the loop into its bucket, setup or per-turn, and justify each placement.
  • I can name the three latency sinks in a turn and which future volume attacks each one.
  • I can explain why Ollama needs no client-side preload when the other two models do.
  • I have my own measured per-stage timings written down as the baseline the rest of the build improves against.
⚡ Exercises — try first, then reveal
Exercise 1 — instrument the seams. Wrap each stage call in time.perf_counter() and print a per-turn timing line like listen 5.0s | stt 1.4s | think 0.9s | speak 6.2s. Which stage surprised you?

Numbers vary by machine; the ranking usually holds — synthesis first, the fixed listen window second, transcription and thinking last. Most people expect the "AI" stages to dominate and find the audio plumbing does. Keep the instrumented loop; it rides along for the rest of the book, and volume 5 turns it into real telemetry.

Exercise 2 — give her a memory of the session. On Ctrl+C, write history to glados/data/last_session.json before exiting, and load it at boot if present. What changed about talking to her?

She now remembers the previous session: restart the loop and she can refer to what you told her before, because the list you feed the model is seeded from the file. You have built the smallest possible persistent memory, and also discovered its flaw — the file grows forever, and stale context crowds the window. Volume 2 begins exactly here, on the question of what deserves to be remembered.

Exercise 3 — the conversation you owe yourself. No code. Talk to her for ten minutes. Note the three moments that most broke the illusion, and match each to the volume that addresses it.

Typical list: the dead five-second listen window (volumes 2 and 9), replies that ignore how you sounded rather than what you said (volume 3), the wait before her voice starts (volumes 8 and 9), no memory of yesterday (volume 2), and nothing physical moves when she talks (volume 7). Your annoyance list is this book's table of contents, which is not a coincidence.

Volume 1 promised a machine you could talk to, and it is running in your room. Volume 2 goes inside her head: memory that survives the power button, a wake word so she listens for her name instead of on a timer, and the event system that starts turning a voice loop into a mind.