「ブラウザからPush-to-Talkで話しかけたら、AIがリアルタイムに声で返してくる」——そんなアプリを、Claude Codeと一緒に1日で作った。最初の予算はOpenAIの無料クレジット$5。使い切るまでに、Web検索によるRAG、配信者風ペルソナの切り替え、Amazon Cognito認証、Terraformによるインフラのコード化まで辿り着いたので、その記録を残しておく。
コードは全部GitHubで公開している: yoshiharu-ishii/realtime_voice(PR単位で今日の試行錯誤の履歴がそのまま残っている)
何を作ったか
構成はシンプルで、ブラウザとOpenAI Realtime APIの間にFastAPIのWebSocket中継サーバーを挟むだけ。
ブラウザ FastAPI (中継) OpenAI
──────── ───────────── ──────
マイク → AudioWorklet /ws でイベント中継 ⇄ Realtime API
スピーカー ← Web Audio APIキーはここで保持 (gpt-realtime)
機能一覧:
- Push-to-Talk通話: ボタン(またはスペースキー)を押している間だけ録音し、離すとAIが音声で応答。応答中に押せば割り込み(バージイン)もできる
- 文字起こしと履歴: 会話はSQLiteに自動保存され、「履歴」タブでペルソナ別に見返せる
- Web検索ツール: モデルが「最新情報が必要だ」と判断すると自分で検索を呼び出す
- ペルソナ切り替え: 標準アシスタント/熱血実況系/加藤純一風/布団ちゃん風。声も切り替わる
- Cognito認証: 未ログインにはアプリのUIを1バイトも見せないサーバー側ゲート
- Terraform: Cognito一式をコード管理。destroy→applyで環境を丸ごと再現できることを実証済み
なぜWebRTCではなくWebSocket中継にしたか
OpenAI Realtime APIのサンプルでよく見るのは「ブラウザからWebRTCで直接OpenAIに繋ぎ、バックエンドは一時トークンを発行するだけ」という構成だ。低遅延で音声品質も良い。
それでも中継方式を選んだのは、全トラフィックが自分のサーバーを通るから。これが後々ずっと効いてくる。
- APIキーがブラウザに一切渡らない
- 会話の全文ログをサーバー側で取れる(履歴機能はこれだけで実現できた)
- ツール実行(Web検索)をサーバーに差し込める
- 企業プロキシ・ファイアウォール環境でも wss/443 の1本で完結する。WebRTCにありがちな「シグナリングは通るのに音声が片方向しか流れない」という故障モードが構造的に存在しない
音質面の不利もPush-to-Talkならほぼ問題にならない。ターン制の会話は遅延のゆらぎに強いし、そもそもサーバーVADを切ってボタンを押している間しか音声を送らないので、無音時間に課金され続けない。これが最強のコスト削減装置で、$5でここまで遊べた最大の理由だと思う。
音声認識との戦い(前編): エイリアシング
最初のバージョンは認識精度がひどかった。「サイ・ヤング賞を取ったことある?」が「もう一度お部屋の採用をしたことある?」になる世界。
犯人はマイク音声のリサンプリング処理だった。48kHz→24kHzの変換をアンチエイリアスフィルタなしの線形補間でやっていたため、高域が折り返して音声が濁っていた。人間の耳には「ちょっとこもってる?」程度でも、音声認識には致命傷になる。
対策は2つ:
- 録音用AudioContextを最初から24kHzで作り、ブラウザ内蔵の高品質リサンプラに任せる
- フォールバック用の自前リサンプラにも面積平均(簡易ローパス)を入れる
効果は劇的で、誤認識されていたフレーズの合成音声を流し込むテストで、ほぼ完全一致の文字起こしが返るようになった。
ハルシネーションとの戦い: Web検索をfunction callingで生やす
精度が上がると次の敵が現れる。知識の鮮度切れだ。
ブルワーズの若手剛腕、ジェイコブ・ミジオロウスキー(2025年デビュー)について聞いたら、モデルは「ミジェ・ロウスキー」という架空の選手をでっち上げ、「今のブルワーズのエースはコービン・バーンズ」と移籍済みの選手を挙げてきた。リアルタイム音声モデルは会話を途切れさせないことに最適化されているので、知らないときに黙るよりそれっぽく話を合わせる傾向が強い。
そこでRealtime APIのfunction callingで web_search ツールをモデルに公開し、中継サーバーがツール呼び出しを検知したらOpenAI Responses APIのWeb検索で調べて結果を返す構成にした。追加のAPIキーは不要、実装は実質50行。
結果、同じ質問に対して4日前の登板成績(7回11奪三振・防御率1.62・最速104.5マイル)まで正確に答えるようになった。かつてRAGと呼んで大掛かりに作っていたものが、いまは「モデルが必要なときに自分で検索する」agentic retrievalとしてこの手軽さで組める。中継アーキテクチャを選んだ設計がここで回収された。
ペルソナ: 布団ちゃんに資産形成を相談する
ペルソナは personas/*.md にfrontmatter(表示名と声)+本文(キャラ設定)を書くだけで、リストボックスに自動反映される仕組みにした。検索の使い方や「知らないことは知らないと言う」などの共通ルールはサーバー側で全ペルソナに自動付与するので、ファイルには口調だけ書けばいい。
「おはよう、調子どう?」への応答がこうなる:
- 熱血実況系: 「さあ来ましたァァー!おはようございます!今朝の調子、どうですかァァァー!」
- 加藤純一風: 「おはよう!調子?いや、こっちはバッチリだぞ!でもお前はどうなんだよ、ちゃんと寝たんか?」
- 布団ちゃん風: 「あー、おはよう。まあ、ぼちぼちって感じかな。そっちはどうよ?ちゃんと飯食った?」
布団ちゃん風に金の相談をしたら「料理で言うなら、うまい出汁を取るのに時間かけるみたいなもんで、コツコツ続けるか、一発勝負の勝機を見極めるかって感じかな」と返ってきた。食べ物で例えろというキャラ設定が完璧に機能している。
ひとつ技術的な注意点: Realtime APIは一度音声を出した後の声(voice)の変更を拒否するので、ペルソナ切り替えはWebSocketを繋ぎ直して新セッションにする方式にした。
Cognito認証: 「一瞬アプリが映る」を許さない
遊びが本格化してきたので認証を入れた。構成はCognito Hosted UI+認可コード+PKCE。バックエンドはHTTP APIとWebSocketの両方でIDトークンをJWKS署名検証する。WebSocketは接続後の最初のメッセージでトークンを渡す設計にした(URLクエリに載せるとアクセスログに残るため)。
こだわったのは未認証時の挙動で、最初の実装は「アプリHTMLを返してからJSがログインへリダイレクト」だった。これだと一瞬アプリのUIが見えてしまう。操作は何もできないとはいえ、アプリの存在と構造が未認証者に開示されるのは気持ち悪い。
そこでIDトークンをCookieに持たせ、GET / の時点でサーバーが検証する方式に変更。未認証には白画面+「ログイン画面へ移動しています…」だけの門番ページを返す。アプリ本体のHTMLは1バイトも渡さない。
キャッシュが生んだ無限ループ
この門番方式には罠があった。ある日サーバーログがこれで埋まった:
GET /?code=64f01f04-... 307 Temporary Redirect
GET /api/auth/config 200
GET /?code=d488db62-... 307 Temporary Redirect
GET /api/auth/config 200
(延々と続く。毎回コードが新しい)
不気味なのは、307の後に来るはずの GET / がログに一度も現れないこと。つまりブラウザは / をサーバーに取りに来ていない。キャッシュに残っていた「ログアウト時代の門番ページ」を使い回していたのだ。門番は律儀にCognitoへ飛び、セッションが生きているので即座に新しい認可コードが発行され、また /?code= に戻ってくる——毎周コードを焼却しながら回る無限ループである。
根本原因は、/ というひとつのURLで認証状態によって「アプリ」と「門番ページ」を出し分けているのに、キャッシュ制御を何も指定していなかったこと。Cache-Control: no-store を付けて解決した。認証で出し分けをするページにキャッシュ制御は必須、という教訓。安全網として「ログイン画面への往復が4回続いたら中断して原因を表示する」ループガードも入れた。
Terraformで「破壊からの再生」を実証する
AWS CLIで手作業構築したCognitoをTerraformにコード化した。既存リソースはimportブロックで無変更のまま取り込み……のはずが、最初のplanで 1 to destroy が出た。generate_secret = false を明示していたのが原因で、この属性はimport時にnullとの差分が「クライアントの作り直し」を強制する。applyしていたら認証が一時停止するところだった。importでは「デフォルト値の明示」が敵になることがある。
属性を省略してplanが 3 to import, 0 to add, 0 to change, 0 to destroy になったのを確認してapply。仕上げに、既存環境を terraform destroy で全部消し、imports.tfを削除して素の terraform apply で再構築する「新環境構築パス」を実際に走らせた。新しいプールで認証フロー全体が動くところまで確認できたので、このリポジトリは「cloneしてapplyすればアプリもインフラも再現できる」状態になった。
デバッグ小話: 文字起こしがサラダになる
ある日突然、会話がこうなった。
俺「会社行ってくる」 AI「さあ来ましたァァーッ!『会計の仕事ってどんな感じ?』という大質問!」
していない質問に答えている。しかも以後のAIの応答はずっと「会計」の文脈で一貫している。これはRealtimeモデルが、聞き取れない音声に対して会話の文脈から発話内容を作文する挙動だ。文脈を持たない文字起こしモデルは「会社行ってくる」と断片を返すので、画面の文字起こしとAIの応答が毎回ズレるという奇妙な状態になる。
原因はマイクでもアプリでもなく、裏で流していたYouTubeの音声だった。スピーカーの音がマイクに回り込み、自分の声と混ざって届いていた。echoCancellation は自タブの音(AIの声)しか消せないので、他アプリの音には無力。
対策として、PTTボタンの真上に入力レベルインジケーターを常設した。音量に応じて緑→黄→赤に変わるので、黙っているのにバーが動けば「何かが混入している」と一目で分かる。あとmacOSのマイクモード「声を分離」が実によく効く。
もうひとつ、instructionsに「聞き取れなかったら質問を創作せず聞き返す」という共通ルールを追加した。モデルは自分の聞こえ具合を自己申告できない(「クリアに聞こえてるぞー!」は営業トークだった)ので、ルールで縛るしかない。
かかったお金
- OpenAI: 最初の$5.14で開発とテストのほぼ全部(約25万トークン、211レスポンス)。残高が-$0.12になって文字起こしが止まったのを見届けてから$110追加
- Cognito: Essentialsティアの無料枠(1万MAU)に対して利用者1人なので$0
- Terraform/その他AWS: $0
Push-to-Talk設計(押している間しか音声を送らない)がコスト面の主役だった。VAD常時ストリーミング型で作っていたら、この予算では遊びきれなかったと思う。
まとめ
- リアルタイム音声AIは、WebSocket中継の設計にしておくと後から何でも差し込める(ログ、検索ツール、認証、監査)
- 音声認識の敵は、リサンプリングのエイリアシング、モデルの知識鮮度、そして背後のYouTube
- 認証で出し分けるページに
Cache-Control: no-storeを忘れると、認可コードを焼却し続ける無限ループが生まれる - Terraform importは「デフォルト値を書かない」勇気。そして一度destroy→再applyまでやって初めて「コード化した」と言える
- 布団ちゃんに資産形成を相談すると出汁の話になる
全部、Claude Codeとの対話だけで書いたコードである。PR 14本、コミット40個ほど。リポジトリは yoshiharu-ishii/realtime_voice で公開しているので、PRの履歴を追うと今日の試行錯誤(無限ループ調査のログや、Terraformで1 to destroyが出て青ざめた跡)がそのまま見られる。次は履歴DBを検索ツール化して「昨日何話したっけ?」に答えられるようにするつもりだ。
リアルタイム音声AI 開発シリーズ
- Claude Codeと1日で作るリアルタイム音声AI ― $5から始めて、RAG・ペルソナ・Cognito認証・Terraformまで(この記事)
- 「コードは読まない」開発スタイル ― Mermaid図解とモジュール分割で人間とAIの分業を作る
- AIが空耳する日 — 音声通話アプリの誤検知対策、文字起こし崩壊の犯人探し、そしてコンテナ化
- terraform applyで音声AIサービスが立つまで — User Pool全消し事故と、死なないAPIキーの謎
- User Poolはインフラではなくデータである — Terraformスタック分割の2軸
- WebSocket中継 vs WebRTC直結 ― 両方実装して固有名詞で殴り合わせたら、精度の勝敗とバグが2匹出てきた
- 無線機から電話へ ― サーバーVADでハンズフリー通話を実装する