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

The Python She Runs On

She is a pipeline, so return beats print

GLaDOS is going to be a pipeline: a microphone feeds a speech recognizer, which feeds a language model, which feeds a voice synthesizer. For that chain to work, every piece has to hand its result to the next piece. So the first Python skill this book insists on is writing functions whose output can be captured, and the enemy of that skill is the most natural habit in the language.

The tempting shortcut is to make functions print() directly. It feels productive; you see output immediately. But a function that prints is a dead end. You cannot feed its text to a speech engine, write it to a log, or assert on it in a test, because the output already escaped to the terminal. The day you want her to speak a greeting instead of printing it, every printing function has to be rewritten. The day you want returned text spoken instead, chapter 3's speak() takes it as an argument and nothing else changes.

The rule, stated once and used for ninety-eight more chapters: a function computes a value and returns it; printing, speaking and logging are the caller's job. This chapter builds a small file that practices the rule, plus the two companions it travels with: typed signatures, and the __main__ guard you met in chapter 1.

◆ Note — if you already write Python

This chapter and the last are the only pure-Python ground school in the book; chapter 3 starts making sound. If -> str, dataclasses and the guard are old friends, skim the failure box (the phantom-greeting bug is worth thirty seconds even for veterans) and move on. If Python is new to you, this book teaches every construct it uses as it uses them, and Python Zero exists when you want the language in full.

A greeting that returns

▣ Build · stage 1 — the smallest thing that runs
# labs/hello_glados.py
def greet(subject_name: str) -> str:
    return f"Hello, {subject_name}. The facility is now online."

print(greet("Test Subject"))
$ uv run python labs/hello_glados.py
Hello, Test Subject. The facility is now online.

Look at where the printing happens: the last line, outside the function. greet builds the text and hands it back; the print is a caller deciding what to do with it. The type hints (: str in, -> str out) document the contract and let your editor catch a wrong argument before the file ever runs.

▣ Build · stage 2 — state, and a function that is all side effect
# labs/hello_glados.py
name = "GLaDOS"
status = "ACTIVE"
subject_count = 1

def greet(subject_name: str) -> str:
    return f"Hello, {subject_name}. The facility is now online."

def report_status() -> None:
    print(f"System: {name}")
    print(f"Status: {status}")
    print(f"Subjects logged: {subject_count}")

print(greet("Test Subject"))
report_status()
$ uv run python labs/hello_glados.py
Hello, Test Subject. The facility is now online.
System: GLaDOS
Status: ACTIVE
Subjects logged: 1

report_status prints on purpose, and its return type says so: -> None announces there is nothing to hand back, because printing the dashboard is the function's whole job. That contrast is the real lesson of this stage. Both kinds of function are valid; you just have to know which one you are writing, and the return type is where you say it.

▣ Build · stage 3 — a formatter, and the guard
# labs/hello_glados.py — full file
name = "GLaDOS"
status = "ACTIVE"
subject_count = 1

def greet(subject_name: str) -> str:
    return f"Hello, {subject_name}. The facility is now online."

def report_status() -> None:
    print(f"System: {name}")
    print(f"Status: {status}")
    print(f"Subjects logged: {subject_count}")

def build_message(speaker: str, text: str) -> str:
    return f"[{speaker}]: {text}"

def main() -> None:
    print(greet("Test Subject"))
    report_status()
    msg = build_message(name, "Interesting. You're still alive.")
    print(msg)

if __name__ == "__main__":
    main()
$ uv run python labs/hello_glados.py
Hello, Test Subject. The facility is now online.
System: GLaDOS
Status: ACTIVE
Subjects logged: 1
[GLaDOS]: Interesting. You're still alive.

build_message is the most reusable function in the file: two arguments in, one formatted string out, no state touched. A pattern you will write dozens of times. And the demo calls now live inside main() behind the guard, so the file has become two things at once — a runnable script, and a toolbox other files can import without triggering the demo.

Why this works: the two lives of a module

Every Python file is a module, and Python sets a variable called __name__ in each one. Its value depends entirely on how the file entered the program. Launch it directly with python labs/hello_glados.py and __name__ is the string "__main__"; pull it in with import hello_glados and __name__ is "hello_glados", the module's own name.

So if __name__ == "__main__": is the file asking: am I the program being run, or a library being borrowed? Code under the guard runs only in the first case. One line, and the same file serves both futures. That matters here because chapter 10's voice loop imports functions from five earlier chapters, and none of their demos should fire when it does.

⚠ Worked failure — why did she greet me on import?

Skip the guard and nothing looks wrong at first; the file runs fine on its own. The bug appears later, when another file tries to reuse a function. Suppose glados_voice.py was written the stage-1 way, with a call at top level:

# glados_voice.py
def greet(subject_name: str) -> str:
    return f"Hello, {subject_name}. The facility is now online."

print(greet("Test Subject"))   # top level — runs on import
# use_voice.py
import glados_voice
print(glados_voice.greet("Chell"))
$ uv run python labs/use_voice.py
Hello, Test Subject. The facility is now online.
Hello, Chell. The facility is now online.

The first line is a phantom greeting nobody asked for. Importing a module executes it top to bottom, so the stray print fired during the import itself. In the finished build that kind of stray output corrupts logs and runs setup you never requested, at import time, before your program has even started. The fix is stage 3's move: demo calls into main(), gated by the guard. Then the import defines greet silently and only a direct launch produces output.

Configuration wants a dataclass

One more habit before the sound starts. Her settings (which microphone, which model, which voice) will travel together through the whole pipeline, and the worst container for them is a handful of loose variables. Python's dataclass turns a group of named, typed fields into a single object with almost no ceremony:

▣ Build · stage 4 — a config object she carries everywhere
# labs/config_demo.py
from dataclasses import dataclass

@dataclass
class VoiceConfig:
    sample_rate: int = 22050
    voice_model: str = "glados-v1"
    volume: float = 0.8

def describe(cfg: VoiceConfig) -> str:
    return f"{cfg.voice_model} at {cfg.sample_rate} Hz, volume {cfg.volume:.0%}"

if __name__ == "__main__":
    cfg = VoiceConfig()
    print(describe(cfg))
    loud = VoiceConfig(volume=1.0)
    print(describe(loud))
$ uv run python labs/config_demo.py
glados-v1 at 22050 Hz, volume 80%
glados-v1 at 22050 Hz, volume 100%

Every field has a name, a type and a default; overriding one at construction leaves the rest alone. When chapter 3 needs a sample rate and chapter 7 needs a voice model, they will read them from an object like this instead of from scattered globals, and when volume 2 moves settings into files on disk, the dataclass is what those files load into. The 22050 is not arbitrary either; you will find out what a sample rate is, physically, two chapters from now.

Checkpoint, and the last silent chapter

✓ Checkpoint — what you can now do
  • Given any function in this chapter, I can say whether it returns a value or exists for a side effect, and point to the return type that declares which.
  • I can predict what x = report_status() leaves in x, and why the three lines still print.
  • I can explain the phantom greeting: what runs at import time, and which single line prevents it.
  • I can build a VoiceConfig with one field overridden and the rest defaulted.
⚡ Exercises — try first, then reveal
Exercise 1 — her first insult. Add a function taunt(subject_name: str, test_count: int) -> str that returns a line like "Congratulations, Chell. You have failed 47 tests. That's a new record." Wire it into main(). Where does the printing go?

The function returns the f-string and prints nothing; main() calls print(taunt("Chell", 47)). If your taunt contains a print, you have rebuilt the dead end this chapter exists to remove: chapter 10 needs that string for the speech engine, not the terminal.

Exercise 2 — prove the guard again, from the other side. Create use_hello.py containing only import hello_glados and run it. What prints, and what would print if you deleted the guard from hello_glados.py?

With the guard: nothing. The import defines four functions and stops. Without it, main() would fire mid-import and the whole demo (greeting, status dashboard, message) would spill out before use_hello.py ran its own first line.

Exercise 3 — extend the config. Add a device_index: int | None = None field to VoiceConfig, where None means "system default microphone." Why is None the right default rather than 0?

Because 0 is a real device index, the first one in the list, and on many machines it is not the default microphone. None says "no preference, let the audio library choose," which is a different statement from "use device zero." Chapter 4 hits exactly this distinction when it enumerates your machine's devices.

That closes the ground school. You have a reproducible workspace, and the three habits the pipeline is built from: return the value, type the signature, guard the entry point. Next chapter, the machine speaks its first words — through someone else's voice for now, because hers takes a dataset you have not collected yet.