1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【個人開発】OpenAI Realtime API でサーバーレスな音声通訳アプリを作った話(BYOK + WebRTC)

1
Posted at

はじめに

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 接続部分はこんな実装です(音声対話モードの例)。要点を絞るため、エラーハンドリングなど一部は省略しています。

src/lib/webrtc.ts
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 以外への通信をブラウザレベルでブロックします。

index.html(抜粋)
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 レスポンスヘッダーとして付与できます。

public/_headers
/*
  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) を忘れると、マイク入力が取れずに無音になるので注意です。

利用する前に必ず設定すること

このアプリを実際に使う場合は、以下を必ず実施してください。

  1. OpenAI Platform で、このアプリ専用の新しい API キーを発行する(既存のキーを流用しない)
  2. Usage limits月額上限を低め(例: $5〜$10)に設定する

クライアント側のコストガードはあくまで補助です。最終防衛ラインは OpenAI ダッシュボードの Usage limit になります。

参考資料

おわりに

OpenAI Realtime API + WebRTC を使えば、サーバーレスでリアルタイムな音声アプリが作れます。BYOK 方式では「キー漏洩」と「課金暴走」対策が設計の中心になり、CSP・エフェメラルトークン・ロガーマスキング・暗号化保存・通信ゲート・コストガードといった多層防御で守るのが現実的でした。リアルタイム音声 × LLM のアプリを作りたい方の参考になれば嬉しいです。

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?