AI-translated from English; not yet reviewed by a fluent editor.

# 用 OpenAI Realtime 构建本地浏览器语音对话

> 通过 WebRTC 用 OpenAI Realtime 构建本地浏览器麦克风对话。完整服务器和页面代码将 API 密钥留在服务器，并演示检查播放、停止麦克风和处理故障的方法。

By BIG CHANGE Editorial

Published: 2026-10-19T19:20:34.863Z
Updated: 2026-10-19T19:20:34.863Z
Canonical: https://bigchange.ai/blog/openai-realtime-browser-voice-webrtc-guide

![Conceptual charcoal illustration of a desktop microphone beside a blank browser on a monitor and a small speaker.](https://bigchange.ai/api/media/file/realtime-browser-voice-hero-v1.png)
AI-generated conceptual illustration by BIG CHANGE; no actual OpenAI interface, browser test or heard reply is depicted.

OpenAI Realtime API 让浏览器通过 WebRTC 发送麦克风音频并播放语音回答。本指南用 `gpt-realtime-2.1` 构建一次本地对话。API 密钥保留在电脑上的 Node 服务器中。你需要具备模型访问权限并启用计费的 OpenAI API 项目；ChatGPT 订阅不支付 API 用量。BIG CHANGE 检查了当前文档和示例代码，但未开启付费会话或听到回答。

## 关键变化

- **发生了什么变化：** OpenAI 文档介绍了浏览器 WebRTC 实时对话路径：本地服务器保管项目密钥，将浏览器的连接提议换成应答。
- **为何重要：** 开发者可先在本地页面检查从麦克风到语音回答的完整路径，再设计更大的语音产品。项目负责人控制密钥，并承担 API 费用。
- **需要注意：** 本地通信成功并不能证明适合生产环境。共享版本需要调用者身份验证和支出控制；取消或超时的请求可能已经到达 OpenAI。

## 开始前

安装 Node.js 22.6 或更高版本，使用带有正常麦克风和扬声器的当前浏览器。浏览器麦克风访问要求安全上下文；`http://localhost` 适用于本地示例。准备具有 `gpt-realtime-2.1` 访问权限且启用计费的 OpenAI API 项目密钥。先检查项目的速率和支出限制。模型页面列明不支持 Free 级别。通过常用密钥管理器或本地 shell 设置，将密钥保留在服务器进程环境中。不要把它放进 `index.html`、提交到 `.env` 文件中，或粘贴到对话记录。[OpenAI WebRTC 指南](https://developers.openai.com/api/docs/guides/voice-webrtc)介绍了可信服务器交换；[模型页面](https://developers.openai.com/api/docs/models/gpt-realtime-2.1)列出当前访问条件与价格。

这里使用 Realtime API 的统一 `/v1/realtime/calls` 路由。WebRTC 指南也介绍了通过 `/v1/live/sessions` 使用 GPT-Live，但它采用不同的会话协议。以下路由和负载须配套使用。示例绑定自己的电脑；本地 Host 和 Origin 检查无法验证共享服务用户的身份。

1. 为演示创建空目录，并在该目录安装 Express：

```bash
npm init -y
npm install express
```

安装会创建 `package.json` 并添加 Web 服务器依赖。将下面两个文件保存在同一目录。代码使用 Node 内置的 `fetch`、`FormData` 和 `AbortSignal.timeout`；审核的设置要求 Node.js 22.6 或更高版本。

2. 将以下内容保存为 `server.mjs`。它接收浏览器的 SDP 提议（拟议 WebRTC 连接的描述），并将它与 Realtime 会话设置一起发送给 OpenAI。标准 API 密钥仅在服务器读取。

```javascript
import express from "express";

const app = express();
// This demo accepts only the local page, including protection against DNS rebinding.
app.use((req, res, next) => {
  const hosts = ["localhost:3000", "127.0.0.1:3000"];
  if (!hosts.includes(req.get("host"))) return res.status(403).send("Local host required");
  if (req.method === "POST" && req.get("origin") !== `http://${req.get("host")}`) {
    return res.status(403).send("Same-origin local page required");
  }
  next();
});
app.use(express.text({ type: "application/sdp", limit: "64kb" }));

app.get("/", (_req, res) => res.sendFile("index.html", { root: process.cwd() }));

app.post("/session", async (req, res) => {
  if (!process.env.OPENAI_API_KEY) return res.status(500).send("Server API key missing");
  if (typeof req.body !== "string" || !req.body.startsWith("v=")) {
    return res.status(400).send("Expected an SDP offer");
  }

  const form = new FormData();
  form.set("sdp", req.body);
  form.set("session", JSON.stringify({
    type: "realtime",
    model: "gpt-realtime-2.1",
    output_modalities: ["audio"],
    audio: { output: { voice: "marin" } },
    instructions: "Be a concise, helpful voice assistant. Do not claim access to external tools."
  }));

  try {
    const upstream = await fetch("https://api.openai.com/v1/realtime/calls", {
      method: "POST",
      headers: { Authorization: `Bearer ${process.env.OPENAI_API_KEY}` },
      body: form,
      signal: AbortSignal.timeout(25000)
    });
    const answer = await upstream.text();
    if (!upstream.ok) {
      console.error("Realtime session rejected", upstream.status);
      return res.status(502).send(`OpenAI session request failed (${upstream.status})`);
    }
    res.type("application/sdp").send(answer);
  } catch (error) {
    console.error("Realtime session network failure", error.name);
    res.status(502).send("Could not reach the Realtime API");
  }
});

app.listen(3000, "127.0.0.1", () => console.log("Open http://localhost:3000"));
```

服务器绑定到 `127.0.0.1:3000`，只接受本地页面的 host 和 POST origin，并把 SDP 应答返回浏览器。25 秒上游请求限制是本示例的选择。超时或浏览器断开并不证明 OpenAI 未接受请求，因此启动结果不明时，重试前检查用量。

3. 将以下内容保存为 `index.html`。页面在点击 **Start voice** 后请求麦克风权限，向本地服务器发送提议并播放收到的音频。**Stop** 会关闭连接并停止麦克风轨道，包括连接仍在建立时。30 秒建立计时器同样是示例选择，并非 API 保证。

```html
<!doctype html>
<html lang="en">
<meta charset="utf-8">
<title>Local Realtime voice check</title>
<button id="start">Start voice</button>
<button id="stop" disabled>Stop</button>
<p id="status" role="status">Idle</p>
<audio id="reply" autoplay controls></audio>
<script type="module">
  const start = document.querySelector("#start");
  const stop = document.querySelector("#stop");
  const status = document.querySelector("#status");
  const reply = document.querySelector("#reply");
  let current;

  function closeSession(session = current, message = "Stopped") {
    if (!session) return;
    session.closed = true;
    clearTimeout(session.timer);
    session.abort.abort();
    session.mic?.getTracks().forEach(track => track.stop());
    if (session.pc) session.pc.onconnectionstatechange = null;
    session.dc?.close();
    session.pc?.close();
    if (current === session) {
      current = null;
      reply.srcObject = null;
      start.disabled = false;
      stop.disabled = true;
      status.textContent = message;
    }
  }

  start.onclick = async () => {
    const session = { abort: new AbortController(), closed: false };
    current = session;
    start.disabled = true;
    stop.disabled = false;
    status.textContent = "Requesting microphone";
    session.timer = setTimeout(() => closeSession(session, "Setup timed out; microphone stopped"), 30000);
    try {
      const mic = await navigator.mediaDevices.getUserMedia({ audio: true });
      if (session.closed) { mic.getTracks().forEach(track => track.stop()); return; }
      session.mic = mic;
      const pc = session.pc = new RTCPeerConnection();
      pc.ontrack = event => { if (!session.closed) reply.srcObject = event.streams[0]; };
      pc.onconnectionstatechange = () => {
        if (session.closed) return;
        status.textContent = `Connection: ${pc.connectionState}`;
        if (pc.connectionState === "connected") clearTimeout(session.timer);
        if (["failed", "closed", "disconnected"].includes(pc.connectionState)) {
          closeSession(session, "Connection ended; microphone stopped. Start a new session.");
        }
      };
      pc.addTrack(mic.getAudioTracks()[0], mic);
      const dc = session.dc = pc.createDataChannel("oai-events");
      dc.onmessage = message => {
        if (session.closed) return;
        try {
          const event = JSON.parse(message.data);
          if (event.type === "session.created") status.textContent = "Session ready: speak when connected";
          if (event.type === "response.done") {
            status.textContent = `Response status: ${event.response?.status || "unknown"}; verify audible playback`;
          }
          if (event.type === "error") closeSession(session, `Realtime error: ${event.error?.message || "unknown"}; stopped`);
        } catch { closeSession(session, "Invalid event; stopped"); }
      };
      dc.onclose = () => { if (!session.closed) closeSession(session, "Event channel closed; stopped"); };
      dc.onerror = () => closeSession(session, "Event channel failed; stopped");
      const offer = await pc.createOffer();
      await pc.setLocalDescription(offer);
      if (session.closed) return;
      // The official WebRTC example submits the generated offer directly.
      const response = await fetch("/session", {
        method: "POST",
        headers: { "Content-Type": "application/sdp" },
        body: offer.sdp,
        signal: session.abort.signal
      });
      if (!response.ok) throw new Error(await response.text());
      const answer = await response.text();
      if (session.closed) return;
      await pc.setRemoteDescription({ type: "answer", sdp: answer });
      if (!session.closed && pc.connectionState !== "connected") status.textContent = "Signaling complete; waiting for connection";
    } catch (error) {
      if (!session.closed) closeSession(session, `Start failed: ${error.message}`);
    }
  };
  stop.onclick = () => closeSession();
  window.addEventListener("pagehide", () => closeSession());
</script>
</html>
```

对等连接或事件通道失败，以及离开页面时，浏览器也会关闭会话。麦克风权限可能在停止或超时后才获准；迟到的轨道会被停止。状态行显示 `response.done` 及其报告的状态。仅有该事件，不能证明回答可听见，也不能证明取消或失败的响应成功。

4. 从包含两个文件的目录启动服务器，并提前在服务器进程环境中设置 `OPENAI_API_KEY`：

```bash
node server.mjs
```

打开 `http://localhost:3000`。应看到 **Start voice**、**Stop**、**Idle** 状态及音频播放器。点击 **Start voice**，允许麦克风访问，并说一个简短、无敏感内容的问题。OpenAI [Realtime 对话指南](https://developers.openai.com/api/docs/guides/realtime-conversations)说明默认语音活动检测可在讲话后的停顿触发回答。要确认你的运行成功，检查浏览器显示麦克风启用，连接达到 `connected`，出现 `session.created` 事件，你听到语音，且 `response.done` 报告响应完成。点击 **Stop** 并确认浏览器麦克风指示灯关闭。这些检查须由你执行；BIG CHANGE 未运行该付费浏览器会话。

## 检查失败时

若麦克风权限被拒绝或没有输入设备，检查浏览器权限和设备，再使用本地 `localhost` URL 或 HTTPS。若页面报告 `/session` 故障，查看服务器 HTTP 状态，核查密钥是否存在、项目模型权限、计费、速率限制和端点。服务器特意不打印密钥或 OpenAI 响应正文。502 也可能表示网络故障或示例的 25 秒期限；它无法说明结果不明的上游请求是否已获接受。

若信令成功但连接失败，检查页面连接状态、网络限制及返回的 SDP 应答。连接正常却无声时，检查麦克风输入和所选输出设备。浏览器可能阻止自动播放：点击音频控件的 **Play**，必要时停止并启动新会话。任何故障都应使用 **Stop** 并确认麦克风指示灯关闭。请求结果不明时，反复启动前先检查项目用量。

## 费用、隐私与限制

OpenAI 列出的 `gpt-realtime-2.1` 非缓存文本输入／输出价格为**每百万 tokens $4/$24**，音频输入／输出为**每百万 tokens $32/$64**，核查于 2026 年 10 月 19 日。费用取决于消耗的 tokens，包括可能携带先前对话上下文的后续轮次。使用前检查[模型价格与限制](https://developers.openai.com/api/docs/models/gpt-realtime-2.1)及自己的项目用量；本指南无法仅按时长估算会话账单。[Realtime 对话指南](https://developers.openai.com/api/docs/guides/realtime-conversations)规定最长会话为 60 分钟。

会话期间，麦克风音频会发送给 OpenAI。仅使用有权传送的语音，避免在测试中使用私人或第三方音频。本地页面没有登录或单用户预算控制。共享服务需要经过身份验证、限速的 `/session` 访问，以及服务器生成的安全标识符；本地 Host 和 Origin 检查不够。示例不调用外部工具或拨打电话。未测量延迟、可靠性、语音质量、节省或生产部署结果。

## 来源与延伸阅读

- [OpenAI WebRTC 指南](https://developers.openai.com/api/docs/guides/voice-webrtc)：统一 Realtime 服务器交换、浏览器音频路径和独立 GPT-Live 协议。
- [OpenAI Realtime 对话指南](https://developers.openai.com/api/docs/guides/realtime-conversations)：会话事件、轮次行为、响应状态和时长限制。
- [OpenAI `gpt-realtime-2.1` 模型页面](https://developers.openai.com/api/docs/models/gpt-realtime-2.1)：模型访问、token 价格和速率限制。实际运行时应重新核查价格和访问条件。

## Sources

- [OpenAI WebRTC 指南](https://developers.openai.com/api/docs/guides/voice-webrtc) — Realtime 统一 calls 接口、可信密钥、SDP、浏览器音频和独立 GPT-Live 协议。
- [OpenAI Realtime 对话](https://developers.openai.com/api/docs/guides/realtime-conversations) — 会话事件、VAD、响应状态及 60 分钟时长。
- [OpenAI gpt-realtime-2.1 模型](https://developers.openai.com/api/docs/models/gpt-realtime-2.1) — 模型专属 token 价格与访问条件。
- [OpenAI 语音成本优化](https://developers.openai.com/api/docs/guides/voice-latency-cost) — 上下文及每个响应的用量。
- [OpenAI API 与 ChatGPT 计费](https://help.openai.com/en/articles/9039756-managing-billing-for-chatgpt-and-the-api-platform) — 各自独立的计费安排。
