OpenAI Realtimeで実現!音声会話AIによるお店予約システム構築方法

未分類

OpenAIの最新技術「Realtime API」を活用することで、音声による自然な会話でお店の予約を行うシステムを構築できます。
従来の「音声認識 → テキスト処理 → 音声合成」という分断されたプロセスを一体化し、よりスムーズで直感的なユーザー体験を提供します。

OpenAI Realtimeとは?

OpenAI Realtimeは、音声入力と出力をリアルタイムで処理するAPIで、以下の特徴があります:

  • gpt-realtimeモデル:音声の自然さ、指示追従性、ツール連携精度が向上し、より人間らしい対話が可能です。
  • MCP(Model Context Protocol):外部ツールやアプリと安全に連携し、ユーザーの音声から直接アクションを起こせます。
  • 画像入力対応:音声だけでなく、画像を通じて情報を取得・解釈できます。
  • SIP(電話発信)サポート:AIが電話をかけて会話できる機能を提供します。

お店の予約システムへの応用

Realtime APIを活用することで、以下のような機能を実現できます:

  • 音声による予約受付:ユーザーが音声で希望日時や人数を伝えると、AIがリアルタイムで処理し、予約を確定します。
  • カレンダー連携:ユーザーのカレンダー情報を取得し、空いている時間帯を提案できます。
  • 電話予約対応:SIP機能を使用して、AIが電話をかけて予約を行うことができます。

実装の流れ

  1. RTC(WebRTC)接続の確立
    Realtime APIでは、WebSocketではなくWebRTCを使用した双方向リアルタイム通信で音声ストリームを送受信します。これにより低遅延で高品質な音声対話が可能です。

  2. 音声入力の取得
    ユーザーの音声をマイクから取得し、RTC経由でAIに送信します。

  3. AIによる処理
    gpt-realtimeモデルがユーザーの意図を理解し、適切なアクションを決定します。

  4. 外部ツールとの連携
    MCPを使用して、カレンダーや予約システムと連携します。

  5. 音声出力の生成
    AIの応答はRTCでリアルタイムに返され、ユーザーに自然な音声として聞かせることができます。

お店予約システムのサンプル

今回は、こちらの開発手法にて、簡易的なお店を音声で予約できる機能を作ってみました。
基本的にフロントのみ実装していて、プロンプトはすべてシステムプロンプトとして渡しています。
※実際にはお店の大容量の情報はMCPサーバーを構築して取得します。

アプリ機能

ドッグサロン向けAI音声予約アプリの機能概要を以下としてみました。

AI音声予約

  • ユーザーはアプリやWebサイト上で、AIと音声で直接やり取りして予約が可能。
  • 予約手続きが会話形式で完結。

Webサイト連携

  • サロンのWebサイトに「会話を開始する」ボタンを設置。
  • ボタンをクリックすると、ブラウザ上でAIとの音声会話が開始。

学習情報

  • 簡単な店舗情報。
  • 予約では、犬種・時間・連絡先電話番号を確認する。
  • 犬種ごとの料金表。

キャラクター表示

  • 音声だけでなく、犬のキャラクターが話している演出を表示。
  • 会話体験を視覚的にも楽しめる。

デモ

簡易版としてフロントエンドのみ実装しています。

ソースコード

ファイル構成

プロジェクトフォルダ/
│
├─ index.html           表示画面
├─ get_ephemeral_token.php   OpenAI API Key取得用
├─ app.js               音声会話処理
└─ img/                 キャラクター表示用画像
   ├─ speach_n.webp
   ├─ speach_a.webp
   └─ speach_i.webp

画像ファイル

img/配下には以下3つの画像ファイルを配置しました。

speach_n.webp
サイレント

speach_a.webp
あ

speach_i.webp
い

今回は簡易的に、「無言の口」「あの口」「いの口」のみとしています。
必要絵あれば「う」「え」「お」の口もご用意ください。

ちなみこの画像ファイルはGoogle AI StudioからGemini 2.5 Flash Imageを使用して作成しています。

Google AI Studio

index.html

表示画面はひとまずキャラクターを表示しています。


<!doctype html>
<html lang="ja">
<head>
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width,initial-scale=1" />
  <title>Realtime WebRTC デモ</title>
  <style>
    body { background:#eee; }
    :root { font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial; }
    button, select, input[type="text"] { font-size: 16px; padding: 8px 12px; margin-right: 8px; }
    button#muteBtn, button#pttBtn { font-size: 12px; padding: 4px 6px; margin-right: 8px; border-radius:4px; border: 1px solid #ddd; }
    .row { padding: 8px 16px; margin:auto;margin-bottom: 12px; align-items: center; box-sizing: border-box; width:500px; }
    .log { margin: 8px 16px;white-space: pre-wrap; background: #eee; border: 1px solid #ddd; padding: 12px; border-radius: 8px; max-height: 260px; overflow: auto; }
    .warn { color: #b45309; font-weight: 600; }
    .ok { color: #065f46; font-weight: 600; }
    .err { color: #991b1b; font-weight: 600; }
    .pill { display:inline-block; padding:2px 8px; border-radius:999px; background:#eef2ff; color:#3730a3; font-size:12px; }
    h1 { margin-right:50px;margin-top:5px;margin-bottom:5px; font-size: 1.2rem; }
    .sitearea { margin-top:50px;margin-bottom:50px;}
    .sitearea .sitearea-inner { margin:auto;padding:50px 20px;background:#fff;border-radius:10px; }
    .sitearea .sitearea-inner .detail { margin-bottom:30px;text-align:center;font-size: 1.8rem;font-weight: 600;}
    .button { padding: 0.75rem 1.5rem;font-size: 1.2rem;font-weight: 600;border: none;border-radius: 999px;cursor: pointer;transition: all 0.3s ease;box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); }
    #connectBtn { background: linear-gradient(135deg, #4facfe, #00f2fe);color: white; }
    #disconnectBtn { background: linear-gradient(135deg, #ff6a6a, #ff3d3d);color: white; }
    #connectBtn:disabled, #disconnectBtn:disabled { background:#ddd;transform: translateY(-2px);box-shadow: 0 6px 12px rgba(150, 150, 150, 0.3); }

    @media (max-width: 768px) {
      h1 {
        width:100%;
      }
      .row {
        width:100%;
      }

      .sitearea {
        margin-top:10px;
        margin-bottom:10px;
      }

      #connectBtn, #disconnectBtn {
        margin:0 auto;
        display:block;
      }
      #connectBtn {
        margin-bottom:20px;
      }
    }

  </style>
</head>
<body>
  <div class="row" style="width:100%;text-align:center;">
    <input id="model" type="hidden" value="gpt-4o-realtime-preview">
    <input id="voice" type="hidden" value="marin">
    <button id="muteBtn" disabled>🎤 ミュート</button>
    <button id="pttBtn" disabled>🎙️ プッシュ・トゥ・トーク</button>
    <span id="micState" class="pill">マイク: <b>未接続</b></span>
  </div>
  <div class="row sitearea">
    <div class="sitearea-inner">
      <div class="detail">
        <img id="staff_image" src="img/speach_n.webp" alt="キャラクター" style="width:100%;margin-bottom:40px;">
        <button id="connectBtn" class="button">会話を開始する</button>
        <button id="disconnectBtn" class="button" disabled onclick="location.reload();">会話を終了する</button>
      </div>
    </div>
  </div>
  <input id="textInput" type="hidden" value="">
  <div class="row">
    <audio id="remoteAudio" autoplay></audio>
  </div>
  <div class="log" id="log">Log<br></div>
  <script src="app.js"></script>
</body>
</html>

get_ephemeral_token.php

OpenAI API Keyをhtmlやjavascriptに直接記載するとセキュリティ的に問題なので、ひとまずサーバーから取得するようにしています。


<?php
$apiKey = 'sk-****************************';  

$url = "https://api.openai.com/v1/realtime/sessions";

$data = [
    "model" => "gpt-4o-realtime-preview-2024-12-17",
    "voice" => "verse"
];

$options = [
    "http" => [
        "header"  => "Content-Type: application/json\r\n" .
                     "Authorization: Bearer $apiKey\r\n",
        "method"  => "POST",
        "content" => json_encode($data),
    ],
];

$context  = stream_context_create($options);
$response = file_get_contents($url, false, $context);

header("Content-Type: application/json");
echo $response;

sk-**************************** はOpenAIから取得したキーを設定してください。

app.js

音声会話の処理をjavascriptで実装しています。


(() => {
  const $ = (id) => document.getElementById(id);
  const logEl = $("log");
  const log = (...args) => {
    const msg = args.map(a => typeof a === "string" ? a : JSON.stringify(a)).join(" ");
    logEl.textContent += msg + "\n";
    logEl.scrollTop = logEl.scrollHeight;
    console.log(...args);
  };

  let pc = null;            // RTCPeerConnection
  let dc = null;            // DataChannel(JSONイベント送受信用)
  let micStream = null;     // getUserMedia のローカルマイク
  let isMuted = false;

  const els = {
    model: $("model"),
    voice: $("voice"),
    connectBtn: $("connectBtn"),
    disconnectBtn: $("disconnectBtn"),
    muteBtn: $("muteBtn"),
    pttBtn: $("pttBtn"),
    micState: $("micState"),
    remoteAudio: $("remoteAudio"),
    textInput: $("textInput"),
    sendBtn: $("sendBtn"),
  };

  function uiConnected(state) {
    els.connectBtn.disabled = state;
    els.disconnectBtn.disabled = !state;
    els.muteBtn.disabled = !state;
    els.pttBtn.disabled = !state;
    els.textInput.disabled = !state;
    els.sendBtn.disabled = !state;
    els.micState.innerHTML = `マイク: <b>${state ? (isMuted ? "ミュート中" : "ON") : "未接続"}</b>`;
  }

  async function fetchEphemeralKey(model, voice) {
    const resp = await fetch("get_ephemeral_token.php", { method: "POST" });
    if (!resp.ok) {
      throw new Error("ephemeral key の取得に失敗しました");
    }
    const data = await resp.json();
    return data.client_secret.value; // ← ephemeral key
  }


  async function connect() {
    if (pc) {
      log("⚠️ すでに接続中です");
      return;
    }

    const model = els.model.value;
    const voice = els.voice.value;

    let apiKey;
    try {
      apiKey = await fetchEphemeralKey(model, voice);
      log("🔑 ephemeral key を取得しました");
    } catch (e) {
      log("❌ ephemeral key エラー:", e);
      alert("APIキー取得に失敗しました。");
      return;
    }

    // 1) マイク取得
    try {
      micStream = await navigator.mediaDevices.getUserMedia({ audio: true });
      els.micState.innerHTML = "マイク: <b>ON</b>";
      log("🎤 マイク取得に成功");
    } catch (e) {
      log("❌ マイク取得に失敗:", e);
      alert("マイクの許可が必要です。");
      return;
    }

    // 2) RTCPeerConnection 準備
    pc = new RTCPeerConnection();
    pc.onconnectionstatechange = () => log("PC state:", pc.connectionState);

    // モデルが生成した音声を受け取って再生
    const mouthEl = document.getElementById("staff_image");

    pc.ontrack = (ev) => {
      log("🔊 リモート音声トラックを受信");
      els.remoteAudio.srcObject = ev.streams[0];

      // ここから口パク処理を追加
      const remoteCtx = new AudioContext();
      const source = remoteCtx.createMediaStreamSource(ev.streams[0]);
      const analyser = remoteCtx.createAnalyser();
      analyser.fftSize = 2048;
      source.connect(analyser);
      const dataArray = new Uint8Array(analyser.frequencyBinCount);

      // 口形判定関数
      function detectMouthShape() {
        analyser.getByteTimeDomainData(dataArray);
        let sum = 0;
        for (let i = 0; i < dataArray.length; i++) {
          let v = (dataArray[i] - 128) / 128;
          sum += v * v;
        }
        const rms = Math.sqrt(sum / dataArray.length);

        // 無音時は speach_n.webp
        if (rms < 0.02) return "silent";  

        if (rms < 0.05) return "a";
        if (rms < 0.1) return "i";
        if (rms < 0.15) return "u";
        if (rms < 0.2) return "e";


        return "o";
      }

      // 口形画像切替
      const mouthImages = {
        silent: "img/speach_n.webp",
        a: "img/speach_a.webp",
        i: "img/speach_i.webp",
        u: "img/speach_i.webp",
        e: "img/speach_i.webp",
        o: "img/speach_i.webp"
      };

      function updateMouth() {
        const shape = detectMouthShape();

        const imgEl = document.getElementById("staff_image");
        if (imgEl.src !== mouthImages[shape]) {  // 画像が変わる時だけ更新
          imgEl.src = mouthImages[shape];
        }
      }

      // 口パク開始
      setInterval(updateMouth, 160);  // 160ms ごとに更新(約24fps)
      // ここまで口パク処理
    };

    // データチャンネル(JSON イベント)
    dc = pc.createDataChannel("oai-events");
    dc.onopen = () => {
      log("🛰️ DataChannel open");

const today = new Date();
const yyyy = today.getFullYear();
const mm = String(today.getMonth() + 1).padStart(2, '0');
const dd = String(today.getDate()).padStart(2, '0');
const todayStr = `${yyyy}年${mm}月${dd}日`;

      const SYSTEM_PROMPT = `あなたはドッグサロンの受付スタッフです。
【基本情報】
- 店名: ドッグサロン
- 営業時間: 10:00-17:00
- 定休日: 毎週金曜日

【対応方針】
会話は必ず『お問合せありがとうございます。ドッグサロンです。』から始めてください。
その後すぐにお客様の予約内容を伺うようにしてください。
まずは犬種を確認し、その後希望の日時、連絡先電話番号を順番に丁寧に聞き出します。
すべて確認できたら内容を復唱し、最終確認を行い、『ご予約を承りました』と伝えてください。
応答は必ず日本語で、明るく丁寧な接客口調で、短めに分かりやすく話してください。

【料金についてのルール】
- その後「料金はいくらですか?」などの問い合わせが来た場合は、覚えている犬種に応じた金額を必ず答えてください。
- 金額は以下の通りです:
  - 柴犬: 6100円
  - トイプードル: 7800円
  - ダックスフンド: 5500円
  - ポメラニアン: 6400円
- 犬種をまだ聞いていない場合は、「犬種を教えていただけますか?」と丁寧に確認してください。`;


      // セッション設定を最初に送信
      const sessionUpdate = {
        type: "session.update",
        session: {
          modalities: ["text", "audio"],
          instructions: SYSTEM_PROMPT,
          voice: voice,
          input_audio_format: "pcm16",
          output_audio_format: "pcm16",
          input_audio_transcription: {
            model: "whisper-1"
          },
          turn_detection: {
            type: "server_vad",
            threshold: 0.5,
            prefix_padding_ms: 300,
            silence_duration_ms: 200
          },
          tools: [],
          tool_choice: "none",
          temperature: 0.8
        }
      };
      dc.send(JSON.stringify(sessionUpdate));
      log("➡️ セッション設定を送信:", sessionUpdate);

      // 初回の応答作成
      const firstMessage = {
        type: "response.create",
        response: {
          modalities: ["audio", "text"]
        }
      };
      dc.send(JSON.stringify(firstMessage));
      log("➡️ 初期応答作成を送信:", firstMessage);
    };

    dc.onmessage = (ev) => {
      try {
        const data = JSON.parse(ev.data);
        // ここで Realtime のイベント(response.completed など)を観察できます
        log("📨 DC message:", data.type || "event", data);
      } catch {
        log("📨 DC text:", ev.data);
      }
    };

    // ローカルのマイクトラックを送信
    for (const track of micStream.getTracks()) {
      pc.addTrack(track, micStream);
    }

    // 3) SDP Offer を作成し、OpenAI Realtime に投げて Answer を受け取る
    const offer = await pc.createOffer({
      offerToReceiveAudio: true,
      offerToReceiveVideo: false
    });
    await pc.setLocalDescription(offer);

    log("⏫ SDP を送信中(Realtimeに接続)…");

    // 公式ガイドに基づくエンドポイント:/v1/realtime?model=…
    // 参考:WebRTC ガイド/発表ページ
    const resp = await fetch(`https://api.openai.com/v1/realtime?model=${encodeURIComponent(model)}`, {
      method: "POST",
      headers: {
        "Authorization": `Bearer ${apiKey}`,
        "Content-Type": "application/sdp",
      },
      body: offer.sdp
    });

    if (!resp.ok) {
      const t = await resp.text();
      log("❌ 接続エラー:", resp.status, t);
      alert("接続に失敗しました。ログをご確認ください。");
      return;
    }

    const answerSDP = await resp.text();
    const answer = { type: "answer", sdp: answerSDP };
    await pc.setRemoteDescription(answer);

    log("✅ Realtime に接続完了");
    uiConnected(true);

  }

  function disconnect() {
    try {
      dc && dc.close();
      pc && pc.close();
      micStream && micStream.getTracks().forEach(t => t.stop());
    } catch {}
    pc = null; dc = null; micStream = null;
    isMuted = false;
    uiConnected(false);
    log("🛑 切断しました");
  }

  function toggleMute() {
    if (!micStream) return;
    isMuted = !isMuted;
    micStream.getAudioTracks().forEach(t => t.enabled = !isMuted);
    els.micState.innerHTML = `マイク: <b>${isMuted ? "ミュート中" : "ON"}</b>`;
    els.muteBtn.textContent = isMuted ? "🎤 ミュート解除" : "🎤 ミュート";
  }

  // プッシュ・トゥ・トーク(押している間だけマイクON)
  let pttActive = false;
  function setupPTT() {
    els.pttBtn.onmousedown = () => {
      if (!micStream) return;
      pttActive = true;
      micStream.getAudioTracks().forEach(t => t.enabled = true);
      els.micState.innerHTML = "マイク: <b>ON(PTT)</b>";
    };
    els.pttBtn.onmouseup = els.pttBtn.onmouseleave = () => {
      if (!micStream) return;
      pttActive = false;
      micStream.getAudioTracks().forEach(t => t.enabled = false);
      els.micState.innerHTML = "マイク: <b>OFF(PTT待機)</b>";
    };
  }

  // テキスト送信 → 音声で返答させる
  function sendText() {
    if (!dc || dc.readyState !== "open") return;
    const text = els.textInput.value.trim();
    if (!text) return;
    
    // テキストメッセージを送信
    const textMessage = {
      type: "conversation.item.create",
      item: {
        type: "message",
        role: "user",
        content: [
          {
            type: "input_text",
            text: text
          }
        ]
      }
    };
    dc.send(JSON.stringify(textMessage));
    
    // 応答作成を要求
    const responseMessage = {
      type: "response.create",
      response: {
        modalities: ["audio", "text"]
      }
    };
    dc.send(JSON.stringify(responseMessage));
    
    log("➡️ テキスト送信:", text);
    els.textInput.value = "";
  }

  // UI イベント
  els.connectBtn.addEventListener("click", connect);
  els.disconnectBtn.addEventListener("click", disconnect);
  els.muteBtn.addEventListener("click", toggleMute);
  setupPTT();
  els.sendBtn.addEventListener("click", sendText);
  els.textInput.addEventListener("keydown", (e) => { if (e.key === "Enter") sendText(); });

  // ページ離脱時にクリーンアップ
  window.addEventListener("beforeunload", disconnect);
})();
今回は超簡易的にSYSTEM_PROMPTにすべてのプロンプトを定義しています。
ここを変更して様々な対応に変更できます。
容量の大きい情報を扱いたい場合はMCPサーバーを構築します。

開発者向けリソース

まとめ

OpenAI Realtimeを活用することで、音声による自然な会話でお店の予約を行うシステムを実現できます。RTCによるリアルタイム通信により、ユーザーは直感的で快適な予約体験を享受でき、店舗側も効率的な予約管理が可能となります。

コメント

タイトルとURLをコピーしました