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. 让您的服务器向 OpenAI 发送 POST 请求,请求中的 JSON 包含 sessiontransport: { type: "webrtc", sdp: ... }
  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,响应中的 JSON 包含会话 ID 和 SDP 应答:

{
  "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 后再关闭对等连接和麦克风轨道。该示例会在发送命令之前注册最终事件监听器。如果连接在此之前失败或超时,则最终用量尚未确认。有关最终用量的处理,请参阅用量与优雅关闭