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), који даје изговорени одговор без спољних алата.

## Велика промена

- **Шта се променило:** 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 је 20. октобра 2026. проверио актуелну документацију за [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 или новији, актуелан прегледач са микрофоном и звучницима и 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 понуду локалне странице, прослеђује је OpenAI-ју серверским кључем и враћа SDP одговор. Прегледач не добија стандардни 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. Неуспела претрага постаје резултат грешке тог позива, док други резултати остају. Контролер за сваки додељени ID излазне ставке чека `conversation.item.done` пре једног `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**. Неуспешан, непотпун или отказан одговор не може покренути делимичне позиве. Ако потврда излаза истекне, контролер затвара сесију уместо да шаље неизвестан дупликат наставка. Када је исход покретања или мреже непознат, зауставите сесију, проверите ослобађање микрофона и пројектну потрошњу пре понављања; прекинут захтев је можда већ направио трошак.

## Трошкови и ограничења

OpenAI [страница модела](https://developers.openai.com/api/docs/models/gpt-realtime-2.1) и [ценовник](https://developers.openai.com/api/docs/pricing), проверени 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 ВАД прекид са серверским одсецањем нерепродукованог звука и максимум од 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. Приступ и трошкови захтевају проверу налога читаоца.
- [OpenAI API цене](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 наводи да се API наплаћује одвојено од ChatGPT претплате. Не утврђује тренутно стање или границе пројекта читаоца.
