OpenAI Realtime API を使うと、ブラウザーは WebRTC でマイク音声を送り、音声回答を再生できます。このガイドでは gpt-realtime-2.1 を使って一つのローカル会話を作ります。API キーは自分のコンピューターの Node サーバーに置きます。モデルへのアクセスと請求設定のある OpenAI API プロジェクトが必要で、ChatGPT の契約では API 利用料を支払えません。BIG CHANGE は現在の文書とサンプルコードを確認しましたが、有料セッションを開いたり回答を聞いたりしていません。

重要な変化

  • 何が変わったか: OpenAI はブラウザー WebRTC による Realtime 会話を文書化しています。ローカルサーバーがプロジェクトキーを保持し、ブラウザーの接続提案を応答と交換します。
  • なぜ重要か: 開発者は大きな音声製品を設計する前に、一つのローカルページでマイクから音声回答までを確認できます。プロジェクト所有者がキーを管理し、API 使用料を支払います。
  • 注意点: ローカルで通信できても本番対応の証明にはなりません。共有版には呼び出し元認証と支出管理が必要です。キャンセルやタイムアウトになった要求も既に OpenAI に届いている場合があります。

始める前に

Node.js 22.6 以降と、動作するマイク・スピーカーのある現行ブラウザーを用意します。マイクへのアクセスには安全なコンテキストが必要です。http://localhost はこのローカル例で条件を満たします。gpt-realtime-2.1 へのアクセスと請求設定が有効な OpenAI API プロジェクトキーを用意します。先にプロジェクトのレートと支出制限を確認してください。モデルページでは Free ティアは非対応です。通常のシークレット管理やローカルシェル設定で、キーをサーバープロセスの環境に置きます。index.html に入れたり、.env ファイルにコミットしたり、会話記録に貼ったりしないでください。OpenAI WebRTC ガイドは信頼できるサーバーでの交換を、モデルページは現在のアクセス条件と価格を説明しています。

ここでは Realtime API の統一 /v1/realtime/calls ルートを使います。WebRTC ガイドには /v1/live/sessions を使う GPT-Live もありますが、セッションプロトコルが異なります。以下のルートとペイロードをセットで使ってください。サンプルは自分のコンピューターだけに接続します。ローカルの Host・Origin チェックは共有サービスのユーザー認証にはなりません。

  1. デモ用の空のディレクトリを作り、その中で Express をインストールします:
Terminal
npm init -y
npm install express

インストールで package.json が作られ、Web サーバーの依存関係が追加されます。次の二つのファイルを同じ場所に保存します。コードでは Node 組み込みの fetch、FormData、AbortSignal.timeout を使います。確認した構成には Node.js 22.6 以降が必要です。

  1. 以下を server.mjs として保存します。ブラウザーの SDP offer(提案する WebRTC 接続の説明)を受け取り、Realtime セッション設定と一緒に OpenAI に送ります。通常の API キーを読むのはこのサーバーだけです。
JavaScript
import express from "express";

const app = express();
// This demo accepts only the local page, including protection against DNS rebinding.
app.use((req, res, next) => {
  const hosts = ["localhost:3000", "127.0.0.1:3000"];
  if (!hosts.includes(req.get("host"))) return res.status(403).send("Local host required");
  if (req.method === "POST" && req.get("origin") !== `http://${req.get("host")}`) {
    return res.status(403).send("Same-origin local page required");
  }
  next();
});
app.use(express.text({ type: "application/sdp", limit: "64kb" }));

app.get("/", (_req, res) => res.sendFile("index.html", { root: process.cwd() }));

app.post("/session", async (req, res) => {
  if (!process.env.OPENAI_API_KEY) return res.status(500).send("Server API key missing");
  if (typeof req.body !== "string" || !req.body.startsWith("v=")) {
    return res.status(400).send("Expected an SDP offer");
  }

  const form = new FormData();
  form.set("sdp", req.body);
  form.set("session", JSON.stringify({
    type: "realtime",
    model: "gpt-realtime-2.1",
    output_modalities: ["audio"],
    audio: { output: { voice: "marin" } },
    instructions: "Be a concise, helpful voice assistant. Do not claim access to external tools."
  }));

  try {
    const upstream = await fetch("https://api.openai.com/v1/realtime/calls", {
      method: "POST",
      headers: { Authorization: `Bearer ${process.env.OPENAI_API_KEY}` },
      body: form,
      signal: AbortSignal.timeout(25000)
    });
    const answer = await upstream.text();
    if (!upstream.ok) {
      console.error("Realtime session rejected", upstream.status);
      return res.status(502).send(`OpenAI session request failed (${upstream.status})`);
    }
    res.type("application/sdp").send(answer);
  } catch (error) {
    console.error("Realtime session network failure", error.name);
    res.status(502).send("Could not reach the Realtime API");
  }
});

app.listen(3000, "127.0.0.1", () => console.log("Open http://localhost:3000"));

サーバーは 127.0.0.1:3000 にバインドし、ローカルページのホストと POST の origin だけを許可し、SDP answer をブラウザーに返します。上流要求の 25 秒制限はこの例での選択です。タイムアウトやブラウザー切断は OpenAI が受け付けなかった証明にはならないため、開始結果が不明なら再試行前に使用量を調べます。

  1. 以下を index.html として保存します。ページは Start voice の後にマイク許可を求め、offer をローカルサーバーへ送り、受信音声を再生します。Stop は接続を閉じ、接続準備中でもマイクトラックを停止します。30 秒の準備タイマーもこの例の選択であり、API の保証ではありません。
HTML
<!doctype html>
<html lang="en">
<meta charset="utf-8">
<title>Local Realtime voice check</title>
<button id="start">Start voice</button>
<button id="stop" disabled>Stop</button>
<p id="status" role="status">Idle</p>
<audio id="reply" autoplay controls></audio>
<script type="module">
  const start = document.querySelector("#start");
  const stop = document.querySelector("#stop");
  const status = document.querySelector("#status");
  const reply = document.querySelector("#reply");
  let current;

  function closeSession(session = current, message = "Stopped") {
    if (!session) return;
    session.closed = true;
    clearTimeout(session.timer);
    session.abort.abort();
    session.mic?.getTracks().forEach(track => track.stop());
    if (session.pc) session.pc.onconnectionstatechange = null;
    session.dc?.close();
    session.pc?.close();
    if (current === session) {
      current = null;
      reply.srcObject = null;
      start.disabled = false;
      stop.disabled = true;
      status.textContent = message;
    }
  }

  start.onclick = async () => {
    const session = { abort: new AbortController(), closed: false };
    current = session;
    start.disabled = true;
    stop.disabled = false;
    status.textContent = "Requesting microphone";
    session.timer = setTimeout(() => closeSession(session, "Setup timed out; microphone stopped"), 30000);
    try {
      const mic = await navigator.mediaDevices.getUserMedia({ audio: true });
      if (session.closed) { mic.getTracks().forEach(track => track.stop()); return; }
      session.mic = mic;
      const pc = session.pc = new RTCPeerConnection();
      pc.ontrack = event => { if (!session.closed) reply.srcObject = event.streams[0]; };
      pc.onconnectionstatechange = () => {
        if (session.closed) return;
        status.textContent = `Connection: ${pc.connectionState}`;
        if (pc.connectionState === "connected") clearTimeout(session.timer);
        if (["failed", "closed", "disconnected"].includes(pc.connectionState)) {
          closeSession(session, "Connection ended; microphone stopped. Start a new session.");
        }
      };
      pc.addTrack(mic.getAudioTracks()[0], mic);
      const dc = session.dc = pc.createDataChannel("oai-events");
      dc.onmessage = message => {
        if (session.closed) return;
        try {
          const event = JSON.parse(message.data);
          if (event.type === "session.created") status.textContent = "Session ready: speak when connected";
          if (event.type === "response.done") {
            status.textContent = `Response status: ${event.response?.status || "unknown"}; verify audible playback`;
          }
          if (event.type === "error") closeSession(session, `Realtime error: ${event.error?.message || "unknown"}; stopped`);
        } catch { closeSession(session, "Invalid event; stopped"); }
      };
      dc.onclose = () => { if (!session.closed) closeSession(session, "Event channel closed; stopped"); };
      dc.onerror = () => closeSession(session, "Event channel failed; stopped");
      const offer = await pc.createOffer();
      await pc.setLocalDescription(offer);
      if (session.closed) return;
      // The official WebRTC example submits the generated offer directly.
      const response = await fetch("/session", {
        method: "POST",
        headers: { "Content-Type": "application/sdp" },
        body: offer.sdp,
        signal: session.abort.signal
      });
      if (!response.ok) throw new Error(await response.text());
      const answer = await response.text();
      if (session.closed) return;
      await pc.setRemoteDescription({ type: "answer", sdp: answer });
      if (!session.closed && pc.connectionState !== "connected") status.textContent = "Signaling complete; waiting for connection";
    } catch (error) {
      if (!session.closed) closeSession(session, `Start failed: ${error.message}`);
    }
  };
  stop.onclick = () => closeSession();
  window.addEventListener("pagehide", () => closeSession());
</script>
</html>

ピアやイベントチャネルの障害、ページを離れた場合もブラウザーはセッションを閉じます。停止やタイムアウト後にマイク許可が届くことがあり、その遅れたトラックも停止します。状態行は response.done と報告された状態を表示します。このイベントだけでは回答が聞こえたことや、キャンセル・失敗した回答の成功を示しません。

  1. 両ファイルのあるディレクトリから、サーバープロセスの環境に OPENAI_API_KEY を設定した状態でサーバーを起動します:
Terminal
node server.mjs

開くのは http://localhost:3000 です。Start voice、Stop、Idle 状態と音声プレーヤーが見えるはずです。Start voice を押してマイクを許可し、機密情報のない短い質問を話します。OpenAI の Realtime 会話ガイドによると、標準の音声活動検出は発話後の間を回答開始に使えます。成功を確認するには、ブラウザーでマイクが使用中、接続が connected、session.created イベントが表示され、音声が聞こえ、response.done が回答完了を示すことを確認します。Stop を押し、ブラウザーのマイク表示が消えることを確認します。これらは読者が行う検査であり、BIG CHANGE はこの有料セッションを実行していません。

確認に失敗した場合

マイク許可が拒否された、または入力機器がない場合は、ブラウザー権限と機器を確認し、ローカルの localhost URL または HTTPS を使います。ページが /session の失敗を示す場合はサーバー側 HTTP 状態を確認し、キーの存在、モデルアクセス、請求、レート制限、エンドポイントを調べます。サーバーは意図的にキーと OpenAI 応答本文を出力しません。502 はネットワーク障害やサンプルの 25 秒期限でも発生し、不明な上流要求が受理されたかどうかは示しません。

シグナリングが成功して接続が失敗したら、ページの接続状態、ネットワーク制限、返された SDP answer を確認します。接続中なのに無音ならマイク入力と出力機器を調べます。自動再生が禁止される場合は音声コントロールの Play を押し、必要なら停止して新しいセッションを始めます。失敗時は Stop を使い、マイク表示が消えたことを確認します。結果不明の要求で開始を繰り返す前にプロジェクト使用量を確認します。

費用、プライバシー、制限

OpenAI が掲載する gpt-realtime-2.1 の非キャッシュ文字入力/出力は 100 万 tokens 当たり $4/$24、音声入力/出力は 100 万 tokens 当たり $32/$64 です。2026 年 10 月 19 日確認。費用は使用 tokens に依存し、後続ターンで以前の会話コンテキストを含む場合もあります。使う前に モデルの料金と制限およびプロジェクト使用量を確認してください。経過時間だけでは請求額を見積もれません。Realtime 会話ガイドはセッション最大時間を 60 分としています。

セッション中はマイク音声が OpenAI に送られます。送信許可のある発話を使い、この確認では私的または第三者の音声を避けてください。このページにはログインやユーザー別予算管理はありません。共有サービスには認証とレート制限のある /session アクセス、サーバーで生成する安全識別子が必要で、ローカル Host・Origin 確認では不十分です。この例は外部ツールも電話も呼び出しません。遅延、信頼性、音声品質、節約、本番導入の結果は測定していません。

出典と参考資料