세상은 멈춰 있지 않습니다.RSS
BIG CHANGE.

Markdown 버전

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

# 읽기 전용 도구를 호출하는 로컬 Realtime 음성 에이전트 만들기

> OpenAI Realtime과 가상의 읽기 전용 도구 두 개로 브라우저 실시간 음성 대화를 만듭니다. 서버·브라우저·컨트롤러의 전체 코드로 호출 ID 연결, 안전한 이어가기, 끼어들기, 마이크 해제를 설명합니다.

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.

말로 답하기 위해 여러 조회가 필요하면 브라우저 음성 대화는 더 복잡해집니다. 모델은 완료된 응답 하나에 여러 함수 호출을 반환할 수 있습니다. 각 결과는 원래의 `call_id` 로 보내고, 모든 결과를 보낸 뒤에 새 응답을 시작해야 합니다. 이 가이드는 개발자에게 실시간 마이크 입력, WebRTC 스트리밍 재생, 가상의 읽기 전용 도구 두 개, 반복 대화, 중단 정리를 포함한 완전한 localhost 예제를 제공합니다. 외부 도구 없이 음성 답변을 만드는 [기본 브라우저 음성 가이드](https://bigchange.ai/blog/openai-realtime-browser-voice-webrtc-guide)를 확장합니다.

## 핵심 변화

- **달라진 점:** OpenAI의 [Realtime API](https://developers.openai.com/api/docs/guides/realtime-conversations) 는 실시간 음성 세션에서 함수 호출을 반환할 수 있습니다. 이 예제는 `call_id` 로 완료된 호출과 로컬 결과를 연결한 후 모델에 이어가기를 요청합니다.
- **중요한 이유:** 개발자는 여러 읽기 전용 조회가 필요한 음성 답변을 시험할 수 있습니다. 마이크 수집과 재생은 브라우저에, 표준 API 키는 로컬 서버에 둡니다.
- **살펴볼 점:** 로컬 검사는 모의 이벤트로 이벤트 순서와 정리를 확인합니다. 실제 계정·브라우저·기기·네트워크에서 소리가 들리고 답이 정확하며 청구가 예상과 같은지는 본인의 유료 세션으로 확인해야 합니다.

예제는 OpenAI의 표준 Realtime WebRTC 엔드포인트 `/v1/realtime/calls` 와 `gpt-realtime-2.1` 을 사용합니다. 별도의 GPT-Live 세션 프로토콜은 사용하지 않습니다. 아래 두 “도서관” 값은 가상의 소프트웨어 테스트 데이터이며 실제 장소 정보가 아닙니다. BIG CHANGE는 2026년 10월 20일 현재 [WebRTC](https://developers.openai.com/api/docs/guides/voice-webrtc), [대화](https://developers.openai.com/api/docs/guides/realtime-conversations), [도구](https://developers.openai.com/api/docs/guides/realtime-mcp), [이벤트](https://developers.openai.com/api/reference/resources/realtime/client-events) 문서를 확인했습니다. 로컬 모의 컨트롤러와 브라우저 검사는 실행했지만 유료 API 세션을 열거나 실제 마이크 수집·소리를 시험하지 않았습니다.

## 1. 접근 조건을 확인하고 파일 다섯 개 만들기

Node.js 22.6 이상, 마이크와 스피커가 있는 최신 브라우저, 그리고 `gpt-realtime-2.1` 에 접근 가능한 OpenAI API 프로젝트가 필요합니다. [모델 페이지](https://developers.openai.com/api/docs/models/gpt-realtime-2.1) 에는 Free 계층이 지원되지 않는다고 나옵니다. 먼저 API 청구와 프로젝트의 요청 속도·지출 한도를 확인하세요. ChatGPT 구독은 API 사용료를 포함하지 않습니다. 평소의 로컬 비밀 관리 방식으로 `OPENAI_API_KEY` 를 **서버 프로세스 환경** 에 넣으세요. 이 파일, 브라우저 코드, 대화 기록에는 적지 마세요. OpenAI로 전송하도록 허가받은 발화만 사용하세요.

새 디렉터리를 만들고 아래 다섯 전체 코드 블록을 각각 위에 적힌 파일명으로 저장하세요. `.mjs` 확장자를 유지하고 모든 파일을 같은 디렉터리에 두세요. 서버는 `127.0.0.1:3000` 에서 수신합니다. 로컬 페이지의 SDP offer를 받아 서버 측 키로 OpenAI에 전달하고 SDP answer를 반환합니다. 브라우저는 표준 API 키를 받지 않습니다. 로컬 Host·Origin 검사는 시연을 localhost에 제한하는 데 도움이 되지만 공유 서비스 사용자를 인증하지는 않습니다.

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

그 디렉터리에서 유일한 서버 의존성을 설치하고 서버를 시작하세요.

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

열 주소는 `http://localhost:3000` 입니다. 페이지에는 **Start voice**, **Stop and release microphone**, 오디오 플레이어, 이벤트 상태가 보여야 합니다. 브라우저 페이지와 컨트롤러 모듈만 제공하며 두 테스트 함수는 브라우저 안에서 실행됩니다. 실제 장소·달력·CRM·업무 계정에는 접근하지 않습니다.

## 2. 시작하고 듣고 결과 확인하기

누를 버튼은 **Start voice** 입니다. 마이크 접근을 허용하고 **Connected** 를 기다리세요. 가상 시연 도서관의 운영 시간과 주소를 물으세요. 브라우저는 WebRTC 미디어 트랙으로 마이크 소리를 보내고 원격 음성 트랙이 오면 곧바로 오디오 컨트롤에 연결합니다. 자동 재생이 차단되면 보이는 컨트롤의 **Play** 를 누르세요. 서버 지시는 유용할 때 짧게 진행 상황을 먼저 말하도록 요청합니다. 모델은 이를 생략할 수 있으며, 이 말을 들었다고 조회 성공이 입증되지는 않습니다.

이벤트 패널은 키·원시 오디오·대화 기록을 출력하지 않고 응답 상태, 호출 ID, 출력 확인을 보여줍니다. 모델은 도구를 사용하지 않거나 하나 또는 여러 개 선택할 수 있습니다. `response.done` 에 `status: completed` 가 있으면 컨트롤러는 **모든** 출력 항목에서 완료된 `function_call` 항목을 찾고 각각의 `call_id` 가 존재하며 고유한지 확인합니다. 허용된 조회를 여러 개 동시에도 실행한 다음 원래 ID마다 하나의 `function_call_output` 을 보냅니다. 실패한 조회는 해당 호출의 오류 결과가 되고 다른 결과는 보존됩니다. 컨트롤러는 지정한 각 출력 항목 ID에 대해 `conversation.item.done` 를 기다린 다음 단 한 번의 `response.create` 를 보냅니다. 모든 확인을 기다리는 것은 이 예제의 보수적인 순서 설계이지 API 요구 사항이 아닙니다. 응답당 최대 여덟 호출, 중단 없는 한 턴당 최대 세 도구 라운드라는 로컬 한도가 있습니다.

확인할 항목은 **Realtime session created**, 연결된 피어, 도구를 사용했다면 호출 ID, 출력 확인, 완료된 후속 응답입니다. 음성 답을 듣고 운영 시간·주소를 `index.html` 의 가상 문자열과 대조하세요. 응답 완료 이벤트만으로 실제 재생이나 답의 정확성을 입증할 수 없습니다. 이는 본인의 과금 대상 브라우저 실행이 필요하며 BIG CHANGE는 수행하지 않았습니다.

## 3. 다음 턴, 끼어들기, 마이크 해제 시험하기

다시 시작하지 말고 다른 질문을 하세요. 끼어들기를 시험하려면 모델이 말하거나 조회가 대기 중일 때 말하세요. WebRTC와 음성 활동 감지에 대해 OpenAI는 취소와 서버가 관리하는 미재생 오디오 잘라내기를 문서화합니다. `input_audio_buffer.speech_started` 가 발생하면 로컬 컨트롤러도 대기 조회를 무효화해 늦은 결과가 중단된 응답을 이어가지 못하게 합니다. 이 코드는 WebSocket 오디오 플레이어가 필요로 하는 별도의 재생 커서와 잘라내기 이벤트를 구현하지 않습니다.

누를 버튼은 **Stop and release microphone** 입니다. 브라우저의 마이크 표시가 꺼지는지 확인하세요. 권한 요청 대기 중에도 정지합니다. 허용이 늦게 오면 반환된 트랙을 즉시 정지합니다. 같은 정리는 트랙 정지, 진행 중인 세션 요청 중단, 데이터 채널·피어 닫기, 재생 일시 정지·분리, 도구 작업 취소를 수행하고 페이지 종료·준비 시간 초과·로컬 세션 시간 초과·연결 실패 시 타이머를 제거합니다. 준비 타이머는 30초, 로컬 세션은 55분이며 OpenAI 문서의 Realtime 최대치는 60분입니다.

## 4. 로컬 검사를 실행하고 실패 복구하기

같은 디렉터리에서 실행하세요.

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

두 스크립트는 모의 이벤트와 브라우저 모형을 사용합니다. 메시지/함수 혼합 응답, 역순으로 완료되는 두 호출, 단 한 번의 이어가기 전 모든 출력·확인, 호출별 오류 격리, 완료 호출만 실행, 라운드 상한, 발화 중단, 늦은 권한 해제, Stop 정리를 검사합니다. API 요청을 보내지 않습니다. 통과해도 네트워크 타이밍, 실제 기기, 음성 품질, 운영 신뢰성을 측정하지는 않습니다.

만약 **Start** 가 실패하면 표시된 상태와 로컬 서버가 받은 상위 HTTP 상태를 읽으세요. 권한, `localhost:3000`, 서버 환경 키, 모델 접근, 청구, 한도, 네트워크를 확인하세요. 502는 상위 거부·네트워크 실패·예제의 25초 요청 기한 만료일 수 있습니다. OpenAI가 요청을 받지 않았다는 증거는 아닙니다. 피어 연결 후 소리가 없으면 입력·출력 기기를 확인하고 **Play** 를 누르세요. 실패·미완료·취소 응답은 부분 호출을 실행하지 못합니다. 출력 확인 대기가 만료되면 컨트롤러는 불확실한 중복 이어가기를 보내지 않고 세션을 닫습니다. 시작·네트워크 결과가 불명확하면 세션을 정지하고 마이크 해제를 확인한 뒤 프로젝트 사용량을 보고 재시도하세요. 중단한 요청도 이미 비용을 발생시켰을 수 있습니다.

## 비용과 한도

OpenAI의 [모델 페이지](https://developers.openai.com/api/docs/models/gpt-realtime-2.1) 및 [가격표](https://developers.openai.com/api/docs/pricing) 를 2026년 10월 20일 확인했습니다. 비캐시 텍스트 입력/출력은 100만 토큰당 4/24달러, 오디오 입력/출력은 32/64달러입니다. 실제 요금은 토큰, 오디오, 반복 대화 컨텍스트에 따라 달라집니다. 청구액·지연·실시간 도구 동작·진행 안내를 측정하지 않았습니다. 마이크 소리는 OpenAI로 전송됩니다. 고정 모의 함수는 실제 업무 연동이나 쓰기 작업을 허가하지 않습니다. 공유 배포에는 별도의 사용자 신원, 한도, 도구 인가, 모니터링, 검토가 필요합니다.

### 출처와 추가 자료

- [OpenAI WebRTC 가이드](https://developers.openai.com/api/docs/guides/voice-webrtc): 브라우저 미디어 트랙, 원격 재생, 데이터 채널, 신뢰 서버의 SDP 교환. 특정 브라우저·계정을 시험한 문서는 아닙니다.
- [Realtime 대화](https://developers.openai.com/api/docs/guides/realtime-conversations): 완료 응답의 전체 출력, 함수 결과 순서, 중단, 60분 세션 최대치. 특정 모델의 도구 호출을 약속하지 않습니다.
- [Realtime 도구](https://developers.openai.com/api/docs/guides/realtime-mcp): 애플리케이션이 담당하는 함수 호출 및 `function_call_output`. 이 로컬 테스트 데이터의 성공을 입증하지 않습니다.
- [클라이언트](https://developers.openai.com/api/reference/resources/realtime/client-events) 및 [서버 이벤트](https://developers.openai.com/api/reference/resources/realtime/server-events) 참고 문서: 항목 ID, 확인, 응답 이벤트 구조. 전체 확인 대기는 예제의 로컬 선택입니다.
- [Realtime 프롬프트 작성](https://developers.openai.com/api/docs/guides/voice-prompting): 조건부 짧은 음성 진행 안내 지침이며 들리는 출력의 보장은 아닙니다.
- [모델 접근](https://developers.openai.com/api/docs/models/gpt-realtime-2.1) 및 [API 가격](https://developers.openai.com/api/docs/pricing): 현재 접근 계층과 토큰 요금. 실제 청구는 프로젝트 사용량으로 확인하세요.

## Sources

- [OpenAI WebRTC 가이드](https://developers.openai.com/api/docs/guides/voice-webrtc) — 브라우저 마이크·원격 오디오 트랙, oai-events 데이터 채널, 표준 API 키로 /v1/realtime/calls 를 사용하는 신뢰 서버 SDP 교환을 설명합니다. 예제의 실제 기기·네트워크 동작을 입증하지 않습니다.
- [OpenAI Realtime 대화](https://developers.openai.com/api/docs/guides/realtime-conversations) — response.done 전체 출력과 함수 결과 후 이어가기, 서버의 미재생 오디오 잘라내기를 동반한 WebRTC VAD 중단, 60분 최대치를 설명합니다. 독자 실행의 도구 사용·음성 정확성을 보장하지 않습니다.
- [도구를 사용하는 OpenAI Realtime](https://developers.openai.com/api/docs/guides/realtime-mcp) — 애플리케이션 함수 도구, 원래 call_id 로 연결된 function_call_output 항목, response.create 이어가기를 설명합니다. 각 출력 확인 대기는 추가된 로컬 순서 설계입니다.
- [OpenAI Realtime 클라이언트 이벤트](https://developers.openai.com/api/reference/resources/realtime/client-events) — 예제의 conversation.item.create, 항목 ID, function_call_output, response.create 구조를 정의합니다. 참고 문서는 통합 테스트가 아닙니다.
- [OpenAI Realtime 서버 이벤트](https://developers.openai.com/api/reference/resources/realtime/server-events) — 예제의 완료 응답, 확정 대화 항목 확인, 오류 이벤트 구조를 정의합니다. 전체 확인 대기는 로컬 선택이며 API 요구나 통합 테스트가 아닙니다.
- [OpenAI 음성 활동 감지](https://developers.openai.com/api/docs/guides/realtime-vad) — 로컬 작업 무효화에 쓰는 speech_started, speech_stopped 를 설명합니다. 여기서 실제 중단은 시험하지 않습니다.
- [OpenAI Realtime 프롬프트 작성](https://developers.openai.com/api/docs/guides/voice-prompting) — 짧은 진행 안내를 조건부로 요청하는 방식을 지원합니다. 유료 세션에서 안내를 관찰하지 않았습니다.
- [OpenAI gpt-realtime-2.1 모델](https://developers.openai.com/api/docs/models/gpt-realtime-2.1) — 모델 ID, Free 계층 미지원, 비캐시 100만 토큰당 요금을 제시합니다. 텍스트 입력/출력 4/24달러, 오디오 입력/출력 32/64달러입니다. 접근과 청구는 독자가 계정에서 확인해야 합니다.
- [OpenAI API 가격](https://developers.openai.com/api/docs/pricing) — 공개된 gpt-realtime-2.1 텍스트·오디오 토큰 요금을 뒷받침하지만 이 가이드의 세션 고정 비용을 제공하지는 않습니다.
- [OpenAI API와 ChatGPT 청구](https://help.openai.com/en/articles/9039756-managing-billing-for-chatgpt-and-the-api-platform) — OpenAI는 API 사용량을 ChatGPT 구독과 별도로 청구한다고 설명합니다. 독자의 현재 잔액·프로젝트 한도를 확정하지는 않습니다.