はじめに
OpenAI の Realtime API を使って、スマホのブラウザで動く リアルタイム文字起こし・音声翻訳・音声対話アプリ を個人開発しました。
最大の特徴は バックエンドサーバーが一切不要 なこと。ユーザーが自分の OpenAI API キーを入力する BYOK(Bring Your Own Key)方式 を採用し、音声はブラウザから WebRTC で OpenAI に直接流します。運営者がランニングコストを負担せず、API キーがブラウザの外に出ることもありません。
この記事では、実際に作って動かしたコードをもとに、アーキテクチャと「BYOK で怖い API キー漏洩・想定外課金」をどう防ぐかを共有します。
📦 ソースコード: https://github.com/centmount1/realtime-transcription-app
この記事でわかる・できること
- OpenAI Realtime API + WebRTC で、サーバーレスな音声アプリを作る構成がわかる
- BYOK アプリで API キーを守る実装パターンがわかる
- Cloudflare Pages へのデプロイ手順がわかる
この記事の対象者
- OpenAI Realtime API を触ってみたい・自分でも音声アプリを作ってみたい人
- BYOK 方式のフロントエンドを安全に作りたい人
- とりあえず動くものを手元で立ち上げてみたい人
動作環境・使用するツールや言語
検証した環境とバージョンは以下の通りです。
- Node.js 20 以上
- pnpm 9 以上
- Vite 7.x / React 19.x / TypeScript 5.x
- Tailwind CSS v4 / shadcn/ui(Radix UI)
- 状態管理: Zustand 5.x
- ローカル DB: Dexie.js(IndexedDB)4.x
なお、アプリ開発には Replit を使いました。pnpm workspace をそのまま動かせて、ブラウザだけで開発からプレビューまで完結するので、こうしたフロントエンド主体のアプリと相性が良かったです。
まずは動かしてみる
細かい解説の前に、最短で起動する手順を置いておきます。
git clone https://github.com/centmount1/realtime-transcription-app
cd realtime-transcription-app
pnpm install
pnpm --filter @workspace/realtime-app run dev
ブラウザで http://localhost:3000 を開けば完了です。デフォルトはモックモードで、OpenAI に接続せず(=課金ゼロで)ダミーの文字起こしが流れ、UI をひと通り触れます。
どんなアプリか
3 つの動作モードを持つ SPA です。
| モード | モデル | 入力 | 出力 |
|---|---|---|---|
| 文字起こし | gpt-realtime-whisper |
音声 | テキスト |
| 翻訳 | gpt-realtime-translate |
音声 | テキスト+音声 |
| 音声対話 | gpt-realtime-2 |
音声 | テキスト+音声 |
- 文字起こし: マイク入力をリアルタイムでストリーミング表示。話者の言語指定または自動検出。
- 翻訳: 70 言語以上 → 13 言語への同時通訳。原文と訳文を並列表示し、訳文を音声でも出力。
- 音声対話: AI とリアルタイムで会話。カスタムシステムプロンプト対応。
セッション履歴は IndexedDB に自動保存され、TXT / Markdown / SRT字幕 でダウンロードできます。
⚠️ 上記のモデル名・対応言語数は開発時点(README 記載)の値です。最新の対応状況は OpenAI の公式ドキュメント で確認してください。
アーキテクチャ:なぜサーバーレスにできるのか
ブラウザ
│
├── React SPA (Vite)
│ ├── openaiGate.ts ← 唯一の OpenAI 通信窓口
│ │ ├── costGuards.ts コスト上限チェック
│ │ ├── realtimeClient.ts エフェメラルトークン発行
│ │ └── webrtc.ts WebRTC 接続・SDP 交換
│ ├── Zustand ストア アプリ状態管理
│ ├── Dexie (IndexedDB) セッション履歴・利用ログ
│ └── sessionStorage API キー(デフォルト)
│
└── ── WebRTC ──────────────────► OpenAI Realtime API
(ブラウザが直接接続・サーバー中継なし)
ポイントは、fetch を使うのは エフェメラルトークンの発行と接続確立のときだけ で、音声ストリーム自体は WebRTC でブラウザと OpenAI が直結する点です。WebSocket は使わず、"oai-events" という単一の DataChannel でイベントをやり取りします。
WebRTC 接続部分はこんな実装です(音声対話モードの例)。要点を絞るため、エラーハンドリングなど一部は省略しています。
export async function connectRealtime(
ephemeralToken: string,
model: string,
onEvent: (e: unknown) => void,
onRemoteAudio: (stream: MediaStream) => void,
) {
const pc = new RTCPeerConnection();
pc.ontrack = (ev) => onRemoteAudio(ev.streams[0]); // OpenAI からの音声を受け取る
// マイク入力をトラックとして追加
const mic = await navigator.mediaDevices.getUserMedia({
audio: { echoCancellation: true, noiseSuppression: true, channelCount: 1 },
});
mic.getTracks().forEach((t) => pc.addTrack(t, mic));
// イベント用 DataChannel(文字起こし結果などがここに流れてくる)
const dc = pc.createDataChannel("oai-events");
dc.onmessage = (e) => onEvent(JSON.parse(e.data));
// 接続確立(fetch を使うのはこの 1 回だけ)
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
const res = await fetch(
`https://api.openai.com/v1/realtime/calls?model=${encodeURIComponent(model)}`,
{
method: "POST",
headers: { Authorization: `Bearer ${ephemeralToken}`, "Content-Type": "application/sdp" },
body: offer.sdp,
},
);
await pc.setRemoteDescription({ type: "answer", sdp: await res.text() });
return { pc, dc };
}
BYOK で一番大事なこと:キーを漏らさない・課金を暴走させない
BYOK は便利な反面、「ユーザーのキーをどう守るか」「うっかり高額課金を防げるか」が設計の肝になります。このアプリでは多層防御を組みました。
Content Security Policy で通信先を縛る
index.html の <meta> タグで CSP を設定し、api.openai.com 以外への通信をブラウザレベルでブロックします。
default-src 'self'
script-src 'self'
connect-src 'self' https://api.openai.com wss://api.openai.com
万一どこかに変な通信コードが紛れ込んでも、キーが外部に飛ぶことはありません。
エフェメラルトークン方式
OpenAI Realtime API は短命のエフェメラルトークンを使う仕組みになっています。長期 API キーは最初のトークン発行リクエスト以外には使わず、実際の音声接続には寿命約 1 分のトークンを使います。セッションごとに新しいトークンを発行するので、トークンが漏れても被害が限定的です。
ロガーでトークンを自動マスキング
console.log を直接使わず専用ロガー経由にし、sk- や eph_ で始まる文字列を出力前に自動マスクします。
sk-abcdef... → sk-***REDACTED***
API キーの保管は「消える」がデフォルト
| 選択肢 | 保管場所 | 暗号化 |
|---|---|---|
| セッションのみ(デフォルト) | sessionStorage | タブを閉じると消える |
| デバイスに記憶する | IndexedDB | PBKDF2 (25万回) + AES-GCM 256bit |
平文での永続化はしません。「記憶する」を選んだ場合のみ、PBKDF2 で導出した鍵を使って AES-GCM で暗号化して保存します。
OpenAI 通信ゲートで接続を一元管理
すべての通信を openaiGate.ts の 1 か所に集約し、次のガードをかけています。
-
navigator.userActivation.isActiveチェックで、ユーザー操作起点でない接続を拒否 - グローバルに 1 セッションのみ保持し、多重接続を防止
- 自動再接続なし(明示的なユーザー操作のみ)
-
pagehide/visibilitychangeで確実に切断
クライアント側コストガード
| 制限 | デフォルト |
|---|---|
| 1 セッションの最大接続時間 | 3 分 |
| 1 日あたりの最大セッション数 | 10 回 |
| 1 日あたりの最大累計接続時間 | 30 分 |
| 再接続クールダウン | 5 秒 |
ただし、これはあくまで補助的な仕組みです。最終防衛ラインは OpenAI ダッシュボードの Usage limit であり、利用者には専用キーの発行と月額上限の設定を必ずお願いしています。
開発を快適にするモックモード
API を叩かずに開発できるよう、デフォルトでモックモードを用意しました。openaiGate.ts が OpenAI に接続せず、ダミーの日本語テキストを 1 文字ずつストリームします。
# モックモード(デフォルト)
pnpm --filter @workspace/realtime-app run dev
# 実 API モード
VITE_USE_REAL_OPENAI=true pnpm --filter @workspace/realtime-app run dev
これで UI の確認や履歴・エクスポート機能のテストを、API 課金を気にせず回せます。実際に OpenAI へつなぐときは VITE_USE_REAL_OPENAI=true を付け、アプリ内の UI から自分の API キーを入力します(キーは環境変数ではなく UI から渡す設計)。この段階で初めて課金が発生するので、Usage limit の設定を必ず先に済ませておいてください。
デプロイ:Cloudflare Pages
完全な静的サイト(バックエンド不要)なので、静的ホスティングに載せるだけで公開できます。今回は Cloudflare Pages にデプロイしました。
まずビルドコマンドはこれです。
pnpm --filter @workspace/realtime-app run build
# → artifacts/realtime-app/dist/public/ に出力
GitHub リポジトリを Cloudflare Pages に連携し、ビルド設定を以下にします。
| 項目 | 値 |
|---|---|
| ビルドコマンド | pnpm --filter @workspace/realtime-app run build |
| 出力ディレクトリ | artifacts/realtime-app/dist/public |
| ルートディレクトリ |
/(monorepo なのでリポジトリ直下のまま) |
セキュリティヘッダーの設定
CSP は index.html の <meta> タグで効きますが、Strict-Transport-Security などは meta では設定できません。Cloudflare Pages では public/_headers ファイルを置くことで、HTTP レスポンスヘッダーとして付与できます。
/*
Content-Security-Policy: default-src 'self'; connect-src 'self' https://api.openai.com wss://api.openai.com; ...
Strict-Transport-Security: max-age=63072000; includeSubDomains; preload
X-Content-Type-Options: nosniff
Permissions-Policy: microphone=(self)
microphone=(self) を忘れると、マイク入力が取れずに無音になるので注意です。
利用する前に必ず設定すること
このアプリを実際に使う場合は、以下を必ず実施してください。
- OpenAI Platform で、このアプリ専用の新しい API キーを発行する(既存のキーを流用しない)
- Usage limits で 月額上限を低め(例: $5〜$10)に設定する
クライアント側のコストガードはあくまで補助です。最終防衛ラインは OpenAI ダッシュボードの Usage limit になります。
参考資料
- OpenAI Realtime API ドキュメント
- ソースコード(GitHub)
- WebRTC API - MDN
- Content Security Policy (CSP) - MDN
- Cloudflare Pages - Monorepos
おわりに
OpenAI Realtime API + WebRTC を使えば、サーバーレスでリアルタイムな音声アプリが作れます。BYOK 方式では「キー漏洩」と「課金暴走」対策が設計の中心になり、CSP・エフェメラルトークン・ロガーマスキング・暗号化保存・通信ゲート・コストガードといった多層防御で守るのが現実的でした。リアルタイム音声 × LLM のアプリを作りたい方の参考になれば嬉しいです。