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

Voice recordings

The dataset is the product

A voice dataset pairs each recording with the exact words spoken in it. Use your own recordings or a performer who has explicitly consented to this cloning use, with rights cleared for the recordings and text. Chapter 7 needs only one suitable reference pair to start; fine-tuning needs a larger curated set. This chapter builds the manifest and teaches a restartable importer without making somebody else's recordings a prerequisite.

The Portal wiki illustrates a useful page structure: downloadable WAV links beside transcripts. Access to a wiki page does not authorize reuse of its recordings, voice cloning, model training or redistribution. Check the recording and text licenses as well as the performer's consent before downloading anything for those purposes. The local HTML examples below teach parsing without a network request. Use the network importer only for a source you have permission to use and whose access rules allow it.

◆ Note — the default dataset is one you can authorize

Record your own original sentence using the microphone workflow from chapter 4. Save a mono WAV as glados/data/voice/reference.wav, and keep its exact transcript in your manifest. For the starter code, record “This is my reference recording for the local assistant.” If you say something else, change REF_TEXT to match. Add more filename|transcript rows as you record; the scraper is optional. Keep a consent and license record beside the manifest, including permitted uses and any limits on sharing audio or trained checkpoints. A polite delay between downloads changes server load, not ownership.

The naive scraper is one line of optimism: find every link ending in .wav and download it. It breaks two ways. First, wiki pages mix real audio URLs with relative upload-page links that also end in .wav but are not files; hand one to a downloader and it throws. Second, the first time the run dies halfway (and over hundreds of network requests, it will), a restart re-downloads everything and duplicates rows in your manifest. The fixes are two disciplines that recur in every data pipeline you will ever write: filter precisely, and make the script restartable.

See, then filter, then remember

▣ Build · stage 1 — parse a page and see every link
# labs/scrape_stage1.py
from bs4 import BeautifulSoup

# A tiny slice of what a Portal wiki page looks like.
html = """
<a href="https://wiki.example/audio/Glados_escape01.wav">Oh. It's you.</a>
<a href="/w/index.php?title=File:Glados_demo.wav">upload page</a>
<a href="https://wiki.example/audio/Glados_cake02.wav">The cake is a lie.</a>
"""
soup = BeautifulSoup(html, "html.parser")
for a in soup.find_all("a", href=True):
    print(a["href"])
$ uv run python labs/scrape_stage1.py
https://wiki.example/audio/Glados_escape01.wav
/w/index.php?title=File:Glados_demo.wav
https://wiki.example/audio/Glados_cake02.wav

uv add requests beautifulsoup4 first. We start against a three-line stand-in for the real page, because before filtering anything you have to see what pages actually contain. find_all("a", href=True) returns every anchor tag that has an href; the tag's URL is a["href"] and its visible text is a.get_text(strip=True). Look at the middle line: it ends in .wav and it is an upload page, not audio. That one link is the whole reason the next stage exists.

▣ Build · stage 2 — two predicates, and keep the transcript
links = []
for a in soup.find_all("a", href=True):
    href = a["href"]
    if href.endswith(".wav") and href.startswith("http"):
        links.append((href, a.get_text(strip=True)))

print(f"Kept {len(links)} of {len(soup.find_all('a', href=True))} links")
for href, transcript in links:
    print(f"  {transcript!r} -> {href}")
$ uv run python labs/scrape_stage2.py
Kept 2 of 3 links
  "Oh. It's you." -> https://wiki.example/audio/Glados_escape01.wav
  'The cake is a lie.' -> https://wiki.example/audio/Glados_cake02.wav

Why two conditions? endswith(".wav") alone keeps the upload page; adding startswith("http") demands an absolute URL, the only kind a downloader can fetch. And the append stores a pair: URL and link text, because on these pages the link text is the transcript, and the (clip, transcript) pair is the dataset row chapter 7 needs. Scrape the label at the same moment you scrape the file; going back for transcripts later is misery.

▣ Build · stage 3 — resume logic, so re-runs cost nothing
import csv
from pathlib import Path

def load_existing(csv_path: Path) -> set[str]:
    if not csv_path.exists():
        return set()
    with open(csv_path, newline="") as f:
        reader = csv.reader(f, delimiter="|")
        return {row[0] for row in reader if row and row[0] != "filename"}

csv_path = Path("glados/data/metadata.csv")
existing = load_existing(csv_path)
print(f"Already have: {sorted(existing)}")
for name in ["Glados_escape01.wav", "Glados_turret07.wav"]:
    if name in existing:
        print(f"  skip {name}")
    else:
        print(f"  + new {name}")
$ uv run python labs/scrape_stage3.py   # after a previous partial run
Already have: ['Glados_cake02.wav', 'Glados_escape01.wav']
  skip Glados_escape01.wav
  + new Glados_turret07.wav

The manifest, metadata.csv, is a pipe-delimited filename|transcript file, and it is the source of truth for "what made it into the dataset." Loading it into a set gives instant membership checks, and the header-row filter keeps the literal word filename out of your skip list. Now a crash mid-run costs nothing: the next run reads the manifest and resumes where the last one stopped. Chapter 1 called this idempotence, on six directories; here it earns its keep on three hundred network requests.

▣ Build · stage 4 — the full scraper
# labs/scrape_glados.py
import argparse, csv, time, urllib.request
from pathlib import Path
from urllib.parse import urlparse
import requests
from bs4 import BeautifulSoup

def scrape_wav_links(url: str) -> list[tuple[str, str]]:
    resp = requests.get(url, timeout=10)
    resp.raise_for_status()
    soup = BeautifulSoup(resp.text, "html.parser")
    return [(a["href"], a.get_text(strip=True))
            for a in soup.find_all("a", href=True)
            if a["href"].endswith(".wav") and a["href"].startswith("http")]

def main() -> None:
    p = argparse.ArgumentParser()
    p.add_argument("--urls", nargs="+", required=True)
    p.add_argument("--output", type=Path, default=Path("glados/data/voice"))
    args = p.parse_args()
    args.output.mkdir(parents=True, exist_ok=True)
    csv_path = args.output / "metadata.csv"
    existing = load_existing(csv_path)

    new_rows = 0
    with open(csv_path, "a", newline="") as f:
        writer = csv.writer(f, delimiter="|")
        if not existing:
            writer.writerow(["filename", "transcript"])
        for url in args.urls:
            for href, transcript in scrape_wav_links(url):
                name = Path(urlparse(href).path).name
                if name in existing or not transcript:
                    continue
                urllib.request.urlretrieve(href, args.output / name)
                writer.writerow([name, transcript])
                existing.add(name)
                new_rows += 1
                time.sleep(0.1)   # be polite to the wiki
    print(f"Added {new_rows} clips; manifest now {len(existing)} entries.")

if __name__ == "__main__":
    main()
$ uv run python labs/scrape_glados.py --urls https://theportalwiki.com/wiki/GLaDOS_voice_lines
Added 312 clips; manifest now 312 entries.

This retained capture illustrates the earlier wiki import, not permission to repeat it. Point --urls at your authorized source, or use the self-recorded manifest without running the scraper. (load_existing comes along from stage 3.) One clean reference pair is enough to try zero-shot synthesis. A larger authorized set can support fine-tuning, but clip count alone says nothing about recording quality. The time.sleep(0.1) between downloads illustrates pacing; it does not establish an acceptable rate for every site. Follow the source's access rules and stop if the server refuses or rate-limits requests.

Why this works: every link meets two judges

Each anchor tag is judged by two independent string tests, and both must pass before the link is treated as a downloadable clip. The four combinations cover everything a wiki page can throw at you: an absolute audio URL passes both and is kept; the relative upload page passes .wav and fails http; an ordinary absolute page link fails .wav; plain navigation fails both. Precise filters are just truth tables you wrote down before the data made you write them.

The resume layer is the second mechanism, and its placement is the point: the membership check consults the manifest, not the network and not the directory listing. The expensive operation, fetching bytes, is attempted only for names the manifest has never recorded, which is precisely what makes the script convergent: run it once or ten times, and you end at the same complete dataset.

⚠ Worked failure — the download that isn't a file

Drop the startswith("http") predicate and the upload-page link slips through to the downloader:

urllib.request.urlretrieve("/w/index.php?title=File:Glados_demo.wav",
                           "glados/data/voice/Glados_demo.wav")
$ uv run python labs/fail_relative.py
Traceback (most recent call last):
  File "labs/fail_relative.py", line 3, in <module>
    urllib.request.urlretrieve("/w/index.php?title=File:Glados_demo.wav",
  File "/usr/lib/python3.11/urllib/request.py", line 241, in urlretrieve
    with contextlib.closing(urlopen(url, data)) as fp:
ValueError: unknown url type: '/w/index.php?title=File:Glados_demo.wav'

unknown url type is urllib telling you the string has no scheme: no https://, so it is not an address, just a path fragment relative to a site the downloader never heard of. This failure is loud, which makes it the kind you want. The quiet version is worse: some wikis serve an HTML error page with a 200 status, and a filter-less scraper happily saves it with a .wav name. You would meet that bug days later as one mysteriously unplayable clip in a training run. Filter at the source, and neither version exists.

◆ Note — curation is the second half

The scraper gets you volume; cloning also wants cleanliness. Some of her lines ship with test-chamber ambience, music stings, or turret fire underneath, and a reference clip with background noise teaches the clone to hum. Two quick passes raise the dataset's grade: run chapter 4's RMS check across every clip to catch near-silent files, and skim the loudest and quietest ten by ear. Park anything suspect in a rejected/ folder rather than deleting it; chapter 7 needs only a handful of pristine clips for its reference, and the fine-tune later is happiest with the clean majority.

Checkpoint, with a dataset on disk

✓ Checkpoint — what you can now do
  • I can state both predicates a link must pass and name the failure each one prevents.
  • I can explain why the transcript is captured in the same pass as the URL, and what the manifest row for one clip looks like.
  • I can trace the resume logic: where the set comes from, what consults it, and why a crash mid-run costs nothing.
  • I know why the polite delay is there and what it costs across three hundred files.
  • My glados/data/voice/ holds hundreds of her clips, and metadata.csv pairs every one with its words.
⚡ Exercises — try first, then reveal
Exercise 1 — audit your haul. Write a ten-line script that walks the manifest and reports: clip count, total minutes of audio, mean clip length, and the five shortest clips. Why do the shortest ones deserve a listen?

soundfile.info(path).duration gives each length without loading samples. Sub-second clips are usually gasps, single words, or truncated downloads; none are useful as cloning references and some are corrupt. Numbers first, ears second: the report tells you where to point your attention, which is the whole trade of data work.

Exercise 2 — the RMS sweep. Reuse chapter 4's rms_level across every clip and flag anything below 0.005 or above 0.3. What did you catch?

Typically a few near-silent files (bad downloads, or lines that are mostly pause) and a few hot ones with effects layered under her voice. Move them to rejected/ and note the manifest rows. If nothing got flagged, loosen the bounds until something does and listen to what lives at your dataset's edges; knowing your outliers is the point, whatever the thresholds.

Exercise 3 — restartability, proven. Delete three WAVs and their manifest rows, re-run the scraper, and explain what it did and did not re-download.

It fetched exactly the three missing clips and appended three rows; everything in the manifest was skipped without a network request. That is the convergence property from the chapter: any partial state, run again, same complete dataset. If it re-downloaded everything, your membership check is reading the wrong source of truth.

Hundreds of clips, every one labeled with the words inside it. This pile is what "sounding like GLaDOS" is actually made of. Next chapter feeds it to a cloning model, and the machine's pleasant stand-in voice is replaced by the one you have been waiting for.