【前半】Gemini Live APIでリアルタイム音声会話を実装する
語学学習アプリを開発するなかで、リアルタイムな「会話モード」を実現するために、TTS+STTの多段構成から Gemini Live API のリアルタイム音声対話に置き換えた記録です。
実際に動かすまでにつまづいたところを、エラーログ付きで順番に解説します。
同じところで詰まっている方の時間を節約できれば幸いです。
1. そもそも何を解決したかったのか
語学学習アプリを開発する際に、「AIと音声で会話する」機能を取り入れたく、当初の構成は以下でした。
- ユーザーの発話を録音し、API(音声→テキスト)に投げる
- 文字起こし結果をAPI(テキスト→AI応答テキスト)に投げる
- 応答テキストをAPI(テキスト→音声)に投げて、返ってきた音声を再生する
| 処理 | 所要時間 |
|---|---|
| transcribe(音声→テキスト) | 約3秒 |
| conversation(AI応答生成) | 約2.5〜7.8秒(不安定) |
| tts(テキスト→音声) | 約3秒 |
| 1往復合計 | 約9〜14秒 |
3つのAPIを直列で呼ぶ以上、各段のネットワーク往復と生成待ちが積み重なり、
モデルを最新・最速のものに替えても、この「直列構成」という骨格がボトルネックとなっており、限界がありました。
このままだと会話アプリとして使えないので検討したのがGemini Live APIでした。
(Gemini Live APIについてはこちらのサイトが参考になります。)
2. Live API の仕組み
2-1. リクエスト/レスポンス型ではなく、ストリーミング型
通常のGemini API(TTSやテキスト生成)は「リクエストを送る→レスポンスが返る」の1往復型(HTTP)で、
Live APIはWebSocketによる双方向ストリーミングです。
(WebSocketについては、こちらの記事が参考になります。)
一度接続を張ったら、その接続の上で音声を流し続け、AIからも音声が流れ続けます。
「話し終わってから処理が始まる」のではなく、「話している最中から処理が走る」ので、応答が速いのが特徴です。
2-2. SDK を使うと楽
生のWebSocketを自前で管理することもできますが、@google/genai SDK を使うと接続管理を肩代わりしてくれます。
import { GoogleGenAI, Modality } from '@google/genai';
const ai = new GoogleGenAI({ apiKey });
const session = await ai.live.connect({
model: 'gemini-3.1-flash-live-preview',
config: {
responseModalities: [Modality.AUDIO],
systemInstruction: 'You are a helpful assistant.',
},
callbacks: {
onopen: () => { /* 接続確立 */ },
onmessage: (msg) => { /* サーバーからのメッセージ */ },
onerror: (e) => { /* エラー */ },
onclose: (e) => { /* 切断(e.code, e.reason) */ },
},
});
2-3. 音声フォーマットが入力と出力で違う(重要)
ここは最初に頭に入れておくとよいかなと思います。
- 入力(マイク→Live API):16-bit PCM / 16kHz / モノラル / リトルエンディアン
- 出力(Live API→スピーカー):16-bit PCM / 24kHz / モノラル
入力16kHz、出力24kHzであり、サンプルレートが違います。
ここを取り違えると「音は鳴るけど声が高い/低い/速い/遅い」「ノイズになる」といったあまり使い物にならなくなります。
3. TTS / STT モデルとの違い
| 観点 | TTS + STT(多段構成) | Live API |
|---|---|---|
| 通信方式 | HTTP(1往復ずつ) | WebSocket(張りっぱなしのストリーム) |
| レイテンシ | 各段が積み重なり遅い | 低レイテンシ・リアルタイム |
| 構成 | STT / LLM / TTS の3つを自前で繋ぐ | 1接続で音声→音声が完結 |
| 割り込み | 基本的に苦手(ターン制) | 発話の被せ(interruption)に対応 |
| 実装難度 | 各APIは素直。繋ぐのは単純 | 接続管理・音声ストリーム処理が必要で複雑 |
| セキュリティ | サーバー経由でキーを隠せる | クライアント直結だとキー露出(後編で言及) |
| 文字起こし | STTの結果がそのまま手に入る | input/outputのtranscriptionをオプションで取得 |
ざっくり言うと、「速さ・自然さ」を取るならLive API、「実装の単純さ・各処理の制御しやすさ」を取るならTTS+STTの多段構成が良いのかなと思います。
「テンポが命」の会話用途ではLive APIが圧倒的に有利です。
逆に、3つの処理それぞれに別々の細工をしたい(例:文字起こし結果に独自処理を挟む、TTSの声を細かく差し替える)のであれば、多段構成のほうが制御しやすい場面もあると考えます。
4. 詰まりどころ
ここからが本記事の核心です。
接続成功から声が返るまで、実際につまづいた点を順番に書いていきたいと思います。
エラーが出るたびにログを1段ずつ仕込んで原因を特定する、という地道なやり方が結局いちばん速かったです。
つまづきポイント1:レスポンスモダリティの制約
切り分けのつもりで「音声もテキストも返して」と指定したら、以下のようなエラーになりました。
WebSocket closed: 1007 The requested combination of response modalities (AUDIO, TEXT)
is not supported by the model.
Live APIのモデルはAUDIOかTEXTのどちらか一方しか返せず、両方の指定はダメなようです。
さらに、gemini-3.1-flash-live-preview は実質AUDIO出力前提で、TEXTのみを指定しても弾かれました。
WebSocket closed: 1007 The requested combination of response modalities (TEXT)
is not supported by the model.
→ 「テキストで動作確認してから音声」という定石が使えないので、最初から音声で勝負することになりました。
代わりに、入力の文字起こし(inputTranscription)をオンにすると「自分の声が認識されているか」を音声出力とは独立に確認でき、切り分けに有効です。
つまづきポイント2:音声送信の「状態ガード」が効きすぎて一度も送信されない
次に、マイクのコールバックは発火しているのに、送信ログが出ない問題に当たりました。
仕込んだログを見ると:
[STEP 1] Audio processor callback called: 200 times
[STEP 2] Status check: idle (not connected/listening, skipping)
送信処理に「状態がlisteningのときだけ送る」というガードがあり、状態がidleのまま変わっていなかったのが原因です。
setupComplete受信時に setStatus('listening') しても直らない。
これはReactのクロージャ問題で、
マイクのコールバック(onaudioprocess)は登録時点のstateを閉じ込めるので、その後stateを更新してもコールバック内からは古い値(idle)しか見えないのです。
解決方法としてはrefを併用して、コールバック内ではstateではなくrefを読むようにします。
(こちらの記事などが参考になります。)
const statusRef = useRef('idle');
// 状態を変えるときは state と ref を両方更新
function goListening() {
setStatus('listening');
statusRef.current = 'listening';
}
// コールバック内は ref を見る(最新値が見える)
processor.onaudioprocess = (e) => {
if (statusRef.current !== 'connected' && statusRef.current !== 'listening') return;
// ...送信処理...
};
setInterval やイベントコールバックのように「一度登録されると古い値を見続ける」場所では、stateではなくrefを使います。
これは Live API に限らず、React開発における頻出パターンですね。
つまづきポイント3:送信形式の仕様変更
状態ガードを抜けて送信まで到達したら、今度はこの問題にぶつかりました。
WebSocket closed: 1007 realtime_input.media_chunks is deprecated.
Use audio, video, or text instead.
sendRealtimeInput({ media: blob }) という古い形式は廃止され、新形式は **audio**フィールドにbase64文字列を渡す仕様になっています。
session.sendRealtimeInput({
audio: {
data: base64Pcm, // PCMをbase64エンコードした「文字列」
mimeType: 'audio/pcm;rate=16000',
},
});
Blobを渡すのではなく、PCMのバイト列をbase64化した文字列を渡すのがポイントで、mimeTypeも必須です。
(mimeTypeについてはこちらのサイトが参考になります。)
つまづきポイント4:受信データをBlobと誤認する
ここでやっと送信が通り、ついにAIが応答を返してくれるようになったのですが、音声再生でこけました。
TypeError: audioBlob.arrayBuffer is not a function
受信処理が音声データをBlobとみなして arrayBuffer() を呼んでしまっていました。
しかし inlineData.data はbase64文字列であり、文字列に arrayBuffer() はないです。
ここは、base64をデコード → Int16 PCM として解釈 → Float32 に変換 → 24kHz で再生、という形で対応しました。
function playFromBase64(b64) {
// Base64をバイナリ(バイト列)に復元
const bytes = Uint8Array.from(atob(b64), c => c.charCodeAt(0));
// バイト列を16bit整数(Int16)の配列として解釈
const pcm16 = new Int16Array(bytes.buffer);
// Web Audio APIで再生するために、32bit浮動小数点数(Float32)の配列を準備
const f32 = new Float32Array(pcm16.length);
// 16bit符号付き整数の最大値(32768)で割ることで、Web Audio APIが扱える -1.0 〜 1.0 の範囲に正規化
for (let i = 0; i < pcm16.length; i++) f32[i] = pcm16[i] / 32768;
// f32 を sampleRate=24000 の AudioBuffer に入れて再生(チャンクをキューで連結)
}
ここまで直して、ようやくAIの声が返ってくるようになりました。
5. 音声処理まわりの実装ノウハウ
詰まりどころ以外で、知っておくと良かったことを列挙しておきます。
5-1. AudioContext の sampleRate は指定が無視されることがある
new AudioContext({ sampleRate: 16000 }) と指定しても、ブラウザ(特にPCのChrome)はハードウェア準拠の48000などに固定し、指定が無視されることがあります。
audioContext.sampleRate の実値を必ずログで確認し、16kHzでなければ自前でダウンサンプリングしてから送るようにしてください。
function downsample(input, srcRate, dstRate) {
if (srcRate === dstRate) return input.slice();
const ratio = srcRate / dstRate;
const out = new Float32Array(Math.floor(input.length / ratio));
for (let i = 0; i < out.length; i++) out[i] = input[Math.floor(i * ratio)];
return out;
}
5-2. 受信音声はチャンクで届く → 再生キューで連結
出力音声は細切れのチャンクで次々届くので、そのまま鳴らすとブツ切りになってしまいます。
前のチャンクの再生終了時刻に次を繋ぐスケジューリングを実装することで、途切れず再生されます。
const startTime = Math.max(ctx.currentTime, nextPlayTime);
source.start(startTime);
nextPlayTime = startTime + buffer.duration;
5-3. ターンの区切りは turnComplete / generationComplete で分かる
サーバーイベントに generationComplete(生成完了)・turnComplete(ターン完了)が来るので、これでAIの発話の切れ目を検知できます。
割り込み(interrupted)イベントもあるので、被せ発話に対応するなら拾うようにしましょう。
6. まとめ
結局、Live API で声が返るまでにけっこうな時間を要してしまいました。
特に詰まりやすいのは、上述の通り、レスポンスモダリティの制約、古い設定パラメータの混入、音声送信の状態管理、送信形式の仕様変更、受信データの形式かなと思っています。
記載した内容を解決して、一通り会話ができるようになったので、後半では本番適用に向けた工夫ポイントを記載します。
(注:本記事のモデル名・API仕様・各種制約は執筆時点のものです。Live APIはプレビュー段階で変更が早いので、最新は公式ドキュメントでご確認ください。)