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

# Membuat agen suara Realtime lokal dengan panggilan alat hanya-baca

> Buat percakapan suara langsung di browser memakai OpenAI Realtime dan dua alat hanya-baca dengan data fiktif. Kode server, browser, dan pengendali yang lengkap menunjukkan pencocokan ID panggilan, kelanjutan aman, interupsi, serta pelepasan mikrofon.

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.

Percakapan suara di browser menjadi lebih rumit saat jawaban lisan memerlukan beberapa pencarian. Model dapat mengembalikan beberapa panggilan fungsi dalam satu respons yang selesai. Setiap hasil harus dikirim dengan `call_id` aslinya, dan respons baru hanya dimulai setelah semua hasil terkirim. Panduan ini menyediakan contoh localhost lengkap untuk pengembang: mikrofon langsung, pemutaran WebRTC mengalir, dua alat hanya-baca fiktif, giliran berulang, serta pembersihan saat terinterupsi. Ini memperluas [panduan dasar suara di browser](https://bigchange.ai/blog/openai-realtime-browser-voice-webrtc-guide) kami, yang menghasilkan jawaban lisan tanpa alat luar.

## Perubahan besar

- **Yang berubah:**Antarmuka [Realtime API](https://developers.openai.com/api/docs/guides/realtime-conversations) OpenAI dapat mengembalikan panggilan fungsi selama sesi suara langsung. Contoh ini mengaitkan panggilan selesai dengan hasil lokal melalui `call_id` sebelum meminta model melanjutkan.
- **Mengapa penting:** Pengembang dapat menguji jawaban lisan yang bergantung pada beberapa pencarian hanya-baca, sementara perekaman dan pemutaran tetap di browser serta kunci API standar berada di server lokal.
- **Yang perlu diperhatikan:** Pemeriksaan lokal menguji urutan peristiwa dan pembersihan dengan peristiwa sintetis. Sesi berbayar Anda sendiri harus memastikan apakah akun, browser, perangkat, dan jaringan menghasilkan jawaban yang terdengar, benar, serta tagihan yang diharapkan.

Contoh memakai endpoint WebRTC Realtime standar OpenAI `/v1/realtime/calls` dengan `gpt-realtime-2.1`. Ini tidak memakai protokol sesi GPT-Live yang terpisah. Dua nilai “perpustakaan” di bawah merupakan data uji perangkat lunak fiktif, bukan informasi tempat nyata. BIG CHANGE memeriksa dokumentasi terkini [WebRTC](https://developers.openai.com/api/docs/guides/voice-webrtc), [percakapan](https://developers.openai.com/api/docs/guides/realtime-conversations), [alat](https://developers.openai.com/api/docs/guides/realtime-mcp) dan [peristiwa](https://developers.openai.com/api/reference/resources/realtime/client-events) pada 20 Oktober 2026. Kami menjalankan pemeriksaan pengendali dan browser sintetis lokal, tetapi tidak membuka sesi API berbayar atau menguji mikrofon dan suara nyata.

## 1. Periksa akses dan buat lima berkas

Anda memerlukan Node.js 22.6 atau lebih baru, browser terkini dengan mikrofon dan pengeras suara, serta proyek API OpenAI yang memiliki akses ke `gpt-realtime-2.1`. [Halaman modelnya](https://developers.openai.com/api/docs/models/gpt-realtime-2.1) menyatakan tingkat Free tidak didukung. Periksa tagihan API dan batas laju serta pengeluaran proyek terlebih dahulu; langganan ChatGPT tidak mencakup API. Letakkan `OPENAI_API_KEY` di **lingkungan proses server** melalui mekanisme lokal Anda untuk menyimpan rahasia. Jangan menuliskannya ke berkas ini, kode browser, atau transkrip. Gunakan ucapan yang diizinkan untuk dikirim ke OpenAI.

Buat direktori baru dan simpan setiap lima blok lengkap di bawah memakai nama berkas di atasnya. Pertahankan ekstensi `.mjs` dan letakkan semua berkas dalam direktori yang sama. Server mendengarkan di `127.0.0.1:3000`. Server menerima tawaran SDP halaman lokal, meneruskannya ke OpenAI dengan kunci sisi server, lalu mengembalikan jawaban SDP. Browser tidak menerima kunci API standar. Pemeriksaan lokal Host dan Origin membantu membatasi demonstrasi ke localhost; keduanya tidak mengautentikasi pengguna layanan bersama.

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

Di direktori itu, pasang satu-satunya dependensi server dan jalankan server:

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

Buka `http://localhost:3000`. Halaman seharusnya menampilkan **Start voice**, **Stop and release microphone**, pemutar audio, dan status peristiwa. Hanya halaman browser dan modul pengendali yang dilayani; dua fungsi uji berjalan di browser dan tidak mengakses tempat nyata, kalender, CRM, atau akun bisnis.

## 2. Mulai, dengarkan, dan periksa hasil

Klik **Start voice**, izinkan mikrofon, lalu tunggu **Connected**. Tanyakan jam buka dan alamat perpustakaan demonstrasi fiktif. Browser mengirim suara mikrofon lewat trek media WebRTC dan memasang suara jarak jauh ke pemutar segera setelah trek tiba. Jika pemutaran otomatis diblokir, tekan **Play** pada kontrol yang terlihat. Instruksi server meminta pengantar suara singkat tentang kemajuan jika berguna. Model dapat melewatkannya, dan mendengar pengantar tidak membuktikan pencarian berhasil.

Panel peristiwa melaporkan status respons, ID panggilan, dan konfirmasi keluaran tanpa mencetak kunci, audio mentah, atau transkrip. Model dapat memilih nol, satu, atau beberapa alat. Ketika `response.done` memiliki `status: completed`, pengendali memindai **semua** item keluaran untuk menemukan item `function_call` yang selesai dan memeriksa bahwa setiap `call_id` ada serta unik. Pengendali menjalankan pencarian yang diizinkan, termasuk beberapa secara bersamaan, lalu mengirim satu `function_call_output` untuk setiap ID asli. Pencarian gagal menjadi hasil galat bagi panggilan itu, sedangkan hasil lain tetap ada. Pengendali menunggu `conversation.item.done` untuk setiap ID item keluaran yang ditetapkan sebelum satu `response.create`. Menunggu semua konfirmasi adalah pilihan urutan konservatif pada contoh ini, bukan kewajiban API. Batasnya delapan panggilan per respons dan tiga putaran alat dalam satu giliran tanpa interupsi; ini batas lokal.

Cari **Realtime session created**, peer terhubung, ID panggilan jika ada, konfirmasi keluaran, dan respons lanjutan yang selesai. Dengarkan jawabannya dan cocokkan jam serta alamat dengan string fiktif di `index.html`. Peristiwa respons selesai saja tidak membuktikan suara terdengar atau jawaban benar. Pengamatan ini memerlukan eksekusi browser berbayar Anda sendiri; BIG CHANGE belum melakukannya.

## 3. Ulangi giliran, interupsi, dan lepaskan mikrofon

Ajukan pertanyaan lain tanpa memulai ulang. Untuk menguji interupsi, berbicaralah saat model bicara atau pencarian masih menunggu. Dengan WebRTC dan deteksi aktivitas suara, OpenAI mendokumentasikan pembatalan dan pemotongan audio yang belum diputar oleh server. Saat menerima `input_audio_buffer.speech_started`, pengendali lokal juga membatalkan validitas pencarian tertunda agar hasil terlambat tidak melanjutkan respons terinterupsi. Kode ini tidak menerapkan kursor pemutaran dan peristiwa pemotongan terpisah yang diperlukan pemutar audio WebSocket.

Klik **Stop and release microphone** dan pastikan indikator mikrofon browser mati. Penghentian juga bekerja ketika permintaan izin tertunda: jika izin datang terlambat, trek yang diperoleh langsung dihentikan. Pembersihan yang sama menghentikan trek, membatalkan permintaan sesi berjalan, menutup kanal data dan peer, menjeda serta melepas pemutaran, membatalkan kerja alat, dan menghapus pewaktu ketika keluar halaman, waktu penyiapan atau sesi lokal habis, atau koneksi gagal. Contoh memakai 30 detik untuk penyiapan dan 55 menit untuk sesi lokal; OpenAI mendokumentasikan maksimum Realtime 60 menit.

## 4. Jalankan pemeriksaan lokal dan pulihkan kegagalan

Di direktori yang sama, jalankan:

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

Dua skrip memakai peristiwa sintetis dan simulasi browser. Keduanya memeriksa respons campuran pesan/fungsi, dua panggilan yang selesai terbalik, semua keluaran dan konfirmasi sebelum satu kelanjutan, isolasi galat per panggilan, eksekusi hanya panggilan selesai, batas putaran, interupsi ucapan, pelepasan izin terlambat, dan pembersihan Stop. Tidak ada permintaan API. Lulus tidak mengukur waktu jaringan, perangkat nyata, kualitas ucapan, atau keandalan produksi.

Jika **Start** gagal, baca status terlihat dan status HTTP hulu yang diterima server lokal. Periksa izin, `localhost:3000`, kunci lingkungan server, akses model, tagihan, batas, dan jaringan. Status 502 bisa berarti penolakan hulu, kegagalan jaringan, atau tenggat permintaan 25 detik pada contoh; bukan bukti bahwa OpenAI tidak pernah menerima permintaan. Jika peer terhubung tanpa suara, periksa perangkat masuk dan keluar lalu tekan **Play**. Respons gagal, tidak lengkap, atau dibatalkan tidak dapat menjalankan panggilan parsial. Jika konfirmasi keluaran kedaluwarsa, pengendali menutup sesi alih-alih mengirim kelanjutan ganda yang tidak pasti. Jika hasil mulai atau jaringan tidak diketahui, hentikan sesi, pastikan mikrofon dilepas, dan periksa penggunaan proyek sebelum mencoba lagi; permintaan yang dibatalkan mungkin telah menimbulkan biaya.

## Biaya dan batas

Sumber harga OpenAI: [halaman model](https://developers.openai.com/api/docs/models/gpt-realtime-2.1) dan [tabel harga](https://developers.openai.com/api/docs/pricing), diperiksa 20 Oktober 2026, mencantumkan teks masuk/keluar tanpa cache US$4/US$24 per juta token dan audio masuk/keluar US$32/US$64. Tagihan nyata bergantung pada token, audio, dan konteks percakapan berulang. Kami tidak mengukur tagihan, latensi, perilaku alat langsung, atau pengantar. Suara mikrofon dikirim ke OpenAI. Fungsi uji tetap tidak mengizinkan integrasi bisnis nyata atau tindakan tulis. Penerapan bersama memerlukan identitas pengguna, batas, otorisasi alat, pemantauan, serta peninjauan sendiri.

### Sumber dan bacaan lanjutan

- [Panduan WebRTC OpenAI](https://developers.openai.com/api/docs/guides/voice-webrtc): trek media browser, pemutaran jarak jauh, kanal data, dan pertukaran SDP lewat server tepercaya. Tidak menguji browser atau akun khusus ini.
- [Percakapan Realtime](https://developers.openai.com/api/docs/guides/realtime-conversations): keluaran penuh respons selesai, urutan hasil fungsi, interupsi, serta batas sesi 60 menit. Tidak menjanjikan model tertentu memanggil alat.
- [Alat Realtime](https://developers.openai.com/api/docs/guides/realtime-mcp): panggilan fungsi milik aplikasi dan `function_call_output`. Tidak membuktikan keberhasilan data uji lokal ini.
- [Referensi peristiwa klien](https://developers.openai.com/api/reference/resources/realtime/client-events) dan [server](https://developers.openai.com/api/reference/resources/realtime/server-events): ID item, konfirmasi, serta bentuk peristiwa respons. Menunggu semua konfirmasi adalah pilihan lokal contoh.
- [Penyusunan instruksi Realtime](https://developers.openai.com/api/docs/guides/voice-prompting): panduan pengantar lisan bersyarat, bukan jaminan keluaran terdengar.
- [Akses model](https://developers.openai.com/api/docs/models/gpt-realtime-2.1) dan [harga API](https://developers.openai.com/api/docs/pricing): tingkat akses dan tarif token terkini. Periksa penggunaan proyek untuk tagihan sebenarnya.

## Sources

- [Panduan WebRTC OpenAI](https://developers.openai.com/api/docs/guides/voice-webrtc) — Mendokumentasikan trek mikrofon browser dan audio jarak jauh, kanal data oai-events, serta pertukaran SDP server tepercaya dengan kunci API standar melalui /v1/realtime/calls. Tidak membuktikan perilaku perangkat atau jaringan nyata pada contoh.
- [Percakapan OpenAI Realtime](https://developers.openai.com/api/docs/guides/realtime-conversations) — Mendokumentasikan keluaran lengkap response.done dan kelanjutan hasil fungsi, interupsi WebRTC VAD dengan pemotongan audio belum diputar oleh server, serta maksimum 60 menit. Tidak menjamin pemakaian alat atau ketepatan ucapan pada eksekusi pembaca.
- [OpenAI Realtime dengan alat](https://developers.openai.com/api/docs/guides/realtime-mcp) — Mendokumentasikan fungsi milik aplikasi, item function_call_output dengan call_id asli, dan kelanjutan response.create. Menunggu setiap konfirmasi keluaran adalah pilihan urutan lokal tambahan.
- [Peristiwa klien OpenAI Realtime](https://developers.openai.com/api/reference/resources/realtime/client-events) — Menentukan struktur conversation.item.create, ID item, function_call_output, dan response.create yang dipakai contoh; referensi bukan uji integrasi.
- [Peristiwa server OpenAI Realtime](https://developers.openai.com/api/reference/resources/realtime/server-events) — Menentukan respons selesai, konfirmasi item percakapan final, dan struktur galat. Menunggu seluruh konfirmasi adalah pilihan lokal, bukan syarat API atau uji integrasi.
- [Deteksi aktivitas suara OpenAI](https://developers.openai.com/api/docs/guides/realtime-vad) — Mendokumentasikan speech_started dan speech_stopped untuk membatalkan validitas kerja lokal; tidak menguji interupsi nyata di sini.
- [Penyusunan instruksi OpenAI Realtime](https://developers.openai.com/api/docs/guides/voice-prompting) — Mendukung permintaan pengantar kemajuan singkat sebagai perilaku bersyarat. Tidak ada pengantar yang diamati dalam sesi berbayar.
- [Model OpenAI gpt-realtime-2.1](https://developers.openai.com/api/docs/models/gpt-realtime-2.1) — Mencantumkan ID model, tingkat Free tidak didukung, dan tarif per juta token tanpa cache: teks masuk/keluar US$4/US$24, audio masuk/keluar US$32/US$64. Akses dan tagihan proyek harus diperiksa lewat akun pembaca.
- [Harga API OpenAI](https://developers.openai.com/api/docs/pricing) — Menguatkan harga token teks dan audio gpt-realtime-2.1 yang tercantum; tidak menyediakan biaya tetap untuk sesi panduan ini.
- [Tagihan OpenAI API dan ChatGPT](https://help.openai.com/en/articles/9039756-managing-billing-for-chatgpt-and-the-api-platform) — OpenAI menyatakan API ditagih terpisah dari langganan ChatGPT. Tidak menetapkan saldo atau batas proyek pembaca saat ini.
