This document explains, in detail, how PodcastTool produces a complete podcast episode:
the research logic, the context budget, the narrative writing strategy and the audio
pipeline. It is the developer companion to README.md (usage) — it answers why the
pipeline is shaped the way it is and how to reason about changes.
PodcastTool is a single-method agent tool:
generate_podcast(topic, language?, podcastName?)
→ MP3 at /podcast/{podcastName-or-topic}/{topic}-{date}.mp3
→ script .md next to it
One call produces: a ~25,000-character narrative script (≈ 30 minutes of speech) and the mixed audio (welcome intro → jingle → episode body, with a soft ducked background bed) encoded as MP3.
The pipeline has six phases:
1. PLAN LLM: news-vs-evergreen judgment + English search queries + three-act outline
2. RESEARCH deterministic: multi-query web search + 15 international RSS feeds + Google News
per-query, deduplicated and relevance-ranked, bounded by the 3X context budget
3. BRIEF LLM: compress the raw research into a research brief ≤ X (facts/dates/numbers)
4. WRITE LLM: the body act by act (in the episode language); bounded per-act enrichment
5. INTRO LLM: short welcome (podcast name) announcing the themes the body actually covers
6. MIX deterministic: TTS narration → mix (intro clean → jingle → body on a ducked bed)
→ WAV → MP3 (GroovyMp3) → /podcast/...
The design goal is result quality with token economy: the raw research never enters the writing prompt (a brief does), the script length is planned up front instead of fixed by post-hoc padding, and every LLM call has a bounded, explicit purpose.
PodcastPlanner.Plan asks the model for a single JSON object:
{
"needsRecentFacts": true,
"searchQueries": ["technology", "AI news", "artificial intelligence trends"],
"acts": [
{"act": 1, "title": "The promise", "beats": ["...", "..."], "targetChars": 8000},
...
]
}The episode is classified by the answer to a conceptual question: "does this episode depend on recent or upcoming facts and events?" The model decides; there is no keyword dictionary.
- A podcast about food events →
needsRecentFacts: true(ongoing/upcoming events matter). - A podcast about food recipes →
false(evergreen; a recipe does not expire).
This is deliberately NOT keyword-based: keyword lists create brittle bugs (the same domain can be news or evergreen depending on the angle) while the conceptual judgment generalizes.
The queries are always English even when the topic is in another language — the research
sources (international feeds, Google News, DuckDuckGo) are English-centric, and a query in a
different language returns nothing useful. The model is instructed to combine multiple themes
with AND and to add variant phrasings ("traveling AND low cost", "cheap holidays",
"budget travel") because a single web query returns very few results (~10), far fewer than
what 30 minutes of narrative needs.
The acts carry title, beats (4-7 key story moments) and targetChars. This is the
"map of reference points" of the narrative style spec: writing is anchored to a planned
structure instead of improvised, and the per-act targets make the final length predictable
(the planner normalizes the targets so they always sum to the episode target X).
PodcastContext.Gather accumulates raw material (search result titles/descriptions, feed
headlines, full article text) until the 3X budget — the empirical rule that producing a
text of X needs up to 3X of context. Everything is capped and every source fails
independently.
needsRecentFacts |
Search 1 | Search 2 | Searches 3+ |
|---|---|---|---|
true (news/events) |
Today |
Week |
no filter |
false (evergreen) |
no filter | no filter | no filter |
The first query targets today, the second this week; the remaining queries are unrestricted so the episode also gets general background for its reflective sections (the brief keeps the recent-vs-background distinction). Evergreen topics never use a time filter.
- Web search: one DuckDuckGo search per English query (the shared
WebSearchEngine, which throttles calls to one every 10 s — a 3-6 query plan costs 30-60 s). - RSS: 15 verified international feeds (BBC, Guardian, DW, France24, Al Jazeera, NHK, UN News, NDTV, Intercept, Drop Site, ProPublica, Project Censored, Global Voices, Zero Hedge, CounterPunch) — headlines only, cheap. Reuters/AP no longer publish public RSS; their content is reached through the Google News per-query feed.
- Google News per-query:
news.google.com/rss/search?q=<query>— the topical bridge: it returns recent items for a query, which is what the "10 results" web search alone cannot cover. - Full pages: only the top 2-3 pages by relevance score are read in full (the ranking extracts URLs from the accumulated lines and scores them by keyword overlap); each page is capped at 8,000 characters. A non-news topic (e.g. recipes) simply scores ~0 on the news feeds and the web carries the research — the pipeline does not special-case it.
URLs are normalized (lowercased, query strings dropped) and de-duplicated in a set; the raw material builder stops as soon as the 3X character budget is reached. Duplicate stories across sources are merged later by the brief.
The raw material (up to 3X) is compressed by a single LLM call into a brief ≤ X that preserves every concrete fact, date, number, name, quote and statistic, merges duplicates and keeps the recent-vs-background distinction. The script writer sees only the brief.
Why: feeding 3X of raw context to the writer wastes tokens on noise and redundant coverage of the same stories. A compressed brief is smaller, cleaner and keeps the facts.
If the brief call fails, the fallback is the deduplicated raw material capped at X.
PodcastScript.Generate writes each act in a separate LLM call:
- Every act prompt carries the full narrative style spec (hook, short sentences, explicit
irony markers,
[pausa musicale]/[breve silenzio]cues every 150-200 characters, no bullets/emoji), the act title + beats + the DETERMINISTIC target length, and — except for act 1 — the last 300 characters of the previous act as a seam so the transition reads as one story. - Episode duration is deterministic (
PodcastConfig/PodcastLengths): the user sets onlydurationMinutesinpodcast.json(default 30); the act count (round (duration−2)/9.333, clamped [3,6]), the equal per-act character target (actsMinutes × 833 / actCount) and the 3× research budget are all derived. After each act the cumulative difference vs the cumulative prediction is applied to the NEXT act's target (bounded to 50-150% of the base), so the total converges to the configured duration without ever trimming the narrative. The LLM never decides the lengths. The config file is never overwritten (the plugin updater skips.jsonfiles). - The output length is NOT capped with maxToken: truncating mid-act could sever the narrative and the provider's cap enforcement proved unreliable. The model writes each act fully. Episodes are kept whole (the operating rule: never cut the narrative for the minute) — the deterministic targets keep the total on schedule; the final duration lands within a few minutes of the configured value. There is NO length trim and no outro repair: the correction happens at write time, the narrative always stays whole.
- Every act prompt and the intro state today's date (UTC) as the temporal reference — the writer must know whether an event "on 28 August" is today, past or upcoming; the planner and the brief already carry it.
- The act markers (
[PRIMO ATTO],[SECONDO ATTO], … up to[SESTO ATTO]) are inserted deterministically by the code, not requested from the model; the measurement/extraction helpers discover them from the body, so any act count works. - After all acts, the length is measured per act (markers excluded). Acts below 80% of their target are expanded inline ("add narrative considerations that deepen the existing beats") — a bounded fallback, at most two rounds, per-act and anchored to the act's content, so it never degrades into generic padding.
- Emoji are removed deterministically at the end (
PodcastScript.FilterEmoji— keeps the ASCII[markers]and punctuation).
The body is written in the episode language (from the language parameter, or detected
from the topic via Utility.DetectLanguage).
The intro is generated after the body, in a separate short LLM call (3-5 sentences,
≤ 700 characters): it welcomes the listeners of the podcast (by name when podcastName is
given) and announces the themes that the body actually covers (the prompt receives the
body's opening). This ordering guarantees the announcement cannot diverge from the content.
The saved .md script is: intro + [SIGLA] + body.
The narration is synthesized sentence by sentence (VoiceConversation.SplitSentences) by
the plugin-local PodcastTts engine (Kokoro, 24 kHz mono int16) — see the migration note
below. The input is normalized before synthesis: canonical apostrophes (every Unicode
apostrophe variant → U+0027 — verified: the typographic ’ breaks the Italian elision
"l'amico", the ASCII apostrophe elides correctly) plus the speakable pipeline. The engine
tries the CUDA provider first and falls back to CPU automatically (it works on machines with
and without a CUDA GPU — see AIOrchestrator/docs/TTS-CUDA-ACCELERATION.md for the install
and the PATH settings). The body's [markers] become silences (0.4-1.0 s) instead of speech.
Benchmark (GTX 1650 Ti, i7-10750H, 2026-08-28) — long-form synthesis (356 s of audio): CPU (default 8 threads) x2.12 realtime; CUDA (12.8 + cuDNN 9.7) x3.20 realtime (~1.5x faster). CPU thread count 4/8/12 shows no scaling (12 threads is worse — oversubscription).
Two CC0 tracks (Open Lo-Fi, CC0-1.0) travel with the plugin under assets/audio/ and are
resolved at runtime from the host assets/ folder: jingle.mp3 (the sigla, used for its
first 12 seconds with a fade-out) and background.mp3 (the bed loop). They are ALSO embedded
as assembly resources (PodcastTool.assets.audio.*): the dev ship target (ShipOnePlugin Ship) excludes assets/, so the embedded copy is the guarantee that the music exists in
every deployment (dev ship, release zip, NuGet) — FindAsset extracts it to a temp cache
only when no on-disk copy is found.
The music is decoded/resampled to 48 kHz stereo with OwnAudioSharp.Basic FileSource
(the full OwnAudioSharp package drags Avalonia/UI; Basic is the audio-only variant).
The Rust native ownaudio_ffi must sit in the host app base directory — verified
empirically: the byte-loaded plugin shim cannot resolve the native from the plugin folder —
so PodcastMixer.EnsureNativeAvailable copies the matching per-RID native from the plugin
payload on first use (idempotent, cross-platform).
Why a custom mix: OwnAudioSharp's Mixer in ClockMode.Offline renders in real time
(measured: 30 s of audio took 120 s wall-clock) — a 30-minute episode would take 30 minutes
to render. The plugin therefore uses OwnAudioSharp for what it is good at (decode,
resample, read) and performs the mix sample-by-sample in a fast deterministic loop
(~seconds for 36 minutes of audio).
Layout and levels:
[intro voice] (no bed)
[0.6 s breath]
[jingle] (no bed, full level, 0.6 s fade-out)
[body voice] on a soft bed: 0.05 under speech, 0.12 in the pauses (smoothed ducking)
- The bed starts only where the body starts (the intro and the jingle are clean).
- Ducking is deterministic at sample level: when the narration sample is speaking (|voice| > 0.008) the bed gain eases to 0.05, otherwise to 0.12, with a per-frame smoothing constant (attack/release) so it does not pump.
- The output is 48 kHz stereo 16-bit WAV, written in one-second blocks.
The final deliverable is MP3, encoded by GroovyMp3 (a pure-managed C# port of LAME). It was chosen over the alternatives:
| Option | Why not |
|---|---|
EggEncoder |
natives are win-x64 only (contentFiles) — the plugin is cross-platform |
Sdcb.FFmpeg |
per-RID ffmpeg natives (~30-60 MB per platform) bloat the plugin zip |
GroovyMp3 |
pure managed, no natives, LAME-derived quality — adequate for speech |
The WAV PCM is fed to Mp3Encoder in chunks (EncodeBuffer + EncodeFinish) and the
bulky WAV intermediate is deleted after encoding.
/podcast/{podcastName-or-topic}/
{topic}-{date}.md # intro + [SIGLA] + body (the script)
{topic}-{date}.mp3 # the audio deliverable (the agent attaches it to the reply)
Both files are versioned with GitSupport.Snapshot; the tool returns the sandbox-relative
paths. The method result tells the agent to attach the MP3 to the user reply.
- Every network/LLM source fails independently (a broken feed never aborts the episode).
- The tool returns actionable
Error: ...messages with the cause (empty topic, plan failure, no context gathered, empty script, audio skipped with the TTS/assets reason). - The audio is best-effort: if the host lacks the TTS assets or the music, the script is still produced and saved with an "Audio skipped: ..." note.
- All loops are bounded: enrichment ≤ 2 rounds, research ≤ 3X budget, feeds ≤ 40 s.
- Plugin-local TTS:
PodcastTtsmirrors the sharedAIOrchestrator.KokoroTts(which landed on AIOrchestrator master after the same-day package cut). OnceGraphene.AIOrchestrator ≥ 1.26.08.29is published,PodcastMixershould switch back to the shared engine andPodcastTts.cscan be deleted. - MP3 quality: GroovyMp3 (LAME port, 128 kbps) is adequate for speech; if music-heavy
episodes need more headroom, the upgrade path is
Sdcb.FFmpeg(per-RID natives — the plugin zip already carriesruntimes/natives). - Long-form: a 60-minute episode doubles X, the 3X budget and the TTS time linearly; the act targets and enrichment scale automatically.
- The
puntatanaming: the episode file uses{topic}-{date}— deterministic and self-describing; a numeric episode counter could replace the date if the host tracks a series.
The harness enables Log.IsEnabled and the tool logs each phase (plan summary, per-query
result counts, raw/brief sizes, per-act lengths, narration/jingle timing, MP3 size) to
logs/<pid>.txt next to the executable — tail it to monitor a run:
Get-Content <harness-bin>/logs/<pid>.txt -Tail 20.