Late on September 22 I was re-reading the Gemini API changelog when one line made me stop. gemini-3.8-flash-tts and gemini-3.8-flash-lite-tts had gone GA, and the changelog said plainly that Flash-Lite TTS is the replacement for gemini-3.1-flash-tts-preview.
I knew that preview name. It lives in a small script behind the healing-sounds app I run as an indie developer — the one that generates the short spoken guidance you hear before playback starts, a few dozen seconds of "turn the screen face down and follow your breath." I had picked a voice half a year ago and hadn't touched the script since.
Preview models eventually disappear, name and all. A week in which the official replacement is spelled out felt like reason enough to move something that was still working. What follows is what I actually did over that week, including the two places where I turned back.
The replacement was not "the same name, now GA"
The first thing I'd like to pass on is the mapping. The successor to the 3.1 Flash TTS preview is Flash-Lite TTS, not 3.8 Flash TTS. The larger 3.8 Flash TTS carries the new capabilities — voice design, and voice cloning with a consent step.
All my guidance audio needs is a calm voice reading a fixed sentence the same way every time. There is nobody whose voice I want to imitate, and no emotion to modulate. Lite is enough for this job — and choosing the model with more features would mostly mean more decisions handed back to me.
So I drew a line at my desk before writing any code: short, fixed guidance lines go to Flash-Lite TTS; long reads and anything that needs expression go to 3.8 Flash TTS. I don't intend to move that line for a while.
| Use | Model I chose | Why |
|---|---|---|
| In-app guidance audio (tens of seconds, fixed text) | gemini-3.8-flash-lite-tts | The official replacement for the 3.1 Flash TTS preview, with everything this use needs |
| Article narration, multiple speakers, styled voices | gemini-3.8-flash-tts | Voice design and cloning live only here |
Before changing the model name, I counted the call sites
The change itself is one line. Precisely because I knew it was one line, I made myself count first. I don't trust my six-months-ago self to remember where he wrote things.
# Find every place the preview name still lives in the guidance-audio repo
grep -rn "flash-tts-preview\|flash-preview-tts" --include="*.py" --include="*.json" --include="*.env*" .Two hits. The constant in the generation script, and the sample value in .env.example. The sample is the one that's easy to miss, and it's a trap: the next time I rebuild an environment, I'd copy the stale name straight in. I updated both to gemini-3.8-flash-lite-tts and moved the model name into an environment variable.
import os
# Keep the model name out of the code so the next swap is a config change
TTS_MODEL = os.environ.get("TTS_MODEL", "gemini-3.8-flash-lite-tts")Why an environment variable? So that the next replacement doesn't begin with grep. I'd like this grep to have been the last one.
I looked at /v1beta/voices raw before filtering anything
If I had only changed the model, the old voice name would probably have kept working. But this release also brought more than 150 prebuilt voices and a /v1beta/voices endpoint to list them. Half a year ago I chose from a handful. I couldn't think of a better moment to choose again.
Before reading the documentation's table, I saved the list as JSON. Code written against guessed field names usually greets me with a KeyError on the first run.
import json
import os
import urllib.request
# Save the raw list and read the field names with my own eyes before writing a filter
API_KEY = os.environ["GEMINI_API_KEY"] # real value comes from the environment, never the code
URL = "https://generativelanguage.googleapis.com/v1beta/voices"
req = urllib.request.Request(URL, headers={"x-goog-api-key": API_KEY})
with urllib.request.urlopen(req, timeout=30) as res:
raw = json.load(res)
with open("voices_raw.json", "w", encoding="utf-8") as f:
json.dump(raw, f, ensure_ascii=False, indent=2)
voices = raw.get("voices", [])
print(f"{len(voices)} voices")
for v in voices:
# Don't assume the schema; skim one line per voice to see what fields exist
print(json.dumps(v, ensure_ascii=False)[:160])Once the list was in hand, I wanted to filter it, and here I made my first mistake. I mechanically kept only voices whose description mentioned "calm" or "warm." The list shrank nicely, but when I listened, several of the survivors had a stiff edge on Japanese sentence endings. A word tag is not a substitute for an ear.
Then I played the same sentence through each candidate
So I changed the approach. Tags would only do the first rough cut. Every remaining candidate would read the same guidance line, get written to a WAV file, and be judged by listening. The line I used is the one the app actually plays.
import base64
import os
import wave
from google import genai
from google.genai import types
client = genai.Client(api_key=os.environ["GEMINI_API_KEY"])
TTS_MODEL = os.environ.get("TTS_MODEL", "gemini-3.8-flash-lite-tts")
# The guidance line the app actually plays; every candidate voice reads this same text
GUIDE_TEXT = "Turn the screen face down and slowly follow your breath."
CANDIDATES = ["Kore", "Charon", "Aoede", "Leda"] # whatever survived the first rough cut
def rate_from_mime(mime: str, default: int = 24000) -> int:
# e.g. "audio/L16;codec=pcm;rate=24000" — take the sample rate from the response
for part in mime.split(";"):
part = part.strip()
if part.startswith("rate="):
return int(part.split("=", 1)[1])
return default
def synth_to_wav(voice: str, text: str, path: str) -> None:
res = client.models.generate_content(
model=TTS_MODEL,
contents=text,
config=types.GenerateContentConfig(
response_modalities=["AUDIO"],
speech_config=types.SpeechConfig(
voice_config=types.VoiceConfig(
prebuilt_voice_config=types.PrebuiltVoiceConfig(voice_name=voice)
)
),
),
)
part = res.candidates[0].content.parts[0]
pcm = part.inline_data.data
if isinstance(pcm, str): # some SDK versions hand back base64 text instead of bytes
pcm = base64.b64decode(pcm)
rate = rate_from_mime(part.inline_data.mime_type or "")
with wave.open(path, "wb") as w:
w.setnchannels(1)
w.setsampwidth(2) # 16-bit
w.setframerate(rate)
w.writeframes(pcm)
for name in CANDIDATES:
out = f"guide_{name}.wav"
synth_to_wav(name, GUIDE_TEXT, out)
print("wrote", out)Reading the sample rate from mime_type is a note to my past self. TTS output is 24 kHz, 16-bit, mono PCM, and I once wrote a WAV header assuming 44.1 kHz — the voice came back as a fast-talking stranger. The ordering of chunks and headers when you stream the audio is something I wrote up in Receiving Gemini TTS via streamGenerateContent and playing from the first chunk.
The listening itself happened late at night with headphones, the same sentence looping through each file. With a line this short, the way a sentence ending falls changes the whole impression. The voice that remained at the end was not the one I had been using.
Two places where I turned back
The first was voice cloning. 3.8 Flash TTS can now clone a voice with a consent step, but I had no reason to borrow anyone's voice for guidance audio. Bringing a new capability into a place where a stock voice already does the job only adds one more thing I'd have to explain.
The second was timing. Even after deciding I preferred the new voice, I didn't swap the audio that's already playing in the app that same day. A voice becomes part of how people remember an app, so the swap will ride along with the next app update, with one line in the release notes. The model swap happens today; the voice swap happens on the next release day. Separating the two took a weight off my shoulders.
One next step
If gemini-3.1-flash-tts-preview is still somewhere in your code, I'd suggest starting with grep and a count. The replacement is Flash-Lite TTS, and you can re-choose the voice from /v1beta/voices — there is more room to choose than there was six months ago.
The other side of this — splitting a long article into pieces and joining them into one narration — is something I wrote during the preview era in Turning articles into audio with the Gemini 3.1 Flash TTS preview, cost estimates included. Read the model name as the new one and the setup should carry over as it is.
I may be missing something, but starting from a few dozen seconds of guidance text is what let me carry the listening test all the way to the end. Small was the point.