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

# 构建使用只读工具调用的本地 Realtime 语音代理

> 使用 OpenAI Realtime 和两个虚构的只读工具，在浏览器中建立实时语音对话。完整服务器、浏览器和控制器代码展示调用标识匹配、安全续接、中断和麦克风释放。

By BIG CHANGE Editorial

Published: 2026-10-19T23:39:53.236Z
Updated: 2026-10-19T23:39:53.236Z
Canonical: https://bigchange.ai/blog/openai-realtime-voice-read-only-tool-calls-guide

![Conceptual charcoal illustration of a person speaking into a tabletop microphone while wearing headphones, with a compact computer and two separate unmarked modules nearby.](https://bigchange.ai/api/media/file/realtime-voice-tool-calls-hero-v1.png)
AI-generated conceptual illustration by BIG CHANGE; no actual OpenAI interface, API session, device test or heard answer is depicted.

当口头回答需要多次查询时，浏览器语音对话会更复杂。模型可在一个已完成的响应中返回多个函数调用。每个结果必须使用原来的 `call_id` 发回，并在所有结果发送后才启动新响应。本指南提供完整 localhost 开发示例：实时麦克风输入、WebRTC 流式播放、两个虚构的只读工具、多轮对话和中断清理。它扩展了我们的 [基础浏览器语音指南](https://bigchange.ai/blog/openai-realtime-browser-voice-webrtc-guide)；后者不调用外部工具即可生成语音回复。

## 关键变化

- **发生了什么变化：** OpenAI 的 [Realtime API](https://developers.openai.com/api/docs/guides/realtime-conversations) 可在实时语音会话中返回函数调用。本示例通过 `call_id` 将已完成的调用与本地结果关联，再请求模型继续。
- **为什么重要：** 开发者可以测试依赖多次只读查询的语音回答，同时把麦克风采集和播放留在浏览器，把标准 API 密钥留在本地服务器。
- **需要关注：** 本地检查使用模拟事件验证事件顺序和清理。你的实际付费会话才能确认自己的账户、浏览器、设备和网络能否产生可听且准确的回答，以及预期账单。

示例使用 OpenAI 标准 Realtime WebRTC 端点 `/v1/realtime/calls`，配合 `gpt-realtime-2.1`。它不使用独立的 GPT-Live 会话协议。下文两个“图书馆”值是虚构软件测试数据，并非真实场所信息。BIG CHANGE 于 2026 年 10 月 20 日核对了当前 [WebRTC](https://developers.openai.com/api/docs/guides/voice-webrtc)、[对话](https://developers.openai.com/api/docs/guides/realtime-conversations)、[工具](https://developers.openai.com/api/docs/guides/realtime-mcp)及[事件](https://developers.openai.com/api/reference/resources/realtime/client-events) 文档。我们运行了本地模拟控制器和浏览器检查，但没有建立付费 API 会话，也没有测试真实麦克风采集或声音。

## 1. 检查访问条件并创建五个文件

你需要 Node.js 22.6 或更高版本、带麦克风和扬声器的当前浏览器，以及有权访问 `gpt-realtime-2.1` 的 OpenAI API 项目。其 [模型页面](https://developers.openai.com/api/docs/models/gpt-realtime-2.1) 显示不支持 Free 层级。先检查 API 计费和项目速率、支出上限；ChatGPT 订阅不包含 API 使用费。通过惯用的本地密钥管理方式，将 `OPENAI_API_KEY` 放入 **服务器进程环境**。不要将它写入这些文件、浏览器代码或转录文本。只使用你获准发送给 OpenAI 的语音。

创建新目录，将下方五个完整代码块分别保存为其上方所列文件名。保留 `.mjs` 扩展名，并把所有文件放在同一目录。服务器监听 `127.0.0.1:3000`。它接收本地页面的 SDP offer，用服务器端密钥转发至 OpenAI，再返回 SDP answer。浏览器不会收到标准 API 密钥。本地 Host 和 Origin 检查有助于把演示限制在 localhost，但不能为共享服务的用户提供身份验证。

**server.mjs**

```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.get("/controller.mjs", (_req, res) => res.sendFile("controller.mjs", { 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: "Answer questions about the fictional demo library only using its read-only tools. State clearly that the data is synthetic software test data, not real venue information. For a lookup that may take noticeable time, give one short spoken progress preamble when useful, then call the tools. A preamble is never a result. Do not invent missing information. If a tool result is an error, say that lookup failed. Respond concisely in speech.",
    tools: [
      { type: "function", name: "get_demo_hours", description: "Read synthetic demo-library opening hours; no external account or network.", parameters: { type: "object", properties: {}, additionalProperties: false } },
      { type: "function", name: "get_demo_address", description: "Read synthetic demo-library address; no external account or network.", parameters: { type: "object", properties: {}, additionalProperties: false } }
    ],
    tool_choice: "auto"
  }));

  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"));
```

**index.html**

```html
<!doctype html>
<html lang="en">
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Local Realtime voice and read-only tool check</title>
<h1>Local voice and tool check</h1>
<p>The library data in this demo is synthetic software test data.</p>
<button id="start">Start voice</button>
<button id="stop" disabled>Stop and release microphone</button>
<p id="status" role="status">Idle</p>
<audio id="reply" autoplay controls></audio>
<pre id="events" aria-label="Event status"></pre>
<script type="module">
import { ToolController } from "/controller.mjs";

const start = document.querySelector("#start");
const stop = document.querySelector("#stop");
const status = document.querySelector("#status");
const events = document.querySelector("#events");
const reply = document.querySelector("#reply");
let current;

function note(line) {
  events.textContent = `${line}\n${events.textContent}`.slice(0, 3000);
}

// These values are synthetic test fixtures, never real business records.
async function runDemoTool(call, signal) {
  if (signal.aborted) throw Error("interrupted");
  let args;
  try { args = JSON.parse(call.arguments); } catch { return { ok: false, error: "Invalid arguments" }; }
  if (!args || Array.isArray(args) || typeof args !== "object" || Object.keys(args).length) {
    return { ok: false, error: "Expected an empty object" };
  }
  if (call.name === "get_demo_hours") return { ok: true, value: "Synthetic demo library hours: Monday-Friday 09:00-17:00." };
  if (call.name === "get_demo_address") return { ok: true, value: "Synthetic demo library address: 1 Example Lane. This is not a real venue." };
  return { ok: false, error: "Tool is not allowed" };
}

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

start.onclick = async () => {
  const session = { abort: new AbortController(), closed: false };
  current = session;
  start.disabled = true;
  stop.disabled = false;
  status.textContent = "Requesting microphone";
  events.textContent = "";
  session.setupTimer = 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) return;
      reply.srcObject = event.streams[0];
      reply.play().catch(() => note("Playback blocked; press Play on the audio control"));
    };
    pc.onconnectionstatechange = () => {
      if (session.closed) return;
      note(`Peer: ${pc.connectionState}`);
      if (pc.connectionState === "connected") {
        clearTimeout(session.setupTimer);
        clearTimeout(session.sessionTimer);
        session.sessionTimer = setTimeout(() => closeSession(session, "Local session limit reached; microphone stopped"), 55 * 60000);
        status.textContent = "Connected; ask about the synthetic demo library";
      }
      if (["failed", "closed", "disconnected"].includes(pc.connectionState)) closeSession(session, "Connection ended; microphone stopped");
    };
    pc.addTrack(mic.getAudioTracks()[0], mic);
    const dc = session.dc = pc.createDataChannel("oai-events");
    const send = event => {
      if (session.closed || dc.readyState !== "open") throw Error("Event channel is not open");
      dc.send(JSON.stringify(event));
    };
    session.controller = new ToolController({
      send, runTool: runDemoTool,
      status: note,
      fatal: message => closeSession(session, `${message}; microphone stopped`)
    });
    dc.onmessage = message => {
      if (session.closed) return;
      let event;
      try { event = JSON.parse(message.data); } catch { closeSession(session, "Invalid event; microphone stopped"); return; }
      if (event.type === "session.created") note("Realtime session created");
      if (event.type === "output_audio_buffer.cleared") note("Server cleared unplayed speech after interruption");
      session.controller.handle(event).catch(() => closeSession(session, "Controller failed; microphone stopped"));
    };
    dc.onclose = () => { if (!session.closed) closeSession(session, "Event channel closed; microphone stopped"); };
    dc.onerror = () => closeSession(session, "Event channel failed; microphone stopped");
    const offer = await pc.createOffer();
    await pc.setLocalDescription(offer);
    if (session.closed) return;
    const response = await fetch("/session", {
      method: "POST", headers: { "Content-Type": "application/sdp" },
      body: offer.sdp, signal: session.abort.signal
    });
    if (!response.ok) throw Error(`Session request failed (${response.status})`);
    const answer = await response.text();
    if (session.closed) return;
    await pc.setRemoteDescription({ type: "answer", sdp: answer });
    if (!session.closed) 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>
```

**controller.mjs**

```javascript
// The same controller runs in the browser and in the synthetic local checks.
export class ToolController {
  constructor({ send, runTool, status, fatal, setTimer = setTimeout, clearTimer = clearTimeout }) {
    Object.assign(this, { send, runTool, status, fatal, setTimer, clearTimer });
    this.epoch = 0;
    this.rounds = 0;
    this.counter = 0;
    this.responses = new Map();
    this.pending = null;
    this.closed = false;
  }

  abortPending() {
    if (!this.pending) return;
    this.pending.abort.abort();
    if (this.pending.timer) this.clearTimer(this.pending.timer);
    this.pending = null;
  }

  close() {
    this.closed = true;
    this.epoch++;
    this.abortPending();
    this.responses.clear();
  }

  fail(message) {
    if (this.closed) return;
    this.status(message);
    this.close();
    this.fatal(message);
  }

  interrupt() {
    if (this.closed) return;
    this.epoch++;
    this.rounds = 0;
    this.abortPending();
    this.status("Speech detected; interrupted result discarded");
  }

  async handle(event) {
    if (this.closed) return;
    if (event.type === "error") {
      this.fail(`Realtime error: ${event.error?.code || event.error?.message || "unknown"}`);
      return;
    }
    if (event.type === "input_audio_buffer.speech_started") { this.interrupt(); return; }
    if (event.type === "response.created") {
      if (event.response?.id) this.responses.set(event.response.id, this.epoch);
      return;
    }
    if (event.type === "conversation.item.done") {
      const p = this.pending;
      const id = event.item?.id;
      if (!p || p.epoch !== this.epoch || !p.acks.has(id)) return;
      p.acks.delete(id);
      if (p.acks.size === 0) {
        if (p.timer) this.clearTimer(p.timer);
        this.pending = null;
        if (!p.abort.signal.aborted && !this.closed && p.epoch === this.epoch) {
          this.status("All tool outputs acknowledged; resuming response");
          this.send({ type: "response.create" });
        }
      }
      return;
    }
    if (event.type !== "response.done") return;
    const response = event.response;
    const id = response?.id;
    const responseEpoch = this.responses.get(id);
    this.responses.delete(id);
    if (responseEpoch === undefined || responseEpoch !== this.epoch) return;
    this.status(`Response ${id}: ${response.status || "unknown"}`);
    if (response.status !== "completed") return; // Never execute partial/cancelled calls.
    const calls = (response.output || []).filter(item => item.type === "function_call");
    if (!calls.length) return;
    if (this.pending) { this.fail("Overlapping tool batches in one turn"); return; }
    if (calls.length > 8 || ++this.rounds > 3) { this.fail("Tool call or round bound reached"); return; }
    const ids = new Set();
    for (const call of calls) {
      if (call.status !== "completed" || !call.call_id || ids.has(call.call_id)) {
        this.fail("Incomplete or repeated function call ID"); return;
      }
      ids.add(call.call_id);
    }
    const p = { epoch: this.epoch, abort: new AbortController(), acks: new Set(), timer: null };
    this.pending = p;
    this.status(`Executing ${calls.length} read-only local call(s) from ${id}`);
    const outputs = await Promise.all(calls.map(async call => {
      try { return { call, result: await this.runTool(call, p.abort.signal) }; }
      catch { return { call, result: { ok: false, error: "Local lookup failed" } }; }
    }));
    if (this.closed || this.pending !== p || p.epoch !== this.epoch || p.abort.signal.aborted) return;
    try {
      for (const { call, result } of outputs) {
        const itemId = `tool_output_${++this.counter}`;
        p.acks.add(itemId);
        this.send({
          type: "conversation.item.create",
          event_id: `tool_event_${this.counter}`,
          item: { id: itemId, type: "function_call_output", call_id: call.call_id, output: JSON.stringify(result) }
        });
        this.status(`Sent result for ${call.call_id}`);
      }
      p.timer = this.setTimer(() => this.fail("Tool output acknowledgement timed out"), 8000);
    } catch { this.fail("Could not send tool outputs"); }
  }
}
```

**protocol-check.mjs**

```javascript
import assert from "node:assert/strict";
import { ToolController } from "./controller.mjs";

const deferred = () => { let resolve, reject; const promise = new Promise((a, b) => { resolve = a; reject = b; }); return { promise, resolve, reject }; };
const tick = () => new Promise(resolve => setImmediate(resolve));
const call = (id, name = "get_demo_hours") => ({ type: "function_call", status: "completed", call_id: id, name, arguments: "{}" });
const created = id => ({ type: "response.created", response: { id } });
const done = (id, status, output = []) => ({ type: "response.done", response: { id, status, output } });

function harness(runTool = async x => ({ ok: true, value: x.name })) {
  const sent = [], statuses = [], failures = [];
  const timers = new Map(); let nextTimer = 0;
  const controller = new ToolController({
    send: e => sent.push(e), runTool,
    status: s => statuses.push(s), fatal: e => failures.push(e),
    setTimer: callback => { const id = ++nextTimer; timers.set(id, callback); return id; },
    clearTimer: id => timers.delete(id)
  });
  return { controller, sent, statuses, failures, timers };
}

// Mixed audio/message output, two asynchronous calls resolved in reverse order.
{
  const a = deferred(), b = deferred();
  const h = harness(x => x.call_id === "call_A" ? a.promise : b.promise);
  await h.controller.handle(created("resp_1"));
  const work = h.controller.handle(done("resp_1", "completed", [
    { type: "message", status: "completed", content: [{ type: "output_audio", transcript: "Checking." }] },
    call("call_A"), call("call_B", "get_demo_address")
  ]));
  b.resolve({ ok: true, value: "address" }); await tick();
  assert.equal(h.sent.length, 0, "first finished lookup must not send or resume early");
  a.resolve({ ok: true, value: "hours" }); await work;
  assert.deepEqual(h.sent.map(x => x.item?.call_id), ["call_A", "call_B"]);
  assert.equal(h.sent.filter(x => x.type === "response.create").length, 0);
  await h.controller.handle({ type: "conversation.item.done", item: { id: h.sent[1].item.id } });
  assert.equal(h.sent.filter(x => x.type === "response.create").length, 0);
  await h.controller.handle({ type: "conversation.item.done", item: { id: h.sent[0].item.id } });
  assert.equal(h.sent.filter(x => x.type === "response.create").length, 1, "one resume after both item acknowledgements");
}

// One local lookup error does not erase the other result.
{
  const h = harness(async x => { if (x.call_id === "bad") throw Error("synthetic failure"); return { ok: true, value: "fixture" }; });
  await h.controller.handle(created("resp_2"));
  await h.controller.handle(done("resp_2", "completed", [call("bad"), call("good")]));
  assert.equal(JSON.parse(h.sent[0].item.output).ok, false);
  assert.equal(JSON.parse(h.sent[1].item.output).ok, true);
  for (const e of h.sent.slice()) await h.controller.handle({ type: "conversation.item.done", item: { id: e.item.id } });
  assert.equal(h.sent.filter(x => x.type === "response.create").length, 1);
}

// Partial/cancelled responses never dispatch a call.
for (const status of ["failed", "incomplete", "cancelled"]) {
  const h = harness();
  await h.controller.handle(created(`resp_${status}`));
  await h.controller.handle(done(`resp_${status}`, status, [call("ignored")]));
  assert.equal(h.sent.length, 0);
}

// Distinct rounds are bounded, and missing/duplicate IDs are rejected.
{
  const h = harness();
  for (let i = 1; i <= 3; i++) {
    await h.controller.handle(created(`round_${i}`));
    await h.controller.handle(done(`round_${i}`, "completed", [call(`call_${i}`)]));
    const last = h.sent.at(-1);
    await h.controller.handle({ type: "conversation.item.done", item: { id: last.item.id } });
  }
  await h.controller.handle(created("round_4"));
  await h.controller.handle(done("round_4", "completed", [call("too_many")]));
  assert.match(h.failures[0], /bound/);
  assert.equal(h.sent.filter(x => x.type === "response.create").length, 3);
}
{
  const h = harness();
  await h.controller.handle(created("dup"));
  await h.controller.handle(done("dup", "completed", [call("same"), call("same")]));
  assert.match(h.failures[0], /repeated/);
}

// User speech or Stop invalidates in-flight lookups and ignores their late results.
for (const mode of ["speech", "stop"]) {
  const d = deferred(), h = harness(() => d.promise);
  await h.controller.handle(created(`resp_${mode}`));
  const work = h.controller.handle(done(`resp_${mode}`, "completed", [call("late")]));
  if (mode === "speech") await h.controller.handle({ type: "input_audio_buffer.speech_started" });
  else h.controller.close();
  d.resolve({ ok: true, value: "late fixture" }); await work;
  assert.equal(h.sent.length, 0);
}

// A late response completion or acknowledgement from the interrupted turn cannot resume it.
{
  const h = harness();
  await h.controller.handle(created("old"));
  await h.controller.handle({ type: "input_audio_buffer.speech_started" });
  await h.controller.handle(done("old", "completed", [call("late_call")]));
  assert.equal(h.sent.length, 0);
  await h.controller.handle(created("new"));
  await h.controller.handle(done("new", "completed", [call("fresh_call")]));
  const output = h.sent[0];
  await h.controller.handle({ type: "input_audio_buffer.speech_started" });
  await h.controller.handle({ type: "conversation.item.done", item: { id: output.item.id } });
  assert.equal(h.sent.filter(x => x.type === "response.create").length, 0);
}

// Missing server acknowledgement closes instead of speculatively resuming.
{
  const h = harness();
  await h.controller.handle(created("no_ack"));
  await h.controller.handle(done("no_ack", "completed", [call("one")]));
  assert.equal(h.timers.size, 1);
  [...h.timers.values()][0]();
  assert.match(h.failures[0], /timed out/);
  assert.equal(h.sent.filter(x => x.type === "response.create").length, 0);
}

console.log("Synthetic controller checks passed: ordering, single resume, error isolation, bounds and interruption.");
```

**browser-check.mjs**

```javascript
import assert from "node:assert/strict";
import fs from "node:fs";
import vm from "node:vm";
import { ToolController } from "./controller.mjs";

const html = fs.readFileSync(new URL("./index.html", import.meta.url), "utf8");
const script = html.match(/<script type="module">([\s\S]*?)<\/script>/)?.[1]
  .replace('import { ToolController } from "/controller.mjs";', "");
assert.ok(script, "inline browser module found");

function fakePage(mediaPromise) {
  const nodes = Object.fromEntries(["#start", "#stop", "#status", "#events", "#reply"].map(k => [k, {
    disabled: k === "#stop", textContent: "", srcObject: null,
    pause() { this.paused = true; }, play() { return Promise.resolve(); }
  }]));
  let fetches = 0, peer;
  class FakePeer {
    constructor() { peer = this; this.connectionState = "new"; }
    addTrack() {}
    createDataChannel() { return this.dc = { readyState: "open", close() { this.closed = true; }, send() {} }; }
    async createOffer() { return { sdp: "v=0" }; }
    async setLocalDescription() {}
    async setRemoteDescription() {}
    close() { this.closed = true; }
  }
  vm.runInNewContext(script, {
    ToolController, document: { querySelector: k => nodes[k] },
    navigator: { mediaDevices: { getUserMedia: () => mediaPromise } },
    RTCPeerConnection: FakePeer,
    fetch: async () => { fetches++; return { ok: true, text: async () => "answer" }; },
    window: { addEventListener() {} },
    AbortController, setTimeout, clearTimeout, console
  });
  return { nodes, peer: () => peer, fetches: () => fetches };
}

// Stop while permission is pending; any late track is released without a session request.
{
  let allowMic;
  const page = fakePage(new Promise(resolve => { allowMic = resolve; }));
  const starting = page.nodes["#start"].onclick();
  page.nodes["#stop"].onclick();
  const track = { stopped: false, stop() { this.stopped = true; } };
  allowMic({ getTracks: () => [track], getAudioTracks: () => [track] });
  await starting;
  assert.equal(track.stopped, true);
  assert.equal(page.fetches(), 0);
  assert.equal(page.nodes["#start"].disabled, false);
}

// Stop after signaling: microphone, data channel, peer and playback all close.
{
  const track = { stopped: false, stop() { this.stopped = true; } };
  const page = fakePage(Promise.resolve({ getTracks: () => [track], getAudioTracks: () => [track] }));
  await page.nodes["#start"].onclick();
  page.nodes["#stop"].onclick();
  assert.equal(page.fetches(), 1);
  assert.equal(track.stopped, true);
  assert.equal(page.peer().closed, true);
  assert.equal(page.peer().dc.closed, true);
  assert.equal(page.nodes["#reply"].paused, true);
  assert.equal(page.nodes["#reply"].srcObject, null);
}

console.log("Synthetic browser lifecycle checks passed: late microphone and full Stop cleanup.");
```

在该目录安装唯一的服务器依赖并启动服务器：

```bash
npm init -y
npm install express
node server.mjs
```

打开 `http://localhost:3000`。页面应显示 **Start voice**、**Stop and release microphone**、音频播放器和事件状态。服务器只提供浏览器页面和控制器模块；两个测试函数在浏览器中运行，不访问任何真实场所、日历、CRM 或业务账户。

## 2. 启动、聆听并检查结果

点击 **Start voice**，允许麦克风访问，并等待 **Connected**。询问虚构演示图书馆的开放时间和地址。浏览器通过 WebRTC 媒体轨道发送麦克风声音，远端语音轨道到达后立即连接至音频控件。如果自动播放受阻，在可见控件上点击 **Play**。服务器提示要求在有用时先简短口述进度。模型可能省略这段话；听到它也不代表查询成功。

事件面板显示响应状态、调用标识和输出确认，不打印密钥、原始音频或转录文本。模型可以选择不调用、调用一个或多个工具。当 `response.done` 包含 `status: completed` 时，控制器扫描 **所有** 输出项，查找已完成的 `function_call` 项，并检查每个 `call_id` 都存在且唯一。它执行允许列表内的查询，包括并行处理多个调用，再为每个原始标识发送一个 `function_call_output`。查询失败会生成该调用的错误结果，其他结果仍保留。控制器为每个分配的输出项标识等待 `conversation.item.done`，然后只发送一次 `response.create`。等待所有确认是本示例的保守顺序设计，并非 API 规定。每个响应最多八个调用，每个未中断轮次最多三轮工具调用；这些是本地限制。

检查是否出现 **Realtime session created**、对端已连接、可能存在的工具调用标识、输出确认，以及已完成的后续响应。聆听回答，将其中的开放时间和地址与 `index.html` 中的虚构字符串对照。响应完成事件本身不能证明声音可听或答案准确。这些观察需要你自行进行付费浏览器运行；BIG CHANGE 没有这样做。

## 3. 再进行一轮、测试中断并释放麦克风

不重启，继续问另一个问题。要测试中断，可在模型说话或查询待完成时讲话。对于 WebRTC 与语音活动检测，OpenAI 文档说明了取消和由服务器管理的未播放音频截断。在收到 `input_audio_buffer.speech_started` 时，本地控制器也会使待完成查询失效，防止迟到结果重新启动被中断的响应。此代码没有实现 WebSocket 音频播放器需要的独立播放游标和截断事件。

点击 **Stop and release microphone**，确认浏览器麦克风指示灯关闭。权限请求仍待处理时也能停止：若权限迟到，返回的轨道会立即停止。同一清理过程会停止轨道、中止未完成的会话请求、关闭数据通道和对端、暂停并断开播放、取消工具工作，并在离开页面、启动超时、本地会话超时或连接失败时清除计时器。示例的启动计时为 30 秒，本地会话为 55 分钟；OpenAI 文档规定 Realtime 最长 60 分钟。

## 4. 运行本地检查并处理故障

在同一目录运行：

```bash
node --check server.mjs
node --check controller.mjs
node protocol-check.mjs
node browser-check.mjs
```

两个脚本使用模拟事件和浏览器替身。它们检查混合消息/函数响应、两个调用倒序完成、全部输出和确认先于唯一续接、每次调用的错误隔离、仅执行已完成调用、轮数上限、语音中断、迟到权限释放和 Stop 清理。它们不发送 API 请求。通过测试不能衡量网络时序、真实设备行为、语音质量或生产可靠性。

如果 **Start** 失败，读取可见状态和本地服务器收到的上游 HTTP 状态。检查权限、`localhost:3000`、服务器环境密钥、模型访问、计费、限制和网络。502 可能来自上游拒绝、网络故障或示例的 25 秒请求期限；它不能证明 OpenAI 从未收到请求。如果对端连接却无声，检查输入和输出设备并点击 **Play**。失败、未完成或已取消的响应不能执行部分调用。输出确认等待超时后，控制器会关闭会话，不发送可能重复且结果不明的续接。当启动或网络结果不明时，停止会话、确认麦克风释放，并在重试前检查项目用量；已中止的请求可能已经计费。

## 费用与限制

OpenAI 的 [模型页面](https://developers.openai.com/api/docs/models/gpt-realtime-2.1)和[价格表](https://developers.openai.com/api/docs/pricing)（2026 年 10 月 20 日核对）列出的非缓存文本输入/输出价格为每百万 token 4/24 美元，音频输入/输出为 32/64 美元。实际费用取决于 token 用量、音频和重复的对话上下文。我们未测量账单、延迟、实时工具行为或开场进度说明。麦克风声音会发送给 OpenAI。固定的测试函数不代表获准接入真实业务或执行写操作。共享部署需要另行设计用户身份、限制、工具授权、监控和审查。

### 来源与延伸阅读

- [OpenAI WebRTC 指南](https://developers.openai.com/api/docs/guides/voice-webrtc)：浏览器媒体轨道、远端播放、数据通道，以及通过可信服务器交换 SDP。它没有测试这个具体浏览器或账户。
- [Realtime 对话](https://developers.openai.com/api/docs/guides/realtime-conversations)：已完成响应的完整输出、函数结果顺序、中断和 60 分钟会话上限。它不保证特定模型会调用工具。
- [Realtime 工具](https://developers.openai.com/api/docs/guides/realtime-mcp)：由应用负责的函数调用及 `function_call_output`。它不能证明这些本地测试数据的运行成功。
- [客户端](https://developers.openai.com/api/reference/resources/realtime/client-events)与[服务器事件](https://developers.openai.com/api/reference/resources/realtime/server-events)参考：项目标识、确认和响应事件结构。等待所有确认是示例的本地选择。
- [Realtime 提示编写](https://developers.openai.com/api/docs/guides/voice-prompting)：有条件地口述开场进度的指导；不保证有可听输出。
- [模型访问](https://developers.openai.com/api/docs/models/gpt-realtime-2.1)与[API 价格](https://developers.openai.com/api/docs/pricing)：当前访问层级和公布的 token 费率。实际账单应检查项目用量。

## Sources

- [OpenAI WebRTC 指南](https://developers.openai.com/api/docs/guides/voice-webrtc) — 说明浏览器麦克风和远端音频媒体轨道、oai-events 数据通道，以及经可信服务器使用标准 API 密钥通过 /v1/realtime/calls 交换 SDP。它不能证明本示例真实设备或网络行为。
- [OpenAI Realtime 对话](https://developers.openai.com/api/docs/guides/realtime-conversations) — 说明 response.done 完整输出和函数结果续接、WebRTC VAD 中断与服务器截断未播放音频，以及 60 分钟会话上限。不能保证读者运行时会调用工具或口述准确。
- [带工具的 OpenAI Realtime](https://developers.openai.com/api/docs/guides/realtime-mcp) — 说明应用负责的函数工具、以原始 call_id 关联的 function_call_output 项和 response.create 续接。示例等待每个输出确认是额外的本地顺序设计。
- [OpenAI Realtime 客户端事件](https://developers.openai.com/api/reference/resources/realtime/client-events) — 定义示例使用的 conversation.item.create、项目标识、function_call_output 和 response.create 结构；参考文档不是集成测试。
- [OpenAI Realtime 服务器事件](https://developers.openai.com/api/reference/resources/realtime/server-events) — 定义示例使用的完整响应、最终对话项确认和错误事件结构。等待所有确认是本地选择，不是 API 要求或集成测试。
- [OpenAI 语音活动检测](https://developers.openai.com/api/docs/guides/realtime-vad) — 说明用于使本地工作失效的 speech_started 和 speech_stopped 信号；此处没有测试真实中断。
- [OpenAI Realtime 提示编写](https://developers.openai.com/api/docs/guides/voice-prompting) — 支持请求有条件的简短进度说明。没有在付费会话中观察过开场说明。
- [OpenAI gpt-realtime-2.1 模型](https://developers.openai.com/api/docs/models/gpt-realtime-2.1) — 列出模型标识、不支持的 Free 层级，以及非缓存每百万 token 费率：文本输入/输出 4/24 美元，音频输入/输出 32/64 美元。项目访问和费用需读者检查账户。
- [OpenAI API 价格](https://developers.openai.com/api/docs/pricing) — 佐证公布的 gpt-realtime-2.1 文本和音频 token 价格；不提供本指南会话的固定费用。
- [OpenAI API 与 ChatGPT 计费](https://help.openai.com/en/articles/9039756-managing-billing-for-chatgpt-and-the-api-platform) — OpenAI 表示 API 用量独立于 ChatGPT 订阅计费。它不能确定读者当前余额或项目限制。
