9 月 22 日の夜、Gemini API の changelog を読み返していて、手が止まりました。gemini-3.8-flash-tts と gemini-3.8-flash-lite-tts が GA になり、Flash-Lite TTS は gemini-3.1-flash-tts-preview の置き換えだと明記されていたのです。
その preview の名前を、私は覚えがありました。個人開発で運用しているヒーリング音源アプリの、再生前に流れる短い案内音声——「画面を伏せて、呼吸に合わせてください」といった数十秒の一言——を生成する小さなスクリプトの中です。半年前に一度声を決めて、それきり触っていませんでした。
プレビューのモデルは、いつか名前ごと消えます。置き換え先が公式の言葉で示された週は、動いているものを動かす理由としては十分でした。ここから先は、その一週間で私が実際にやったことと、途中で引き返したことの記録です。
置き換え先は「同じ名前の GA 版」ではありませんでした
最初にお伝えしたいのは、置き換えの対応関係です。3.1 Flash TTS プレビューの後継は 3.8 Flash TTS ではなく、Flash-Lite TTS のほうだと changelog は書いています。上位の 3.8 Flash TTS には、音声デザインや、同意確認つきの声の複製といった新しい機能が乗っています。
案内音声に求めているのは、落ち着いた声で、決まった文を、毎回同じ調子で読むことだけです。声を似せたい誰かもいませんし、感情を揺らす必要もありません。要するに私の用途は Lite で足りるのです——というより、機能が増えたモデルを選ぶと、増えた分の判断が自分に返ってきます。
ここで一度、机の上で線を引きました。短く固定された案内文は Flash-Lite TTS、長い読み物や表現が要るものは 3.8 Flash TTS。この線は今後も動かさないつもりです。
| 用途 | 私が選んだモデル | 理由 |
|---|---|---|
| アプリ内の案内音声(数十秒・定型文) | gemini-3.8-flash-lite-tts | 3.1 Flash TTS プレビューの正式な置き換え先で、必要な機能が揃っている |
| 記事の朗読・複数話者・声の演出 | gemini-3.8-flash-tts | 音声デザインや声の複製は、こちらにしかない |
モデル名を書き換える前に、呼び出し箇所を数えました
書き換え自体は一行です。けれど一行だと分かっているときほど、私は先に数えるようにしております。半年前の自分が、どこに何を書いたか信用できないからです。
# 案内音声を生成するリポジトリの中で、preview の名前が残っている場所を洗い出す
grep -rn "flash-tts-preview\|flash-preview-tts" --include="*.py" --include="*.json" --include="*.env*" .出てきたのは 2 箇所でした。生成スクリプトの定数と、.env.example の見本です。見本のほうは見落としやすく、次に環境を作り直す自分が古い名前をそのまま写す罠になります。両方を gemini-3.8-flash-lite-tts へ揃え、モデル名は環境変数から読む形に寄せました。
import os
# モデル名はコードに埋めず、環境変数で差し替えられるようにしておく
TTS_MODEL = os.environ.get("TTS_MODEL", "gemini-3.8-flash-lite-tts")なぜ環境変数に出すのかというと、次に置き換えが来たとき、grep から始めずに済むからです。今回の grep は、そのための最後の grep にしたいと思っております。
声は /v1beta/voices を、まず生のまま眺めました
モデルを替えるだけなら、声の名前はそのままでも動いたはずです。ところが今回は、既製音声が 150 以上になり、/v1beta/voices というエンドポイントから一覧を引けるようになっていました。半年前は片手で数えられる候補から選んだ声です。選び直す機会として、これ以上のものはないと感じました。
私はドキュメントの表を読む前に、まず一覧を JSON のまま保存しました。フィールド名を推測して書いたコードは、たいてい最初の実行で KeyError を返すのです。
import json
import os
import urllib.request
# 一覧をそのまま保存し、フィールド名を自分の目で確かめてから絞り込みを書く
API_KEY = os.environ["GEMINI_API_KEY"] # 実際の値は環境変数から。コードに書かない
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:
# フィールドを決め打ちしない。どんな項目があるかを一行で眺める
print(json.dumps(v, ensure_ascii=False)[:160])一覧が手に入ると、次は絞り込みたくなります。ここで一度、失敗しました。説明文に「calm」や「warm」が入っている声だけを機械的に抜いたところ、候補はそれなりに減ったものの、聴いてみると日本語の語尾が硬いものが混ざっていたのです。言葉のタグは、耳の代わりにはなりませんでした。そう気づいたのは、候補を半分ほど聴き進めたあとでした。
同じ一文を、候補の声で順に聴き比べました
そこで絞り込みの基準を変えました。タグで落とすのは最初の一段だけにして、残った候補には全部、同じ案内文を読ませて WAV に書き出し、自分の耳で聴くことにしたのです。読ませる文は、アプリで実際に流している一文にしました。
import base64
import os
import struct
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")
# アプリで実際に流している案内文。候補の声すべてに同じ文を読ませる
GUIDE_TEXT = "画面を伏せて、ゆっくりと呼吸に合わせてください。"
CANDIDATES = ["Kore", "Charon", "Aoede", "Leda"] # 一段目の絞り込みで残した声を並べる
def rate_from_mime(mime: str, default: int = 24000) -> int:
# 例: "audio/L16;codec=pcm;rate=24000" — サンプルレートは応答から読む
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): # SDK の版によって bytes ではなく base64 文字列で届く
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) # 16bit
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)サンプルレートを応答の mime_type から読んでいるのは、以前の自分への戒めです。TTS の出力は 24kHz・16bit・モノラルの PCM で、それを 44.1kHz と決め打ちしてヘッダを書いた夜に、声が早口の別人になった経験があります。ストリーミングで受け取るときの連結とヘッダの順序は、Gemini TTS を streamGenerateContent で受け取り、先頭のチャンクから鳴らすに書き残したとおりです。
聴き比べは、深夜にヘッドホンで、同じ一文を続けて流す形で行いました。数十秒の案内文だからこそ、語尾の落ち方ひとつで印象が変わります。最後に残ったのは、以前の声とは別の一本でした。
引き返した二つのこと
一つ目は、声の複製です。3.8 Flash TTS 側には同意確認つきで声を複製する機能が入りましたが、案内音声に誰かの声を借りる理由が私にはありませんでした。既製の声で足りるところに新機能を持ち込むと、説明しなければならない事柄が一つ増えるだけなのです。
二つ目は、声を替えるタイミングです。新しい声のほうが好みだと分かっても、既にアプリで流れている音声をその日のうちに差し替えるのはやめました。声はアプリの一部として覚えられていますので、アプリの次回更新に合わせ、更新内容に一行添えて替えることにしています。モデルの置き換えは今日、声の置き換えは次の更新日。 二つを分けたことで、肩の荷がひとつ下りました。
次の一歩
もし手元のコードに gemini-3.1-flash-tts-preview が残っているなら、まず grep で数えるところから始めていただければと思います。置き換え先は Flash-Lite TTS で、声は /v1beta/voices から選び直せます——半年前より、選ぶ自由は増えています。
長い記事を割って繋ぎ、一本のナレーションにする側の話は、プレビュー時代に書いたGemini 3.1 Flash TTS プレビューで記事を音声化するに、コストの見積もりまで含めて残しております。モデル名を読み替えていただけば、そのまま今の構成に載せられるはずです。
私自身は、数十秒の案内文という小さな用途から手を付けました。小さいからこそ、聴き比べを最後までやり切れたのだと、いまは感じています。