Home

Agent Workflows in Dead Poets FM

How one command turns a poem title into a produced radio segment: a Claude director pass, five designed voices, and a deterministic FFmpeg mix.

TL;DR

  • Dead Poets FM is an AI poetry radio station: classic poems, expressive narration, ambient soundscapes, and a WebGL CRT television to watch while you listen.
  • The sound comes from a three-stage pipeline, not a single TTS call. Claude annotates each poem into a directed performance script, ElevenLabs synthesizes it with pinned voice settings, and FFmpeg mixes narration over an ambient bed with versioned constants.
  • The whole workflow hides behind one Claude Code command. /generatepoem researches the poem, verifies it is public domain, routes it to a voice, generates the audio and cover art, uploads everything to the CDN, and syncs the database.
  • The station currently runs 14 poems by 13 poets across 5 designed voices, and every constant that shapes the sound (the 35-tag vocabulary, the voice settings, the mix profile) lives in the repo under version control.

Dead Poets FM is a side project that reads classic poetry aloud, the way a late-night radio station would: a warm voice, a soft bed of piano or strings, and a CRT television glowing in the dark. Under the nostalgia is a pipeline I have rebuilt three times, and the current version has one property I am proud of: one command takes a poem from title to published segment.

The naive way to build this is a single API call: poem text in, speech out. I started there, and the results were flat. TTS models read poetry the way they read terms of service. Line breaks get steamrolled, emotional turns get missed, and the pause that gives a stanza its weight never arrives.

The fix was to stop treating text-to-speech as the product and start treating it as one stage in a system. Everything below is in the open repo, so you can steal any of it.

The pipeline at a glance

A poem passes through three stages. Each stage has one job, one output format, and one set of constants, all checked into the repository.

Input

raw poem text

Stage 1 · Direction

Claude annotates a performance script (35-tag vocabulary)

Stage 2 · Synthesis

ElevenLabs v3, one of 5 designed voices, pinned settings

Stage 3 · Mix

FFmpeg filtergraph: lead-in, ambient bed, fade-out

Output

final master, 192 kbps MP3, served from the CDN
Three stages, three contracts. The LLM never touches audio, the TTS model never makes creative decisions, and the mixer never calls an API.

Stage 1: Claude directs, it does not narrate

The first clever thing in the pipeline is what Claude is asked to be. It is not the narrator and it is not a rewriter. It is the director: it reads the poem, understands the emotional arc, and marks up a performance.

The markup language is deliberately tiny. The prompt constrains Claude to a fixed vocabulary of 35 tags: 27 emotions ([wistfully], [defiantly], [tenderly]), 6 expressive actions ([sighs], [whispers], [gasps]), and 2 pauses ([short pause], [long pause]). Emphasis is CAPITALIZED, at most a few words per stanza. And one integrity rule sits above everything: every line of the source poem survives verbatim. No paraphrasing, no dropped lines, no explanations in the output.

raw_poem.txt
Two roads diverged in a yellow wood,
And sorry I could not travel both
And be one traveler, long I stood
directed_script.txt
[thoughtfully] Two roads diverged in a
yellow wood, [short pause]
[wistfully] And sorry I could not
travel BOTH [short pause]
And be one traveler... [hesitates]
long I stood [short pause]
The director pass. Same words, new information: where to breathe, where to lean in, what to stress. The text itself is untouched.

Constraining the vocabulary is what makes the output auditable. A diff between the raw poem and the directed script should show only bracketed tags and capitalization. If anything else changed, the generation is rejected. You cannot review “make it emotional”, but you can review a tag diff.

The directed script also turned out to be a real intermediate representation. When I experimented with Grok TTS as a second synthesis backend, I did not touch the pipeline. I wrote a second system prompt that compiles the same poem into Grok’s dialect (wrapping tags like <soft>, <slow>, <whisper> instead of ElevenLabs’ inline ones). One director, two performance languages, the way a compiler targets two architectures from one AST.

Stage 2: five voices, zero knobs at generation time

The voices were designed once, with ElevenLabs Voice Design, and given fixed roles. Poems route to voices by rule, not by vibe:

The five production voices. Routing priority: Irish poets first, women poets second, then match by mood.
VoiceCharacterGets assigned
dpfm-man-1older, gravellydefiant, intense (Invictus, Do Not Go Gentle)
dpfm-man-2mature 40s, warm baritonereflective, romantic (The Road Not Taken)
dpfm-man-3commanding, theatricalepic, dramatic (Ozymandias, Brahma)
dpfm-woman-1female narratorwomen poets (Dickinson)
dpfm-irish-bardIrish accent, contemplativeIrish poets (Yeats, Heaney)

Generation settings are pinned in code: stability 0.0 (the v3 “Creative” end, so the audio tags actually land), similarity boost 0.7, style 0.5, speaker boost on. Nothing is tuned per poem. If a reading sounds wrong, the fix goes into the directed script, where it can be reviewed, not into a knob nobody will remember touching.

The subtle bit here is chunking. The API takes 5,000 characters per request, and long poems exceed it. The splitter packs stanzas greedily up to 4,500 characters and only ever cuts on a blank line, so every seam between API calls lands exactly where the script already has a [long pause]. The MP3 buffers concatenate cleanly, and the join is inaudible because the join point is silence by design.

Stage 3: a mix you can replay

Narration alone sounds like a voicemail. The station sound comes from an ambient bed (five original tracks: two soft pianos, three string beds) mixed underneath, and the mix is a single FFmpeg filtergraph:

[0] adelay=1500|1500                                  [voice]
[1] volume=0.25, aloop=loop=-1, atrim=0:total,
    afade=t=out:st=total-2:d=2                        [music]
[voice][music] amix=inputs=2:duration=longest         [out]
The whole mix, three filter lines. Delay the voice by the lead-in, loop and trim the bed to the exact total, fade the tail, sum the two.
Mix constants, versioned in the repo. Changing the station’s sound is a code review.
ConstantDefaultWhat it does
music volume0.25bed sits well under the voice
lead-in1.5 smusic breathes before the first line
trail-out4.0 smusic holds after the last line
fade-out2.0 sthe last two seconds of the trail
master bitrate192 kbpsfinal MP3 encode

Determinism is the point. The two API stages are creative and therefore variable; the mix stage is math. Given the same narration stem and the same bed, the master is byte-identical. When I regenerate a narration, the station still sounds like the station, because the sound of the station lives in these constants and not in anyone’s DAW session.

What it sounds like

Three finished segments straight from the station, one per voice family. Each one is the full pipeline you just read about: a directed script, a pinned voice, and the deterministic mix.

OzymandiasPercy Bysshe Shelleydpfm-man-3 · commanding, theatrical0:00 / 1:18
InvictusWilliam Ernest Henleydpfm-man-1 · older, gravelly0:00 / 0:52
Hope is the Thing with FeathersEmily Dickinsondpfm-woman-1 · female narrator0:00 / 0:46
Streaming from the station’s CDN, exactly as they play on deadpoets.fm. Note the routing at work: defiance goes to the gravel, grandeur goes to the theater, Dickinson goes to the woman’s voice.

One command, the whole workflow

Adding a poem used to be an afternoon: find a clean text, check the copyright, write the data entry, run three CLI tools in order, compress the art, upload, seed the database. All of it is now a Claude Code agent invoked as:

/generatepoem "Ozymandias" by Percy Bysshe Shelley

The agent runs phases in order and stops at the first failure:

  • Research. It finds the authoritative text, the author’s dates, the publication year, and enough history for the poem’s info card.
  • The public-domain gate. If the poem was not published before 1928, the agent refuses and stops. A hard legal check sits in front of every creative step.
  • Voice routing. It applies the priority table above and proposes a voice before spending any audio credits.
  • Production. Direction pass, synthesis, mix, plus cover art compressed to WebP at quality 80 and capped at 1920 px (typically 70 to 80% smaller than the source image).
  • Publish. Assets upload to Cloudflare R2, the poem row syncs to Supabase, and a check-poem-sync script exists precisely because static data and database once drifted and I never want to debug that twice.

The pattern worth stealing: the agent automates the pipeline, but the pipeline is what guarantees quality. Each phase emits an artifact I can inspect (a script, a stem, a master), so automation never means “and then something happened”.

The part you can see

The listening surface is a WebGL CRT television, built with React Three Fiber and a custom GLSL fragment shader. It earns a mention here because it is the same philosophy as the mixer: a handful of tunable constants, versioned, producing one consistent look. The shader stacks barrel distortion (a radial 1 + r²k warp), resolution-locked scanlines, a slow-moving refresh line, chromatic aberration shifted along the radial direction, a phosphor glow that blooms only above a luminance threshold, per-frame noise, and a subtle 8 Hz flicker. Poems without cover art get authentic TV static instead of a placeholder.

The numbers

The station, quantified, as of July 2026.
MetricValue
poems in rotation14
poets13
designed voices in production5
ambient beds5
performance-tag vocabulary35
narration length range46 to 150 s
TTS chunk ceiling4,500 chars
steps I run by hand to add a poem1

The takeaway

Quality came from orchestration, not from swapping model providers. A strong TTS model still underperforms on unstructured input; a mid model with a good director pass beats it comfortably.

If you are building anything in this space, the transferable rules are:

  • Put a structured intermediate representation between the LLM and the media model, and make it diffable.
  • Constrain the LLM to a small vocabulary so its output can be audited.
  • Pin every generation setting and make post-processing deterministic. Creativity in the stages that need it, math everywhere else.
  • Automate the full path behind one command, and put the validation gates inside the automation, not around it.

Further listening and reading