GEMINI LABEN
CHAT — 8月26日から Google Chat が Gemini のハブになります。検索・下書き・会話の追いつき・タスクとイベントの管理を、Workspace の文脈を持ったまま行えるようになります。残り3日ですANDROID — 9月4日から Android の Google アシスタントを Gemini が置き換えます。残り12日です。アシスタント前提で組んだ音声ショートカットは、置き換え後の挙動を確認しておきたいところですSCALE — Gemini アプリが8月11日に月間10億ユーザーへ到達しましたROBOTICS — gemini-robotics-er-1.6-preview は8月31日で停止します。残り8日です。後継は ER 2 系で、空間推論・多段のツール連携・複数ロボットの協調に対応しますFLASH — Gemini 3.7 Flash は8月13日に GA となり、AI Pro と Ultra 向けの Gemini Spark のエンジンとして160か国以上で動いています。導入価格は12月31日までですCLASSROOM — Gemini in Classroom が8月10日から全年齢の学生へ開放されました。フラッシュカード・練習問題・学習ガイド・ガイド付きプロンプトが使えますCHAT — 8月26日から Google Chat が Gemini のハブになります。検索・下書き・会話の追いつき・タスクとイベントの管理を、Workspace の文脈を持ったまま行えるようになります。残り3日ですANDROID — 9月4日から Android の Google アシスタントを Gemini が置き換えます。残り12日です。アシスタント前提で組んだ音声ショートカットは、置き換え後の挙動を確認しておきたいところですSCALE — Gemini アプリが8月11日に月間10億ユーザーへ到達しましたROBOTICS — gemini-robotics-er-1.6-preview は8月31日で停止します。残り8日です。後継は ER 2 系で、空間推論・多段のツール連携・複数ロボットの協調に対応しますFLASH — Gemini 3.7 Flash は8月13日に GA となり、AI Pro と Ultra 向けの Gemini Spark のエンジンとして160か国以上で動いています。導入価格は12月31日までですCLASSROOM — Gemini in Classroom が8月10日から全年齢の学生へ開放されました。フラッシュカード・練習問題・学習ガイド・ガイド付きプロンプトが使えます
記事一覧/API / SDK
API / SDK/2026-08-23中級

Gemini TTS を streamGenerateContent で受け取り、先頭のチャンクから鳴らす

Gemini の TTS をストリーミングで受け取ると、音声は「ファイル」ではなく PCM の断片で届きます。チャンクごとに WAV ヘッダを付けた場合に何が起きるかを手元で測り、連結とヘッダの正しい順序を実装まで示します。

Gemini API217TTS3ストリーミング13音声処理2Python43

個人開発の合間に、記事を音声にして stand.fm に置く作業を続けています。テキストを渡し、返ってきた音声をそのまま1本のファイルにする。素朴な流れです。

ただ、聞き手として自分の配信を再生したとき、押してから音が出るまでの待ち時間がずっと気になっていました。生成が終わるまで待ってから書き出しているのですから、当然といえば当然です。

streamGenerateContentgemini-3.1-flash-tts-preview でも使えるようになったので、この待ち時間を先頭のチャンクで埋められないかを確かめました。実装してみると、待ち時間そのものより先に、受け取ったものを何と見なすかでつまずきます。

ストリーミングで返ってくるのは音声ファイルではありません

通常の生成と同じく、応答は candidates[0].content.parts の配列で届きます。音声の場合、テキストの代わりに inlineData が入っています。

REST を直接叩いた場合、inlineData.data は base64 文字列です。Python SDK(google-genai)を使う場合はすでに bytes へ復号された状態で届きます。ここは経路によって型が変わるので、受け取り側の想定を先に決めておくと後が楽になります。

もう一つ重要なのが mimeType です。手元では次の形で届きました。

audio/L16;codec=pcm;rate=24000

L16 は16ビットのリニア PCM、rate=24000 はサンプリングレートです。つまり返ってくるのは ヘッダを持たない生の波形データ であって、MP3 や WAV のような「ファイル」ではありません。

レートは固定値として書かず、mimeType から読むようにしています。モデルや設定によってここが変わったとき、ハードコードした数字は再生速度の狂いという形でしか表に出てこないためです。

import re
 
def parse_rate(mime_type: str, default: int = 24000) -> int:
    m = re.search(r"rate=(\d+)", mime_type or "")
    return int(m.group(1)) if m else default
 
# audio/L16;codec=pcm;rate=24000 -> 24000
# audio/pcm;rate=16000           -> 16000
# audio/pcm                      -> 24000(既定値)

受信側は次のようになります。チャンクを1つ受け取るたびに、inlineData があれば PCM として積み上げるだけです。

import wave
from google import genai
from google.genai import types
 
client = genai.Client(api_key="YOUR_API_KEY")
MODEL = "gemini-3.1-flash-tts-preview"
 
def synthesize(text: str, out_path: str) -> None:
    pcm = bytearray()
    rate = None
 
    stream = client.models.generate_content_stream(
        model=MODEL,
        contents=text,
        config=types.GenerateContentConfig(
            response_modalities=["AUDIO"],
            speech_config=types.SpeechConfig(
                voice_config=types.VoiceConfig(
                    prebuilt_voice_config=types.PrebuiltVoiceConfig(voice_name="Kore")
                )
            ),
        ),
    )
 
    for chunk in stream:
        for part in (chunk.candidates[0].content.parts or []):
            blob = getattr(part, "inline_data", None)
            if blob is None or not blob.data:
                continue
            if rate is None:
                rate = parse_rate(blob.mime_type)
            pcm += blob.data          # ここでは連結するだけ
 
    with wave.open(out_path, "wb") as w:
        w.setnchannels(1)
        w.setsampwidth(2)             # 16bit
        w.setframerate(rate or 24000)
        w.writeframes(bytes(pcm))     # ヘッダを書くのは最後の1回だけ

書き出しを最後に1回だけ行っている点が、次の節の話につながります。

チャンクごとにヘッダを付けると、10分の1しか鳴りません

最初に書いた実装では、届いたチャンクを1つずつ WAV として保存し、あとで結合していました。ファイルとしては開けますし、再生も始まります。ところが途中で止まります。

何が起きているのかを、合成した波形で確かめました。24kHz・16ビット・モノラルで0.6秒のチャンクを10個、合計6.0秒ぶんを用意し、2つの方式で書き出した結果です。

方式出力サイズプレイヤーが再生する長さ
PCM を連結してから WAV ヘッダを1回288,044 バイト6.000 秒
チャンクごとに WAV 化して連結288,440 バイト0.600 秒

出力サイズはほとんど同じです。差は44バイトのヘッダが1個か10個かだけで、波形そのものは1バイトも欠けていません。それでも再生されるのは先頭の0.6秒、つまり全体の10分の1でした。

WAV ヘッダの data チャンクには、それに続くバイト数が書かれています。先頭のヘッダは「このあと28,800バイトの音声が続く」と宣言していますから、プレイヤーはそこで終わりだと判断します。以降の9チャンクは、ヘッダごとデータの外側に置き去りになります。

24kHz・16ビット・モノラルなら、1秒あたりのバイト数は次の計算で出ます。

24,000 サンプル/秒 × 2 バイト × 1 チャンネル = 48,000 バイト/秒

288,000 ÷ 48,000 = 6.0 秒。バイト数から尺が逆算できるのは、生の PCM を扱うときの利点です。チャンクが何個に分かれていても、連結して割り算するだけで正しい長さが出ます。

base64 のまま持ち回ると、この 288,000 バイトが 384,000 バイトになります。ちょうど1.3333倍です。手元の6秒でも96,000バイトの差ですから、10分の読み上げなら無視できない量になります。文字列のまま配列に溜めず、受け取った時点で bytes に戻す設計にしておきたいところです。

16ビットの境界を跨ぐチャンクの扱い

チャンクの切れ目は、サンプルの切れ目と一致しません。1サンプルが2バイトである以上、奇数バイトで届くチャンクは サンプルの途中で切れている ことになります。

バイト列のまま連結する限り、これは問題になりません。前のチャンクの末尾1バイトと、次のチャンクの先頭1バイトが素直につながるからです。

危ないのは、チャンクごとに数値サンプルへ変換してから足していく実装です。奇数の端数を捨てながら進むことになります。

実際に測ってみました。7,201・7,203・7,199・7,205・7,192 バイトという5つのチャンク(合計36,000バイト)を、チャンク単位で16ビット配列に変換した場合の結果です。

項目バイト列のまま連結チャンクごとに変換
得られたサンプル数18,00017,998
捨てられたバイト04
正しい波形と一致しないサンプル014,386

失われたのはわずか4バイトです。それなのに、17,998サンプルのうち14,386サンプルが本来と違う値になりました。1バイトずれた時点で上位バイトと下位バイトが入れ替わり、そこから先の波形が別物になるためです。

耳で聞くと、ある瞬間から砂嵐のような音が混じります。私自身、生成品質の問題だと思って何度もプロンプトを書き直しかけたのですが、原因は受け取り側にありました。

端数を持ち越す形にすれば、この問題は消えます。

import array
 
class PcmDecoder:
    """チャンク境界がサンプル境界と一致しなくても壊れない"""
    def __init__(self) -> None:
        self._carry = b""
 
    def feed(self, chunk: bytes) -> array.array:
        buf = self._carry + chunk
        n = len(buf) // 2 * 2      # 2の倍数まで
        self._carry = buf[n:]      # 端数は次回へ
        samples = array.array("h")
        samples.frombytes(buf[:n])
        return samples

先ほどの5チャンクをこの実装に通したところ、18,000サンプルすべてが正しい波形と一致し、持ち越しの残りも0バイトになりました。音量メーターや簡易な無音検出をリアルタイムで動かす場合は、この形が必要になります。

ファイルに落とすだけなら、そもそも数値へ戻す必要はありません。私は用途に応じて、バイト連結だけで済ませる経路と、この PcmDecoder を挟む経路を分けています。

再生を先に始めるためのヘッダ

待ち時間を削るという当初の目的に戻ります。ブラウザやプレイヤーへ流し込むなら、44バイトのヘッダを 音声より先に 送ってしまう手があります。

import struct
 
def wav_header(rate: int, channels: int = 1, width: int = 2,
               data_size: int = 0x7FFFFFFF) -> bytes:
    byte_rate = rate * channels * width
    block_align = channels * width
    return (
        b"RIFF" + struct.pack("<I", 0xFFFFFFFF) + b"WAVE"
        + b"fmt " + struct.pack("<IHHIIHH", 16, 1, channels, rate,
                                byte_rate, block_align, width * 8)
        + b"data" + struct.pack("<I", data_size)
    )

長さが確定していないので、data のサイズには収まりきらない大きな値を入れておきます。手元で書き出して読み返すと、宣言されたフレーム数は 1,073,741,823 になりました。実体は1.0秒ぶんの48,000バイトだけです。

私はこの方式を、聞き手が総再生時間を気にしない用途に限って使っています。先頭から順に読むプレイヤーであれば正しく鳴らせます。読み込んだ48,000バイトはビット単位で元の PCM と一致していました。副作用として、シークバーが正しい長さを示しません。総再生時間が意味を持つ配信物なら、この方式は向きません。

ファイルとして保存する場合は、先に44バイトぶんの空きを書いておき、書き終えてから戻ってサイズ欄を埋めるのが確実です。Python の wave モジュールは close() の時点でこれを行うため、先ほどの synthesize() のように最後にまとめて書けば意識せずに済みます。

用途ごとに整理すると次のようになります。

用途ヘッダを書く位置注意点
ファイルに保存する最後に1回再生開始は生成完了後になる
そのまま流して聞かせる先頭に仮の値で1回総再生時間とシークが機能しない
波形をリアルタイムに解析するヘッダ不要サンプル境界の持ち越しが必須

それでもストリーミングにしない場面

ここまで書いておいて逆のことを言うのですが、私の配信の本編では結局ストリーミングを使っていません。

記事の読み上げは公開前に生成できます。聞き手が再生ボタンを押す時点では、音声はすでにファイルとして存在しているべきものです。ストリーミングが効くのは、テキストがその場で決まる用途に限られます。

ストリーミングは待ち時間の見え方を変えるだけで、生成そのものを速くはしません。事前に作れるものを実行時に作る構成は、待ち時間と呼び出し回数の両方を読者に負担させることになります。どこで生成するかの判断については、実行時呼び出しと配布前の一括処理をどう分けたかに、呼び出し回数の見積もりまで含めて書きました。

まとめ

Gemini の TTS をストリーミングで受け取るときに押さえる点は、次の一行に集約されます。届くのは PCM の断片であり、ヘッダは全体に対して1回だけ書く

もし今の実装でチャンクごとに音声ファイルを作っているなら、書き出したファイルの尺を測ってみてください。先頭チャンクぶんの長さしかなければ、原因はここにあります。私の場合、生成のパラメータを疑って半日を使ったあとに気づきました。

お読みいただきありがとうございました。手元の実装を見直すきっかけになれば嬉しく思います。

シェア

お読みいただきありがとうございます

Gemini Lab は広告なしで運営しており、サーバー費用などの運営コストはメンバーシップのご支援で賄っています。実装コード・ベンチマーク・本番設計パターンなど、実務でお役立ていただける記事を毎日更新しています。もし読んでよかったと感じていただけましたら、ぜひご覧ください。

  • コピー&ペーストで使える実装コード付き
  • 毎日新しい上級ガイドを追加
  • ¥580/月 または ¥2,480 の永久アクセス
メンバーシップを見る →

もしこの記事がお役に立ちましたら、チップ(¥150)で応援いただけると大変励みになります。広告なしでの運営を続けるため、皆さまのご支援が大きな力になっています。

関連記事

API / SDK2026-04-27
Gemini API のストリーミング応答をユーザー操作で中止する — AbortController と asyncio の正しい使い方
チャットUIの『停止』ボタンを押したのに Gemini API のストリーミングが裏で動き続けている — そんな見落としを防ぐための、AbortController と asyncio.CancelledError を使った安全な中止処理の実装方法をまとめます。
API / SDK2026-08-17
generated_images が消えたあとの受け取り方 — 戻り値から画像ファイルになるまでの3つの分岐
generate_content へ書き換えたあと、保存の行で落ちるのは戻り値の形が変わっているからです。画像 part が0件のとき・枚数が揺れるとき・拡張子を決めるときの3分岐と、そのまま使える受け取り関数をまとめます。
API / SDK2026-08-15
generate_images は2027年まで残ります。それでも8月17日に画像生成は止まります
google-genai 2.18.1 には generate_images がまだ残っており、SDK の非推奨メッセージが指す期限は2027年です。一方で imagen-4.0 系のモデルは8月17日に止まります。2つの期限の違いと、手元で確認できること・できないことを実測で整理しました。
📚RECOMMENDED BOOKS
大規模言語モデル入門
山田育矢
LLM開発
生成AIプロンプトエンジニアリング入門
我妻幸長
プロンプト
Claude CodeによるAI駆動開発入門
平川知秀
AI駆動開発
※ アフィリエイトリンクを含みます
もっと見る →