●FLASH35 — Gemini 3.5 Flash is now GA and powers gemini-flash-latest, making everyday generation faster and more affordable●AGENTS — Managed Agents launch in public preview in the Gemini API, running in secure, isolated Google-hosted Linux sandboxes●MEDIA — Nano Banana 2 Lite and Gemini Omni Flash bring faster image and high-quality video generation across AI Studio and the API●TTS — Streaming speech generation is now supported for gemini-3.1-flash-tts-preview via streamGenerateContent●TRANSLATE — A new audio model detects 70+ languages for live speech-to-speech translation while preserving natural intonation●SPENDCAP — Project-level spend caps for billing have been added in Google AI Studio to keep costs under control●FLASH35 — Gemini 3.5 Flash is now GA and powers gemini-flash-latest, making everyday generation faster and more affordable●AGENTS — Managed Agents launch in public preview in the Gemini API, running in secure, isolated Google-hosted Linux sandboxes●MEDIA — Nano Banana 2 Lite and Gemini Omni Flash bring faster image and high-quality video generation across AI Studio and the API●TTS — Streaming speech generation is now supported for gemini-3.1-flash-tts-preview via streamGenerateContent●TRANSLATE — A new audio model detects 70+ languages for live speech-to-speech translation while preserving natural intonation●SPENDCAP — Project-level spend caps for billing have been added in Google AI Studio to keep costs under control
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.
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 symptom
What you hear
Layer that fixes it
Prosody reset
A stiffness, as if re-reading from the top at each sentence
Context priming
Boundary click
A brief pop at the join
Zero-crossing snap and edge fade
Uneven pauses
Sentences collide, or drag
Punctuation-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 genaifrom google.genai import typesclient = 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.
Killing the click (zero-crossing snap and edge fade)
Even with the intonation carried across, a small click can remain at the waveform joint. The cause is that the audio edge is cut at a point where the sample does not cross zero. When a nonzero amplitude switches to the next silence (zero), a step forms and it snaps.
The fix has two parts. First trim the edge to a point near zero, then apply a very short fade. Around 8 milliseconds is enough -- it flattens the step without eating into the phonemes.
import numpy as npSR = 24000 # Gemini TTS returns 24kHz 16bit monoFADE = 192 # 8ms @ 24kHzdef pcm_to_np(pcm: bytes) -> np.ndarray: return np.frombuffer(pcm, dtype=np.int16).astype(np.float32)def np_to_pcm(a: np.ndarray) -> bytes: return np.clip(a, -32768, 32767).astype(np.int16).tobytes()def snap_zero_crossing(a: np.ndarray, head: bool, window: int = 240) -> np.ndarray: n = min(window, len(a)) if n == 0: return a if head: idx = int(np.argmin(np.abs(a[:n]))) return a[idx:] idx = int(np.argmin(np.abs(a[-n:]))) return a[: len(a) - (n - 1 - idx)]def edge_fade(a: np.ndarray, n: int = FADE) -> np.ndarray: n = min(n, len(a)) if n == 0: return a ramp = np.linspace(0.0, 1.0, n, dtype=np.float32) a = a.copy() a[:n] *= ramp a[-n:] *= ramp[::-1] return a
With naive concatenation, the click was audible at roughly a third of the chunk boundaries. After the zero-crossing snap and edge fade, it sits low enough that you have to hunt for it. Nothing flashy -- but the quieter the source, the more it matters.
Evening out the pauses with punctuation (adaptive silence)
A fixed pause length will feel wrong somewhere. Make the gap after a sentence equal to the gap after a paragraph and the paragraphs read as one continuous run. Give no pause after a closing quotation mark and the speaker seems to barrel into the next line without a breath.
So we vary the silence by the sentence-ending punctuation. Here are the values that settled well for me. The best figures shift by material, so use these as a starting point.
Sentence ending
Inserted silence (sec)
Intent
Ends on a period
0.28
Longer than a comma, shorter than a paragraph
Ends on ! or ?
0.34
Hold the resonance one beat longer
After a closing quote
0.36
Room for the speaker to draw breath
Paragraph or topic boundary
0.60
Signal that the scene has shifted
def gap_for(sentence: str, is_paragraph_end: bool) -> float: if is_paragraph_end: return 0.60 s = sentence.rstrip() if s.endswith(("”", "\"", "」", "』")): return 0.36 if s.endswith(("!", "?")): return 0.34 return 0.28def silence(sec: float) -> np.ndarray: return np.zeros(int(SR * sec), dtype=np.float32)
Re-rendering one chunk (an idempotent seam ledger)
Rebuilding the whole track just to fix one sentence wastes both time and money. Worse, priming is non-deterministic: every rebuild nudges the intonation across the entire piece, so parts you never touched drift anyway.
The answer is to record, per segment, what each clip was generated from, and re-render only what changed.
import hashlib, json, osdef h(s: str) -> str: return hashlib.sha1(s.encode("utf-8")).hexdigest()[:12]def render(sentences, gaps, ledger_path, seg_dir): os.makedirs(seg_dir, exist_ok=True) style_hash = h(STYLE + VOICE) old = json.load(open(ledger_path)) if os.path.exists(ledger_path) else {"segments": []} prev = {seg["idx"]: seg for seg in old.get("segments", [])} segments, prev_tail = [], "" for i, s in enumerate(sentences): th = h(s) cached = prev.get(i) reuse = ( cached and cached["text_hash"] == th and cached.get("style_hash") == style_hash and os.path.exists(os.path.join(seg_dir, cached["audio"])) ) name = cached["audio"] if reuse else f"seg_{i:03d}.pcm" if not reuse: open(os.path.join(seg_dir, name), "wb").write(synth(s, prev_tail)) segments.append({"idx": i, "text_hash": th, "style_hash": style_hash, "gap_after": gaps[i], "audio": name}) prev_tail = s[-24:] json.dump({"voice": VOICE, "sample_rate": SR, "segments": segments}, open(ledger_path, "w"), ensure_ascii=False, indent=2) return segmentsdef assemble(segments, seg_dir) -> bytes: out = [] for seg in segments: pcm = open(os.path.join(seg_dir, seg["audio"]), "rb").read() a = pcm_to_np(pcm) a = snap_zero_crossing(a, head=True) a = snap_zero_crossing(a, head=False) a = edge_fade(a) out.append(a) out.append(silence(seg["gap_after"])) return np_to_pcm(np.concatenate(out))
Keeping generation and assembly separate means you never re-synthesize just to tweak the polishing parameters. Silence lengths and fades can be rebuilt from the PCM on disk as many times as you like.
Field notes the docs do not mention
A few things I stumbled on that the documentation leaves out.
Do not mistake the sample rate. Gemini's TTS is 24kHz. Write 16kHz or 44.1kHz into a WAV header and playback runs fast or drags. It is easy to blame the voice quality instead.
Always check finish_reason. Anything other than STOP means the audio was cut short and stitched anyway. Before you suspect the seam, confirm the chunk came back whole.
Decide how far a priming change propagates. Edit one sentence and the next sentence's primer changes too. Strictly you should re-render the following segment as well, but tolerating a faint intonation difference and confining the change to the edited chunk is a practical policy. My rule of thumb: re-render the next one for an in-paragraph edit, stop at a paragraph head.
Drop priming at paragraph heads. Where the topic shifts hard, you sometimes want the intonation to reset. Pass an empty prev_tail for the first sentence of a paragraph and let it read as a new flow.
Key the cache on the input. Since priming is non-deterministic, the audio file itself is where you guarantee reproducing the same sound. As long as the text and style hashes match, treat the PCM on disk as the source of truth. That is what makes re-rendering safe.
In closing -- the first move
None of this is flashy. Priming carries the intonation, the zero-crossing snap and edge fade remove the click, punctuation varies the silence, and the ledger localizes re-rendering. Four layers, all of them small acts of polishing stacked up.
Start by recording one article both ways -- naively joined and built with this design -- and listen to them back to back. You will find one boundary where your ear catches. Begin by fixing that single spot; that is enough.
Erasing the small snags that linger in the ear is quiet, unglamorous work, but it steadies the experience of the person listening. I hope it helps in your own build.
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.