はじめに:リアルタイム音声AIの新時代

AIとの対話において「遅延(レイテンシー)」は、ユーザー体験(UX)を決定づける極めて重要な要素です。従来の音声対話システムは、ユーザーの音声をテキストに変換(STT)し、LLMにテキストを入力して回答を生成し、その回答を再度音声に変換(TTS)するという複数のステップを挟む必要があり、どうしても数秒単位の遅延が発生していました。

この課題を解決するために登場したのが「OpenAI Realtime API」です。さらに、新たにサポートされたWebRTC接続を使用することで、サーバーサイドでの複雑なWebSocket中継を挟むことなく、ブラウザからダイレクトに超低遅延な音声双方向通信が可能になりました。

以前紹介した Gemini Multimodal Live APIのPython実装ガイド ではWebSocketを使用した対話アプリの実装を解説しましたが、今回はWeb上の標準プロトコルであるWebRTCを採用し、フロントエンドにNext.js(App Router)を用いて、実用的なリアルタイム音声AIアシスタントを構築する完全な手順をガイドします。


WebRTCによるRealtime APIのアーキテクチャ

WebRTC(Web Real-Time Communication)は、ブラウザ間で音声や映像などのデータをリアルタイムに双方向通信するためのオープン標準技術です。OpenAI Realtime APIのWebRTC実装では、ブラウザ(クライアント)とOpenAIのサーバーが直接WebRTCのピア接続(PeerConnection)を確立します。

しかし、ブラウザから直接接続するからといって、クライアント側にOpenAIの「APIキー」をハードコードすることは絶対に避ける必要があります。悪意あるユーザーにAPIキーが容易に盗取され、高額な不正利用を招くリスクがあるためです。

これを防ぐために、以下のような「Ephemeral Token(一時利用トークン)」を用いた安全なシグナリングフローを構築します。

[ ブラウザ (Client) ]       [ Next.js API Route ]      [ OpenAI API ]
         |                            |                         |
         |--- 1. トークン要求 ------->|                         |
         |                            |--- 2. セッション作成 -->|
         |                            |<-- 3. Ephemeral Token --|
         |<-- 4. トークンを返却 ------|
         |
         |========== 5. WebRTC 接続確立 (SDP Offer/Answer) ========>|
         |<========= 6. 超低遅延・音声双方向メディア通信 ==========>|
  1. Ephemeral Tokenの取得: ブラウザは、Next.jsのAPI Route(サーバーサイド)に対してトークン要求を行います。
  2. OpenAIセッションの作成: サーバーサイドは環境変数に秘匿した「OpenAI APIキー」を使用し、OpenAIの /v1/realtime/sessions エンドポイントを叩いて、有効期限が1分間の一時トークン(Ephemeral Token)を発行します。
  3. 接続の確立: ブラウザは受け取った一時トークンを使用して、OpenAIのWebRTCゲートウェイとSDP(Session Description Protocol)を交換し、WebRTC PeerConnectionを確立します。

公式の仕様やアップデートの詳細は、OpenAI Realtime API WebRTC Reference を参照してください。


開発環境の準備とセットアップ

Next.js(App Router)プロジェクトを新規作成し、必要な設定を行います。

1. プロジェクトの初期化

ターミナルで以下のコマンドを実行し、TypeScriptとTailwind CSSを含むNext.jsプロジェクトを作成します。

npx create-next-app@latest realtime-voice-ai --typescript --tailwind --app
cd realtime-voice-ai

2. 環境変数の設定

プロジェクトのルートディレクトリに .env.local ファイルを作成し、OpenAIのAPIキーを設定します。

OPENAI_API_KEY=your_openai_api_key_here

3. 公式ドキュメントとツールの確認

WebRTCの標準APIを使用するため、追加のSDKや外部ライブラリ(npmパッケージなど)のインストールは不要です。ブラウザ標準の RTCPeerConnectionnavigator.mediaDevices をそのまま活用して、ピュアで軽量なアプリケーションを構築できます。実装にあたっては、MDN WebRTC API ドキュメント も非常に参考になります。


Next.jsでの具体的な実装手順

1. サーバーサイド:Ephemeral Token取得APIの実装

まずは、安全に一時トークンを取得するためのAPI Routeを構築します。app/api/session/route.ts を作成し、以下のコードを記述します。

import { NextResponse } from "next/server";

export async function POST() {
  const apiKey = process.env.OPENAI_API_KEY;
  if (!apiKey) {
    return NextResponse.json(
      { error: "OpenAI API key is not configured on the server." },
      { status: 500 }
    );
  }

  try {
    // OpenAI Realtime Session APIを呼び出して、Ephemeral Tokenを発行
    const response = await fetch("https://api.openai.com/v1/realtime/sessions", {
      method: "POST",
      headers: {
        Authorization: `Bearer ${apiKey}`,
        "Content-Type": "application/json",
      },
      body: JSON.stringify({
        model: "gpt-4o-realtime-preview-2024-12-17",
        voice: "alloy", // alloy, ash, ballad, coral, echo, sage, shimmer などから選択可能
      }),
    });

    if (!response.ok) {
      const errText = await response.text();
      return NextResponse.json(
        { error: `OpenAI API error: ${errText}` },
        { status: response.status }
      );
    }

    const data = await response.json();
    // クライアント側には ephemeral_token などの必要な情報のみを返却する
    return NextResponse.json({
      client_secret: data.client_secret.value,
    });
  } catch (error) {
    console.error("Session creation failed:", error);
    return NextResponse.json(
      { error: "Internal Server Error" },
      { status: 500 }
    );
  }
}

2. クライアントサイド:音声通話UIとWebRTCロジックの実装

次に、ブラウザからマイクの使用許可を取り、WebRTC経由でOpenAIと接続するフロントエンド画面を構築します。app/page.tsx を以下のように書き換えてください。

"use client";

import { useState, useRef } from "react";

export default function Home() {
  const [isConnecting, setIsConnecting] = useState(false);
  const [isConnected, setIsConnected] = useState(false);
  const [status, setStatus] = useState("マイクの使用を許可して、会話を開始してください。");

  const peerConnectionRef = useRef<RTCPeerConnection | null>(null);
  const localStreamRef = useRef<MediaStream | null>(null);
  const audioElRef = useRef<HTMLAudioElement | null>(null);

  // WebRTC接続を開始する関数
  const startSession = async () => {
    setIsConnecting(true);
    setStatus("一時トークンを取得中...");

    try {
      // 1. 自作APIから一時トークンを取得
      const tokenRes = await fetch("/api/session", { method: "POST" });
      if (!tokenRes.ok) throw new Error("セッショントークンの取得に失敗しました。");
      const tokenData = await tokenRes.json();
      const EPHEMERAL_KEY = tokenData.client_secret;

      setStatus("マイクを初期化中...");
      // 2. マイク音声の取得(エコーキャンセラーなどの音響処理を有効化)
      const localStream = await navigator.mediaDevices.getUserMedia({
        audio: {
          echoCancellation: true,
          noiseSuppression: true,
          autoGainControl: true,
        },
      });
      localStreamRef.current = localStream;

      // 3. RTCPeerConnectionの初期化
      const pc = new RTCPeerConnection();
      peerConnectionRef.current = pc;

      // 4. 音声出力用のHTMLAudioElementを生成してDOMにアタッチ
      const audioEl = document.createElement("audio");
      audioEl.autoplay = true;
      audioElRef.current = audioEl;

      // OpenAIから音声トラックが送られてきた際のリスナー登録
      pc.ontrack = (e) => {
        if (audioElRef.current) {
          audioElRef.current.srcObject = e.streams[0];
        }
      };

      // ローカルのマイク入力をWebRTCの送信トラックに追加
      localStream.getTracks().forEach((track) => pc.addTrack(track, localStream));

      // データチャネルの作成(制御コマンドやテキストデータを送受信可能にするため)
      const dc = pc.createDataChannel("oai-events");
      dc.onopen = () => {
        console.log("OpenAI DataChannel opened");
        setStatus("接続完了!自由に話しかけてください。");
        setIsConnected(true);
        setIsConnecting(false);
      };
      dc.onmessage = (e) => {
        const event = JSON.parse(e.data);
        console.log("Received OpenAI event:", event);
      };

      // 5. SDP Offer(接続の提案)を作成してLocalDescriptionに設定
      setStatus("接続をネゴシエーション中...");
      const offer = await pc.createOffer();
      await pc.setLocalDescription(offer);

      // 6. OpenAIのWebRTCエンドポイントにSDPを送信(一時トークンをBearerヘッダーにセット)
      const baseUrl = "https://api.openai.com/v1/realtime";
      const model = "gpt-4o-realtime-preview-2024-12-17";
      const sdpResponse = await fetch(`${baseUrl}?model=${model}`, {
        method: "POST",
        body: offer.sdp,
        headers: {
          Authorization: `Bearer ${EPHEMERAL_KEY}`,
          "Content-Type": "application/sdp",
        },
      });

      if (!sdpResponse.ok) {
        throw new Error("OpenAIへのSDP送信に失敗しました。");
      }

      // 7. OpenAIから返却されたSDP AnswerをRemoteDescriptionに設定
      const answerSdp = await sdpResponse.text();
      const answer: RTCSessionDescriptionInit = {
        type: "answer",
        sdp: answerSdp,
      };
      await pc.setRemoteDescription(answer);

    } catch (err: any) {
      console.error(err);
      setStatus(`エラーが発生しました: ${err.message}`);
      stopSession();
    }
  };

  // WebRTC接続を切断する関数
  const stopSession = () => {
    setStatus("接続を終了しています...");
    
    if (peerConnectionRef.current) {
      peerConnectionRef.current.close();
      peerConnectionRef.current = null;
    }
    if (localStreamRef.current) {
      localStreamRef.current.getTracks().forEach((track) => track.stop());
      localStreamRef.current = null;
    }
    if (audioElRef.current) {
      audioElRef.current.srcObject = null;
      audioElRef.current = null;
    }

    setIsConnected(false);
    setIsConnecting(false);
    setStatus("マイクの使用を許可して、会話を開始してください。");
  };

  return (
    <main className="flex min-h-screen flex-col items-center justify-center p-6 bg-slate-900 text-slate-100">
      <div className="max-w-md w-full bg-slate-800 rounded-2xl p-8 shadow-2xl border border-slate-700 text-center">
        <h1 className="text-2xl font-bold mb-2 text-transparent bg-clip-text bg-gradient-to-r from-teal-400 to-blue-500">
          Realtime WebRTC AI Assistant
        </h1>
        <p className="text-sm text-slate-400 mb-8">Next.js × OpenAI Realtime API</p>

        <div className="h-32 flex items-center justify-center mb-8 px-4 py-2 bg-slate-950 rounded-xl border border-slate-800">
          <p className="text-sm font-medium leading-relaxed">{status}</p>
        </div>

        <div className="flex justify-center gap-4">
          {!isConnected ? (
            <button
              onClick={startSession}
              disabled={isConnecting}
              className="px-6 py-3 rounded-full bg-gradient-to-r from-teal-500 to-blue-600 hover:from-teal-600 hover:to-blue-700 font-semibold shadow-lg transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed"
            >
              {isConnecting ? "接続中..." : "会話をはじめる"}
            </button>
          ) : (
            <button
              onClick={stopSession}
              className="px-6 py-3 rounded-full bg-red-600 hover:bg-red-700 font-semibold shadow-lg transition-all duration-200"
            >
              会話を終了する
            </button>
          )}
        </div>
      </div>
    </main>
  );
}

トラブルシューティング:開発時に遭遇しやすいエラーと対策

WebRTCや音声デバイスを扱う実装では、通常のWeb開発とは異なる特有の問題が発生しがちです。以下に、代表的なエラー例と解決方法をまとめました。

1. NotAllowedError: Permission denied(マイクが起動しない)

  • 原因: ブラウザのセキュリティポリシー(セキュリティコンテキスト)により、WebRTCやメディアデバイス(マイク/カメラ)の操作は、**「HTTPSによる暗号化通信」**が必須となっています(ただし、ローカル開発環境である http://localhost のみ例外的に許可されます)。
  • 解決策:
    • ローカル環境で検証する場合は、必ずブラウザのアドレスバーが http://localhost:3000 になっていることを確認してください(ローカルIPアドレス、例: http://192.168.x.x で接続するとエラーになります)。
    • 開発中のアプリを外部メンバーに検証してもらう場合や、本番環境にデプロイする際は、必ずSSL証明書(HTTPS化)が設定されたサーバー環境にデプロイしてください。

2. WebRTC接続が failed になる / ICE接続エラーが発生する

  • 原因: 社内ネットワーク、学内LAN、パブリックWi-Fiといったセキュリティの厳しいファイアウォール(Symmetric NAT等)が介在している場合、OpenAIのWebRTCゲートウェイとの直接的なP2P/メディアパケットのやり取りが遮断されるケースがあります。
  • 解決策:
    • モバイル回線(テザリング等)に一度切り替えて接続テストを行い、ネットワーク制限が原因であるかを特定します。
    • 自作サーバー同士ではなく「OpenAIが提供するWebRTCゲートウェイへのアクセス」であるため、利用しているルーターやファイアウォール設定で、OutboundのUDPポートや特定のドメインへのアクセス制限が課されていないかを確認・緩和します。

3. ハウリングや不快なエコーが発生する

  • 原因: スピーカーから出力されたAIの音声を、自分のマイクがそのまま再度拾ってしまい、音声の無限ループ(フィードバック・ループ)が発生している状態です。
  • 解決策:
    • マイクストリームを取得する際、以下のように明示的に音響制御オプション(echoCancellation: true など)を設定しているかコードを再確認してください。
      navigator.mediaDevices.getUserMedia({
        audio: { echoCancellation: true, noiseSuppression: true }
      })
      
    • 根本的な対策として、スピーカーからの音を拾いにくくするために「ヘッドホンやイヤホンを着用する」ことを推奨します。

まとめ

OpenAI Realtime APIをWebRTCで用いることにより、WebSocket中継サーバーの実装が不要となり、フロントエンド主導での非常にスマートな音声AIアプリ開発が可能になりました。レスポンスの速さは驚異的であり、文字通り「AIと直接電話している感覚」をブラウザ上で体験することができます。

より本格的なユーザー体験(UX)や、会話ログのデータベース保存機能などを爆速で実装したい場合は、Cursor・Vercel・Supabaseを用いたAIアプリ構築ガイド を参考に、フルスタックなエコシステムを統合してみてください。超低遅延なリアルタイム音声機能を活かした、次世代のAIアプリケーションをぜひ作り上げてみましょう!