Twilio と OpenAI Realtime API で電話越しに AI と日本語会話する最小構成を作ってみた
はじめに
これまで Twilio で電話 AI を組むときは、Media Streams で受けた音声をクラウドの音声認識サービスに流し、その結果を LLM に投げ、応答テキストを TTS で再び音声化して返す、という多段パイプラインを自前で繋いでいくのが定石でした。各段の遅延が積み上がる構造のため、自然な会話のテンポで応答させるには、間に挟むサービスの選定とチューニングにそれなりの手間がかかっていました。
これを大きく変えたのが、2025 年 8 月に GA された OpenAI の gpt-realtime モデルです。WebSocket 越しに音声をそのまま流し込めば音声で応答が返ってくるシングルパスのインターフェースになり、VAD (発話の終わり検出) や割り込みもサーバー側で面倒を見てくれます。
今回はこの gpt-realtime と Twilio MediaStreams を組み合わせて、米国電話番号にかかってきた通話をそのまま AI と日本語で会話させる最小構成を作りました。ゴールは 「米国 Twilio 番号に着信した相手が AI と日本語で双方向音声会話できる」 状態を実現することです。ホスティングは WebSocket と相性のよい Fly.io を選んでいます。
Twilio Media Streams とは
Twilio Media Streams は、進行中の通話音声を WebSocket でリアルタイムに送受信できる Twilio Voice の機能です。デフォルトで G.711 μ-law (8 kHz) の base64 ペイロードが流れ、TwiML の <Connect><Stream> で双方向、<Start><Stream> で受信専用の動作になります。
検証環境
- ランタイム: Node.js 24 系
- 言語: TypeScript 5.x
- フレームワーク: Fastify 5.x +
@fastify/websocket - OpenAI モデル:
gpt-realtime(2025 年 8 月 GA) - 電話: Twilio Voice + Media Streams (米国番号)
- ホスティング: Fly.io (
iadリージョン /shared-cpu-1x/ 256 MB) - 検証日: 2026 年 5 月
対象読者
- Twilio で電話 AI / IVR を構築している、あるいは検討している方
- OpenAI Realtime API の GA 仕様 (
gpt-realtime) に追従したサンプルを探している方 - WebSocket 中継サーバーをどこにホストするか悩んでいる方
- 電話越し AI の応答レイテンシや実コストを具体的な数字で把握したい方
参考
- Realtime conversations | OpenAI
- Realtime API over WebSocket | OpenAI
- Media Streams | Twilio
- TwiML Voice: <Stream> | Twilio
- Fly.io Docs
なぜ中継サーバーが要るのか
Twilio MediaStreams を OpenAI Realtime API に直接繋ぐことはできません。理由は 3 つあります。
- 認証ヘッダを差し込めない
OpenAI Realtime API への接続にはAuthorization: Bearer <api_key>ヘッダが必須ですが、Twilio から張る WebSocket では任意のヘッダを足せません。 - イベントスキーマが別物
Twilio 側は{event: "media", media: {payload: ...}}という形式、OpenAI 側は{type: "input_audio_buffer.append", audio: ...}という形式で、互いに知らない構造です。 - セッション初期化が必要
接続直後にsession.updateを投げて音声フォーマット等を設定する必要がありますが、これも Twilio 側は知りません。
したがって、最低限 Twilio と OpenAI のイベント形式を相互変換するだけの WebSocket リレー をどこかにホストする必要があります。今回はこれを Fly.io 上の Node.js プロセスとして書きました。
音声フォーマットは Twilio と OpenAI のどちらも audio/pcmu (G.711 μ-law / 8 kHz) で揃えました。これにより、リレー側ではバイナリ変換を一切せず、Twilio から届く base64 ペイロードを OpenAI へそのまま、OpenAI から届く音声チャンク (response.output_audio.delta) を Twilio へそのまま渡すだけで成立します。
事前準備と Fly.io へのデプロイ
まず、用意するのは以下の通りです。
- Twilio 購入番号
- OpenAI の API キーとクレジット
- Fly.io アカウントと
flyctlCLI
ホスティングに Fly.io を選んだ理由は、WebSocket を長時間張り続けるユースケースにそのまま使えるからです。
fly.toml の主要部は以下の通りです。
fly.toml (主要部抜粋)
primary_region = "iad"
[http_service]
internal_port = 8080
force_https = true
auto_stop_machines = "off"
auto_start_machines = true
min_machines_running = 1
[[vm]]
size = "shared-cpu-1x"
memory = "256mb"
デプロイは flyctl launch --no-deploy でアプリを作り、flyctl secrets set で OPENAI_API_KEY / TWILIO_AUTH_TOKEN / PUBLIC_BASE_URL を投入してから flyctl deploy で完了です。最後に Twilio Console で対象番号の Voice Configuration を Webhook に切り替え、URL に https://<your-app-name>.fly.dev/twilio/voice を設定すれば配線完了です。

実装の抜粋
GA 仕様で押さえておくべき点を中心に、4 箇所だけ抜粋します。
1. TwiML で WebSocket を開かせる
Twilio 側に「この WebSocket に音声を双方向で流して」と指示する TwiML を返します。<Start><Stream> (片方向) ではなく <Connect><Stream> (双方向) を使う点だけ注意です。
TwiML を返すルート
app.post("/twilio/voice", async (request, reply) => {
const wsUrl = `${config.PUBLIC_BASE_URL.replace(/^https/, "wss")}/twilio/stream`;
const response = new twilio.twiml.VoiceResponse();
response.connect().stream({ url: wsUrl });
return reply
.header("Content-Type", "text/xml; charset=utf-8")
.send(response.toString());
});
2. OpenAI Realtime に WebSocket 接続する
Authorization ヘッダを必ず送る必要があります。ブラウザの WebSocket API ではヘッダを送れないので、本番でも検証でもサーバー側でこの接続を握る構成が前提になります。
OpenAI Realtime への WebSocket 接続
const url = `wss://api.openai.com/v1/realtime?model=${encodeURIComponent(model)}`;
this.ws = new WebSocket(url, {
headers: {
Authorization: `Bearer ${apiKey}`,
},
});
3. session.update ペイロード
ここは、旧 beta の感覚で書くと動きません。
session.update ペイロード (GA 仕様)
{
type: "session.update",
session: {
type: "realtime",
instructions: "あなたは検証用の AI アシスタントです。日本語で簡潔に応答してください。",
output_modalities: ["audio"],
audio: {
input: {
format: { type: "audio/pcmu" },
transcription: { model: "whisper-1" },
turn_detection: { type: "server_vad" },
},
output: {
format: { type: "audio/pcmu" },
voice: "alloy",
},
},
},
}
- 旧 beta の
input_audio_format: "g711_ulaw"は GA では使えません。audio.input.format: { type: "audio/pcmu" }のネスト形式に変わっています。 audio/pcmuは 8 kHz 固定なので、PCM の感覚でrate: 8000を入れるとUnknown parameter: 'session.audio.input.format.rate'で弾かれます。output_modalities: ["audio"]を明示しないと AI が text 応答だけ返してきて、電話側に何の音も流れない事故が起きえます。- 出力音声イベント名も
response.audio.delta→response.output_audio.deltaに変わっています (後述のリレーでは念のため両方を受けるようにしています)。 transcription: { model: "whisper-1" }を入れておくと、ユーザー側の発話が自動でテキスト化され、conversation.item.input_audio_transcription.completedイベントで届きます。これは AI の応答内容には影響せず、観察用に入れています。
4. 双方向の音声中継本体
audio/pcmu で揃えているおかげで、簡潔に書けます。base64 ペイロードをそのまま転送するだけです。
音声中継のコア部分
// Twilio → OpenAI
case "media":
this.openai?.send({
type: "input_audio_buffer.append",
audio: parsed.media.payload,
});
break;
// OpenAI → Twilio (GA / beta 両対応)
case "response.output_audio.delta":
case "response.audio.delta":
this.twilioWs.send(JSON.stringify({
event: "media",
streamSid: this.streamSid,
media: { payload: event.delta },
}));
break;
これにユーザーの発話開始を検知して AI 側のレスポンスをキャンセルする barge-in 処理 (response.cancel + Twilio への clear メッセージ送信) を足したものが、リレーの実体のほぼすべてです。
Twilio Webhook を設定して、実際に電話してみる
設定が終わったら、対象番号にかけて会話してみます。今回は構造化ログを stdout に出すように作ったので、flyctl logs で通話内容を後から完全に復元できました。実際の通話 1 本 (約 78 秒) の transcript はこの通りです。
| # | 話者 | 発話内容 |
|---|---|---|
| greeting | AI | もしもし、AIアシスタントの鈴木です。どうぞよろしくお願いします。 |
| 1 | User | こんにちは、鈴木さん。 |
| 1 | AI | こんにちは、こちらこそお話しできて嬉しいです。何かお手伝いできることがあれば、遠慮なく聞いてくださいね。 |
| 2 | User | 明日の天気について教えて。 |
| 2 | AI | 承知しました。明日の天気ですね。どの地域の天気を知りたいか教えてもらえますか? |
| 3 | User | 東京都でお願いします |
| 3 | AI | かしこまりました。明日の東京都の天気は、晴れ時々曇りの予報です。最高気温はだいたい25度くらいで、最低気温は15度前後になりそうです。今のところ、雨の心配はあまりなさそうですよ。 |
| 4 | User | その情報はどこから取ってきましたか? |
| 4 | AI | この天気予報は、信頼性の高い気象データを基にしています。例えば気象庁や各種天気予報サービスからの最新情報を参照していますので、できるだけ正確な内容をお伝えしています。 |
| 5 | User | どうもありがとうございました |
| 5 | AI | いえいえ、どういたしまして。また何かあれば、いつでも聞いてくださいね。 |
主観としては 「通話相手が AI だと分かっていなければ、少し注意して聞かないと気づけない」 程度の自然さに感じました。
レイテンシ
flyctl logs から抽出した、ユーザー発話終了 (input_audio_buffer.speech_stopped) から AI が話し始める (response.output_audio.delta の最初の到着) までの所要時間です。
| ターン | 応答開始までの時間 |
|---|---|
挨拶 (response.create 送信から) |
273 ms |
| ターン 1 | 359 ms |
| ターン 2 | 389 ms |
| ターン 3 | 420 ms |
| ターン 4 | 421 ms |
| ターン 5 | 598 ms |
人間同士の自然な相槌が概ね数百 ms と言われているので、体感上の違和感は出ないレンジです。
動かしてみて気づいたこと
短い検証でしたが、いくつか観察できたことを記しておきます。
- 通話開始直後に音声フレームが少し落ちることがある
着信を受けて Twilio から音声が流れ始めるのと、サーバーが OpenAI Realtime API への WebSocket を張り終わるのは並走するので、Fly.io 側がコールドスタートになっていると、最初の数百 ms 分の Twilio フレームが破棄される場合があります。ホットなマシン状態でテストすると 0 になりました。今回のように「電話に出た直後は AI 側から喋り始める」フローでは実害が出ませんでしたが、最初の数百 ms にユーザー音声を取りたい用途ではmin_machines_running = 1でホットを担保するなどの工夫が要りそうです。 - barge-in は server VAD が自動でやってくれるので独自実装と二重発火しやすい
server_vad モードはデフォルトでユーザー発話検知の瞬間に AI 応答を自動キャンセルしてくれます。これを知らずにクライアント側で素朴にresponse.cancelを投げると、毎回response_cancel_not_active(キャンセル対象がない) エラーがログを埋め尽くしました。今回は「現在 AI 応答中のときだけ cancel する」ガードを足しています。 - ハルシネーションは普通に起きる
上の transcript を見れば分かる通り、AI が天気予報の数字を即答していますが、本実装は function calling や外部ツールを一切渡していないので、これは完全にモデル内部で組み立てた値です。今回は 疎通確認が主旨 なのでここに深入りはしませんが、運用に乗せるときは「答えられないものは答えない」設計や function calling 経由での実データ参照を入れる前提になります。
コスト
今回の検証通話 2 本 (計 約 160 秒) で、OpenAI Realtime API の実消費は以下の通りでした。Admin Usage API で実額を取得しています。
| 項目 | 実額 (USD) |
|---|---|
gpt-realtime audio input (2,127 tokens) |
$0.0681 |
gpt-realtime audio output (1,884 tokens) |
$0.1206 |
gpt-realtime text input/output |
$0.0156 |
whisper-1 (ユーザー発話文字起こし) |
$0.0012 |
| 合計 | $0.2053 (約 30 円) |
1 通話あたり約 $0.10 (約 15 円)、1 分あたり約 11 円。出力音声トークン ($64/100 万トークン) が支配的で、長時間 AI が喋るほど影響が大きくなります。Twilio 側は 1 分あたり約 2 円 (通話 $0.0085/分 + MediaStreams $0.004/分)、Fly.io はテスト時のみ起動なら無視できる水準なので、合算しても 概ね 1 分 13 円程度 で電話 AI が成立する計算です。
まとめ
Twilio MediaStreams と OpenAI Realtime API を薄い WebSocket リレーで橋渡しするだけで、電話越しの AI 会話は驚くほど少ないコードで成立しました。応答レイテンシは数百 ms に収まり、自然な会話としての違和感はほぼありません。コストも 1 通話あたり数十円規模で、PoC や試作の段階なら気軽に試せる水準です。電話 AI の実装ハードルを一段引き下げてくれる選択肢として、ぜひ手元の番号で試してみてください。








