◉GEMINI LABJP
●ARGON — gemini-4-argon is generally available as of 10/13, aimed at complex coding, agents, math and science●DEEP RES — deep-research-pro-preview-12-2025 shuts down 10/23, 6 days left. Move to deep-research-preview-04-2026●NANO — gemini-nano-banana-2.1 is GA (10/06). gemini-3.1-flash-image is deprecated with no shutdown date yet●MCP — One write-up cut an 85,000-token tool result to 310 tokens by wrapping it in an ADK FunctionTool●FLASH — gemini-3.7-flash and 3.5-flash now route automatically to newer Flash models (10/08)●NEW — Stop Re-Reading Every Crash Report: Gre…●ARGON — gemini-4-argon is generally available as of 10/13, aimed at complex coding, agents, math and science●DEEP RES — deep-research-pro-preview-12-2025 shuts down 10/23, 6 days left. Move to deep-research-preview-04-2026●NANO — gemini-nano-banana-2.1 is GA (10/06). gemini-3.1-flash-image is deprecated with no shutdown date yet●MCP — One write-up cut an 85,000-token tool result to 310 tokens by wrapping it in an ADK FunctionTool●FLASH — gemini-3.7-flash and 3.5-flash now route automatically to newer Flash models (10/08)●NEW — Stop Re-Reading Every Crash Report: Gre…
Articles/Dev Tools
⟐ Dev Tools/2026-07-14Advanced

Making TTS seams inaudible: prosody continuity and silence boundaries for long-form narration

Splitting a long article by sentence and synthesizing each piece leaves audible seams: prosody resets at every sentence start, clicks appear at PCM joints, and pauses feel uneven. Four layers -- context priming, zero-crossing edge fades, punctuation-aware silence, and an idempotent seam ledger -- make the joins disappear, with full code.

Gemini TTSSpeech synthesisNarrationSolo development

✦ Premium Article

I was preparing the guidance voice for a calming app I run as an indie developer, using Gemini's TTS. I had synthesized it sentence by sentence, stitched the pieces together, and sat down to hear the whole thing for the first time. And I stopped.

At every boundary between sentences, the speaker seemed to swap for a person for an instant. Each individual clip sounded clean, yet the moment they were joined the result felt restless. A voice meant to ease someone toward sleep tensed up, faintly, at each seam.

There was not one cause but three. A reset in intonation, a small click at each PCM joint, and uneven pauses. Together they produced a recording that announced its own seams. This article separates those three, and gives working code for each. Not merely splitting and joining -- taking it all the way to where the ear stops noticing the joins.

Why seams catch the ear

You cannot voice a long article in a single request. There is a ceiling on how much one call can synthesize, and a few thousand characters will cut off partway. So you split by sentence. That much is unavoidable.

The problem is what happens when you join the pieces back. In my own runs, the discontinuity came in three flavors, each with a different character.

Seam symptomWhat you hearLayer that fixes it
Prosody resetA stiffness, as if re-reading from the top at each sentenceContext priming
Boundary clickA brief pop at the joinZero-crossing snap and edge fade
Uneven pausesSentences collide, or dragPunctuation-aware silence

Naive "split by sentence, insert a fixed pause, concatenate raw PCM" leaves the reset and the click intact. A constant pause length keeps the pacing uneven. From here, we peel the problem apart one layer at a time.

Passing the previous ending into the next call (context priming)

The most obvious offender is intonation snapping back to blank at each sentence start. The model has no idea how the previous sentence ended; each request arrives as a fresh introduction.

So we hand it a short tail of the prior sentence and ask it to continue in the same manner. Only the current sentence is spoken. The previous one is nothing more than a cue for carrying the cadence forward.

from google import genai
from google.genai import types
 
client = genai.Client()
MODEL = "gemini-3.1-flash-tts-preview"
VOICE = "Kore"
STYLE = "Read in a calm, low voice at a steady, unhurried pace."
 
def build_prompt(text: str, prev_tail: str) -> str:
    if prev_tail:
        return (
            f"{STYLE}\n"
            f"You just finished reading: \"{prev_tail}\". Keep the same timbre and the flow of intonation.\n"
            f"Now read only the following sentence:\n{text}"
        )
    return f"{STYLE}\nRead the following sentence:\n{text}"
 
def synth(text: str, prev_tail: str = "") -> bytes:
    resp = client.models.generate_content(
        model=MODEL,
        contents=build_prompt(text, prev_tail),
        config=types.GenerateContentConfig(
            response_modalities=["AUDIO"],
            speech_config=types.SpeechConfig(
                voice_config=types.VoiceConfig(
                    prebuilt_voice_config=types.PrebuiltVoiceConfig(voice_name=VOICE)
                )
            ),
        ),
    )
    cand = resp.candidates[0]
    if cand.finish_reason and cand.finish_reason.name != "STOP":
        raise RuntimeError(f"unexpected finish_reason: {cand.finish_reason.name}")
    return cand.content.parts[0].inline_data.data  # 24kHz 16bit mono PCM

It does not line up perfectly. Priming is a nudge, not a guarantee. Even so, the stiffness at sentence starts visibly dropped in my runs. I keep the tail to around 20 to 24 characters. Hand over more and the model will occasionally read the cue itself. Keep the cue short -- just enough to convey the flow.

✦

Thank you for reading this far.

Continue Reading

What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.

WHAT YOU'LL LEARN
✦Separate the three distinct causes of seams at sentence boundaries and take away code that addresses each one
✦Context priming carries the previous sentence's cadence into the next call, while zero-crossing edge fades remove the click at the join
✦Take home an idempotent seam ledger -- JSON schema and regeneration code -- so re-rendering one chunk never rebuilds the whole track
Secure payment via Stripe · Cancel anytime
✦

Unlock This Article

Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.

or
Unlock all articles with Membership →
Share

Thank You for Reading

Gemini Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.

  • ✦Copy-paste ready implementation code
  • ✦New advanced guides published daily
  • ✦$5/mo or $10 for lifetime access
View Membership →

Related Articles

⟐ Dev Tools2026-10-14
Stop Re-Reading Every Crash Report: Grep a Known-Bug Table First and Keep Only an Index in GEMINI.md
A routine for investigating app crash reports without making the model read every past bug in full: a one-line-per-bug lookup table, what belongs in GEMINI.md, and a grep helper you can paste in.
⟐ Dev Tools2026-10-06
Three Veo 3.1 previews stop on Oct 22. I started with an inventory of every call site
Three Veo 3.1 preview models shut down on Oct 22, with gemini-omni-1.1-flash as the listed replacement. Before swapping model names, here is a search script that finds every call site in a repo, and a config pattern that stops loudly after the deadline instead of drifting to another model.
⟐ Dev Tools2026-09-29
Gemini CLI Kept Saying 'API key not valid' After I Gave It a New Key — It Was Reading an Old One From a Third Place
I switched Gemini CLI from Google login to a paid API key, and the 400 'API key not valid' error would not go away. The key can come from four places — the shell, a project .env, a home .env, and settings.json — and a short script that lights them up in order narrows the cause to one.