AI-translated from English; not yet reviewed by a fluent editor.
# Bumuo ng lokal na Realtime voice agent na may read-only tool calls
> Bumuo ng live na usapang boses sa browser gamit ang OpenAI Realtime at dalawang gawa-gawang read-only tool. Ipinapakita ng buong server, browser at controller code ang pagtugma ng call ID, ligtas na pagpapatuloy, pagputol at pagpapalaya ng mikropono.
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

AI-generated conceptual illustration by BIG CHANGE; no actual OpenAI interface, API session, device test or heard answer is depicted.
Mas nagiging masalimuot ang usapang boses sa browser kapag kailangan ng ilang lookup para sumagot. Maaaring magbalik ang modelo ng maraming function call sa isang natapos na response. Dapat ibalik ang bawat resulta sa sarili nitong `call_id`, at magsimula lamang ng bagong response matapos maipadala ang mga resulta. Nagbibigay ang gabay ng buong localhost example para sa developer: live na mikropono, tuloy-tuloy na WebRTC playback, dalawang gawa-gawang read-only tool, sunod-sunod na turn at paglilinis kapag naputol. Pinalalawak nito ang aming [panimulang gabay sa browser voice](https://bigchange.ai/blog/openai-realtime-browser-voice-webrtc-guide), na nagbibigay ng sinasalitang sagot nang walang panlabas na tool.
## Ang malaking pagbabago
- **Ano ang nagbago:** Ang [Realtime API](https://developers.openai.com/api/docs/guides/realtime-conversations) ng OpenAI ay maaaring magbalik ng function calls sa live voice session. Itinutugma ng halimbawang ito ang natapos na tawag sa lokal na resulta gamit ang `call_id` bago ipagpatuloy ang modelo.
- **Bakit mahalaga:** Maaaring subukan ng developer ang sinasalitang sagot na umaasa sa mahigit isang read-only lookup, habang nasa browser ang pagkuha at pagpapatugtog ng tunog at nasa lokal na server ang karaniwang API key.
- **Dapat bantayan:** Sinusuri ng lokal na checks ang pagkakasunod ng event at paglilinis gamit ang gawa-gawang events. Ang sarili mong bayad na session ang dapat magpatunay kung ang account, browser, device at network mo ay nagbibigay ng naririnig at tamang sagot at inaasahang singil.
Ginagamit ng halimbawa ang karaniwang Realtime WebRTC endpoint ng OpenAI na `/v1/realtime/calls` kasama ang `gpt-realtime-2.1`. Hindi nito ginagamit ang hiwalay na GPT-Live session protocol. Ang dalawang “library” value sa ibaba ay gawa-gawang software test data, hindi impormasyon tungkol sa totoong lugar. Sinuri ng BIG CHANGE ang kasalukuyang dokumentasyon ng [WebRTC](https://developers.openai.com/api/docs/guides/voice-webrtc), [pag-uusap](https://developers.openai.com/api/docs/guides/realtime-conversations), [mga tool](https://developers.openai.com/api/docs/guides/realtime-mcp) at [mga event](https://developers.openai.com/api/reference/resources/realtime/client-events) noong Oktubre 20, 2026. Nagpatakbo kami ng lokal na synthetic controller at browser checks, ngunit hindi nagbukas ng bayad na API session o sumubok ng tunay na mikropono o tunog.
## 1. Suriin ang access at gawin ang limang file
Kailangan mo ng Node.js 22.6 o mas bago, kasalukuyang browser na may mikropono at speaker, at OpenAI API project na may access sa `gpt-realtime-2.1`. Sinasabi ng [pahina ng modelo](https://developers.openai.com/api/docs/models/gpt-realtime-2.1) na hindi suportado ang Free tier. Suriin muna ang API billing at rate at spending limits ng proyekto; hindi sakop ng ChatGPT subscription ang API. Ilagay ang `OPENAI_API_KEY` sa **environment ng server process** gamit ang karaniwang lokal mong paraan ng paghawak ng sikreto. Huwag itong ilagay sa mga file, browser code o transcript. Gumamit lamang ng pananalitang may pahintulot kang ipadala sa OpenAI.
Gumawa ng bagong direktoryo at i-save ang bawat isa sa limang buong block sa filename na nakasaad sa ibabaw nito. Panatilihin ang mga extension na `.mjs` at ilagay ang lahat sa parehong direktoryo. Nakikinig ang server sa `127.0.0.1:3000`. Tinatanggap nito ang SDP offer ng lokal na pahina, ipinapasa sa OpenAI gamit ang server-side key at ibinabalik ang SDP answer. Walang karaniwang API key na natatanggap ang browser. Tumutulong ang lokal na Host at Origin checks na ikulong ang demo sa localhost; hindi nito pinatutunayan ang pagkakakilanlan ng gumagamit ng shared service.
**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.");
```
Sa direktoryong iyon, i-install ang nag-iisang dependency ng server at simulan ito:
```bash
npm init -y
npm install express
node server.mjs
```
Buksan ang `http://localhost:3000`. Dapat makita sa pahina ang **Start voice**, **Stop and release microphone**, audio player at event status. Tanging browser page at controller module ang inihahatid; sa browser tumatakbo ang dalawang fixture function at wala itong ina-access na totoong lugar, kalendaryo, CRM o business account.
## 2. Simulan, makinig at siyasatin ang resulta
Pindutin ang **Start voice**, payagan ang mikropono at hintayin ang **Connected**. Itanong ang oras at address ng gawa-gawang demo library. Ipinapadala ng browser ang tunog ng mikropono sa WebRTC media track at ikinakabit ang malayuang boses sa audio control pagdating ng track. Kung naharang ang autoplay, pindutin ang **Play** sa nakikitang control. Hinihingi ng server prompt ang maikling sinasalitang pahayag ng progreso kapag kapaki-pakinabang. Maaaring hindi ito sabihin ng modelo, at ang pagkarinig nito ay hindi patunay na nagtagumpay ang lookup.
Ipinapakita ng event panel ang response status, call IDs at output acknowledgements nang hindi inililimbag ang key, raw audio o transcript. Maaaring pumili ang modelo ng wala, isa o maraming tool. Kapag ang isang `response.done` ay may `status: completed`, sinusuri ng controller ang **lahat** ng output item para sa natapos na `function_call` items at tinitiyak na ang bawat `call_id` ay naroon at natatangi. Pinapatakbo nito ang pinapayagang lookups, pati maraming tawag nang sabay, at nagpapadala ng isang `function_call_output` para sa bawat orihinal na ID. Nagiging error result para sa tawag ang nabigong lookup, habang nananatili ang ibang resulta. Hinihintay ng controller ang `conversation.item.done` para sa bawat naitalagang output item ID bago ang iisang `response.create`. Ang paghihintay sa lahat ng acknowledgement ay maingat na lokal na pasya sa pagkakasunod, hindi ipinahayag na pangangailangan ng API. Hanggang walong tawag bawat response at tatlong tool round bawat hindi naputol na turn ang pinapayagan; lokal na limitasyon ang mga ito.
Hanapin ang **Realtime session created**, nakakonektang peer, tool call IDs kung mayroon, output acknowledgements at natapos na kasunod na response. Makinig sa sagot at ihambing ang sinabing oras at address sa gawa-gawang strings sa `index.html`. Ang completed response event lamang ay hindi patunay ng naririnig na playback o wastong sagot. Kailangan ang sarili mong may-singil na browser run para dito; hindi ito ginawa ng BIG CHANGE.
## 3. Umulit ng turn, putulin at palayain ang mikropono
Magtanong muli nang hindi nagre-restart. Para subukan ang pagputol, magsalita habang nagsasalita ang modelo o may nakabinbing lookup. Sa WebRTC na may voice activity detection, dokumentado ng OpenAI ang cancellation at server-managed na pagputol ng hindi pa napatutugtog na audio. Sa pagtanggap ng `input_audio_buffer.speech_started`, pinawawalang-bisa rin ng lokal na controller ang nakabinbing lookup upang hindi maipagpatuloy ng huling dumating na resulta ang naputol na response. Hindi ginagawa ng code ang hiwalay na playback cursor at truncation events na kailangan ng WebSocket audio player.
Pindutin ang **Stop and release microphone** at tiyaking patay na ang microphone indicator ng browser. Gumagana rin ang Stop habang hinihintay ang pahintulot: kung huli itong dumating, agad pinatitigil ang ibinalik na track. Pinatitigil din ng paglilinis ang tracks, ina-abort ang kasalukuyang session request, isinasara ang data channel at peer, pini-pause at inaalis ang playback, kinakansela ang tool work at nililinis ang timers kapag umalis sa pahina, nag-timeout ang setup o lokal na session, o nabigo ang koneksyon. Gumagamit ang sample ng 30 segundong setup timer at 55 minutong lokal na session timer; dokumentado ng OpenAI ang 60 minutong maximum ng Realtime.
## 4. Patakbuhin ang lokal na checks at ayusin ang pagkabigo
Sa parehong direktoryo, patakbuhin:
```bash
node --check server.mjs
node --check controller.mjs
node protocol-check.mjs
node browser-check.mjs
```
Gumagamit ang dalawang script ng gawa-gawang events at browser mocks. Sinusuri nila ang halong message/function response, dalawang tawag na baligtad ang pagtatapos, lahat ng output at acknowledgement bago ang iisang pagpapatuloy, paghihiwalay ng error bawat tawag, pagpapatakbo lamang ng natapos na tawag, round limits, speech interruption, late permission release at Stop cleanup. Wala silang API request. Ang pagpasa ay hindi sukatan ng network timing, totoong device, kalidad ng boses o reliability sa production.
Kung mabigo ang **Start**, basahin ang nakikitang status at upstream HTTP status ng lokal na server. Suriin ang pahintulot, `localhost:3000`, server environment key, model access, billing, limits at network. Maaaring sanhi ng 502 ang upstream rejection, network failure o 25 segundong deadline ng sample; hindi nito pinatutunayang hindi natanggap ng OpenAI ang request. Kung konektado ngunit walang tunog, tingnan ang input at output device at pindutin ang **Play**. Hindi maaaring magpatakbo ng partial calls ang failed, incomplete o cancelled response. Kapag nag-timeout ang output acknowledgement, isinasara ng controller ang session sa halip na magpadala ng hindi tiyak na dobleng continuation. Kapag hindi tiyak ang start o network outcome, itigil ang session, tiyaking malaya na ang mikropono at suriin ang project usage bago umulit; maaaring may singil na ang aborted request.
## Gastos at limitasyon
Ang [pahina ng modelo](https://developers.openai.com/api/docs/models/gpt-realtime-2.1) at [talahanayan ng presyo](https://developers.openai.com/api/docs/pricing) ng OpenAI, na sinuri noong Oktubre 20, 2026, ay naglilista ng hindi naka-cache na text input/output sa US$4/US$24 bawat milyong token at audio input/output sa US$32/US$64. Nakadepende ang aktuwal na singil sa tokens, audio at inuulit na konteksto ng pag-uusap. Wala kaming sinukat na bill, latency, live tool behavior o preamble. Ipinapadala sa OpenAI ang tunog ng mikropono. Hindi nagpapahintulot ng totoong business integration o pagsusulat ang nakapirming test functions. Kailangan ng shared deployment ang sarili nitong user identity, limits, tool authorization, monitoring at review.
### Mga sanggunian at karagdagang babasahin
- [OpenAI WebRTC na gabay](https://developers.openai.com/api/docs/guides/voice-webrtc): browser media track, remote playback, data channel at SDP exchange sa trusted server. Hindi nito sinusubok ang partikular na browser o account.
- [Realtime na pag-uusap](https://developers.openai.com/api/docs/guides/realtime-conversations): buong output ng natapos na response, sunod ng function results, interruption at 60 minutong maximum. Hindi nito ipinapangakong gagamit ng tool ang isang modelo.
- [Realtime na mga tool](https://developers.openai.com/api/docs/guides/realtime-mcp): function calls na pag-aari ng application at `function_call_output`. Hindi nito pinatutunayan ang tagumpay ng lokal na fixtures.
- [Mga event ng client](https://developers.openai.com/api/reference/resources/realtime/client-events) at [server](https://developers.openai.com/api/reference/resources/realtime/server-events): item IDs, acknowledgement at anyo ng response event. Lokal na pasya ang paghihintay sa lahat ng acknowledgement.
- [Pagsulat ng Realtime prompt](https://developers.openai.com/api/docs/guides/voice-prompting): patnubay sa kondisyonal na sinasalitang preamble; hindi garantiya ng naririnig na output.
- [Model access](https://developers.openai.com/api/docs/models/gpt-realtime-2.1) at [presyo ng API](https://developers.openai.com/api/docs/pricing): kasalukuyang access tiers at token rates. Suriin ang project usage para sa aktuwal na bill.
## Sources
- [OpenAI WebRTC na gabay](https://developers.openai.com/api/docs/guides/voice-webrtc) — Dokumentado ang browser microphone at remote audio tracks, oai-events data channel at trusted-server SDP exchange gamit ang standard API key sa /v1/realtime/calls. Hindi nito pinatutunayan ang totoong device o network behavior ng sample.
- [OpenAI Realtime na pag-uusap](https://developers.openai.com/api/docs/guides/realtime-conversations) — Dokumentado ang buong response.done output, pagpapatuloy matapos ang function result, WebRTC VAD interruption na may server truncation ng hindi pa narinig na audio, at 60 minutong maximum. Hindi garantisado ang tool use o wastong sinasalitang sagot sa reader run.
- [OpenAI Realtime na may mga tool](https://developers.openai.com/api/docs/guides/realtime-mcp) — Dokumentado ang application-owned function tools, function_call_output na may orihinal na call_id at response.create continuation. Dagdag na lokal na pasya ang paghihintay sa bawat output acknowledgement.
- [OpenAI Realtime client events](https://developers.openai.com/api/reference/resources/realtime/client-events) — Tinutukoy ang conversation.item.create, item IDs, function_call_output at response.create na gamit ng sample; hindi integration test ang reference.
- [OpenAI Realtime server events](https://developers.openai.com/api/reference/resources/realtime/server-events) — Tinutukoy ang completed response, finalized conversation-item acknowledgements at error shapes. Lokal na pasya ang all-acknowledgement wait, hindi pangangailangan ng API o integration test.
- [OpenAI pagtukoy ng aktibidad ng boses](https://developers.openai.com/api/docs/guides/realtime-vad) — Dokumentado ang speech_started at speech_stopped para pawalang-bisa ang lokal na gawain; walang totoong interruption na sinubok dito.
- [OpenAI Realtime pagsulat ng prompt](https://developers.openai.com/api/docs/guides/voice-prompting) — Sinusuportahan ang paghiling ng maikling progress preamble bilang kondisyonal na asal. Wala itong naobserbahan sa bayad na session.
- [OpenAI gpt-realtime-2.1 na modelo](https://developers.openai.com/api/docs/models/gpt-realtime-2.1) — Nakalista ang model ID, hindi suportadong Free tier at hindi naka-cache na presyo bawat milyong token: text input/output US$4/US$24, audio input/output US$32/US$64. Kailangang suriin ng mambabasa ang sariling access at singil.
- [OpenAI API na presyo](https://developers.openai.com/api/docs/pricing) — Kinukumpirma ang nakalistang text at audio token rates ng gpt-realtime-2.1; walang nakapirming presyo para sa session ng gabay.
- [OpenAI API at ChatGPT billing](https://help.openai.com/en/articles/9039756-managing-billing-for-chatgpt-and-the-api-platform) — Sinasabi ng OpenAI na hiwalay ang API billing sa ChatGPT subscription. Hindi nito pinatutunayan ang kasalukuyang balanse o limitasyon ng proyekto ng mambabasa.
Newsletter ng BIG CHANGE
Ang kabuuang larawan, sa sarili mong bilis.
Mga kamakailang kuwento tungkol sa AI at robotics, mga pagbabagong dapat bantayan, at mga praktikal na ideyang magagamit. Pumili ng araw-araw na briefing, lingguhang digest, o buwanang pananaw.
Next scheduled send (UTC): . Your first edition arrives at the next scheduled send after you confirm.
Privacy mo, pasya mo.
Tumutulong ang kinakailangang storage na panatilihing ligtas ang site at tandaan ang mga pinili mo. Nananatiling naka-off ang opsyonal na Google Analytics hangga’t hindi mo ito pinapahintulutan. Mababasa mo ang lahat ng kuwento gamit lamang ang kinakailangang storage. Mga detalye tungkol sa privacy