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

# Crear un agente de voz Realtime local con herramientas de solo lectura

> Cree una conversación de voz en directo en el navegador con OpenAI Realtime y dos herramientas ficticias de solo lectura. El código completo de servidor, navegador y controlador muestra cómo asociar identificadores, continuar con seguridad, interrumpir y liberar el micrófono.

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.

Una conversación de voz en el navegador se complica cuando una respuesta necesita varias consultas. El modelo puede devolver varias llamadas a funciones en una misma respuesta terminada. Cada resultado debe enviarse con su propio `call_id`, y solo debe iniciarse otra respuesta después de enviar los resultados. Esta guía ofrece un ejemplo localhost completo para desarrolladores: micrófono en directo, reproducción WebRTC en continuo, dos herramientas ficticias de solo lectura, turnos repetidos y limpieza tras interrupciones. Amplía nuestra [guía básica de voz en el navegador](https://bigchange.ai/blog/openai-realtime-browser-voice-webrtc-guide), que obtiene una respuesta hablada sin herramientas externas.

## El gran cambio

- **Qué cambió:** La [API Realtime](https://developers.openai.com/api/docs/guides/realtime-conversations) de OpenAI puede devolver llamadas a funciones durante una sesión de voz en directo. Este ejemplo asocia las llamadas terminadas con sus resultados locales mediante `call_id` antes de pedir al modelo que continúe.
- **Por qué importa:** Un desarrollador puede probar una respuesta hablada que dependa de varias consultas de solo lectura, manteniendo la captura y reproducción del micrófono en el navegador y la clave API estándar en un servidor local.
- **Qué observar:** Las comprobaciones locales prueban el orden de eventos y la limpieza con eventos ficticios. Su propia sesión de pago debe determinar si su cuenta, navegador, dispositivos y red producen respuestas audibles, correctas y la factura prevista.

El ejemplo utiliza el punto de conexión WebRTC Realtime estándar de OpenAI `/v1/realtime/calls` con `gpt-realtime-2.1`. No utiliza el protocolo de sesiones GPT-Live independiente. Los dos valores de «biblioteca» siguientes son datos de prueba ficticios, no información sobre un lugar real. BIG CHANGE revisó la documentación actual de [WebRTC](https://developers.openai.com/api/docs/guides/voice-webrtc), [conversaciones](https://developers.openai.com/api/docs/guides/realtime-conversations), [herramientas](https://developers.openai.com/api/docs/guides/realtime-mcp) y [eventos](https://developers.openai.com/api/reference/resources/realtime/client-events) el 20 de octubre de 2026. Ejecutamos comprobaciones locales ficticias del controlador y del navegador, pero no abrimos una sesión API de pago ni probamos captura real del micrófono o sonido.

## 1. Comprobar el acceso y crear los cinco archivos

Necesita Node.js 22.6 o posterior, un navegador actual con micrófono y altavoces, y un proyecto API de OpenAI con acceso a `gpt-realtime-2.1`. Su [página del modelo](https://developers.openai.com/api/docs/models/gpt-realtime-2.1) indica que el nivel Free no está admitido. Compruebe primero la facturación API y los límites de gasto y frecuencia del proyecto; una suscripción a ChatGPT no cubre la API. Coloque `OPENAI_API_KEY` en el **entorno del proceso servidor** mediante su mecanismo local habitual para secretos. Manténgala fuera de estos archivos, el código del navegador y las transcripciones. Use voz que tenga permiso para enviar a OpenAI.

Cree un directorio nuevo y guarde cada uno de los cinco bloques completos con el nombre de archivo indicado encima. Conserve las extensiones `.mjs` y mantenga todos los archivos en el mismo directorio. El servidor escucha en `127.0.0.1:3000`. Acepta la oferta SDP de la página local, la envía a OpenAI con la clave del servidor y devuelve la respuesta SDP. El navegador no recibe ninguna clave API estándar. Las comprobaciones locales de Host y Origin ayudan a limitar esta demostración a localhost; no autentican a usuarios de un servicio compartido.

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

En ese directorio, instale la única dependencia del servidor e inícielo:

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

Abra `http://localhost:3000`. La página debe mostrar **Start voice**, **Stop and release microphone**, un reproductor de audio y el estado de los eventos. Solo se sirven la página del navegador y el módulo controlador; las dos funciones ficticias se ejecutan en el navegador y no acceden a ningún lugar, calendario, CRM o cuenta empresarial.

## 2. Iniciar, escuchar e inspeccionar el resultado

Pulse **Start voice**, permita el acceso al micrófono y espere a **Connected**. Pregunte el horario y la dirección de la biblioteca ficticia de demostración. El navegador envía el sonido del micrófono por su pista multimedia WebRTC y conecta la voz remota al reproductor en cuanto llega esa pista. Si se bloquea la reproducción automática, pulse **Play** en el control visible. La instrucción del servidor pide un breve anuncio hablado de progreso cuando sea útil. El modelo puede omitirlo, y oírlo no prueba que una consulta haya tenido éxito.

El panel de eventos informa de estados de respuesta, identificadores de llamada y confirmaciones de salida sin imprimir su clave, audio sin procesar ni transcripción. El modelo puede elegir cero, una o varias herramientas. Cuando un `response.done` tiene `status: completed`, el controlador examina **todos** los elementos de salida en busca de elementos `function_call` terminados y comprueba que cada `call_id` esté presente y sea único. Ejecuta las consultas permitidas, incluidas varias simultáneamente, y envía un `function_call_output` por cada identificador original. Una consulta fallida produce un resultado de error para esa llamada; se conservan los demás resultados. El controlador espera `conversation.item.done` para cada identificador de elemento de salida asignado antes de enviar un solo `response.create`. Esperar esas confirmaciones es una decisión conservadora de orden de este ejemplo, no una exigencia declarada de la API. Permite como máximo ocho llamadas por respuesta y tres rondas de herramientas por turno sin interrupciones; son límites locales.

Busque **Realtime session created**, el par conectado, los identificadores de llamadas si los hay, las confirmaciones de salida y una respuesta posterior terminada. Escuche la respuesta y compare el horario y dirección indicados con las cadenas ficticias de `index.html`. Un evento de respuesta terminada no demuestra por sí solo reproducción audible o exactitud. Estas observaciones requieren su propia ejecución facturada en el navegador; BIG CHANGE no la ha realizado.

## 3. Repetir un turno, interrumpir y liberar el micrófono

Haga otra pregunta sin reiniciar. Para probar una interrupción, hable mientras el modelo responde o una consulta sigue pendiente. Con WebRTC y detección de actividad de voz, OpenAI documenta la cancelación y el truncamiento del audio no reproducido gestionado por el servidor. Al recibir `input_audio_buffer.speech_started`, el controlador local también invalida las consultas pendientes para que un resultado tardío no reanude la respuesta interrumpida. Este código no implementa el cursor de reproducción y los eventos de truncamiento independientes que necesita un reproductor de audio WebSocket.

Pulse **Stop and release microphone** y confirme que se apaga el indicador del micrófono del navegador. La parada también funciona con una solicitud de permiso pendiente: si el permiso llega tarde, la pista devuelta se detiene inmediatamente. La misma limpieza detiene pistas, abandona solicitudes de sesión en curso, cierra canal de datos y par, pausa y desconecta la reproducción, cancela herramientas y borra temporizadores al salir de la página, agotarse el tiempo de inicio o sesión local, o fallar la conexión. El ejemplo usa 30 segundos para iniciar y 55 minutos para la sesión local; OpenAI documenta un máximo Realtime de 60 minutos.

## 4. Ejecutar las comprobaciones locales y recuperarse de fallos

En el mismo directorio, ejecute:

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

Los dos scripts usan eventos ficticios y simulaciones del navegador. Comprueban respuestas con mensaje y funciones, dos llamadas que terminan en orden inverso, todas las salidas y confirmaciones antes de una continuación única, aislamiento de errores por llamada, ejecución solo de llamadas terminadas, límites de rondas, interrupción de voz, liberación tras permisos tardíos y limpieza con Stop. No hacen solicitudes API. Superarlos no mide tiempos de red, dispositivos reales, calidad de voz ni fiabilidad en producción.

Si **Start** falla, lea el estado visible y el estado HTTP del servicio al que llama el servidor local. Compruebe permisos, `localhost:3000`, clave del entorno del servidor, acceso al modelo, facturación, límites y red. Un 502 puede reflejar un rechazo del servicio remoto, fallo de red o vencimiento del plazo de 25 segundos del ejemplo; no prueba que OpenAI nunca recibiera la solicitud. Si el par conecta sin sonido, compruebe dispositivos de entrada y salida y pulse **Play**. Una respuesta fallida, incompleta o cancelada no puede ejecutar llamadas parciales. Si vence la espera de una confirmación de salida, el controlador cierra la sesión en vez de enviar una continuación duplicada incierta. Si se desconoce el resultado de inicio o red, detenga la sesión, confirme que se liberó el micrófono y revise el consumo del proyecto antes de reintentar; una solicitud abandonada puede haber generado costes.

## Costes y límites

La [página del modelo](https://developers.openai.com/api/docs/models/gpt-realtime-2.1) y la [tabla de precios](https://developers.openai.com/api/docs/pricing) de OpenAI, revisadas el 20 de octubre de 2026, indican 4/24 dólares por millón de tokens de texto de entrada/salida sin caché y 32/64 dólares para audio de entrada/salida. El cargo real depende de los tokens, audio y contexto de conversación repetido. No medimos factura, latencia, herramientas en directo ni anuncio previo. El sonido del micrófono se transmite a OpenAI. Las funciones ficticias fijas no autorizan integraciones empresariales reales ni acciones de escritura. Un despliegue compartido necesitaría identidad de usuario, límites, autorización de herramientas, supervisión y revisión propias.

### Fuentes y lecturas adicionales

- [Guía WebRTC de OpenAI](https://developers.openai.com/api/docs/guides/voice-webrtc): pista multimedia del navegador, reproducción remota, canal de datos e intercambio SDP con servidor de confianza. No prueba este navegador o cuenta concretos.
- [Conversaciones Realtime](https://developers.openai.com/api/docs/guides/realtime-conversations): salida completa de respuestas terminadas, secuencia de resultados de funciones, interrupción y máximo de sesión de 60 minutos. No promete que un modelo concreto llame a una herramienta.
- [Herramientas Realtime](https://developers.openai.com/api/docs/guides/realtime-mcp): llamadas a funciones gestionadas por la aplicación y `function_call_output`. No establece el éxito de estos datos de prueba locales.
- [Referencias de eventos del cliente](https://developers.openai.com/api/reference/resources/realtime/client-events) y del [servidor](https://developers.openai.com/api/reference/resources/realtime/server-events): identificadores de elementos, confirmaciones y estructuras de eventos de respuesta. Esperar todas las confirmaciones es una decisión local del ejemplo.
- [Instrucciones Realtime](https://developers.openai.com/api/docs/guides/voice-prompting): orientación para un anuncio hablado previo condicionado; no garantiza salida audible.
- [Acceso al modelo](https://developers.openai.com/api/docs/models/gpt-realtime-2.1) y [precios API](https://developers.openai.com/api/docs/pricing): niveles de acceso y tarifas de tokens actuales. Revise el uso de su proyecto para conocer la factura real.

## Sources

- [Guía WebRTC de OpenAI](https://developers.openai.com/api/docs/guides/voice-webrtc) — Documenta pistas multimedia de micrófono y audio remoto, canal oai-events e intercambio SDP con servidor de confianza y clave API estándar mediante /v1/realtime/calls. No prueba el comportamiento real de dispositivos o red con el ejemplo.
- [Conversaciones OpenAI Realtime](https://developers.openai.com/api/docs/guides/realtime-conversations) — Documenta salida completa de response.done y continuación con resultados de funciones, interrupción WebRTC VAD con truncamiento servidor del audio no reproducido y máximo de 60 minutos. No garantiza herramientas ni exactitud hablada en la ejecución del lector.
- [OpenAI Realtime con herramientas](https://developers.openai.com/api/docs/guides/realtime-mcp) — Documenta herramientas de función gestionadas por la aplicación, elementos function_call_output asociados al call_id original y continuación response.create. Esperar la confirmación de cada salida es una decisión local adicional.
- [Eventos del cliente OpenAI Realtime](https://developers.openai.com/api/reference/resources/realtime/client-events) — Define las estructuras de conversation.item.create, identificadores de elementos, function_call_output y response.create usadas; la referencia no es una prueba de integración.
- [Eventos del servidor OpenAI Realtime](https://developers.openai.com/api/reference/resources/realtime/server-events) — Define estructuras de respuestas terminadas, confirmaciones de elementos finalizados y errores usados. Esperar todas las confirmaciones es una decisión local, no requisito API ni prueba de integración.
- [Detección de actividad de voz OpenAI](https://developers.openai.com/api/docs/guides/realtime-vad) — Documenta speech_started y speech_stopped para invalidar el trabajo local; no prueba aquí una interrupción real.
- [Instrucciones OpenAI Realtime](https://developers.openai.com/api/docs/guides/voice-prompting) — Respalda pedir un breve anuncio de progreso de forma condicionada. No se observó ninguno en una sesión pagada.
- [Modelo OpenAI gpt-realtime-2.1](https://developers.openai.com/api/docs/models/gpt-realtime-2.1) — Enumera identificador del modelo, nivel Free no admitido y tarifas por millón de tokens sin caché: texto entrada/salida 4/24 dólares, audio entrada/salida 32/64 dólares. Acceso y cargos requieren comprobar la cuenta del lector.
- [Precios API de OpenAI](https://developers.openai.com/api/docs/pricing) — Corrobora tarifas de tokens de texto y audio de gpt-realtime-2.1; no ofrece un coste fijo para la sesión de esta guía.
- [Facturación de API OpenAI y ChatGPT](https://help.openai.com/en/articles/9039756-managing-billing-for-chatgpt-and-the-api-platform) — OpenAI indica que el uso API se factura aparte de una suscripción ChatGPT. No establece saldo actual ni límites del proyecto del lector.
