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

# Создайте локального голосового агента Realtime с инструментами только для чтения

> Создайте живой голосовой разговор в браузере с OpenAI Realtime и двумя вымышленными инструментами только для чтения. Полный код сервера, браузера и контроллера показывает сопоставление ID вызовов, безопасное продолжение, прерывание и освобождение микрофона.

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), которое получает устный ответ без внешних инструментов.

## Главное изменение

- **Что изменилось:**Интерфейс [Realtime API](https://developers.openai.com/api/docs/guides/realtime-conversations) OpenAI может возвращать вызовы функций в живом голосовом сеансе. Пример связывает завершенные вызовы с локальными результатами через `call_id`, прежде чем просить модель продолжить.
- **Почему это важно:** Разработчик может проверить устный ответ, зависящий от нескольких запросов только для чтения, оставив захват микрофона и воспроизведение в браузере, а обычный ключ API — на локальном сервере.
- **На что обратить внимание:** Локальные проверки испытывают порядок событий и очистку на синтетических событиях. Собственный платный сеанс должен установить, дают ли ваши учетная запись, браузер, устройства и сеть слышимые, правильные ответы и ожидаемый счет.

Пример использует стандартную конечную точку OpenAI Realtime WebRTC `/v1/realtime/calls` с `gpt-realtime-2.1`. Он не использует отдельный протокол сеансов GPT-Live. Два значения «библиотеки» ниже — вымышленные тестовые данные программы, а не сведения о реальном месте. BIG CHANGE проверил актуальную документацию по [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) 20 октября 2026 года. Мы выполнили локальные синтетические проверки контроллера и браузера, но не открывали платный сеанс API и не испытывали реальный захват микрофона или звук.

## 1. Проверьте доступ и создайте пять файлов

Нужны Node.js 22.6 или новее, современный браузер с микрофоном и динамиками и проект OpenAI API с доступом к `gpt-realtime-2.1`. Его [страница модели](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** на видимом элементе управления. Серверная инструкция просит при необходимости коротко сообщить голосом о ходе работы. Модель может пропустить это сообщение; даже слышимое сообщение не доказывает успех запроса.

Панель событий показывает состояния ответов, ID вызовов и подтверждения результатов, не выводя ключ, исходное аудио или расшифровку. Модель может выбрать ни одного, один или несколько инструментов. Когда у `response.done` установлено `status: completed`, контроллер просматривает **все** выходные элементы, ищет завершенные элементы `function_call` и проверяет наличие и уникальность каждого `call_id`. Он выполняет разрешенные запросы, в том числе несколько параллельно, затем отправляет один `function_call_output` для каждого исходного ID. Неудачный запрос становится результатом ошибки этого вызова, остальные результаты сохраняются. Контроллер ожидает `conversation.item.done` для каждого назначенного ID выходного элемента и только затем отправляет один `response.create`. Ожидание всех подтверждений — консервативный локальный выбор порядка в этом примере, а не требование API. Разрешены не более восьми вызовов на ответ и трех раундов инструментов на непрерванный ход; это локальные пределы.

Ищите **Realtime session created**, подключенного участника, ID вызовов при их наличии, подтверждения результатов и завершенный последующий ответ. Послушайте ответ и сравните часы и адрес с вымышленными строками в `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**. Неудачный, неполный или отмененный ответ не выполняет частичные вызовы. Если подтверждение результата не приходит вовремя, контроллер закрывает сеанс вместо отправки неопределенного дубликата продолжения. При неизвестном исходе запуска или сети остановите сеанс, подтвердите освобождение микрофона и проверьте расход проекта перед повтором; прерванный запрос мог уже вызвать расходы.

## Стоимость и ограничения

Источники цен: [страница модели](https://developers.openai.com/api/docs/models/gpt-realtime-2.1) и [таблица цен](https://developers.openai.com/api/docs/pricing) OpenAI, проверенные 20 октября 2026 года, указывают 4/24 доллара США за миллион токенов текста на входе/выходе без кеша и 32/64 доллара за аудио на входе/выходе. Реальные расходы зависят от токенов, аудио и повторяющегося контекста разговора. Мы не измеряли счет, задержку, поведение живых инструментов или вступительное сообщение. Звук микрофона передается 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): ID элементов, подтверждения и структуры событий ответа. Ожидание всех подтверждений — локальный выбор примера.
- [Инструкции для 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): текущие уровни доступа и опубликованные тарифы токенов. Фактический счет проверяется по расходу вашего проекта.

## Sources

- [Руководство OpenAI по WebRTC](https://developers.openai.com/api/docs/guides/voice-webrtc) — Описывает дорожки микрофона и удаленного аудио, канал oai-events и доверенный серверный SDP-обмен со стандартным ключом API через /v1/realtime/calls. Не доказывает поведение реальных устройств или сети в этом примере.
- [Разговоры 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) — Описывает функции приложения, элементы function_call_output с исходным call_id и продолжение response.create. Ожидание подтверждения каждого результата — дополнительный локальный выбор порядка.
- [Клиентские события OpenAI Realtime](https://developers.openai.com/api/reference/resources/realtime/client-events) — Определяет структуры conversation.item.create, ID элементов, 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) — Указывает ID модели, неподдерживаемый уровень Free и тарифы без кеша за миллион токенов: текст вход/выход 4/24 доллара США, аудио вход/выход 32/64. Доступ и расходы требуют проверки аккаунта читателя.
- [Цены API OpenAI](https://developers.openai.com/api/docs/pricing) — Подтверждает опубликованные тарифы текстовых и аудиотокенов gpt-realtime-2.1; не дает фиксированной стоимости сеанса этого руководства.
- [Оплата OpenAI API и ChatGPT](https://help.openai.com/en/articles/9039756-managing-billing-for-chatgpt-and-the-api-platform) — OpenAI указывает отдельную от подписки ChatGPT оплату API. Не устанавливает текущий баланс или лимиты проекта читателя.
