# Text To Podcast

> Turn a web article or PDF into a .m4a podcast episode with proper metadata. Use when the user wants to convert a URL, PDF, or plain text into a spoken podcast audio file. The pipeline is orchestrated by scripts/podcast.py (the deterministic spine — input classification, slug, file naming, fallback wiring, chunking, multivoice tag building, the verify-expand content-preservation gate, TTS-source selection, date/description fixup, assembly, cleanup) with the LLM handling four text slots: reformat, expand, multivoice tag-hole assignment (conditional), and metadata extraction.

- Skill: `jessedc/text-to-podcast` (Agent Skill, multi-file: 23 files)
- Install (CLI): `npx skillmds@latest add jessedc/text-to-podcast`
- Raw SKILL.md: https://api.skillmd.com/api/skills/jessedc/text-to-podcast/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: DevOps & Infra
- Author: jessedc (https://skillmd.com/u/jessedc)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/jessedc/text-to-podcast

---


# text-to-podcast

Turn a web article or PDF into a `.m4a` podcast episode with proper metadata.

`scripts/podcast.py` is the deterministic spine of the pipeline. It owns input classification, slug derivation, file naming, the web2txt→web2pdf+pdf2txt fallback, chunking, the `verify-expand` content-preservation gate, multivoice tag building, TTS-source selection, date/description fixup, assembly, and cleanup. It never makes a judgment call about text — *which* expansions to apply is an LLM slot (`llm-expand`), gated by the deterministic verifier.

On `next.action = llm-*`, **you fill the slot yourself** — read `next.prompt`, apply it to `next.inputs`, write `next.output`, then run `next.then`. The spine never calls an LLM; it only emits `llm-*` actions. The envelope also carries a pre-rendered `next.llm.argv` — **ignore `next.llm`**, it is irrelevant to this skill.

## Where This Skill's Files Live

All `scripts/` and `assets/` paths below are relative to this skill's own directory — wherever your agent harness installs it (e.g. `~/.agents/skills/text-to-podcast/`) or where you clone the repo — **not** the current working directory. Prefix commands with that path: `uv run --script <skill_dir>/scripts/podcast.py …`.

## Prerequisites

`podcast.py` probes for its external dependencies at the earliest moment — `plan` runs the gate before emitting anything actionable, and `podcast.py check-prereqs [input]` checks independently. `uv`, `ffmpeg`, and `espeak-ng` are required for every path; `docker` is only checked for URL inputs (the web2pdf fallback). If a required tool is missing, `plan` returns `ok: false` with `next.action: human` and a `brew install …` hint instead of proceeding.

## The state-envelope contract

Every `podcast.py` subcommand prints one JSON **state envelope** to stdout whose `next` field tells you exactly what to run next:

```json
{"slug": "...", "stage": "raw|clean|tts|tagged|wav|m4a|done",
 "produced": ["<slug>.raw.txt"],
 "next": {"action": "cli|llm-reformat|llm-expand|llm-tag-dialogue|llm-metadata|human|done",
          "command": "...", "prompt": "assets/reformat-prompt.md",
          "inputs": [...], "output": "...", "then": "...",
          "llm": {"harness": "claude", "argv": ["claude", "-p", "..."]}}}
```

**`next.action` is the contract:**
- `cli` → run `next.command` yourself.
- `llm-reformat` → apply `next.prompt` to `next.inputs`, write `next.output`, then run `next.then`.
- `llm-expand` → apply `next.prompt` (`assets/expand-prompt.md`) to `next.inputs`, write `next.output` (the speech-friendly text), then run `next.then`. `verify-expand` checks it against `assets/expand-allowlist.json`.
- `llm-tag-dialogue` → fill every `speaker: "?"` slot in `next.inputs` (`<slug>.tags.json`) from the declared speakers (a constrained choice, not a free-form rewrite); write back to `next.output`, then run `next.then`. Only fires when `--dialogue` is set AND at least one paragraph has no leading `Speaker:` label.
- `llm-metadata` → apply `next.prompt` to `next.inputs` → `<slug>.meta.json` (validated against `assets/metadata-schema.json`), then run `assemble` once the WAV exists.
- `human` → ask the user (failure fallback only — e.g. a verifier gives up after a retry).
- `done` → the `.m4a` exists.

The `next.llm` field is a pre-rendered argv for an unrelated CLI harness; **ignore it** — you fill the slot yourself.

## Stage list

```
plan → fetch|ingest → llm-reformat → verify → llm-expand → verify-expand
      → [if --dialogue] tag-dialogue → [llm-tag-dialogue if holes] → merge-tags → verify-tags
      → tts (single) | tts_multivoice (dialogue)
      → llm-metadata
      → assemble → done
```

Multi-voice is declared upfront via `plan --dialogue [--voice-map "HOST=female,GUEST=male"]`. When `--dialogue` is set, the spine routes through `tag-dialogue` → `tts_multivoice.py`; otherwise straight to `pick-tts-source` → `tts.py`. There is no auto-detection and no human voice-map confirmation gate; if `--voice-map` is omitted the spine defaults (first→female, second→male) and prints a non-blocking notice.

## Running a conversion

Start with `plan`, which also fail-fast checks prerequisites, then follow `next`:

```sh
uv run --script scripts/podcast.py plan <url-or-pdf-or-text> [--dialogue] [--voice-map "HOST=female,GUEST=male"]
# → prints the full stage list, a prerequisites block, and the first command to run.
```

If `plan` returns `ok: false` with `next.action: human`, install the missing prerequisites (`hint` gives the brew command) and re-run `plan` before going further. Then loop: run the `cli`/`done` actions directly; for the `llm-*` actions, do the text transform described below and write the named output file, then run `next.then`. The envelope always names the prompt, the input files, the output file, and the follow-up command — you never re-derive them.

### Routing

Hand `plan` whatever the user gave you — URL, PDF, or pasted text. It classifies the input (`classify_input`) and picks the first subcommand: URLs go to `fetch`, PDFs and plain text both go to `ingest`. You never pick the entry point yourself.

### Jumping in mid-pipeline

Normally you run `plan` and let it tell you where to start. The exception: if the user already has intermediate artifacts on disk (e.g. they reformatted `<slug>.clean.txt` by hand), you can skip `plan` and call the subcommand directly. Only do this when the state is known and complete; otherwise fall back to `plan`. The spine writes every envelope to `<slug>.log.jsonl`, which you may consult for resumability.

## The LLM steps

These are the only places judgment is applied. Everything else is `podcast.py`.

### 1. Reformat — clean the raw text (`llm-reformat`)

Read `assets/reformat-prompt.md` and apply it to the raw text named in `next.inputs`. Write the result to `next.output` (typically `<slug>.clean.txt`, or `<slug>.clean.NNN.txt` per chunk when `fetch`/`ingest` reports `needs_chunking: true`).

The prompt's hard rules:
- Body content must be preserved character-for-character. No paraphrasing, summarizing, or reordering.
- Only inline page chrome (ads, nav, tag lists, copyright footers) may be removed.
- PDF column-wrap line breaks within a paragraph are joined into one line.
- Section headings go on their own line with blank lines above and below.

**Chunking is deterministic.** When the raw text is long, `fetch`/`ingest` sets `needs_chunking: true` and points `next.command` at `podcast.py chunk`. Run it: it splits at paragraph boundaries into `<slug>.raw.NNN.txt` chunks of ≤2500 words, and the envelope's `inputs`/`outputs` list each chunk pair. Reformat each chunk separately, then run `podcast.py merge-chunks <slug>` to concatenate into `<slug>.clean.txt`.

**Then verify mechanically — never trust the reformat unchecked:**

```sh
uv run --script scripts/podcast.py verify <slug>.raw.txt <slug>.clean.txt
```

This wraps `scripts/checkclean.py`, which fails (exit 1) if the clean text contains words not present in the raw (the model rewrote something) or retains less than half of the raw words (silent truncation). On failure the envelope re-emits `llm-reformat` with a `reason` and `retry_count` for the offending region; after one retry it emits `next.action: human` (never loops). On success it routes to `expand`.

### 2. Expand — speech-friendly text (`llm-expand`)

Read `assets/expand-prompt.md` and apply it to `<slug>.clean.txt` (or per-chunk `<slug>.clean.NNN.txt`). Write the result to `next.output` (`<slug>.tts.txt` or per-chunk `<slug>.tts.NNN.txt`). The prompt expands glued units (`190GW`→`190 gigawatts`), abbreviations where unambiguous (`Q1 2028`→`the first quarter of 2028`), symbols (`&`→`and`, `~50`→`approximately 50`, `±`→`plus or minus`), and does light prosody repair — while preserving meaning, ordering, and every informational fact. **No summarizing, no commentary, no new claims, no introduced `Speaker:` labels.** The permitted expansions are exactly those in `assets/expand-allowlist.json`; do not invent others.

Then verify mechanically:

```sh
uv run --script scripts/podcast.py expand <slug>          # → emits the llm-expand plan
# (you fill the slot → <slug>.tts.txt)
uv run --script scripts/podcast.py verify-expand <slug>    # → deterministic predicate
```

`verify-expand` checks (first failure wins): paragraph/sentence counts did not collapse (no silent summarization); every content token of `clean` is present in `tts` verbatim **or** explained by the allowlist (a `from→to` pair where a `to` form appears in `tts`); and no `Speaker:` labels were introduced. On failure it re-emits `llm-expand` for the failing chunk with a `reason` and `retry_count`; after one retry it emits `human`. The allowlist starts strict; loosen `assets/expand-allowlist.json` only if you observe false-failures.

`<slug>.clean.txt` is left untouched — it is the faithful text for metadata; expansions would fail `checkclean`, which is why this pass runs after `verify`.

### 3. Metadata extraction (`llm-metadata`)

Read `assets/metadata-prompt.md` and apply it to `<slug>.clean.txt`. The model returns a single JSON object validated against `assets/metadata-schema.json`:

```json
{"title": "...", "artist": "...", "description": "...", "date": "YYYY-MM-DD"}
```

Write it to `<slug>.meta.json`. `podcast.py assemble` (and `finalize-metadata`) handle the rest deterministically: empty `date` → today, `description` >500 chars → written to `<slug>.desc.txt` with `--description-file`, missing `title`/`artist` → sensible defaults. You don't fix these up yourself. Run `podcast.py finalize-metadata <slug>` to preview the exact `wav2m4a.py` argv before assembly.

## Multi-voice dialogues (`--dialogue`)

For interview Q&A, panels, screenplays — anything with repeated `Speaker:` labels — a single narrator is flat. Declare it upfront:

```sh
uv run --script scripts/podcast.py plan <input> --dialogue --voice-map "HOST=female,GUEST=male"
```

`plan` writes `<slug>.voices.json` (`{dialogue, voice_map, defaulted}`) and routes through tag-dialogue. After `verify-expand` succeeds:

```sh
uv run --script scripts/podcast.py tag-dialogue <slug>    # → <slug>.tags.json
```

`tag-dialogue` **requires `<slug>.voices.json`** — it fails fast (`human`) if you forgot `--dialogue`. It splits `<slug>.tts.txt` (or `.clean.txt`) into blank-line paragraphs and writes `<slug>.tags.json`. Each entry is `{index, speaker}` for paragraphs whose leading `Label:` matches a declared speaker, or `{index, speaker: "?", text_preview}` for everything else. The envelope's `holes` lists the indices that need a speaker.

- **No holes** → `next.action` is `cli` → `merge-tags`. The LLM is not invoked.
- **Holes present** → `next.action` is `llm-tag-dialogue`. **Your only edit is to replace each `speaker: "?"` in `<slug>.tags.json` with one of the declared speakers** (use `text_preview` for context). You don't rewrite paragraphs, you don't touch `<slug>.tagged.txt`, you don't transcribe voice ids. Then run:

```sh
uv run --script scripts/podcast.py merge-tags <slug>   # → <slug>.tagged.txt
uv run --script scripts/podcast.py verify-tags <slug>    # → prints the tts_multivoice.py command
```

`merge-tags` validates that no `?` remains, strips leading `Speaker:` labels, prepends `[SPEAKER]` per paragraph, and writes `<slug>.tagged.txt`. `verify-tags` asserts every paragraph has a known-speaker tag with no residual labels and emits the ready-to-run `tts_multivoice.py` argv — the voice map comes from `<slug>.tags.json` (copied from `<slug>.voices.json` at tag time), so you never transcribe voice ids.

Run the printed command (the output WAV is `<slug>.wav`). Input format:

```
[INTERVIEWER] Walk me through a time you set the strategy for a team you owned.

[CANDIDATE] The strategy was a clear build-versus-buy call. My team was tasked with…
```

When producing a multi-voice version alongside a single-voice file, write to a distinct name (e.g. `<slug>-2voice.m4a`). The output WAV then continues through `finalize-metadata`/`assemble` unchanged.

## Text → WAV (speech synthesis)

**Single-voice path** (no `--dialogue`):

```sh
uv run --script scripts/podcast.py pick-tts-source <slug>
# → prints the exact tts.py command (synthesizes from <slug>.tts.txt, produced by llm-expand)
```

**Multi-voice path:** `verify-tags` already emits the `tts_multivoice.py` argv. You don't call `pick-tts-source` in this case.

Run the printed command. `tts.py` / `tts_multivoice.py` flags:

- `--voice` accepts an **alias** (`female`→`af_heart` default, `male`→`bm_george`) or a **full Kokoro id** (`af_bella`, `am_michael`, `bf_emma`, `bm_lewis`, …). The id prefix encodes language+gender (`a`=American, `b`=British; `f`=female, `m`=male). Run `uv run --script scripts/tts.py -h` or read `VOICES` in `scripts/tts.py` for the full list.
- `--speed` (default `1.0`).
- First run downloads the Kokoro-82M weights (~315 MB). The scripts split on blank lines, so very long inputs stream paragraph by paragraph.

## Final assembly

```sh
uv run --script scripts/podcast.py assemble <slug>           # deletes intermediates
uv run --script scripts/podcast.py assemble <slug> --keep    # keep them for debugging
```

This runs `scripts/wav2m4a.py` with the finalized metadata. By default it **masters** the audio: a voice EQ/compression chain plus two-pass loudness normalization to −16 LUFS (Apple Podcasts / EBU R128) with a −1.5 dBTP true-peak ceiling, then a 48 kHz resample. See the module docstring in `scripts/wav2m4a.py` for the full chain. Pass `--no-master` to `wav2m4a.py` only if you bypass `assemble` and encode untouched. After producing the `.m4a`, intermediates (`.pdf`, `.raw.txt`, `.clean.txt`, `.tts.txt`, `.voices.json`, `.tags.json`, `.tagged.txt`, `.wav`, `.desc.txt`, `.meta.json`, `.log.jsonl`, and any chunk files) are deleted unless `--keep` is set.

## File naming

All intermediate files are derived from a single `<slug>` that `podcast.py` owns — you don't name them. For reference:

| Stage | File |
|-------|------|
| Raw text | `<slug>.raw.txt` (chunked: `<slug>.raw.NNN.txt`) |
| Clean text | `<slug>.clean.txt` (chunked: `<slug>.clean.NNN.txt`) |
| TTS text (post-expand) | `<slug>.tts.txt` (chunked: `<slug>.tts.NNN.txt`) |
| Declared voice map (dialogue) | `<slug>.voices.json` |
| Multi-voice tag slots (conditional) | `<slug>.tags.json` |
| Multi-voice tagged text (conditional) | `<slug>.tagged.txt` |
| WAV | `<slug>.wav` |
| Metadata JSON | `<slug>.meta.json` |
| Long description | `<slug>.desc.txt` |
| Resumability log | `<slug>.log.jsonl` |
| Final | `<slug>.m4a` |
| PDF (fallback only) | `<slug>.pdf` |

## Worked example (single voice)

```sh
URL="https://example.com/posts/the-mythical-agent-month"
S="$HOME/.agents/skills/text-to-podcast"   # or your clone path

uv run --script "$S/scripts/podcast.py" plan "$URL"
# → slug=example-com-posts-the-mythical-agent-month, first command = fetch

uv run --script "$S/scripts/podcast.py" fetch "$URL"
# → <slug>.raw.txt, next.action=llm-reformat

# (you) apply assets/reformat-prompt.md → <slug>.clean.txt
uv run --script "$S/scripts/podcast.py" verify "<slug>.raw.txt" "<slug>.clean.txt"
# → next.action=cli (expand)

uv run --script "$S/scripts/podcast.py" expand "<slug>"
# → next.action=llm-expand
# (you) apply assets/expand-prompt.md → <slug>.tts.txt
uv run --script "$S/scripts/podcast.py" verify-expand "<slug>"
# → next.action=cli (pick-tts-source)

uv run --script "$S/scripts/podcast.py" pick-tts-source "<slug>"
# → prints: uv run --script scripts/tts.py <slug>.tts.txt <slug>.wav [--voice female]
# (run it)

uv run --script "$S/scripts/podcast.py" metadata "<slug>"
# → next.action=llm-metadata
# (you) apply assets/metadata-prompt.md → <slug>.meta.json
uv run --script "$S/scripts/podcast.py" assemble "<slug>"
# → <slug>.m4a, intermediates deleted
```

## Worked example (multi-voice)

```sh
uv run --script "$S/scripts/podcast.py" plan "$URL" --dialogue --voice-map "HOST=female,GUEST=male"
# ... fetch, reformat, verify, expand, verify-expand as above ...
uv run --script "$S/scripts/podcast.py" verify-expand "<slug>"
# → next.action=cli (tag-dialogue)
uv run --script "$S/scripts/podcast.py" tag-dialogue "<slug>"        # → <slug>.tags.json
# (you fill `speaker: "?"` slots, IF any; else skip)
uv run --script "$S/scripts/podcast.py" merge-tags "<slug>"         # → <slug>.tagged.txt
uv run --script "$S/scripts/podcast.py" verify-tags "<slug>"       # → prints the tts_multivoice.py command
# (run it → <slug>.wav)
uv run --script "$S/scripts/podcast.py" metadata "<slug>"
# (you) apply assets/metadata-prompt.md → <slug>.meta.json
uv run --script "$S/scripts/podcast.py" assemble "<slug>"
```

## Troubleshooting

For any `ok: false` envelope, read its `reason` and `hint` first — they name the problem and the exact next command. The notes below cover cases the envelope does not.

**Fetch (JS-heavy pages)** — `fetch`'s fallback bumps Gotenberg's wait delay automatically; if a page still loads blank, retry `fetch` with `WAIT_DELAY=15s` in the env (up to `30s`).

**Expand allowlist too strict** — a legitimate expansion flagged by `verify-expand` is a config gap, not a model error: loosen `assets/expand-allowlist.json` and document the addition.

**TTS prosody / OOM** — robotic prosody → different `--voice` (e.g. `male`, or `bm_lewis`) or `--speed 0.95`. `--voice` rejected → use an alias or full Kokoro id. OOM on a paragraph → break that paragraph up in the `.clean.txt`, re-run `expand`, then re-synthesize.

**Multi-voice tag in wrong voice** — if a tag comes out in the wrong voice despite `verify-tags` passing, the bracketed tag doesn't match the `--voice TAG=VOICE` arg `verify-tags` emits (tags are case-sensitive, must be first on the paragraph) — re-confirm the voice map with the user and re-run `tag-dialogue`.

**Metadata defaults unwanted** — `assemble` fills empty `date` with today and falls back to sensible defaults for missing `title`/`artist`. If those defaults are wrong, re-run the metadata prompt with a longer excerpt including the byline, or edit `<slug>.meta.json` manually before `assemble`. (Description over 500 chars → `assemble` routes it to `<slug>.desc.txt` automatically; no action needed.)

**ffmpeg fails at assemble** — check disk space, or lower `--bitrate` via `wav2m4a.py`, or pass `--no-master` to isolate the mastering chain.

**General** — `assemble --keep` retains intermediates (including `<slug>.log.jsonl`). You can start at any subcommand if you know the state; consult `<slug>.log.jsonl` to resume from the last logged envelope.

