DIE WELT BLEIBT NICHT STEHEN.RSS
BIG CHANGE.

Markdown-Ausgabe

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

# Einen lokalen Realtime-Sprachagenten mit schreibgeschützten Werkzeugaufrufen bauen

> Erstellen Sie ein Live-Sprachgespräch im Browser mit OpenAI Realtime und zwei synthetischen Werkzeugen mit rein lesendem Zugriff. Vollständiger Server-, Browser- und Controller-Code zeigt die Zuordnung von Aufruf-IDs, sichere Fortsetzung, Unterbrechung und Mikrofonfreigabe.

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.

Ein Sprachgespräch im Browser wird anspruchsvoller, wenn eine Antwort mehrere Abfragen benötigt. Das Modell kann in einer abgeschlossenen Antwort mehrere Funktionsaufrufe zurückgeben. Jedes Ergebnis muss unter der ursprünglichen `call_id` zurückgesendet werden; eine neue Antwort darf erst nach dem Senden der Ergebnisse starten. Dieser Leitfaden liefert Entwicklern ein vollständiges localhost-Beispiel: Live-Mikrofon, WebRTC-Wiedergabe, zwei synthetische rein lesende Werkzeuge, weitere Gesprächsrunden und Aufräumen bei Unterbrechungen. Es erweitert unseren [grundlegenden Browser-Sprachleitfaden](https://bigchange.ai/blog/openai-realtime-browser-voice-webrtc-guide), der ohne externe Werkzeuge eine gesprochene Antwort erzeugt.

## Die große Veränderung

- **Was sich geändert hat:** Die [Realtime API](https://developers.openai.com/api/docs/guides/realtime-conversations) von OpenAI kann in einer Live-Sprachsitzung Funktionsaufrufe zurückgeben. Dieses Beispiel ordnet abgeschlossene Aufrufe über `call_id` lokalen Ergebnissen zu, bevor es das Modell fortfahren lässt.
- **Warum das wichtig ist:** Entwickler können eine gesprochene Antwort mit mehreren rein lesenden Abfragen testen. Mikrofonaufnahme und Wiedergabe bleiben im Browser, der normale API-Schlüssel auf einem lokalen Server.
- **Worauf zu achten ist:** Die lokalen Prüfungen testen Ereignisreihenfolge und Aufräumen mit synthetischen Ereignissen. Erst Ihre eigene kostenpflichtige Sitzung zeigt, ob Konto, Browser, Geräte und Netzwerk hörbare, richtige Antworten und die erwartete Rechnung liefern.

Das Beispiel verwendet den normalen Realtime-WebRTC-Endpunkt von OpenAI `/v1/realtime/calls` mit `gpt-realtime-2.1`. Es verwendet nicht das getrennte GPT-Live-Sitzungsprotokoll. Die beiden „Bibliothekswerte“ unten sind synthetische Software-Testdaten, keine Angaben zu einem realen Ort. BIG CHANGE prüfte am 20. Oktober 2026 die aktuelle Dokumentation zu [WebRTC](https://developers.openai.com/api/docs/guides/voice-webrtc), [Gesprächen](https://developers.openai.com/api/docs/guides/realtime-conversations), [Werkzeugen](https://developers.openai.com/api/docs/guides/realtime-mcp) und [Ereignissen](https://developers.openai.com/api/reference/resources/realtime/client-events). Wir führten lokale synthetische Controller- und Browser-Prüfungen durch, öffneten aber keine kostenpflichtige API-Sitzung und testeten weder echte Mikrofonaufnahme noch Ton.

## 1. Zugang prüfen und die fünf Dateien anlegen

Sie benötigen Node.js 22.6 oder neuer, einen aktuellen Browser mit Mikrofon und Lautsprechern sowie ein OpenAI-API-Projekt mit Zugang zu `gpt-realtime-2.1`. Die [Modellseite](https://developers.openai.com/api/docs/models/gpt-realtime-2.1) nennt die Free-Stufe als nicht unterstützt. Prüfen Sie zuerst API-Abrechnung sowie Raten- und Ausgabenlimits Ihres Projekts; ein ChatGPT-Abonnement deckt die API nicht ab. Hinterlegen Sie `OPENAI_API_KEY` in der **Umgebung des Serverprozesses** über Ihre übliche lokale Geheimnisverwaltung. Schreiben Sie den Schlüssel nicht in diese Dateien, Browser-Code oder Transkripte. Verwenden Sie nur Sprache, die Sie an OpenAI senden dürfen.

Erstellen Sie ein neues Verzeichnis und speichern Sie jeden der fünf vollständigen Blöcke unter dem darüberstehenden Dateinamen. Behalten Sie die Endungen `.mjs` bei und legen Sie alle Dateien in dasselbe Verzeichnis. Der Server lauscht auf `127.0.0.1:3000`. Er nimmt das SDP-Angebot der lokalen Seite entgegen, leitet es mit dem serverseitigen Schlüssel an OpenAI weiter und gibt die SDP-Antwort zurück. Der Browser erhält keinen normalen API-Schlüssel. Lokale Host- und Origin-Prüfungen helfen, diese Demonstration auf localhost zu begrenzen; sie authentifizieren keine Nutzer eines gemeinsam verwendeten Dienstes.

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

Installieren Sie in diesem Verzeichnis die einzige Serverabhängigkeit und starten Sie den Server:

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

Öffnen Sie `http://localhost:3000`. Die Seite sollte **Start voice**, **Stop and release microphone**, einen Audioplayer und den Ereignisstatus anzeigen. Nur Browser-Seite und Controller-Modul werden ausgeliefert; die beiden Testfunktionen laufen im Browser und greifen weder auf einen realen Ort noch Kalender, CRM oder Geschäftskonten zu.

## 2. Starten, zuhören und das Ergebnis prüfen

Klicken Sie auf **Start voice**, erlauben Sie Mikrofonzugriff und warten Sie auf **Connected**. Fragen Sie nach Öffnungszeiten und Adresse der synthetischen Demo-Bibliothek. Der Browser sendet den Mikrofonton über seine WebRTC-Medien-Spur und verbindet entfernte Sprache mit dem Audioplayer, sobald die Spur eintrifft. Wird automatisches Abspielen blockiert, drücken Sie **Play** am sichtbaren Bedienelement. Der Server-Prompt fordert bei Bedarf eine kurze gesprochene Fortschrittsankündigung. Das Modell kann sie auslassen; auch eine hörbare Ankündigung beweist keinen erfolgreichen Abruf.

Das Ereignisfeld zeigt Antwortstatus, Aufruf-IDs und Ausgabebestätigungen, ohne Schlüssel, Roh-Audio oder Transkript auszugeben. Das Modell kann kein, ein oder mehrere Werkzeuge wählen. Hat ein `response.done` den Wert `status: completed`, durchsucht der Controller **alle** Ausgabeelemente nach abgeschlossenen `function_call`-Elementen und prüft, ob jede `call_id` vorhanden und eindeutig ist. Er führt erlaubte Abfragen auch gleichzeitig aus und sendet dann für jede ursprüngliche ID ein `function_call_output`. Ein fehlgeschlagener Abruf wird zum Fehlerergebnis dieses Aufrufs; andere Ergebnisse bleiben erhalten. Der Controller wartet für jede zugewiesene Ausgabeelement-ID auf `conversation.item.done` und sendet erst dann genau ein `response.create`. Auf sämtliche Bestätigungen zu warten ist die konservative Reihenfolgewahl dieses Beispiels, keine dokumentierte API-Pflicht. Es erlaubt höchstens acht Aufrufe pro Antwort und drei Werkzeugrunden in einem ununterbrochenen Gesprächszug; das sind lokale Grenzen.

Achten Sie auf **Realtime session created**, den verbundenen Peer, gegebenenfalls Werkzeugaufruf-IDs, Ausgabebestätigungen und eine abgeschlossene Folgeantwort. Hören Sie die Antwort an und vergleichen Sie genannte Öffnungszeiten und Adresse mit den synthetischen Zeichenfolgen in `index.html`. Ein abgeschlossenes Antwortereignis belegt weder hörbare Wiedergabe noch Richtigkeit. Diese Beobachtungen erfordern Ihren eigenen abgerechneten Browser-Lauf; BIG CHANGE hat keinen durchgeführt.

## 3. Weitere Runde, Unterbrechung und Mikrofonfreigabe

Stellen Sie ohne Neustart eine weitere Frage. Sprechen Sie für einen Unterbrechungstest während einer Modellantwort oder laufenden Abfrage. Für WebRTC mit Sprachaktivitätserkennung dokumentiert OpenAI Abbruch und serverseitiges Abschneiden noch nicht abgespielter Audiodaten. Bei `input_audio_buffer.speech_started` macht der lokale Controller auch ausstehende Abfragen ungültig, damit ein spätes Ergebnis die unterbrochene Antwort nicht fortsetzt. Dieser Code implementiert nicht den gesonderten Wiedergabezeiger und die Kürzungsereignisse, die ein WebSocket-Audioplayer braucht.

Klicken Sie auf **Stop and release microphone** und prüfen Sie, ob die Mikrofonanzeige des Browsers erlischt. Stoppen funktioniert auch während einer offenen Berechtigungsabfrage: Trifft die Erlaubnis spät ein, wird die erhaltene Spur sofort gestoppt. Derselbe Ablauf stoppt Spuren, bricht laufende Sitzungsanfragen ab, schließt Datenkanal und Peer, pausiert und trennt die Wiedergabe, beendet Werkzeugarbeit und entfernt Timer bei Verlassen der Seite, Einrichtungs- oder lokalem Sitzungszeitlimit oder Verbindungsfehlern. Das Beispiel nutzt 30 Sekunden für den Aufbau und 55 Minuten als lokale Sitzungsgrenze; OpenAI dokumentiert maximal 60 Minuten für Realtime.

## 4. Lokale Prüfungen ausführen und Fehler beheben

Führen Sie im selben Verzeichnis aus:

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

Die beiden Skripte nutzen synthetische Ereignisse und Browser-Nachbildungen. Sie prüfen gemischte Nachrichten-/Funktionsantworten, zwei in umgekehrter Reihenfolge fertige Aufrufe, alle Ausgaben und Bestätigungen vor genau einer Fortsetzung, Fehlerisolation pro Aufruf, Ausführung nur abgeschlossener Aufrufe, Rundengrenzen, Sprachunterbrechung, verspätete Berechtigungsfreigabe und Stop-Aufräumen. Sie stellen keine API-Anfrage. Erfolgreiche Tests messen weder Netzwerkzeiten, echtes Geräteverhalten, Sprachqualität noch Produktionszuverlässigkeit.

Falls **Start** fehlschlägt, lesen Sie den sichtbaren Status und den vom lokalen Server erhaltenen vorgelagerten HTTP-Status. Prüfen Sie Berechtigung, `localhost:3000`, Schlüssel in der Serverumgebung, Modellzugang, Abrechnung, Limits und Netzwerk. Ein 502 kann einen vorgelagerten Fehler, Netzwerkfehler oder das 25-Sekunden-Anfragelimit des Beispiels bedeuten; er beweist nicht, dass OpenAI die Anfrage nie erhalten hat. Besteht die Verbindung ohne Ton, prüfen Sie Ein- und Ausgabegeräte und drücken **Play**. Eine fehlgeschlagene, unvollständige oder abgebrochene Antwort darf keine Teilaufrufe ausführen. Läuft eine Ausgabebestätigung ab, schließt der Controller die Sitzung, statt eine möglicherweise doppelte Fortsetzung zu senden. Bei ungewissem Start- oder Netzwerkergebnis stoppen Sie die Sitzung, bestätigen die Mikrofonfreigabe und prüfen vor einem erneuten Versuch die Projektnutzung; auch eine abgebrochene Anfrage kann bereits Kosten verursacht haben.

## Kosten und Grenzen

Die [Modellseite](https://developers.openai.com/api/docs/models/gpt-realtime-2.1) und die [Preistabelle](https://developers.openai.com/api/docs/pricing) von OpenAI nennen nach Prüfung am 20. Oktober 2026 für ungecachte Texteingabe/-ausgabe 4/24 US-Dollar pro Million Token und für Audioeingabe/-ausgabe 32/64 US-Dollar. Tatsächliche Kosten hängen von Token, Audio und wiederholtem Gesprächskontext ab. Wir maßen weder Rechnung, Latenz, Live-Werkzeugverhalten noch Vorankündigung. Mikrofonton wird an OpenAI übertragen. Die festen synthetischen Funktionen erlauben keine echten Geschäftsintegrationen oder Schreibaktionen. Eine gemeinsame Bereitstellung bräuchte eigene Nutzeridentität, Grenzen, Werkzeugautorisierung, Überwachung und Prüfung.

### Quellen und weiterführende Informationen

- [OpenAI-WebRTC-Leitfaden](https://developers.openai.com/api/docs/guides/voice-webrtc): Browser-Medien-Spur, entfernte Wiedergabe, Datenkanal und SDP-Austausch über einen vertrauenswürdigen Server. Er testet nicht diesen konkreten Browser oder dieses Konto.
- [Realtime-Gespräche](https://developers.openai.com/api/docs/guides/realtime-conversations): vollständige Ausgabe abgeschlossener Antworten, Funktionsresultatfolge, Unterbrechung und 60-Minuten-Sitzungsgrenze. Er verspricht keinen Werkzeugaufruf eines bestimmten Modells.
- [Realtime-Werkzeuge](https://developers.openai.com/api/docs/guides/realtime-mcp): anwendungseigene Funktionsaufrufe und `function_call_output`. Er belegt keinen Erfolg dieser lokalen Testdaten.
- [Client-](https://developers.openai.com/api/reference/resources/realtime/client-events) und [Serverereignisse](https://developers.openai.com/api/reference/resources/realtime/server-events): Element-IDs, Bestätigungen und Antwortstrukturen. Das Warten auf alle Bestätigungen ist eine lokale Wahl des Beispiels.
- [Realtime-Prompting](https://developers.openai.com/api/docs/guides/voice-prompting): Anleitung für eine bedingte gesprochene Vorankündigung, keine Garantie hörbarer Ausgabe.
- [Modellzugang](https://developers.openai.com/api/docs/models/gpt-realtime-2.1) und [API-Preise](https://developers.openai.com/api/docs/pricing): aktuelle Zugangsstufen und veröffentlichte Tokenpreise. Die tatsächliche Rechnung ergibt sich aus Ihrer Projektnutzung.

## Sources

- [OpenAI-WebRTC-Leitfaden](https://developers.openai.com/api/docs/guides/voice-webrtc) — Dokumentiert Browser-Mikrofon- und entfernte Audio-Spuren, den Datenkanal oai-events und SDP-Austausch über einen vertrauenswürdigen Server mit normalem API-Schlüssel über /v1/realtime/calls. Er belegt kein echtes Geräte- oder Netzwerkverhalten dieses Beispiels.
- [OpenAI-Realtime-Gespräche](https://developers.openai.com/api/docs/guides/realtime-conversations) — Dokumentiert vollständige response.done-Ausgabe, Fortsetzung nach Funktionsresultaten, WebRTC-VAD-Unterbrechung mit serverseitigem Kürzen ungespielten Tons und die 60-Minuten-Grenze. Keine Garantie für Werkzeugnutzung oder gesprochene Richtigkeit beim Leser.
- [OpenAI Realtime mit Werkzeugen](https://developers.openai.com/api/docs/guides/realtime-mcp) — Dokumentiert anwendungseigene Funktionen, function_call_output-Elemente mit ursprünglicher call_id und Fortsetzung durch response.create. Auf jede Ausgabebestätigung zu warten ist eine zusätzliche lokale Reihenfolgewahl.
- [OpenAI-Realtime-Clientereignisse](https://developers.openai.com/api/reference/resources/realtime/client-events) — Definiert die verwendeten Strukturen von conversation.item.create, Element-IDs, function_call_output und response.create; die Referenz ist kein Integrationstest.
- [OpenAI-Realtime-Serverereignisse](https://developers.openai.com/api/reference/resources/realtime/server-events) — Definiert abgeschlossene Antworten, Bestätigungen finalisierter Gesprächselemente und Fehlerstrukturen des Beispiels. Das Warten auf alle Bestätigungen ist eine lokale Wahl, keine API-Pflicht oder Integrationstest.
- [OpenAI-Sprachaktivitätserkennung](https://developers.openai.com/api/docs/guides/realtime-vad) — Dokumentiert speech_started und speech_stopped zum Ungültigmachen lokaler Arbeit; eine echte Unterbrechung wurde hier nicht getestet.
- [OpenAI-Realtime-Prompting](https://developers.openai.com/api/docs/guides/voice-prompting) — Unterstützt eine bedingte kurze Fortschrittsankündigung per Prompt. In einer kostenpflichtigen Sitzung wurde keine beobachtet.
- [OpenAI-Modell gpt-realtime-2.1](https://developers.openai.com/api/docs/models/gpt-realtime-2.1) — Nennt Modell-ID, nicht unterstützte Free-Stufe und ungecachte Preise pro Million Token: Text ein/aus 4/24 US-Dollar, Audio ein/aus 32/64 US-Dollar. Projektzugang und Kosten verlangen die Kontoprüfung des Lesers.
- [OpenAI-API-Preise](https://developers.openai.com/api/docs/pricing) — Bestätigt veröffentlichte Text- und Audiopreise für gpt-realtime-2.1; liefert keine feste Sitzungsgebühr für diesen Leitfaden.
- [OpenAI-API- und ChatGPT-Abrechnung](https://help.openai.com/en/articles/9039756-managing-billing-for-chatgpt-and-the-api-platform) — OpenAI erklärt, dass API-Nutzung getrennt vom ChatGPT-Abonnement abgerechnet wird. Aktuelles Guthaben und Projektlimits des Lesers werden dadurch nicht festgestellt.