【後編】Gemini Live APIで音声会話を「本番運用」する
前編では、Gemini Live APIで「とりあえず音声会話が成立する」最小疎通までのつまづきポイントを解説しました。
今回はその続きで、実際にユーザーが使える本番機能に仕上げるまでに工夫した点や注意すべき点を書いていきます。
1. AIから会話を始めさせる
最小疎通の状態では「こちらが話しかけないとAIが反応しない」ユーザー起点で会話が始まる仕様でした。
ですが、語学学習の会話練習では、相手から会話が始まることで、初心者が「何を言えばいいか分からず固まる」のを防ぐことができます。
そこで、接続確立(setupComplete)の直後に、システムから会話開始を促すテキストを送るように工夫しました。
onmessage: (e) => {
if (e.setupComplete) {
// 接続完了直後に、AIへ「あなたから挨拶して」と指示を送る
session.sendRealtimeInput({
text: `Start the conversation by greeting the customer as the ${partner} at ${placeName}. Speak in ${language}.`
});
}
// ...
}
ポイントは、responseModalities がAUDIOのみのモデルでも、こちらからの入力としてテキストを送るのは可能ということです(返ってくるのが音声なだけ)。
これでAIが場面に合った挨拶から音声で話し始めてくれるようになりました。
2. 会話の保存と振り返り ― transcriptionの取得と連結
Live APIは音声をやり取りするので、会話のテキストが自動では手に入らない。
語学学習アプリとして、ユーザが会話の出来や内容を振り返れるようにするために、会話履歴の保存や添削フィードバック機能をつけたいと考えていました。
ですが、これらの機能を実現するにはテキストが必要です。
これはinput/output transcriptionを有効にして取得するようにしました。
const session = await ai.live.connect({
model: '...',
config: {
responseModalities: [Modality.AUDIO],
systemInstruction,
inputAudioTranscription: {}, // ユーザー発話の文字起こし
outputAudioTranscription: {}, // AI発話の文字起こし
},
// ...
});
詰まり:transcriptionは「断片」で届く
有効化すると、serverContent.inputTranscription / outputTranscription にテキストが届きます。
しかし、2単語くらいずつの断片で次々来るため、素直に「届くたびに1メッセージ」として扱うと、以下のようになってしまいます。
AI: There is a very
AI: nice ramen shop
AI: nearby.
会話の吹き出しがブツ切りとなってしまい、このまま保存すると履歴が読めないし、添削も意味をなさない状態になります。
解決方法としては、断片をバッファに連結し、区切りイベントで確定するようにすることです。
// 断片が届くたびにバッファへ連結
if (serverContent.outputTranscription) {
aiUtteranceRef.current += serverContent.outputTranscription.text ?? '';
}
// turnComplete を受けたら、1発話として確定
if (serverContent.turnComplete) {
finalizeAiUtterance(); // バッファを { speaker:'ai', text } にしてturns配列へ、バッファclear
finalizeUserUtterance();
}
turnComplete / generationComplete が発話の区切りの合図になります。
これで発話単位のきれいな turns({ speaker, text }[])が組めて、あとはDBへの保存処理・フィードバックAPIにそのまま渡せるようになります。
補足:transcriptionのデータは
{ text: "..." }のオブジェクト形式で届きます。文字列だと思って<div>{transcription}</div>のようにReactで描画すると "Objects are not valid as a React child" で落ちてしまうので、.textを取り出して文字列として扱うようにします
残課題:被せて話すとturnが切れる
リアルタイムは相手の発話に被せて話せるのが利点だが、その分 turn の区切り判定が難しいです。
被せ気味に話すと、発話の途中でturn が確定してしまうことがあります(「I'm looking for a」で切れる等)。
区切り判定のチューニングは今後も継続的に検討が必要な課題です。
3. 本番化の核心 ― エフェメラルトークン
ここが本番運用で一番重要かなと思います。
なぜ生APIキーではダメか
Live APIにクライアント(ブラウザ)から直接繋ぐ場合、接続に認証情報が必要となります。
生のAPIキーをフロントに置くと、開発者ツールで丸見えとなり、情報を抜かれて悪用・課金される危険性があります。
通常のサーバー経由API(/api/...)はキーがサーバー環境変数に隠れるので安全ですが、Live APIはWebSocketの長時間接続で、Vercelのようなサーバーレスでの中継に対して不向きです。
そこでエフェメラルトークンを使う方向になりました。
(エフェメラルトークンについてはこちらのサイトが参考になります。)
仕組み
- ブラウザが自サーバーの
/api/live-tokenにトークンを要求- サーバーが本物のAPIキーでGoogleから短命トークンを発行
- サーバーはブラウザにトークンだけ返す(本物キーは渡さない)
- ブラウザはトークンでLive APIに直結
トークンは短命(数十分)・権限限定なので、漏れても被害が小さいです。
トークン発行は一瞬のHTTPなのでVercelでも問題なく、音声ストリームはブラウザ↔Live APIが直接担うため、Vercelと相性が良いことが挙げられます。
サーバー側(Next.js App Router)
// src/app/api/live-token/route.ts
import { GoogleGenAI } from '@google/genai';
export async function POST() {
// (認証チェック:ログインユーザーのみ発行)
const client = new GoogleGenAI({ apiKey: process.env.GEMINI_API_KEY });
const token = await client.authTokens.create({
config: {
uses: 1,
expireTime: new Date(Date.now() + 30 * 60 * 1000).toISOString(),
newSessionExpireTime: new Date(Date.now() + 60 * 1000).toISOString(),
httpOptions: { apiVersion: 'v1alpha' }, // 発行はv1alpha
},
});
return Response.json({ token: token.name });
}
クライアント側
トークンを取得して接続するが、WebSocket closed: 1006(error: undefined)で即切れてしまいました。
// ダメだった(v1alpha指定なし)
const ai = new GoogleGenAI({ apiKey: ephemeralToken });
原因は「エフェメラルトークンはLive APIで、かつv1alphaバージョンでのみ機能する」というところにあり、 接続側でもv1alphaを指定する必要がありました。
// 正解
const ai = new GoogleGenAI({
apiKey: ephemeralToken,
httpOptions: { apiVersion: 'v1alpha' }, // ← これが必須
});
const session = await ai.live.connect({ /* ... */ });
トークンの name を apiKey として渡し、apiVersion: 'v1alpha' を付け、これで接続が維持されました。
そして仕上げに、開発者ツールのNetwork/Sourcesで本物のキーが出てこないことを確認して、本番化の前提をクリアしました。
4. ストリーミングならではのUX設計:会話中は字幕を出さない
技術が動くのと、ユーザにとって良い体験になるのは別なので、リアルタイム会話のUIで以下の判断をしました。
会話中は、会話のテキスト(字幕)を画面に出さない。
理由は語学学習として重要で、
- 字幕が出ると、ユーザーは音声を聞かず字幕を読んでしまい、リスニングの訓練にならない。
- AIの発話を字幕で先読みして、相手が話し終わる前に返答を準備してしまう。実際の会話と違う癖がつく。
そのため、「会話中は聞くことに集中、文字での確認は終了後の振り返り画面で」と役割を分けました。
会話中の画面は、話者の状態を示すビジュアルだけにして字幕は出さないようにしました。
「取得したデータを全部画面に出す」のが親切とは限らない、という良い例で、学習目的から逆算してUIを考えていくことが大事だと実感しました。
5. その他のつまづきポイント(ReactとWeb Audio)
Live APIそのものではないが、組み込む過程でつまづいたもの。
マウント時の無限ローディング
会話コンポーネントがマウント時にユーザー情報を取得し、その間スピナーを出す仕様にしていたが、ローディングが永遠に終わらない事象にあたった。
原因は isMountedRefの扱いでした。
Reactのフロントエンド開発(特に Strict Mode)では、コンポーネントが「マウント → アンマウント → 再マウント」という二重マウントの挙動をします。
最初のアンマウントで isMountedRef.current = false になり、再マウント時に true へ戻していませんでした。
その結果 if (isMountedRef.current) setLoading(false) が実行されず、無限ローディングに陥っていました。
useEffect(() => {
isMountedRef.current = true; // ← 再マウントで true に戻す(これが抜けていた)
loadUser();
return () => { isMountedRef.current = false; };
}, []);
クリーンアップに「会話終了処理」を混ぜない
アンマウント時のクリーンアップ(useEffectの戻り値)で、リソース解放だけでなく「会話終了+フィードバック生成+保存」まで呼んでしまっていました。
すると、二重マウント時にマウント直後にフィードバック生成が走り、状態が壊れて画面が固まってしまいました。
純粋な後片付け(cleanupResources:AudioContextやstreamを閉じる)と、ユーザー操作の終了処理(endConversation:発話確定→片付け→フィードバック生成)は完全に分離する必要があります。
アンマウント時は前者だけを呼ぶのが鉄則です。
useEffect(() => {
return () => { cleanupResources(); }; // 後片付けのみ。終了処理は呼ばない
}, []);
7. まとめ:本番化チェックリスト
最小疎通の先、本番運用に検討したことは、3つに大別されるかなと思っています。
- ユーザ体験の最適化
- AIから会話を開始する、会話に集中して後からフィードバックを受けれるようにする
- データの保存と活用
- 1つの発話として綺麗に整形する
- セキュリティ面の担保
- APIキーの露出を避けるためにエフェメラルトークンを採用する
前編の内容も通してまとめると、多段構成(毎ターン9〜14秒)から、リアルタイム会話が実現でき、体感は別物にすることができました。
新しいことをすると壁にぶつかることが多いが、エラーのたびに段階ログを仕込んで一段ずつ潰すことが解消への近道でした。
似たような試みをされる方の時間短縮に貢献できれば幸いです。
(注:モデル名・API仕様・各種制約は執筆時点のものです。Live APIはプレビュー段階で変更が早いので、最新は公式ドキュメントをご確認ください。)