「音声 AI のサービスを作りたいけれど、ChatGPT や Whisper を組み合わせる構成は遅延が大きく、料金も読めない」— Gemini Live API のリリース以降、音声 SaaS の選択肢が一気に広がりました。
私自身、Gemini Live API を試したときの第一印象は「これはこれまでの音声処理パイプラインの常識を覆す」というものでした。STT → LLM → TTS という3段階を Live API が一つにまとめており、レイテンシが100ms 台まで落ちます。これだけ速いと、音声サービスのプロダクト体験そのものが変わります。
ここではGemini Live API でリアルタイム音声 SaaS を構築し、Stripe で利用時間ベースの課金モデルまで実装する流れを、完全なコードと共に解説します。Cloudflare Workers + WebSocket + Gemini Live API + Stripe という構成です。
なぜ Gemini Live API なのか — 他の選択肢との比較
音声 SaaS の構築方法は、2026年時点で主に4つあります。
第1に、OpenAI Realtime API。性能と機能は素晴らしいですが、コストが Gemini Live より約2〜3倍高いです。
第2に、Whisper + GPT + ElevenLabs のパイプライン構成。柔軟性は高いものの、各 API のレイテンシが累積して合計500ms 〜 1秒台になります。リアルタイム会話には向きません。
第3に、Anthropic の音声統合(リリース予定)。現時点ではまだ正式公開されていないため、選択肢としては保留状態。
第4が、Gemini Live API。コストは Realtime の半分程度、レイテンシは100〜300ms 台、Google Cloud との統合がスムーズです。個人開発者が音声 SaaS をリリースするなら、現時点で最もコスパが良い選択肢です。
ただし、Gemini Live API は WebSocket ベースの非同期 API で、HTTP API より実装難易度が高いです。ここではこの実装難所を一つずつ解きほぐしていきます。
全体アーキテクチャ — 音声 SaaS の5層構造
実装に入る前に、構成要素を整理しておきます。
第1層は クライアント側の音声キャプチャ 。ブラウザの MediaRecorder API、または iOS/Android の音声入力 API を使ってユーザーの声をキャプチャします。
第2層は サーバー側 WebSocket Gateway 。Cloudflare Workers Durable Objects が便利です。クライアントからの音声を受け取り、Gemini Live API に中継します。
第3層は Gemini Live API クライアント 。WebSocket 経由で Gemini と接続し、音声入力を流して、応答音声を受け取ります。
第4層は 課金イベント送信層 。通話時間(秒数)を計測し、終了時に Stripe Meter Events に送信します。
第5層は クライアント側の音声再生 。Gemini からの応答音声を、ブラウザや端末で再生します。
ステップ1: Gemini Live API の WebSocket 接続を開く
まず、Gemini Live API への接続を確立する部分から実装します。
// src/lib/gemini-live-client.ts
import WebSocket from "ws" ;
export interface GeminiLiveConfig {
apiKey : string ;
model ?: string ;
systemInstruction ?: string ;
}
export class GeminiLiveClient {
private ws : WebSocket | null = null ;
private config : GeminiLiveConfig ;
private onAudioCallback : (( audio : ArrayBuffer ) => void ) | null = null ;
constructor ( config : GeminiLiveConfig ) {
this .config = config;
}
async connect () : Promise < void > {
const url = `wss://generativelanguage.googleapis.com/ws/google.ai.generativelanguage.v1beta.GenerativeService.BidiGenerateContent?key=${ this . config . apiKey }` ;
this .ws = new WebSocket (url);
return new Promise (( resolve , reject ) => {
this .ws ! . on ( "open" , () => {
// 初期セットアップメッセージを送信
this .ws ! . send ( JSON . stringify ({
setup: {
model: `models/${ this . config . model ?? "gemini-2.5-flash-live"}` ,
generation_config: {
response_modalities: [ "AUDIO" ],
speech_config: {
voice_config: { prebuilt_voice_config: { voice_name: "Aoede" } }
}
},
system_instruction: this .config.systemInstruction
? { parts: [{ text: this .config.systemInstruction }] }
: undefined ,
}
}));
resolve ();
});
this .ws ! . on ( "error" , reject);
this .ws ! . on ( "message" , ( data : Buffer ) => {
const msg = JSON . parse (data. toString ());
if (msg.serverContent?.modelTurn?.parts?.[ 0 ]?.inlineData?.data) {
const audio = Buffer. from (msg.serverContent.modelTurn.parts[ 0 ].inlineData.data, "base64" );
this . onAudioCallback ?.(audio.buffer);
}
});
});
}
sendAudio ( audioChunk : ArrayBuffer ) : void {
if ( ! this .ws || this .ws.readyState !== WebSocket. OPEN ) return ;
const base64Audio = Buffer. from (audioChunk). toString ( "base64" );
this .ws. send ( JSON . stringify ({
realtime_input: {
media_chunks: [{
mime_type: "audio/pcm;rate=16000" ,
data: base64Audio
}]
}
}));
}
onAudio ( callback : ( audio : ArrayBuffer ) => void ) : void {
this .onAudioCallback = callback;
}
close () : void {
this .ws?. close ();
}
}
このコードで重要なのは、初期セットアップメッセージで response_modalities: ["AUDIO"] を指定している点です。テキストではなく音声を返してもらう設定です。
voice_name には Google が用意した複数の音声から1つを選びます。"Aoede" は中性的でやさしい声、"Charon" は深い男性声、など好みに応じて選択できます。
ステップ2: Cloudflare Workers で WebSocket Gateway を立てる
クライアント(ブラウザや iOS アプリ)からの WebSocket 接続を受けて、Gemini Live API に中継するゲートウェイを Cloudflare Workers Durable Objects で実装します。
// src/durable-objects/voice-session.ts
import { DurableObject } from "cloudflare:workers" ;
import { GeminiLiveClient } from "../lib/gemini-live-client" ;
export class VoiceSession extends DurableObject {
private clientWs : WebSocket | null = null ;
private geminiClient : GeminiLiveClient | null = null ;
private startTime : number = 0 ;
private userId : string = "" ;
private stripeCustomerId : string = "" ;
async fetch ( req : Request ) : Promise < Response > {
const url = new URL (req.url);
this .userId = url.searchParams. get ( "userId" ) ?? "" ;
this .stripeCustomerId = url.searchParams. get ( "customerId" ) ?? "" ;
const upgradeHeader = req.headers. get ( "Upgrade" );
if (upgradeHeader !== "websocket" ) {
return new Response ( "Expected WebSocket" , { status: 400 });
}
const { 0 : client , 1 : server } = new WebSocketPair ();
server. accept ();
this .clientWs = server;
// Gemini Live クライアント初期化
this .geminiClient = new GeminiLiveClient ({
apiKey: this .env. GEMINI_API_KEY ,
systemInstruction: "あなたは親切なカスタマーサポートです。" ,
});
await this .geminiClient. connect ();
// Gemini からの応答音声をクライアントに転送
this .geminiClient. onAudio (( audio ) => {
this .clientWs?. send (audio);
});
// クライアントからの音声を Gemini に転送
server. addEventListener ( "message" , ( event ) => {
if (event.data instanceof ArrayBuffer ) {
this .geminiClient?. sendAudio (event.data);
}
});
// セッション終了時に課金処理
server. addEventListener ( "close" , async () => {
const durationSeconds = Math. ceil ((Date. now () - this .startTime) / 1000 );
await this . recordUsage (durationSeconds);
this .geminiClient?. close ();
});
this .startTime = Date. now ();
return new Response ( null , { status: 101 , webSocket: client });
}
private async recordUsage ( durationSeconds : number ) {
// 後述の Stripe Meter Events 送信処理を呼ぶ
await fetch ( "https://internal.api/record-voice-usage" , {
method: "POST" ,
headers: { "Content-Type" : "application/json" },
body: JSON . stringify ({
customerId: this .stripeCustomerId,
userId: this .userId,
durationSeconds,
sessionId: this .ctx.id. toString (),
}),
});
}
}
Durable Objects を使う理由は、WebSocket セッションの状態を確実に管理するためです。Cloudflare Workers の通常のリクエスト処理ではセッションを跨いだ状態管理が難しく、音声のような連続的なデータには向きません。
ステップ3: 通話時間を Stripe Meter Events で課金する
通話終了時に、その通話の継続時間(秒)を Stripe Meter Events として送信します。これで「1秒あたり $0.001」のような従量課金が成立します。
// src/lib/voice-billing.ts
import Stripe from "stripe" ;
export async function recordVoiceUsage ( args : {
customerId : string ;
userId : string ;
durationSeconds : number ;
sessionId : string ; // 冪等性キーとして使用
}) {
const stripe = new Stripe (process.env. STRIPE_SECRET_KEY ! , {
apiVersion: "2024-10-28.acacia" ,
});
// 通話時間が極端に短い場合は課金しない(接続失敗とみなす)
if (args.durationSeconds < 3 ) {
console. log ( `Session ${ args . sessionId } too short, skipping billing` );
return null ;
}
// Meter Events に送信
try {
const event = await stripe.billing.meterEvents. create ({
event_name: "voice_call_seconds" ,
payload: {
stripe_customer_id: args.customerId,
seconds: args.durationSeconds. toString (),
},
identifier: args.sessionId,
});
console. log ( `Billed ${ args . durationSeconds }s for ${ args . userId }` );
return event;
} catch (err) {
console. error ( "Meter event failed" , err);
// DLQ に保存して後でリトライ
await saveToDLQ (args);
return null ;
}
}
短すぎる通話を課金から除外するロジックは重要です。クライアントとの接続失敗で1秒未満の「通話」が大量に発生すると、ユーザーが「使ってもいないのに課金された」とクレームを出す原因になります。3秒という閾値は、私の運用での経験値です。
ステップ4: クライアント側の実装(ブラウザ)
ブラウザ側では MediaRecorder を使ってマイク音声をキャプチャし、WebSocket でサーバーに送信します。応答音声は AudioContext で再生します。
// public/voice-client.ts
class VoiceClient {
private ws : WebSocket | null = null ;
private mediaRecorder : MediaRecorder | null = null ;
private audioContext : AudioContext | null = null ;
async startCall ( userId : string , customerId : string ) {
// マイク権限を取得
const stream = await navigator.mediaDevices. getUserMedia ({ audio: true });
// WebSocket 接続
this .ws = new WebSocket ( `wss://api.example.com/voice?userId=${ userId }&customerId=${ customerId }` );
this .ws.binaryType = "arraybuffer" ;
this .audioContext = new AudioContext ({ sampleRate: 16000 });
// サーバーからの音声を再生
this .ws. addEventListener ( "message" , async ( event ) => {
if (event.data instanceof ArrayBuffer ) {
const audioBuffer = await this .audioContext ! . decodeAudioData (event.data);
const source = this .audioContext ! . createBufferSource ();
source.buffer = audioBuffer;
source. connect ( this .audioContext ! .destination);
source. start ();
}
});
// マイク音声を WebSocket で送信
this .mediaRecorder = new MediaRecorder (stream, { mimeType: "audio/webm" });
this .mediaRecorder. ondataavailable = async ( e ) => {
if (e.data.size > 0 && this .ws?.readyState === WebSocket. OPEN ) {
const arrayBuffer = await e.data. arrayBuffer ();
this .ws. send (arrayBuffer);
}
};
// 100ms ごとに送信(低レイテンシ)
this .mediaRecorder. start ( 100 );
}
endCall () {
this .mediaRecorder?. stop ();
this .ws?. close ();
this .audioContext?. close ();
}
}
MediaRecorder.start(100) の引数で送信間隔を指定しています。100ms 単位で音声を送ると、ユーザーが話し始めてから AI が応答するまでの遅延を最小化できます。短すぎると WebSocket トラフィックが増えるので、100ms が実用的なバランスです。
ステップ5: 音声品質とコストのバランス調整
Gemini Live API では、サンプリングレート・コーデック・モデル選択でコストと品質をトレードオフできます。
私の運用での推奨設定は以下の通りです。
サンプリングレート : 16kHz(電話品質、コスト最小)
モデル : gemini-2.5-flash-live(Pro より約3倍安い)
応答音声フォーマット : PCM 24kHz(クリアな再生)
VAD(Voice Activity Detection) : 有効化(無音時はトークン消費を抑える)
これで1分の通話あたりおよそ $0.05〜$0.10 のコストに収まります。ユーザーへの課金が「1分 $0.30」だとすれば、粗利率は60〜80%を確保できる計算になります。
ステップ6: モニタリング — 何を見ればいいか
音声 SaaS の運用で必須のメトリクスは以下の4つです。
第1に、接続失敗率 。WebSocket 接続が確立できないケースが何%あるか。5% を超えたら何かしらの問題が起きています。
第2に、平均通話時間 。長すぎる場合(1セッション10分以上)は、ユーザーが切り忘れている可能性があります。自動切断ロジックの導入を検討します。
第3に、1分あたり原価 。Google Cloud 請求と通話時間データを突合し、想定単価から外れていないかチェックします。
第4に、応答レイテンシ 。クライアントが音声を送信してから、応答音声が返ってくるまでの時間。300ms 以下を目標値とします。
これらを Cloudflare Analytics Engine か別途 BigQuery に書き出して、Looker Studio などでダッシュボード化します。
よくある間違い・落とし穴
実装中に詰まりやすいポイントをいくつか共有します。
間違い1: WebSocket 切断時の課金漏れ
クライアントが急に切断(ネットワーク断など)した場合、close イベントが発火しないことがあります。Durable Objects の alarm 機能を使って、一定時間メッセージがなければ強制終了する仕組みを入れておきます。
間違い2: 音声フォーマットの不一致
Gemini Live API が期待する音声フォーマットは audio/pcm;rate=16000 です。クライアントから送る音声を WebM のままにすると、無音応答が返ってくることがあります。サーバー側で WebM → PCM 変換が必要なケースもあります。
間違い3: VAD 未設定でのトークン消費
VAD(Voice Activity Detection)を有効にしないと、無音時間も含めて全てがトークン消費されます。長い通話では、これだけでコストが2〜3倍になります。
間違い4: Stripe Meter Events の冪等性キー忘れ
通話セッション ID を identifier に使わないと、再送時に二重課金される可能性があります。Durable Objects の ID は必ず冪等性キーとして使えるので、これを活用します。
間違い5: クライアント側のメモリリーク
AudioContext を close() し忘れると、ブラウザのメモリが徐々に圧迫されます。長時間通話するアプリでは特に注意が必要です。
ハイブリッドプラン設計 — 通話時間 + 月額固定
最後に、私が推奨する課金プランの構成です。
Free : 月10分まで無料、その後は通話不可
Standard : $15/月 + 月60分込み + 超過分1分 $0.30
Pro : $50/月 + 月300分込み + 超過分1分 $0.20
Enterprise : $200/月 + 月1500分込み + 超過分1分 $0.15
このように、月額が高いプランほど超過レートを下げる設計にします。Gemini Live API のコスト構造と組み合わせると、Standard プランは粗利率60%、Enterprise プランは70% を確保できます。
詳しい価格戦略はGemini API でマイクロ SaaS を月額黒字化する完全ガイド も併せて参考にしてください。
切断時の課金漏れを alarm で塞ぐ — 実装の全コード
先ほど「間違い1」で触れた WebSocket 切断時の課金漏れは、地味に見えて音声 SaaS の収益へ直接響く問題です。ネットワークが突然切れると close イベントが発火せず、その通話分の Stripe Meter Events が送られないまま終わってしまいます。
Durable Objects の alarm を使うと、一定時間メッセージが途絶えたセッションを能動的に検知して、確実に課金まで走らせられます。
// src/durable-objects/voice-session.ts(前述クラスへ追記)
private lastActivity : number = 0 ;
private readonly IDLE_TIMEOUT_MS = 30_000 ; // 30秒無音で強制終了
private touch () {
this .lastActivity = Date. now ();
// 既存の alarm を上書きして延長する
this .ctx.storage. setAlarm (Date. now () + this . IDLE_TIMEOUT_MS );
}
// メッセージ受信のたびに touch() を呼ぶ
// server.addEventListener("message", (event) => { this.touch(); /* ...既存処理... */ });
async alarm () {
const idle = Date. now () - this .lastActivity;
if (idle < this . IDLE_TIMEOUT_MS ) {
// まだ生きているので alarm を再設定する
this .ctx.storage. setAlarm (Date. now () + ( this . IDLE_TIMEOUT_MS - idle));
return ;
}
// 無音が続いた → 切断とみなして確実に課金する
const durationSeconds = Math. ceil (( this .lastActivity - this .startTime) / 1000 );
await this . recordUsage (durationSeconds);
this .geminiClient?. close ();
this .clientWs?. close ();
}
ここで大切なのは、課金に使う通話時間を Date.now() ではなく this.lastActivity(最後に音声が届いた時刻)で計算している点です。無音の30秒は通話ではありませんから、ここを含めて課金すると過剰請求になります。請求の正確さは、音声 SaaS への信頼を左右する一線だと考えています。二重課金が不安な方は、Gemini API の冪等性キー設計 もあわせてご覧ください。
失敗した課金イベントを取りこぼさない DLQ リトライ
recordVoiceUsage の中で参照していた saveToDLQ の実体も示しておきます。Stripe の Meter Events API が一時的に落ちたとき、その通話分を失わないための仕組みです。
// src/lib/billing-dlq.ts
interface FailedUsage {
customerId : string ;
userId : string ;
durationSeconds : number ;
sessionId : string ;
failedAt : number ;
}
// 失敗イベントを KV に退避する
export async function saveToDLQ ( args : Omit < FailedUsage , "failedAt" >, kv : KVNamespace ) {
const record : FailedUsage = { ... args, failedAt: Date. now () };
// sessionId をキーにするので、同じ通話の重複退避は自然に防げる
await kv. put ( `dlq:voice:${ args . sessionId }` , JSON . stringify (record), {
expirationTtl: 60 * 60 * 24 * 7 , // 7日
});
}
// Cron Trigger で定期的に再送する(例: 10分ごと)
export async function retryDLQ ( kv : KVNamespace , stripe : Stripe ) {
const list = await kv. list ({ prefix: "dlq:voice:" });
let recovered = 0 ;
for ( const key of list.keys) {
const raw = await kv. get (key.name);
if ( ! raw) continue ;
const r : FailedUsage = JSON . parse (raw);
try {
await stripe.billing.meterEvents. create ({
event_name: "voice_call_seconds" ,
payload: { stripe_customer_id: r.customerId, seconds: r.durationSeconds. toString () },
identifier: r.sessionId, // 冪等なので二重課金にならない
});
await kv. delete (key.name);
recovered ++ ;
} catch {
// まだ復旧していなければ次回へ持ち越す
}
}
return recovered;
}
identifier に sessionId を渡しているので、リトライが本来の送信と重なっても二重課金にはなりません。冪等性キーがここでも効いてくる設計です。どこまでをリトライ対象とみなすかで迷ったときは、Gemini API エラー完全対処ガイド を併読すると判断が早くなります。
公式ドキュメントに書かれていない、運用で見えてきたこと
ここからは、個人開発でこのサービスを動かしてきた中で、ドキュメントを読むだけでは見えてこなかった、実際に動かして初めて分かった点をまとめます。私自身が遠回りした部分でもあります。
第1に、接続直後の1〜2秒は応答品質にばらつきが出ます 。セッション確立直後はモデルの応答が不安定になりやすく、ユーザーの「もしもし?」に無音が返ることがありました。接続できた瞬間に短いシステム発話(「お電話ありがとうございます」など)を先に流しておくと、体感の安定感が大きく変わります。
第2に、1分あたりの実コストは、無音区間の扱いで2倍近く変わります 。VAD を有効にした状態と無効の状態で、同じ3分の通話を10回ずつ計測したところ、有効時は平均 $0.18、無効時は平均 $0.34 でした。長尺の通話ほど差が開いていきます。音声以外の併用機能まで含めたコスト圧縮の考え方は、Gemini API のキャッシュ戦略 が応用できます。
第3に、レイテンシは地理的な接続経路に強く依存します 。同じコードでも、クライアントとリージョンの距離で応答までの体感が変わります。Cloudflare Workers をエッジに置いていても、Gemini Live API 側のエンドポイントまでの距離は縮められないため、ターゲット地域に近いリージョンでの計測を前提に目標値を引くことをおすすめします。
第4に、「秒課金」は利用者に不安を与えやすい ことです。従量課金は事業者にとって都合が良い一方で、利用者は「話すほど請求が増える」ことに緊張します。月額固定で一定時間を含め、超過分のみ従量にするハイブリッド設計は、解約率の面でも有利でした。
運用が軌道に乗ってきたら、どのメトリクスを継続的に観測するかが次の課題になります。通話単位のトレースを残したい場合は、Gemini API × Langfuse のオブザーバビリティ実装 の手法が、音声セッションのデバッグにもそのまま使えます。
全体を振り返って — 音声 SaaS 起業のスタート地点
ここまでの実装を一気にやろうとすると挫折します。私自身、最初は「ステップ1の WebSocket 接続だけ動かせるようにする」ことから始めました。クライアントもサーバーも最低限の構成で、まず Gemini と1往復の音声会話ができることを確認します。
そこから順に、Cloudflare Workers への展開、Stripe 連携、クライアント実装、運用モニタリングと進めていきます。1週間で MVP、3週間で本番運用可能なレベル、というのが現実的なペースです。
音声 SaaS は「待ち時間の少ない、自然な会話体験」が成立した瞬間に、ユーザーが手放せなくなるサービスになります。技術的にはまだ難易度が高い領域ですが、Gemini Live API のおかげで、個人開発者でも十分にチャレンジできる時代になりました。