For the complete documentation index, see llms.txt. Markdown versions of documentation pages are available by appending .md to the page URL.
主要導覽

WebRTC

使用 WebRTC 連接瀏覽器語音應用程式。

選擇應用程式使用的 API。每個 API 都有各自的身分驗證、工作階段建立流程與事件規範。

將瀏覽器連接至 GPT-Live

在瀏覽器語音應用程式中使用 WebRTC。麥克風輸入與生成的語音透過協商後的媒體軌道傳輸。資料通道則負責傳送逐字稿、工作階段更新與委派工作的 JSON 事件。

瀏覽器會建立工作階段描述協定 (SDP) 提議。應用程式伺服器使用專案 API 金鑰,透過 POST /v1/live/sessions 以該提議換取回覆。請將金鑰與工作階段組態保留在受信任的伺服器上。

開始之前

你需要:

  • 具有 GPT-Live 存取權的專案 API 金鑰。
  • 可執行所選 SDK 範例的伺服器執行環境。Node.js 範例需要 Node.js 22.6 或更新版本。
  • 已取得麥克風權限,且透過 HTTPS 或 localhost 執行應用程式的瀏覽器。

此範例使用 Responses 委派功能,搭配 gpt-5.6-terra 與託管網頁搜尋。關於後端指示與應用程式工具,請參閱委派與工具。關於語音與後端用量,請參閱成本最佳化

瞭解連線順序

  1. 透過使用者操作要求麥克風存取權,並將麥克風軌道加入對等連線。
  2. 建立 SDP 提議之前,先建立資料通道並註冊事件監聽器。
  3. 設定本機描述,等待 ICE 候選項目收集完成,然後將提議傳送至伺服器。
  4. 讓伺服器以 POST 將包含 sessiontransport: { type: "webrtc", sdp: ... } 的 JSON 傳送至 OpenAI。
  5. 將傳回的 SDP 回覆設為遠端描述。等到資料通道收到 session.started 後,再傳送應用程式指令。

HTTP 請求會啟動工作階段。 請勿透過資料通道傳送 session.start 範例中的 oai-events 字串是資料通道的標籤。

使用 POST /v1/live/sessions 建立 WebRTC 工作階段時,初始化期間會先收取 15 秒的語音時長費用。工作階段開始執行後,這筆費用會抵扣時長費用,並非在執行中的工作階段額外加計 15 秒。費用計算方式請參閱 WebRTC 初始化費用

建立應用程式伺服器

將伺服器範例儲存至新目錄,並在其環境中設定 OPENAI_API_KEY。若使用 Node.js,請使用 server.mjs,並透過 npm install openai express 安裝 openaiexpress。若使用 Python,請安裝 openai。此範例會綁定至 127.0.0.1,接受來自 http://localhost:3000 的工作階段請求,並從執行範例的目錄提供 index.html

在下方選擇伺服器語言;每個版本都會在連接埠 3000 提供 index.html 與相同的 /api/session 端點。請使用支援 Live 的 SDK 版本。一次只能執行一個版本。

import express from "express";
import OpenAI from "openai";
import { readFile } from "node:fs/promises";
import { resolve } from "node:path";

const app = express();
const client = new OpenAI({ maxRetries: 0 });
const port = 3000;
const origin = `http://localhost:${port}`;
const indexPath = resolve("index.html");

app.use(express.json({ limit: "64kb" }));
app.get("/", async (_request, response) => {
  response.type("html").send(await readFile(indexPath, "utf8"));
});

// Local-only demo. Add your application's authentication and authorization
// before exposing session creation to other users.
app.post("/api/session", async (request, response) => {
  if (request.headers.origin !== origin) {
    response.status(403).json({ error: "Unexpected request origin" });
    return;
  }
  if (typeof request.body?.sdp !== "string" || !request.body.sdp.trim()) {
    response.status(400).json({ error: "An SDP offer is required" });
    return;
  }
  if (!process.env.OPENAI_API_KEY) {
    response.status(503).json({ error: "Set OPENAI_API_KEY on the server" });
    return;
  }

  try {
    const result = await client.live.create({
      session: {
        model: "gpt-live-1",
        instructions:
          "Be concise. Delegate requests needing current information to the backend, which can search the web.",
        delegation: {
          type: "responses",
          responses: {
            model: "gpt-5.6-terra",
            instructions:
              "Use web search when current facts are needed. Return concise, grounded results for a spoken conversation.",
            tools: [{ type: "web_search" }],
            tool_choice: "auto",
          },
        },
      },
      transport: {
        type: "webrtc",
        sdp: request.body.sdp,
      },
    });
    // Preserve the SDK's typed session ID and SDP answer.
    response.status(201).json(result);
  } catch (error) {
    if (!(error instanceof OpenAI.APIError)) throw error;
    console.error("Live session creation failed", error.status);
    response
      .status(error.status ?? 502)
      .json({ error: "Live session creation failed" });
  }
});

app.listen(port, "127.0.0.1", () => console.log(`Open ${origin}`));

讓其他使用者存取伺服器之前,請透過應用程式的身分驗證、授權、請求限制及 HTTPS 保護 /api/session。此本機範例中的來源檢查並不會驗證使用者身分。

建立瀏覽器用戶端

在執行伺服器的目錄中建立 index.html

<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>GPT-Live connection</title>
  </head>
  <body>
    <script type="module">
      // Paste the browser code below here.
    </script>
  </body>
</html>

將下列程式碼貼入模組指令碼中。這段程式碼會新增開始與結束控制項、連接麥克風與音訊輸出,並處理工作階段事件。/api/session 是應用程式伺服器上的路由。

const start = document.createElement("button");
start.textContent = "Start conversation";
const stop = document.createElement("button");
stop.textContent = "End conversation";
stop.disabled = true;
const status = document.createElement("p");
const audio = new Audio();
audio.autoplay = true;
audio.controls = true;
document.body.append(start, stop, status, audio);

let peer;

let events;

let microphone;

let closeTimeout;
let ready = false;
let finalized = false;

function cleanup() {
  clearTimeout(closeTimeout);
  microphone?.getTracks().forEach((track) => track.stop());
  events?.close();
  peer?.close();
  audio.srcObject = null;
  ready = false;
  start.disabled = false;
  stop.disabled = true;
}

start.addEventListener("click", async () => {
  start.disabled = true;
  finalized = false;
  status.textContent = "Connecting…";
  try {
    const connection = new RTCPeerConnection();
    peer = connection;
    connection.addEventListener("track", (event) => {
      audio.srcObject = new MediaStream([event.track]);
      audio.play().catch(() => {
        status.textContent =
          "Select play on the audio controls to hear the assistant.";
      });
    });
    microphone = await navigator.mediaDevices.getUserMedia({ audio: true });
    for (const track of microphone.getAudioTracks()) {
      connection.addTrack(track, microphone);
    }

    // Create the event channel before creating the SDP offer.
    events = connection.createDataChannel("oai-events");
    events.addEventListener("message", ({ data }) => {
      const event = JSON.parse(data);
      if (event.type === "session.started") {
        ready = true;
        stop.disabled = false;
        status.textContent = "Connected: " + event.session.id;
      } else if (event.type === "session.closed") {
        finalized = true;
        console.log("Final session usage", event.usage);
        status.textContent = "Conversation ended.";
        cleanup();
      } else {
        // Save transcript and nested Responses events as needed by your app.
        console.log(event);
      }
    });
    events.addEventListener("close", (event) => {
      if (event.target !== events) return;
      if (!finalized) {
        status.textContent = "Disconnected without final session usage.";
        cleanup();
      }
    });

    const offer = await connection.createOffer();
    await connection.setLocalDescription(offer);
    if (connection.iceGatheringState !== "complete") {
      await new Promise((resolve, reject) => {
        const timeout = setTimeout(() => {
          connection.removeEventListener("icegatheringstatechange", onState);
          reject(new Error("Timed out while gathering ICE candidates"));
        }, 10_000);
        function onState() {
          if (connection.iceGatheringState !== "complete") return;
          clearTimeout(timeout);
          connection.removeEventListener("icegatheringstatechange", onState);
          resolve(undefined);
        }
        connection.addEventListener("icegatheringstatechange", onState);
        onState();
      });
    }

    const sdp = connection.localDescription?.sdp;
    if (!sdp) throw new Error("Missing local SDP offer");
    const response = await fetch("/api/session", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ sdp }),
    });
    if (!response.ok) throw new Error(await response.text());

    const result = await response.json();
    console.log("Created session", result.session.id);
    await connection.setRemoteDescription({
      type: "answer",
      sdp: result.transport.sdp,
    });
    // The HTTP request started this session. Do not send session.start here.
  } catch (error) {
    status.textContent =
      error instanceof Error ? error.message : String(error);
    cleanup();
  }
});

stop.addEventListener("click", () => {
  if (!ready || !events || events.readyState !== "open") return;
  stop.disabled = true;
  status.textContent = "Finishing the conversation…";
  // The session.closed handler is already registered. Keep media and events
  // alive while pending work drains; only clean up after the final event.
  events.send(JSON.stringify({ type: "session.close" }));
  closeTimeout = setTimeout(() => {
    status.textContent = "Incomplete finalization: no session.closed event.";
    cleanup();
  }, 15_000);
});

執行所選的伺服器(node server.mjspython server.py),開啟 http://localhost:3000,然後選取 開始對話。狀態變更為 已連線後,提出需要最新資訊才能回答的問題,以試用託管搜尋。如果瀏覽器阻擋自動播放,請使用音訊控制項。

讀取工作階段回應

請求成功時會傳回 HTTP 201,以及包含工作階段 ID 與 SDP 回覆的 JSON:

{
  "session": { "id": "live_123" },
  "transport": { "type": "webrtc", "sdp": "<SDP answer>" }
}

讀取 result.session.id,並將 result.transport.sdp 傳入 setRemoteDescription。請將工作階段 ID 視為不應解析的識別值,完整保留原樣,包括其前綴。

處理媒體與事件

透過媒體軌道傳送麥克風音訊並接收生成的語音。WebRTC 會透過 SDP 協商音訊格式,因此請勿在工作階段組態中設定 audio.format。請勿透過資料通道傳送 session.input_audio.append,也不要預期會在資料通道收到 session.output_audio.delta

使用資料通道傳輸逐字稿增量、工作階段指令,以及巢狀的 response.event 訊息。逐字稿處理與生命週期事件的相關資訊,請參閱管理工作階段;如果伺服器需要自己的事件連線,請參閱伺服器端控制

若要結束對話,請傳送 session.close,並持續接收,直到收到 session.closed 後,再關閉對等連線與麥克風軌道。此範例會在傳送指令之前註冊最終事件監聽器。如果連線在此之前失敗或逾時,最終用量便無法確認。最終用量的處理方式請參閱用量與正常關閉