AI活用

OpenAI Realtime APIの使い方|JavaScriptでリアルタイム音声AIを作る

OpenAI Realtime APIへJavaScriptとWebRTCで接続し、APIキーを隠すunified interface、マイク・音声応答、DataChannel、VAD、割り込み、再接続を実装します。

この記事の目次
  1. 結論:ブラウザはWebRTC、APIキーはサーバーに置く
  2. Realtime APIで作れるもの
  3. 音声チャットと音声認識の違い
  4. WebRTCとWebSocketの選択
  5. 安全な接続構成を決める
  6. unified interfaceを主経路にする
  7. プロジェクトを準備する
  8. Node.jsでセッションを初期化する
  9. マイク音声を送って応答を受け取る
  10. RTCPeerConnectionとaudio要素を作る
  11. DataChannelでイベントを読む
  12. テキストで動作確認する
  13. 割り込みと発話区間を制御する
  14. server_vadとsemantic_vad
  15. 割り込み時のイベントを記録する
  16. 切断・再接続・利用上限を設計する
  17. 停止処理でtrackと接続を閉じる
  18. 60分より短いアプリ側上限を持つ
  19. 再接続は利用者へ知らせる
  20. 本番運用で追加する安全策
  21. よくあるエラー
  22. 実装チェックリスト
  23. まとめ:接続より先に、状態と上限を設計する

ブラウザでOpenAI Realtime APIの音声会話を作る場合は、WebRTCとunified interfaceを使い、標準APIキーを自分のサーバーだけに置く構成から始められます。ブラウザはマイクをWebRTCへ追加し、接続用のSDPだけを自分のサーバーへ送り、サーバーがOpenAIの /v1/realtime/calls とセッションを初期化します。

Realtime APIは、音声を録音し終えてから文字起こし・生成・読み上げを順番に実行する方式ではなく、音声やイベントを双方向に流して低遅延の対話を作るAPIです。利用者の発話中断、AI音声への割り込み、ツール呼び出し、リアルタイムの状態表示を扱えます。

この記事では、Node.jsサーバーとブラウザJavaScriptの最小構成、DataChannelのイベントログ、VAD(Voice Activity Detection:発話区間検出)、切断・再接続、運用チェックまで解説します。OpenAIの公式WebRTCガイドが2026年7月20日時点で示す現行インターフェースを基準にしています。

情報確認日:2026年7月20日(日本時間)

結論:ブラウザはWebRTC、APIキーはサーバーに置く

ブラウザ
  ├─ RTCPeerConnectionを作る
  ├─ マイクtrackを追加
  ├─ DataChannelを作る
  └─ SDP offerを自分のサーバーへPOST
           ↓
自分のNode.jsサーバー
  ├─ 利用者を認証・制限
  ├─ session設定とSDPをFormDataへ入れる
  └─ 標準APIキーで /v1/realtime/calls へPOST
           ↓
OpenAI Realtime API
  └─ SDP answerを返す
           ↓
ブラウザ
  ├─ remoteDescriptionを設定
  ├─ AI音声をaudio要素で再生
  └─ DataChannelでイベントを送受信

最初の実装で守ること

  • 標準APIキーをHTML・JavaScript・WebSocket URLへ含めない
  • ブラウザ接続はWebSocketよりWebRTCを優先する
  • セッション発行endpointにも利用者認証とレート制限を付ける
  • マイク利用中・AI音声再生中・切断中を画面へ明示する
  • VADの発話開始・終了・割り込みをログへ記録する
  • 最大セッション時間や回線切断を前提に再接続を設計する
スポンサーリンク

Realtime APIで作れるもの

音声チャットと音声認識の違い

方式 入力 出力 向く用途
音声認識 音声 文字 議事録、字幕、検索
音声合成 文字 音声 読み上げ、案内
Realtime音声対話 連続音声・イベント 音声・イベント 会話練習、音声操作、対話案内

音声認識と音声合成を別APIでつなぐ方式は、各段階の文字を保存・審査しやすい利点があります。Realtimeは自然な割り込みや低遅延対話に向きますが、セッション、音声再生、発話区間、ツール実行を同時に管理します。

WebRTCとWebSocketの選択

接続 主な実行場所 音声の扱い 選ぶ目安
WebRTC ブラウザ・モバイル MediaStreamとして扱いやすい 利用者のマイクとスピーカーを直接使う
WebSocket 信頼できるバックエンド 音声データの符号化・buffer管理が必要 電話基盤やサーバー処理と接続する

WebRTC(Web Real-Time Communication)は、ブラウザで音声・映像・データを双方向に送る標準技術です。SDP(Session Description Protocol)は、接続で使う音声形式や通信条件を交換する記述です。OpenAIはブラウザからの音声対話では、より安定した性能のためWebRTCを推奨しています。

安全な接続構成を決める

unified interfaceを主経路にする

現行の公式WebRTCガイドは、ブラウザのSDPを自分のサーバーへ送り、サーバーがセッション設定と組み合わせてOpenAIへ送るunified interfaceを案内しています。標準APIキーはバックエンドから出ません。

別方式として、バックエンドで短期のephemeral key(短期キー)を作り、ブラウザがOpenAIと直接セッション初期化する方法もあります。unified interfaceは初期化時に自分のサーバーが経路へ入りますが、構成を一か所で固定しやすいため、この記事では主実装にします。

発行endpointも保護する:APIキーを隠しても、誰でも /session を呼べると第三者に利用枠を使われます。ログイン確認、CSRF対策、利用回数・時間の上限、許可origin、異常検知を実装してください。

プロジェクトを準備する

mkdir realtime-webrtc-sample
cd realtime-webrtc-sample
npm init -y
npm pkg set type=module
npm install express
export OPENAI_API_KEY="your_api_key_here"
export OPENAI_REALTIME_MODEL="gpt-realtime-2.1"

公式例では現行のRealtimeモデルとして gpt-realtime-2.1、音声として marin が示されています。利用可能なモデル・音声・料金は変わるため、環境変数と許可リストで管理し、公開前に公式のモデルページを確認します。

Node.jsでセッションを初期化する

import crypto from "node:crypto";
import express from "express";

const app = express();
const model = process.env.OPENAI_REALTIME_MODEL;

if (!process.env.OPENAI_API_KEY || !model) {
  throw new Error("Required environment variables are missing");
}

app.use(express.text({
  type: ["application/sdp", "text/plain"],
  limit: "64kb",
}));

app.post("/session", async (request, response) => {
  // 本番では認証済みの内部user IDを使う
  const internalUserId = "authenticated-user-id";
  const safetyId = crypto
    .createHash("sha256")
    .update(internalUserId)
    .digest("hex");

  const session = {
    type: "realtime",
    model,
    output_modalities: ["audio"],
    audio: {
      input: {
        turn_detection: { type: "semantic_vad" },
      },
      output: { voice: "marin" },
    },
    instructions:
      "日本語で簡潔に答えてください。" +
      "重要な操作は実行前に確認してください。",
  };

  const formData = new FormData();
  formData.set("sdp", request.body);
  formData.set("session", JSON.stringify(session));

  const openaiResponse = await fetch(
    "https://api.openai.com/v1/realtime/calls",
    {
      method: "POST",
      headers: {
        Authorization: `Bearer ${process.env.OPENAI_API_KEY}`,
        "OpenAI-Safety-Identifier": safetyId,
      },
      body: formData,
    },
  );

  const body = await openaiResponse.text();
  if (!openaiResponse.ok) {
    console.error("Realtime session failed", {
      status: openaiResponse.status,
    });
    response.status(502).send("Session initialization failed");
    return;
  }

  response.type("application/sdp").send(body);
});

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

OpenAI-Safety-Identifier を使う場合は、ブラウザが自由に決めた値ではなく、信頼できるバックエンドで認証済み利用者から作る、プライバシーを保つ安定した値を送ります。生のメールアドレスをヘッダーへ入れません。

マイク音声を送って応答を受け取る

RTCPeerConnectionとaudio要素を作る

let peerConnection = null;
let localStream = null;
let dataChannel = null;

async function connectRealtime() {
  peerConnection = new RTCPeerConnection();

  const remoteAudio = document.querySelector("#remote-audio");
  remoteAudio.autoplay = true;

  peerConnection.addEventListener("track", (event) => {
    remoteAudio.srcObject = event.streams[0];
  });

  localStream = await navigator.mediaDevices.getUserMedia({
    audio: {
      echoCancellation: true,
      noiseSuppression: true,
    },
  });

  for (const track of localStream.getTracks()) {
    peerConnection.addTrack(track, localStream);
  }

  dataChannel = peerConnection.createDataChannel("oai-events");
  setupDataChannel(dataChannel);

  const offer = await peerConnection.createOffer();
  await peerConnection.setLocalDescription(offer);

  const sdpResponse = await fetch("/session", {
    method: "POST",
    headers: { "Content-Type": "application/sdp" },
    body: offer.sdp,
  });

  if (!sdpResponse.ok) {
    throw new Error(`Session failed: ${sdpResponse.status}`);
  }

  await peerConnection.setRemoteDescription({
    type: "answer",
    sdp: await sdpResponse.text(),
  });
}

WebRTCではブラウザがMediaStreamの音声trackを送るため、最小構成でPCMをBase64へ変換する必要はありません。マイク許可は利用者の明示操作から要求し、接続中の表示と停止ボタンを必ず用意します。

DataChannelでイベントを読む

const importantEvents = new Set([
  "session.created",
  "session.updated",
  "input_audio_buffer.speech_started",
  "input_audio_buffer.speech_stopped",
  "response.created",
  "response.done",
  "error",
]);

function setupDataChannel(channel) {
  channel.addEventListener("open", () => {
    updateConnectionState("connected");
  });

  channel.addEventListener("message", (event) => {
    const serverEvent = JSON.parse(event.data);

    if (importantEvents.has(serverEvent.type)) {
      console.log({
        type: serverEvent.type,
        eventId: serverEvent.event_id ?? null,
        responseId: serverEvent.response?.id ?? null,
      });
    }

    if (serverEvent.type === "error") {
      updateConnectionState("error");
    }
  });

  channel.addEventListener("close", () => {
    updateConnectionState("disconnected");
  });
}

DataChannel(データチャネル)は、音声trackとは別にJSONイベントを双方向で送る経路です。音声自体をDataChannelへ詰めるのではなく、セッション更新、会話項目、ツール呼び出し、発話開始・終了などのイベントに使います。

テキストで動作確認する

マイクの問題とAPIイベントの問題を切り分けるため、DataChannelがopenになった後にテキスト入力を送る確認も便利です。

function sendText(text) {
  if (!dataChannel || dataChannel.readyState !== "open") {
    throw new Error("DataChannel is not open");
  }

  dataChannel.send(JSON.stringify({
    type: "conversation.item.create",
    item: {
      type: "message",
      role: "user",
      content: [
        { type: "input_text", text },
      ],
    },
  }));

  dataChannel.send(JSON.stringify({
    type: "response.create",
    response: {
      output_modalities: ["audio"],
    },
  }));
}

音声入力はVAD設定により自動的にターンとして処理できます。テキスト入力では会話項目を作った後、response.create で応答を明示的に開始します。

割り込みと発話区間を制御する

server_vadsemantic_vad

VAD 区切り方 向く場面 調整点
server_vad 主に無音時間で発話終了を判断 反応速度を細かく調整したい threshold、silence duration、padding
semantic_vad 発話内容から言い終わりを判断 考えながら話す会話、途中で切られたくない場面 eagerness

threshold(しきい値)は音声として検出する強さ、silence durationは終了とみなす無音時間、paddingは発話の前後に含める余白です。eagernessはsemantic VADがどれだけ早く発話終了と判断するかの設定です。騒音・言語・話速の実データで評価します。

割り込み時のイベントを記録する

AIが話している途中で利用者が話し始めた場合、自然な会話ではAI音声を止め、新しい利用者発話を優先します。少なくとも次の時間を記録すると、どこで遅れているか分かります。

00:00.000 connect button
00:00.420 microphone granted
00:00.930 data channel open
00:02.100 input_audio_buffer.speech_started
00:04.860 input_audio_buffer.speech_stopped
00:05.120 response.created
00:05.470 first remote audio
00:08.300 input_audio_buffer.speech_started
00:08.360 model audio interrupted
00:10.200 response.done

「遅い」という感想だけでなく、接続、発話終了、応答開始、最初の音声、完了を分けて測ります。VADの終了判断が遅いのか、モデル応答が遅いのか、再生開始が遅いのかで対処が変わります。

切断・再接続・利用上限を設計する

停止処理でtrackと接続を閉じる

function disconnectRealtime() {
  dataChannel?.close();

  for (const track of localStream?.getTracks() ?? []) {
    track.stop();
  }

  peerConnection?.close();
  dataChannel = null;
  localStream = null;
  peerConnection = null;
  updateConnectionState("disconnected");
}

画面を閉じてもマイクtrackが残る実装は避けます。停止ボタン、ページ離脱、エラー、ログアウトのすべてから同じcleanupを呼びます。再接続前に古い接続を閉じ、複数のマイクtrackやDataChannelを重ねないようにします。

60分より短いアプリ側上限を持つ

OpenAIのRealtime conversationsガイドでは、Realtimeセッションの最大時間は60分と案内されています。最大まで使えることと、アプリが60分間無制限に開いてよいことは別です。利用目的に応じて5分・15分など短い上限、無音タイムアウト、残り時間表示を設けます。

接続が切れたら、古い会話状態をそのまま再利用できると仮定せず、新しいセッションを作ります。必要な業務状態は自分のサーバーへ最小限保存し、新セッションのinstructionsや会話項目へ安全に戻します。

再接続は利用者へ知らせる

  • connectingconnectedreconnectingdisconnectedを表示する
  • 再接続中はマイク入力が相手へ届かない可能性を知らせる
  • 自動再接続は回数と待機時間に上限を付ける
  • 新しいセッションを無制限に発行しない
  • 同じツール操作を自動的に再実行しない

本番運用で追加する安全策

領域 最低限の対策
認証 session endpointをログイン利用者だけに限定
費用 1人あたりの同時接続、時間、日次上限を設定
プライバシー 録音・文字起こし・保存の有無と目的を明示
ツール実行 引数検証、認可、重要操作の利用者確認
ログ event ID、response ID、時間、終了理由。音声本文は最小化
UI マイク・再生・処理・切断の状態を常時表示

音声は個人情報や機密情報を含みやすく、周囲の人の会話も拾います。「録音していない」と表示するなら、サーバーや分析ツールも含め本当に保存していないか確認します。ブラウザ権限を得たことを、あらゆる目的で音声を利用する同意とみなしてはいけません。

APIキー管理の基本はAI APIキーの安全な管理方法を参照してください。Realtimeでは短時間に連続イベントが発生するため、漏えい対策と利用上限の両方が必要です。

よくあるエラー

症状 確認点
マイク許可が出ない HTTPS、利用者操作からの呼び出し、ブラウザ権限
相手の音声が聞こえない autoplay制限、audio要素、trackイベント、出力機器
session作成が失敗 APIキー、モデル権限、SDP Content-Type、OpenAI応答status
DataChannelが開かない local/remote description、ICE・ネットワーク、接続状態
利用者の途中で応答する VAD方式、無音時間、eagerness、入力ノイズ
費用が増える 接続時間、無音中の扱い、同時接続、再接続ループ

実装チェックリスト

  1. ブラウザに標準APIキーが含まれていない
  2. session endpointに認証、origin確認、回数制限がある
  3. モデルと音声をサーバー側の許可リストで固定している
  4. マイク利用の目的と保存方針を開始前に表示している
  5. 接続・マイク・AI発話・切断の状態が見える
  6. 発話開始・終了、response開始・完了を測定している
  7. 割り込み時に古い音声や重要操作を継続しない
  8. 停止時にMediaStream trackとPeerConnectionを閉じる
  9. セッション時間、無音、日次利用量に上限がある
  10. 回線断の再接続と失敗時の手動復帰をテストしている

まとめ:接続より先に、状態と上限を設計する

OpenAI Realtime APIのブラウザ音声アプリは、RTCPeerConnection へマイクtrackとDataChannelを追加し、SDPを自分のサーバー経由で /v1/realtime/calls へ渡す構成で始められます。標準APIキーはバックエンドに置き、OpenAIから返るSDP answerをブラウザのremote descriptionへ設定します。

動くだけのデモから本番へ進むときは、VAD、割り込み、マイク表示、利用者確認、時間上限、切断、再接続、ツール認可を分けて設計してください。他社の音声APIと比較する場合も、モデルの印象だけでなく認証方式、イベント、セッション上限、データ方針を同じ条件で確認します。

スポンサーリンク