This is a submission for the Hacktoberfest Open-Source AI Challenge Week 2: Side Hustle
What I Built
Most podcasts that have been running for a few years have the same problem: hundreds of old episodes with no transcript, no chapters and no show notes worth reading. That archive is what new listeners stumble on through search, and it is the part nobody wants to pay per-hour SaaS prices to fix.
chaptermill takes a podcast's RSS feed and, on a machine you already own, turns the back catalog into the files a producer actually delivers:
- a timestamped transcript (SRT, VTT, JSON),
- chapters as a Podcasting 2.0
chapters.jsonand as YouTube chapter lines, - 2–4 pull quotes, each checked word for word against the transcript before it is accepted,
- Markdown show notes with a summary, topics and a credit line.
Who pays for it: freelance podcast producers (show notes are a standard deliverable) and independent shows with a big archive. The hosted tools price by the audio hour. On their pricing pages today, Castmagic's Hobby plan is $19/month billed annually for 30 transcribed hours in total, and its Team plan, 400 hours, is $139/month billed annually; Podsqueeze's Agency Lite plan is $89/month for 600 minutes per month. A 300-episode archive of half-hour shows is 150 hours. With chaptermill, at the rate of my demo run, the same archive costs about $4.40 in open-weight model calls and a day of runtime on a 2015-era desktop. The side hustle writes itself: a flat-fee "archive pass" for small shows, where the marginal cost is cents. For scale: Hacker Public Radio, whose episodes I used for the demo, numbers its episodes past 4,700. At the average length of the nine I processed (28.5 minutes) that is roughly 2,250 hours, or about $65 of model calls at my demo run's rate. That is an estimate from a sample of nine, not a quote.
To be fair to the incumbents: Apple Podcasts and Spotify now generate chapters for some shows inside their own apps. That helps listeners on those apps. It does not give the producer a file to put in the feed, on YouTube or on the show's website, and it does not touch the archive in one go.
Demo
Live: https://bowen1314.github.io/chaptermill/
Nine recent episodes of Hacker Public Radio (a community podcast released under CC BY-SA 4.0), processed on my home server (one run, plus a re-run of two episodes that I explain below), with nothing edited by hand. Pick an episode; press a chapter or a quote and the player jumps there; open the full transcript and the current line follows the audio. Every file is downloadable.
Further down the page is the part I am proudest of: the report from a second run during which a script SIGKILLed the worker 9 times and the Temporal server once, while the worker injected model-API 503s, cut-off downloads and failing tool calls on purpose.
Code
Bowen1314
/
chaptermill
Turn a podcast back catalog into transcripts, chapters, verified pull quotes and show notes with open-weight models; Temporal makes it survive crashes.
chaptermill
Point it at a podcast RSS feed and leave it running on an ordinary PC. It turns the back catalog into transcripts, chapters, pull quotes that are checked word for word against the transcript, and show notes ready to paste (the demo run did 4.3 hours of audio in 44 minutes on a 2015-era desktop). Open-weight models do the work. Temporal makes it durable: a crash, a dropped connection or a rate-limited model API means it carries on from where it stopped, without redoing finished work or re-buying model replies it already has.
Live demo: https://bowen1314.github.io/chaptermill/ (nine Hacker Public Radio episodes, plus the report from a run where I kept killing the worker and the Temporal server)
Built for the DEV Hacktoberfest Open-Source AI Challenge, week 2 (Side Hustle), Temporal category.
Who it is for
Freelance podcast producers who write show notes for clients, and independent shows sitting on…
MIT licensed. About 1,900 lines of Python plus 560 lines of tests, including workflow tests on Temporal's time-skipping test server.
How I Built It
Three open pieces do all the work:
| Piece | What it does | License |
|---|---|---|
faster-whisper base.en
|
transcription, locally, on 2 CPU threads | MIT |
| Qwen3-235B-A22B-Instruct-2507 | the show-notes agent, through Nebius Token Factory's OpenAI-compatible API | Apache-2.0 |
| Temporal (dev server + Python SDK) | durable orchestration, retries, signals, queries | MIT |
The machine is a shared home server: an Intel i3-6100 (2 cores), 3.8 GB of RAM and one spinning disk, also running a dozen other services.
The shape of it
A CatalogWorkflow reads the feed and starts one child EpisodeWorkflow per episode, two at a time, inside a dollar budget. Each episode workflow downloads the audio, cuts it into 5-minute chunks (one activity per chunk, on its own task queue so Whisper never starves the rest), merges the transcript, then runs the agent loop and writes the files.
The agent gets a one-line-per-minute outline and three tools: read_transcript(start, end), search_transcript(query) and submit_notes(...). Every model call and every tool call is a separate Temporal activity, so each one has its own timeout and retry policy, and a crash in turn 6 resumes at turn 6:
for _turn in range(agent.MAX_TURNS):
await self._wait_for_cap() # durable wait for a raise_cap signal if over budget
resp = await workflow.execute_activity_method(
Activities.llm_turn,
LlmRequest(model=inp.model, messages=messages, tools=agent.TOOLS, ...),
start_to_close_timeout=timedelta(minutes=3),
heartbeat_timeout=timedelta(seconds=30), # a dead worker is noticed in 30 s
retry_policy=LLM_RETRY, # 429/5xx retried, other 4xx fail fast
)
messages.append(resp.message)
for call in resp.message.get("tool_calls") or []:
if call["function"]["name"] == "submit_notes":
v = await workflow.execute_activity_method(Activities.validate_notes, ...)
out = "Accepted." if v.ok else "Rejected. Fix these and call submit_notes again:\n- " + "\n- ".join(v.errors)
... # read_transcript / search_transcript: one activity each
messages.append({"role": "tool", "tool_call_id": call["id"], "content": out})
Audio, transcripts and chunk results stay on disk; workflow history carries file paths and the agent's conversation, nothing bigger.
The model is checked, not trusted
submit_notes does not end the loop. A validator compares the submission with the transcript, and if anything is off, the error goes back to the model as the tool result. In the demo run it rejected 4 submissions; the model's next submission fixed each of them. Two of them are worth showing, because they are opposite failures:
- HPR4749: the model's first quote silently cut a sentence out of the middle ("And from the website, I'm gonna quote it here.") and stitched the rest together. Rejected; the resubmission quoted the host exactly.
- HPR4747: the model wrote "in place of cat", which is what the host said (the Unix command). Whisper had transcribed it as "CAD". The validator rejected the correct word, and the final quote says "CAD".
That second one is the honest limit of this design: quotes are guaranteed to match the transcript, not the audio. A custom vocabulary for Whisper is the obvious next step.
Making it durable, for real
Temporal's AI cookbook patterns map almost one to one onto this pipeline:
-
Retries in one place. The HTTP client's own retries are off. Errors are classified: 408/409/429/5xx are retryable, other 4xx fail fast, and a
Retry-Afterheader becomes the activity'snext_retry_delay. - Idempotent model calls. Each reply is written to a ledger and to a cache keyed by a hash of the request before the activity returns, so if the worker dies a millisecond later, the retry reads the cache instead of buying the reply again.
- Chunks as checkpoints. A finished chunk is a file. Whisper runs at temperature 0, so a re-run chunk produces the same text.
- Heartbeats on every long activity, including a side thread that heartbeats while the CPU activity is stuck loading the model.
-
Budget as a durable wait. Episodes that would go over the run's budget wait in
workflow.wait_conditionuntil araise_budgetsignal arrives, for as long as it takes;pause/resumesignals and aprogressquery come for free.
Then I tried to break it. scripts/chaos.py started a three-episode catalog and, every 45 to 110 seconds, SIGKILLed the worker, kept it down for 5 to 20 seconds and started it again; on the third kill it took the Temporal server down too. On top of that, the worker failed 30% of model calls with a fake 503, cut off 50% of audio downloads and broke 15% of transcript tool calls, each only on an activity's first two attempts. The result:
| Kills | 9 SIGKILLs of the worker at random moments, one of them together with the Temporal server |
| Episodes finished | 3 of 3, in 12.5 minutes, with no manual intervention |
| Transcripts vs. the undisturbed run | identical, segment for segment (584 segments) |
| Activity attempts | 88 for 67 activities: 8 retried transcription chunks, 8 retried model calls, 3 retried downloads, 2 retried tool calls |
| Model replies received | 18, none bought twice |
| Requests in flight at a kill | 1: its reply died with the process and the retry asked again (the provider may have billed both) |
| Fake 503s absorbed | 6 |
| Model cost | $0.021 |
Each kill cost at most the one chunk or model call in progress. The unluckiest chunk, in HPR4740, was killed three times and finished on its fourth attempt; nothing before it was redone.
The box itself added chaos I had not planned. During the clean run, another service on the same disk was writing about 16 MB/s, and Linux reported I/O pressure between 40% and 87%. The Temporal dev server keeps everything in SQLite, so every history write queued behind that disk, and a few tasks were handed to my worker after the worker had stopped waiting for them. Temporal's answer was a timeout and a retry; the run finished anyway, with 20 workflow-task timeouts in its history. Halfway through I restarted the server with SQLite in WAL mode and synchronous=normal (still safe when the process is killed, which is the failure I care about here): before the switch 19 of 78 workflow tasks timed out, after it 1 of 222. Not a controlled experiment, but a big enough difference that the chaos script now starts the server that way. I also learned not to fsync inside an async activity: on that disk a single fsync blocked the event loop the workflows share for seconds. Disk-heavy activities now run as sync activities on a thread pool.
Numbers from the demo run
| Episodes | 9 Hacker Public Radio episodes, 4.3 h of audio, 38,318 words |
| Wall clock | 44 minutes, two episodes at a time |
| Transcription | 26 minutes for 4.3 h of audio (9.8× real time), Whisper base.en on 2 CPU threads |
| Agent | 72 turns, 91 tool calls, 4 rejected submissions, all fixed |
| Model replies paid for | 74, none bought twice: $0.125 in total, $0.029 per audio hour |
| Difference from HPR's own transcripts | 12.6% of words (HPR's are also made with Whisper and not proofread, so this is two machine transcripts disagreeing, not an error rate) |
One honest miss: the first run finished 7 of 9. On the two longest interviews the agent spent all ten of its turns reading the transcript window by window and never called submit_notes. I added a "three turns left, submit now" message and re-ran just those two. Every transcript chunk and the first nine model replies of each came straight from cache; only the last turn was new, at about a third of a cent each. The $0.125 above includes the wasted turns.
Why Does Open Innovation Matter?
Cost. The expensive half of this job, turning audio into text, costs nothing per minute when the model runs on your own CPU. OpenAI lists its Whisper API at an estimated $0.006 per minute, $0.36 per audio hour; my entire language-model bill was $0.029 per audio hour. Open weights move the dominant cost from "per minute, forever" to an old PC's spare time.
Privacy. The audio never leaves the machine. Only transcript text goes to the model endpoint, and because the model is open-weight, a producer whose clients will not allow even that can point chaptermill at their own server. (I want to be precise: for the demo I used a hosted endpoint; I did not run the 235B model at home.)
Reproducibility and no lock-in. The Whisper weights are a file on my disk that nobody can update or retire under me, so running an episode again gives the same transcript; the chaos run shows it, with transcripts identical segment for segment to the undisturbed run. The agent talks to any OpenAI-compatible server, so switching model or provider is a config change, not a rewrite. I did try Gemma 3 27B first and it ignored the tool definitions on that endpoint, so "any" means any model whose tool calling works.
Fine-tuning for a niche. The "cat"/"CAD" miss above is exactly the kind of error open weights let you fix yourself. A producer whose shows are full of jargon (Unix commands, drug names, player names) can fine-tune base.en on a few hours of their own corrected transcripts, or at least hand Whisper a vocabulary prompt, and run the result on the same CPU. A closed transcription API may let you pass a prompt, but you cannot retrain it on your show. I have not done this yet; it is next on the list.
The orchestration is open too. Temporal's dev server ran on the same old box, with a SQLite file, no account and no cloud.
Prize Categories
Temporal. The category asks for an agent that "survives failures, retries flaky tool calls, and picks up where it left off". chaptermill's agent is a Temporal workflow where every model call and tool call is an activity, and the demo page shows the evidence from a run where I killed the worker 9 times and the server once.

Top comments (0)