3
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を最速体験:WebRTCで作るシンプル音声AI

Last updated at Posted at 2025-09-18

この記事を書く前に、Google AI StudioからGeminiのリアルタイムAPIを使ってみましたが、(レスポンス速度・品質)全く比較にならないほどです。OPENAIのRealtime APIは恐らく現時点で業界最高性能であることは間違いありません。プレイグラウンドで使ってみるだけでその異常な性能は解ると思います。(2025年9月現在)

今までのが全部飛ぶレベル

gpt-realtimeという単一のモデルで音声返答まで作るという謎のテクノロジーで、レスポンスを高速化したというものがAPI経由で公開されています。なぜかあまり話題になっていないですが、とんでもない性能であることは、リリースすぐに記事に書きましたが、実際にどうやって利用すればよいの?と困惑しておりました。

一番単純に遊べるデモ

プレイグラウンドで試すだけでもその凄さは十分伝わりますが、「どうやって自分のアプリに組み込めばよいのか?」となるとよくわからない…。私も最初は試行錯誤していました。

結論から言うと、WebRTCを使うのが最も簡単で強力だということが分かりました。

最初は、Pythonで実装してみたのですが、Realtime APIは非同期処理が基本なので async が多用され、コードが妙に複雑化。デバッグもしづらく、結局やめました。AIにコードを作らせても、どうしてもごちゃごちゃしたものになってしまいます。

Azureの公式デモを発見!

そんな中、ネットを探していると Azure向けの公式デモ があるのを発見。これをベースに、ChatGPTに頼んで OpenAIのAPIキーさえあればAzure環境なしでも動くコード に変換してもらいました。

WebRTCを使えば、ものすごくシンプルに作れる!

下記はその作ったhtmlファイルです。

APIキーは localStorage に保存したものを使います。初回起動時には入力するように求められるのでそこに入力してください。
ソースコードを読んでいただければ怪しい処理はないことは納得いただけると思います。

Screenshot_2025-09-18_23-33-13.png

現在のコードだと自分の話した内容は書き起こし出来ていません。ただ、AIが話す内容はリアルタイムで追記され、確定すると入れ替わるという処理になっています。
これが、ほんの数百行のHTML/JSで実現できるのは OpenAI Realtime APIの凄さ だと思います。

<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8" />
<title>OpenAI Realtime WebRTC Demo - Event Counter</title>
<style>
  /* --- ページ全体のデザイン --- */
  body {
    font-family: system-ui, -apple-system, "Segoe UI", Roboto,
                 "Hiragino Kaku Gothic ProN", "Noto Sans JP",
                 "Yu Gothic", sans-serif;
    margin: 12px;
  }
  #controls { margin-bottom: 8px; }
  button { margin-right: 8px; padding: 6px 10px; }

  /* 会話表示エリア */
  #chatContainer {
    max-width: 800px;
    margin-top: 8px;
    border: 1px solid #eee;
    padding: 12px;
    height: 360px;
    overflow: auto;
    background: #fff;
  }
  #chatContainer p { margin: 6px 0; padding: 8px; border-radius: 6px; }
  .ai { background: #f0f0f0; text-align: left; white-space: pre-wrap; }

  /* 右上に固定表示するイベントカウンターパネル */
  #eventPanel {
    position: fixed;
    right: 12px;
    top: 12px;
    width: 260px;
    max-height: 70vh;
    overflow: auto;
    border-radius: 8px;
    background: #ffffffee;
    box-shadow: 0 6px 18px rgba(0,0,0,0.08);
    padding: 10px;
    font-size: 13px;
    z-index: 999;
  }
  #eventPanel h3 { margin: 0 0 8px 0; font-size: 14px; }
  #eventTable { width: 100%; border-collapse: collapse; }
  #eventTable td { padding: 4px 6px; vertical-align: middle; }
  #eventTable td.name { max-width: 160px; word-break: break-all; color: #222; }
  #eventTable td.count { width: 48px; text-align: right; font-weight: 600; color: #111; }

  #logContainer {
    margin-top: 10px;
    color: #444;
    font-size: 13px;
    max-width: 800px;
  }
</style>
</head>
<body>
<!-- ボタン類 -->
<div id="controls">
  <button id="startBtn">Start Session</button>
  <button id="stopBtn" disabled>Stop Session</button>
  <button id="clearStatsBtn">イベントカウントをクリア</button>
</div>

<!-- 簡易ログを表示するエリア -->
<div id="logContainer"></div>

<h2>会話</h2>
<div id="chatContainer"></div>

<!-- 受信イベントをカウントして表示するパネル -->
<div id="eventPanel" aria-live="polite" title="受信イベントの種類と回数">
  <h3>受信イベント(回数)</h3>
  <table id="eventTable">
    <tbody id="eventTableBody">
      <!-- JavaScriptで埋められる -->
    </tbody>
  </table>
</div>

<script>
/* ===============================
   設定と初期処理
================================ */
const WEBRTC_URL = "https://api.openai.com/v1/realtime?model=gpt-realtime";
let API_KEY = localStorage.getItem("OPENAI_API_KEY");

// 初回起動時にAPIキーを入力して保存
if(!API_KEY){
  API_KEY = prompt("OpenAI API Keyを入力してください:");
  if(API_KEY){
    localStorage.setItem("OPENAI_API_KEY", API_KEY);
  } else {
    alert("APIキーが設定されていません。ページをリロードしてください。");
  }
}

let pc = null; // RTCPeerConnection(WebRTCの本体)
let dc = null; // DataChannel(イベント受信用)

// 会話表示用
const chatContainer = document.getElementById("chatContainer");
// item_idごとに<p>要素を管理する
const aiParagraphs = new Map();

// イベントカウント用
const eventCounts = new Map();
const eventTableBody = document.getElementById("eventTableBody");

// ボタンの動作を登録
document.getElementById("startBtn").onclick = startSession;
document.getElementById("stopBtn").onclick = stopSession;
document.getElementById("clearStatsBtn").onclick = ()=>{
  clearEventCounts();
  renderEventCounts();
};

/* ===============================
   セッション開始処理
================================ */
async function startSession() {
  if(!API_KEY) return;

  try {
    // WebRTC接続を作成
    pc = new RTCPeerConnection();

    // --- AIからの音声を再生する用のaudioタグを作成 ---
    const audioEl = document.createElement("audio");
    audioEl.autoplay = true;
    document.body.appendChild(audioEl);

    // リモート音声を受け取ったらaudioにセット
    pc.ontrack = (event)=>{
      audioEl.srcObject = event.streams[0];
      console.log("🎧 Remote audio track received");
    };

    // --- マイク入力を取得して送信 ---
    const micStream = await navigator.mediaDevices.getUserMedia({ audio:true });
    micStream.getTracks().forEach(track=>pc.addTrack(track,micStream));
    console.log("🎤 Mic input added");

    // --- DataChannelを作成(AIからのイベント受信用) ---
    dc = pc.createDataChannel("oai-events");
    dc.onopen = ()=>{
      console.log("✅ Data channel open");
      log("セッション開始しました。");
      document.getElementById("stopBtn").disabled = false;
      document.getElementById("startBtn").disabled = true;
    };

    // DataChannelで受け取ったメッセージ(JSON形式)
    dc.onmessage = (evt)=>{
      const msg = JSON.parse(evt.data);
      const eventName = msg && msg.type ? String(msg.type) : "unknown";
      incrementEventCount(eventName); // イベントをカウント
      console.log("📩 Event:", msg);

      /* --- 会話表示ロジック --- */
      // 1) conversation.item.created → 表示用の<p>要素を準備
      if(msg.type === "conversation.item.created" && msg.item && msg.item.id){
        const itemId = msg.item.id;
        if(!aiParagraphs.has(itemId)){
          const p = document.createElement("p");
          p.className = "ai";
          p.textContent = "";
          aiParagraphs.set(itemId, { p, appended: false });
        }
      }

      // 2) response.audio_transcript.delta → 途中の文字列を受信
      if(msg.type === "response.audio_transcript.delta"){
        const itemId = msg.item_id || (msg.item && msg.item.id);
        const deltaText = msg.delta || msg.text || "";
        if(!deltaText) return;

        let entry = itemId ? aiParagraphs.get(itemId) : undefined;

        if(!entry){
          // 念のため遅れて作成
          const p = document.createElement("p");
          p.className = "ai";
          p.textContent = "AI: " + deltaText;
          chatContainer.appendChild(p);
          aiParagraphs.set(itemId || ("unknown_" + Date.now()), { p, appended: true });
          scrollToBottom();
        } else {
          if(!entry.appended){
            // 最初の文字列が来たら<p>をDOMに追加
            entry.p.textContent = "AI: " + deltaText;
            chatContainer.appendChild(entry.p);
            entry.appended = true;
            scrollToBottom();
          } else {
            // 文字をどんどん追記
            entry.p.textContent += deltaText;
            scrollToBottom();
          }
        }
      }

      // 3) response.audio_transcript.done → 確定したらMapから削除
      if(msg.type === "response.audio_transcript.done"){
        const itemId = msg.item_id || (msg.item && msg.item.id);
        if(!itemId) return;
        const entry = aiParagraphs.get(itemId);
        if(entry){
          if(entry.appended){
            entry.p.textContent = entry.p.textContent.trim();
          }
          aiParagraphs.delete(itemId);
        }
      }
    };

    // --- SDPを使ったWebRTCハンドシェイク ---
    const offer = await pc.createOffer();
    await pc.setLocalDescription(offer);

    // OpenAIにOfferを送信 → Answerを取得
    const sdpResponse = await fetch(WEBRTC_URL,{
      method:"POST",
      headers:{ "Authorization":`Bearer ${API_KEY}`, "Content-Type":"application/sdp" },
      body: offer.sdp
    });

    const answer = { type:"answer", sdp: await sdpResponse.text() };
    await pc.setRemoteDescription(answer);
    console.log("🎤 Session started");

  } catch(err){
    console.error("❌ Error:",err);
    log("エラーが発生しました。詳細はコンソールを確認してください。");
  }
}

/* ===============================
   セッション終了処理
================================ */
function stopSession(){
  if(dc){ dc.close(); dc=null; }
  if(pc){ pc.close(); pc=null; }
  log("セッションを終了しました。");
  console.log("🛑 Session stopped");

  document.getElementById("stopBtn").disabled=true;
  document.getElementById("startBtn").disabled=false;
  aiParagraphs.clear();
  renderEventCounts(); // カウンタは保持
}

/* ===============================
   補助関数
================================ */
function log(msg){
  const div=document.getElementById("logContainer");
  const p=document.createElement("p");
  p.textContent=msg;
  div.appendChild(p);
}

// チャット欄を下にスクロール
function scrollToBottom(){
  chatContainer.scrollTop = chatContainer.scrollHeight;
}

/* ---------- イベントカウント関連 ---------- */
function incrementEventCount(eventName){
  const prev = eventCounts.get(eventName) || 0;
  eventCounts.set(eventName, prev + 1);
  renderEventCounts();
}

function clearEventCounts(){
  eventCounts.clear();
}

// イベントカウントを表に表示
function renderEventCounts(){
  const entries = Array.from(eventCounts.entries()).sort((a,b)=> b[1] - a[1]);
  eventTableBody.innerHTML = "";

  if(entries.length === 0){
    const tr = document.createElement("tr");
    const td = document.createElement("td");
    td.colSpan = 2;
    td.textContent = "まだイベントが受信されていません。";
    td.style.fontStyle = "italic";
    tr.appendChild(td);
    eventTableBody.appendChild(tr);
    return;
  }

  for(const [name, count] of entries){
    const tr = document.createElement("tr");
    const tdName = document.createElement("td");
    tdName.className = "name";
    tdName.textContent = name;

    const tdCnt = document.createElement("td");
    tdCnt.className = "count";
    tdCnt.textContent = String(count);

    tr.appendChild(tdName);
    tr.appendChild(tdCnt);
    eventTableBody.appendChild(tr);
  }
}

// 初期状態は「まだイベントがない」
renderEventCounts();
</script>
</body>
</html>

コピペしてhtmlとして保存してブラウザで開けば動くはずです。インストールなどは不要です。
(ただし使うにはOPENAIの課金済みAPIKEYが必要です。)

300行程度のhtmlですが、これで十分マイクで話し、AIがリアルタイムに返答する仕組みが構築できる感じです。本当はもっとコンパクトに出来ますが、右上にデバッグ用に発火したイベントを統計とるようになってます。ちなみにChatGPTが作ったものなので、どっかおかしかったり、欲しい機能が有ればコピペして頼めばこれをベースにいろいろ作れると思います。

お試しでどんなもんか見る用途として使ってください。

ドキュメントがなさすぎて困る

試しに2回程度やり取りした時に受信したイベント一覧、わずか数十秒の間に恐ろしい数のイベントが非同期で飛んできます。

gpt2.png

問題はRealtime APIの非同期イベントが多すぎて、何をどう使えばよいのか分かりにくい点です。今後、公式ドキュメントが充実すれば使いやすくなるはずです。

今回の記事はここまでです。コードを読んでもらえれば、仕組みとしてはかなり単純に実装できそうなことをお解りいただけると思います。


仕組み(ざっくり)

  • ブラウザがマイクを取る → RTCPeerConnection を作る → ローカル音声を追加して Offer を作成。
  • Offer(SDP)を Realtime API に送ると Answer(SDP)が返る → これで音声の送受信と DataChannel(イベント)が確立。
  • DataChannelで来る「部分テキスト(delta)」や「確定(done)」を画面へ反映するだけ。

よく来るイベント(覚えておくと便利)

  • conversation.item.created:応答ブロックが生成された通知(段落を準備)
  • response.audio_transcript.delta:途中文字列(UIに追記)
  • response.audio_transcript.done:その応答の確定(最終処理)

簡単な注意点(必読)

  • APIキー:localStorageは手軽ですが本番公開では危険。公開する場合はサーバー経由で短期トークンを発行してください。
  • プライバシー:マイク音声を送るのでユーザーの同意が必須です。
  • 接続問題:公開するなら TURN が必要になることがあります(NAT越え対策)。
  • コスト:Realtime APIは音声量で課金されます。大量利用の前にコスト設計を。

すぐできる改善(ワンポイント)

  • イベントパネルはデバッグの宝。まず何がよく来るか観察してからUIを絞ると良いです。
  • 部分テキストは頻繁に来るので、確定前は薄めの表示にして done で強調するUXが読みやすいです。
  • ユーザー発話の同時書き起こしを追加すれば会話ログがより実用的になります。
3
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
3
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?