ブラウザで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_vadとsemantic_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や会話項目へ安全に戻します。
再接続は利用者へ知らせる
connecting、connected、reconnecting、disconnectedを表示する- 再接続中はマイク入力が相手へ届かない可能性を知らせる
- 自動再接続は回数と待機時間に上限を付ける
- 新しいセッションを無制限に発行しない
- 同じツール操作を自動的に再実行しない
本番運用で追加する安全策
| 領域 | 最低限の対策 |
|---|---|
| 認証 | 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、入力ノイズ |
| 費用が増える | 接続時間、無音中の扱い、同時接続、再接続ループ |
実装チェックリスト
- ブラウザに標準APIキーが含まれていない
- session endpointに認証、origin確認、回数制限がある
- モデルと音声をサーバー側の許可リストで固定している
- マイク利用の目的と保存方針を開始前に表示している
- 接続・マイク・AI発話・切断の状態が見える
- 発話開始・終了、response開始・完了を測定している
- 割り込み時に古い音声や重要操作を継続しない
- 停止時にMediaStream trackとPeerConnectionを閉じる
- セッション時間、無音、日次利用量に上限がある
- 回線断の再接続と失敗時の手動復帰をテストしている
まとめ:接続より先に、状態と上限を設計する
OpenAI Realtime APIのブラウザ音声アプリは、RTCPeerConnection へマイクtrackとDataChannelを追加し、SDPを自分のサーバー経由で /v1/realtime/calls へ渡す構成で始められます。標準APIキーはバックエンドに置き、OpenAIから返るSDP answerをブラウザのremote descriptionへ設定します。
動くだけのデモから本番へ進むときは、VAD、割り込み、マイク表示、利用者確認、時間上限、切断、再接続、ツール認可を分けて設計してください。他社の音声APIと比較する場合も、モデルの印象だけでなく認証方式、イベント、セッション上限、データ方針を同じ条件で確認します。