Table of Contents
A batch text-to-speech call returns one segment of audio. String together 30 of them and you've got yourself a podcast. This article covers the five stages between individual segments and a produced show. If you are joining generated speech into anything longer than a single response, an audiobook, a course module, a daily briefing, the pipeline is the same shape.
Each stage is a place where the obvious approach hands you a file that passes every check you run and still fails in front of a listener. Here's what it gets wrong:
- Send each line as its own call, and your show reads like a list instead of two people talking.
- Take the container the endpoint offers, and your file reports a duration in hours.
- Join first and calculate later, and your chapter marks drift out of sync with the audio.
- Set the gap you want, and you get dead air between every line anyway.
- Encode at the rate you rendered at, and a podcast client lists your episode and refuses to play it.
The code in this post comes from HN Radio, a podcast that has been producing itself every morning since Flux TTS reached general availability on August 12.
Key takeaways
- Batch is stateless. Every segment is an independent request. Conversational feel comes from your script and your pacing, not from the model.
- Ask for raw samples.
encoding=linear16withcontainer=nonemakes the response body the sample buffer. Joining two responses is then joining two byte strings. - Calculate timings before you join. Concatenation erases the boundaries. Chapter marks come from the gap plan, and the same plan has to feed the stitcher.
- Budget for silence you did not add. Every rendered segment arrives with silence on its tail and almost none on its front. Insert a gap on top of that and you are tuning one of two variables.
- A valid file and a playable file are different questions. At 24 kHz,
libmp3lameemits a legal MP3 that most podcast clients list and then refuse to play.
The pipeline, end to end
The worked example throughout is HN Radio. At 3am it reads the previous day's Hacker News front page, picks three stories, follows their links and comments to write a script for two voices, renders every line through Deepgram Flux TTS, and stitches the result into a five or six minute episode you can subscribe to in any podcast player.
Thirteen stages run in order, and Figure 1 shows all of them. ingest through write produce text. From render on it is audio, and that half is what you are building here.
Figure 1: The thirteen stages, parsed from hn_radio/pipeline.py. Gray produces text, green is audio, and the green half is the rest of this article.
Hacker News is full of version strings, tool names, and acronyms, which makes it a deliberately hostile test of one claim: that plain text is enough. Every episode is produced from unmarked text. No SSML, no pronunciation dictionary, no per-segment tuning.
Step 1: Send one request per spoken line
Flux TTS has two transports. The streaming WebSocket is built for live conversation. The batch REST endpoint takes a finished string and returns the audio in one response, which is what you want when the audio is produced hours before anyone hears it.
Batch is a POST to https://api.deepgram.com/v2/speak. The text goes in the JSON body. The voice and the media format go on the query string:
The voice is the model parameter. Authorization is a Token header. The response body is the audio itself.
Rendering a whole script is then a loop:
That loop has two properties that shape the rest of the pipeline.
It is stateless. Each call renders the text you sent and nothing else. Nothing carries over from the segment before it.
Your listener hears exactly what you wrote, so the conversational feel is something you author in the script and in the gaps between lines. It also makes a re-render deterministic: the same text and the same voice give you the same audio, which is what makes a failed episode safe to rebuild.
Flux TTS gives you two parameters that shape delivery: speed which takes 0.85 to 1.15 in 0.05 steps and expressivity (currently in beta) which runs from -2 for calm to 2 for animated. HN Radio sets neither on any segment, because the show exists to test whether unmarked text carries the delivery on its own.
Cross-turn context is a real Flux TTS feature, and it lives on the streaming WebSocket, while Batch is a single turn by design. Losing that context is the right trade when nothing is waiting on the audio.
It is serial. One episode is about 30 requests, each with a 60 second timeout and up to three retries with backoff. For a 3am job with nobody waiting on it, serial is easier to reason about. A failure is one segment, the retry is cheap, and the log reads in script order.
——————
Note: raw HTTP is the supported path for batch, not a workaround. The published Python SDK exposes Speak V2 as a streaming WebSocket client only, with no batch method. Deepgram documents Flux TTS batch as a raw request, and the official fastapi-flux starter proxies it the same way. Reach for the SDK when you add a streaming or voice-agent feature.
——————
Step 2: Request raw PCM, and write one header at the end
Four media parameters decide how much work you leave for the rest of the pipeline:
By setting container=none the response body is the pulse-code modulation (PCM) sample buffer and nothing else. No header, no metadata, no framing, all to the benefit that joining two responses becomes joining two byte strings.
Using container=wav instead gets you a header, and on the batch path that header is a placeholder reporting a data length of 0x7FFF0000, roughly 2 GB, on about two minutes of audio.
Every tool downstream believes that header. Some read past the end of the buffer and hand you whatever was in memory, some refuse the file outright and the ones that recover do it silently, so you learn nothing and the bug survives to the next stage. A duration that reads as hours on a two-minute clip is the tell, and it shows up in a media inspector long before it shows up in anything that sounds wrong.
Headerless audio removes the whole class of problem instead of patching one instance of it. You write exactly one header, at the end, once you know the real length.
The other three constants each do a specific job downstream. SAMPLE_RATE sets every timing calculation in the pipeline, and it decides which MP3 flavor you get in Step 5. SAMPLE_WIDTH is what converts a byte count into seconds. CHANNELS doubles that byte count the day you move to stereo. Everything downstream is arithmetic on the three of them, in both directions:
Bytes to seconds on the way in, seconds to bytes on the way out. Change the sample rate and every timing in the pipeline moves with it. That's why the constants live in one module and get read from there, instead of being typed as 24000 in a second file.
Code defensively at the boundary. The renderer drops a leading RIFF header if one ever comes back despite container=none, and it raises on an empty body instead of appending zero bytes and moving on (hn_radio/render.py, render_segment). Both guards are cheap, and both turn a silent corruption into a loud failure.
Step 3: Join the segments, and keep the offsets you calculated
Joining is the part you expect to be hard. It's twelve lines:
Silence is zero samples. Joining is +. You write the single WAV header at the end, through the standard library wave module, once you know the byte count. You never touch an audio library.
What needs care is where each segment starts. Chapter marks, the transcript, and the seek buttons on the episode page all read from a list of start times, and joining is the step that destroys the information those times come from. a + silence + b carries no marker saying where a ended, and silence detection cannot tell a gap you inserted from a pause the model produced. The boundary exists only before you join, and that forces the order: calculate the gap list once, and hand the same list to the stitcher and to the offset calculator. Give them different lists and the audio is fine, the chapters are wrong, and nothing raises an error. Your listener hears a correct show while the seek buttons drift further out of sync with every segment. Figure 2 shows what concatenation destroys and why the offsets have to exist before it runs.
Figure 2: Three segments with planned silence gaps, their start offsets ticked above. Concatenation leaves one continuous buffer with no boundaries in the bytes, and the same three offsets carry down as the chapter marks.
The one automatic guard checks that the gap list is the right length (hn_radio/stitch.py, gap_list). It catches a list of the wrong size but it cannot catch a list of the wrong values, and adding that check is not cheap, because verifying it means measuring the boundaries that concatenation just erased.
Step 4: Measure the silence you did not add
Your first few passes will sound like monologues. The gap a listener hears is not the gap you configured, because every segment arrives with silence already baked into it, and the gap you insert sits on top of that.
The silence sits almost entirely on the tail. Across 38 segments of HN Radio's render cache, the tail runs a median 0.25 seconds while the lead-in runs a median 0.02 seconds (hn_radio/pacing.py). The renderer pads the end of a line and barely touches the start.
A configured 0.45 second gap lands at a median 0.73 seconds of real dead air, ranging from 0.50 to 1.16 across boundaries and swinging up to half a second from one boundary to the next. Figure 3 is one real boundary, drawn from the cached samples.
Figure 3: One boundary from episode 2026-08-04, drawn from the cached PCM. The three parts of the silence are one unbroken flat line, which is why the problem is invisible until you measure it.
Those are measurements from one repository's cache, not a published specification. Measure your own rather than trusting a number in a comment, including this one. Cache the raw per-segment audio before any processing, then count leading and trailing samples below a fixed amplitude threshold.
Conversational turn transitions in real speech sit nearer 0.20 seconds. The show was running at roughly three and a half times that, which is why it read as a monologue instead of an exchange. The fix has two halves:
Make each segment's own silence a known quantity. Trim both edges to zero, pad back to a fixed 0.06 seconds, and refuse to act on a segment that reads as too short or as entirely silence (hn_radio/pacing.py, normalize_edges). A segment that is all quiet is usually a failed render, and trimming it to nothing turns an audible problem into an invisible one. Once the edges are constant, the gap you insert is the gap a listener hears.
Stop using one value everywhere. The pause a listener wants between two people trading a thought is shorter than the pause they want when the show changes topic. Six kinds of boundary get six values, and Figure 4 sorts them by duration.
Figure 4: The six boundary types and the gap each one inserts. Conversational turns cluster tight, structural moves get a real beat, and the widest is 5.6 times the tightest.
The two kinds separate into groups rather than sitting on a smooth scale, which is the argument for having six values instead of one. Each was set by ear against measured edges, and they live in config (hn_radio/pacing.py), so changing the feel of the show is not a code change.
Step 5: Encode a format podcast clients will play
Encode the finished WAV to MP3 at 44.1 kHz, 128k, with ID3v2.3 tags:
Figure 5: One episode in Overcast, from the show page through the chapter list to the show notes. Cover art, per-segment chapters, and timestamped links all survive the encode. This is the check Step 5 exists for: a client that lists the episode and then actually plays it.
-ar 44100 is a compatibility flag, not a quality setting. The speech endpoint returns 24 kHz, and MPEG-1 Layer III supports only 32, 44.1 and 48 kHz. Hand libmp3lame a 24 kHz stream and it quietly produces MPEG 2 Layer III instead. That is a completely legal MP3 that Apple Podcasts and many other clients will list, with cover art and chapters, and then refuse to play.
-id3v2_version 3 is there because ffmpeg defaults to version 2.4 and podcast players prefer 2.3. A tag version mismatch produces the same symptom: the episode appears in the app, and does not play.
Where the script comes from, and how to change it
Everything above starts after the script exists. The pipeline takes a list of stories with their linked pages and a handful of comments. Nothing downstream of that point knows where any of it came from, so swapping the source means writing one module that produces those objects.
Ranking is source-specific. HN Radio weights stories by points and front-page position to pick three out of a pool of thirty. A chronological feed has neither, so a different source needs its own answer to "which three are worth five minutes today."
The script needs a source of commentary. The host and co-host perform real comments from the busiest thread between them, and that is what turns a summary into an exchange. A feed with no discussion layer under it has nothing to fill that, and what you get back is a single narrator reading summaries.
Start building with Deepgram Flux TTS
Once configured, it's two commands to render a produced episode:
Four things those commands do not install for you: uv, ffmpeg, a Deepgram key, and an Anthropic key.
The Anthropic key is optional. make episode defaults to --writer panel, which is deterministic and offline. It assembles the script from the summaries pulled out of each linked article, every speech call is still real, and you get a complete episode without one. --writer claude writes the conversation instead.
———————
Start here. Get a Deepgram API key. New accounts get $200 in credit and no card is required.
Want to hear the output before you build anything? Listen on the show page, or add it to your podcast player: Apple Podcasts, Overcast, Pocket Casts, Castro, AntennaPod, or copy the raw RSS feed if your player is not listed.
———————
Then run it. One episode is about 30 speech requests, so a single local run takes you well past your first call, and a scheduled one crosses that line again every morning.
To point it at something other than Hacker News, start at the seam above. Write an ingest module, decide how your source ranks, and decide what stands in for the comment segments.
Bring what you build to the community, make it yours! The version that reads your team's changelog, your city's local news, last night's box scores, or a week of lecture notes back to you is the one nobody has heard yet.









