La API Realtime de OpenAI permite enviar audio del micrófono y reproducir una respuesta hablada mediante WebRTC. Esta guía crea una conversación local con gpt-realtime-2.1. La clave API permanece en un servidor Node del ordenador. Necesitas un proyecto de API OpenAI con acceso al modelo y facturación; la suscripción de ChatGPT no paga el uso de API. BIG CHANGE comprobó la documentación y el código, pero no abrió una sesión de pago ni escuchó una respuesta.

El gran cambio

  • Qué cambió: OpenAI documenta una vía WebRTC del navegador para conversar con Realtime: un servidor local conserva la clave e intercambia la oferta de conexión del navegador por una respuesta.
  • Por qué importa: El desarrollador puede comprobar todo el recorrido del micrófono a la respuesta hablada en una página local antes de diseñar un producto mayor. El propietario del proyecto controla la clave y paga la API.
  • Qué vigilar: Un intercambio local correcto no demuestra preparación para producción. Una versión compartida necesita autenticación y control de gasto; una solicitud cancelada o agotada puede haber llegado ya a OpenAI.

Antes de empezar

Instala Node.js 22.6 o posterior y usa un navegador actual con micrófono y altavoz funcionales. El acceso al micrófono exige un contexto seguro; http://localhost sirve para este ejemplo local. Prepara una clave de proyecto API OpenAI con acceso a gpt-realtime-2.1 y facturación habilitada. Comprueba primero los límites de velocidad y gasto. La página del modelo indica que el nivel Free no está admitido. Conserva la clave en el entorno del proceso servidor con tu gestor de secretos o configuración local habitual. No la incluyas en index.html, no la guardes en un archivo .env del repositorio ni la pegues en una transcripción. La guía WebRTC de OpenAI documenta el intercambio con un servidor de confianza; la página del modelo enumera acceso y precios actuales.

Esta es la ruta unificada /v1/realtime/calls de la API Realtime. La guía WebRTC también describe GPT-Live mediante /v1/live/sessions, que usa otro protocolo de sesión. Mantén juntos la ruta y el contenido siguientes. El ejemplo se limita a tu ordenador; los controles locales Host y Origin no autentican usuarios de un servicio compartido.

  1. Crea un directorio vacío para la demostración e instala Express desde él:
Terminal
npm init -y
npm install express

La instalación crea package.json y añade la dependencia del servidor web. Guarda los dos archivos siguientes en ese directorio. El código utiliza los componentes integrados de Node fetch, FormData y AbortSignal.timeout; la configuración revisada necesita Node.js 22.6 o posterior.

  1. Guarda lo siguiente como server.mjs. Recibe la oferta SDP del navegador (descripción de la conexión WebRTC propuesta) y la envía a OpenAI con la configuración Realtime. Solo este servidor lee la clave API estándar.
JavaScript
import express from "express";

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

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

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

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

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

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

El servidor escucha en 127.0.0.1:3000, acepta únicamente el host y el origen POST de la página local y devuelve la respuesta SDP. El límite de 25 segundos para la solicitud al proveedor es una elección del ejemplo. Un tiempo agotado o una desconexión no prueba que OpenAI rechazara la solicitud; inspecciona el uso antes de repetir un inicio incierto.

  1. Guarda lo siguiente como index.html. La página solicita permiso del micrófono después de Start voice, envía la oferta al servidor local y reproduce el audio entrante. Stop cierra la conexión y detiene las pistas del micrófono incluso durante el inicio. El plazo de 30 segundos también es una decisión del ejemplo, no una garantía de API.
HTML
<!doctype html>
<html lang="en">
<meta charset="utf-8">
<title>Local Realtime voice check</title>
<button id="start">Start voice</button>
<button id="stop" disabled>Stop</button>
<p id="status" role="status">Idle</p>
<audio id="reply" autoplay controls></audio>
<script type="module">
  const start = document.querySelector("#start");
  const stop = document.querySelector("#stop");
  const status = document.querySelector("#status");
  const reply = document.querySelector("#reply");
  let current;

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

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

El navegador cierra además la sesión si falla el par o el canal de eventos y cuando se abandona la página. El permiso puede llegar después de detener o agotar el plazo; la pista tardía se detiene. La línea de estado muestra response.done con su estado declarado. El evento por sí solo no demuestra que la respuesta fuera audible ni que una respuesta cancelada o fallida terminara bien.

  1. Inicia el servidor desde el directorio de los dos archivos, con OPENAI_API_KEY ya definido en el entorno de ese proceso:
Terminal
node server.mjs

Abre http://localhost:3000. Debes ver Start voice, Stop, el estado Idle y un reproductor. Pulsa Start voice, permite el micrófono y formula una pregunta breve sin información sensible. La guía de conversaciones Realtime dice que la detección de actividad de voz predeterminada puede convertir una pausa tras hablar en una respuesta. Para comprobar tu ejecución, verifica que el navegador muestra un micrófono activo, que la conexión llega a connected, que aparece un evento session.created, que oyes voz y que response.done indica una respuesta completada. Pulsa Stop y comprueba que se apaga el indicador del micrófono. Estas pruebas te corresponden; BIG CHANGE no realizó esta sesión de pago.

Si falla la comprobación

Si se deniega el permiso o no aparece dispositivo, revisa permisos y micrófono y usa la URL local localhost o HTTPS. Si la página comunica un fallo /session, mira el estado HTTP del servidor y verifica clave, acceso al modelo, facturación, límites y endpoint. El servidor no imprime la clave ni el cuerpo de respuesta de OpenAI. Un 502 también puede ser fallo de red o el plazo de 25 segundos; no revela si se aceptó una solicitud incierta.

Si la señalización funciona pero falla la conexión, mira su estado, las restricciones de red y la respuesta SDP. Si conecta sin sonido, revisa entrada de micrófono y salida elegida. El navegador puede bloquear reproducción automática: pulsa Play en el control de audio y, si hace falta, detén e inicia otra sesión. Ante cualquier fallo, usa Stop y confirma que el indicador se apaga. Comprueba el uso antes de repetir inicios con resultado incierto.

Coste, privacidad y límites

OpenAI publica para gpt-realtime-2.1 texto no almacenado en caché de entrada/salida a $4/$24 por millón de tokens y audio de entrada/salida a $32/$64 por millón de tokens, comprobado el 19 de octubre de 2026. El coste depende de tokens consumidos, incluidos turnos posteriores que pueden incorporar contexto previo. Consulta precios y límites del modelo y el uso del proyecto antes de empezar; la duración sola no permite estimar la factura. La guía de conversaciones Realtime fija una duración máxima de 60 minutos.

El audio del micrófono se envía a OpenAI durante la sesión. Utiliza voz que tengas permiso para transmitir y evita audio privado o ajeno. Esta página local no tiene inicio de sesión ni presupuesto por usuario. Un servicio compartido necesitaría acceso /session autenticado y limitado por tasa, y un identificador de seguridad generado por el servidor; los controles Host y Origin locales no bastan. El ejemplo no llama herramientas externas ni hace llamadas telefónicas. No se midieron latencia, fiabilidad, calidad de voz, ahorro ni resultados de despliegue.

Fuentes y lecturas adicionales