音声で答えるために複数の照会が必要になると、ブラウザー音声会話の処理は複雑になります。モデルは完了した1つの応答で複数の関数呼び出しを返せます。各結果は元の call_id で返し、結果をすべて送ってから次の応答を始める必要があります。本ガイドは開発者向けに、ライブマイク入力、WebRTCストリーム再生、架空の読み取り専用ツール2つ、繰り返しのターン、割り込み時の後処理を含む完全なlocalhost例を提供します。外部ツールなしで音声応答を得る 基本的なブラウザー音声ガイド を拡張する内容です。
大きな変化
- 変わったこと: OpenAIの Realtime API はライブ音声セッション内で関数呼び出しを返せます。この例は
call_idで完了済み呼び出しをローカル結果と対応付けてから、モデルに続行を求めます。 - 重要な理由: 開発者は複数の読み取り専用照会に依存する音声回答を試せます。マイク収録と再生はブラウザー、通常のAPIキーはローカルサーバーに置きます。
- 注意点: ローカル検査は模擬イベントで順序と後処理を確認します。自分のアカウント、ブラウザー、機器、ネットワークで音が聞こえ、回答が正しく、請求が想定どおりかは、自分の有料セッションで確認する必要があります。
例はOpenAI標準のRealtime WebRTCエンドポイント /v1/realtime/calls と gpt-realtime-2.1 を使います。別のGPT-Liveセッションプロトコルは使いません。下の「図書館」の2つの値は架空のソフトウェアテストデータであり、実在施設の情報ではありません。BIG CHANGEは2026年10月20日に最新の WebRTC、会話、ツール、イベント の文書を確認しました。ローカルの模擬コントローラー・ブラウザー検査は実施しましたが、有料APIセッション、実際のマイク収録や音声は試していません。
1. 利用条件を確認し、5つのファイルを作る
Node.js 22.6以降、マイクとスピーカーを備えた現在のブラウザー、そして gpt-realtime-2.1 にアクセスできるOpenAI APIプロジェクトが必要です。モデルのページ ではFree階層は非対応です。まずAPI課金、プロジェクトのレート制限と支出上限を確認してください。ChatGPT契約にAPI利用は含まれません。通常のローカル秘密情報管理の方法で OPENAI_API_KEY を サーバープロセス環境 に置きます。これらのファイル、ブラウザーコード、文字起こしには書かないでください。OpenAIへの送信が許可された発話だけを使います。
新しいディレクトリーを作り、下の5つの完全なコードを、それぞれ上にあるファイル名で保存します。.mjs 拡張子を保ち、全ファイルを同じディレクトリーに置きます。サーバーの待受先は 127.0.0.1:3000 です。ローカルページのSDP offerを受け取り、サーバー側キーでOpenAIへ転送してSDP answerを返します。ブラウザーに通常のAPIキーは渡りません。ローカルのHost・Origin検査はデモをlocalhostに限定する助けになりますが、共有サービス利用者の認証にはなりません。
server.mjs
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
<!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
// 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
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
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.");そのディレクトリーで唯一のサーバー依存パッケージを入れ、起動します。
npm init -y
npm install express
node server.mjs開く場所は http://localhost:3000 です。ページには Start voice、Stop and release microphone、音声プレーヤー、イベント状態が表示されるはずです。配信するのはブラウザーページとコントローラーモジュールだけです。2つのテスト関数はブラウザー内で動き、実際の施設、予定表、CRM、業務アカウントにはアクセスしません。
2. 開始し、聞いて結果を確かめる
クリックするのは Start voice です。マイクを許可し、Connected を待ちます。架空のデモ図書館の開館時間と住所を聞いてください。ブラウザーはWebRTCメディアトラックでマイク音声を送り、遠隔音声トラックが到着したら音声コントロールに接続します。自動再生が止められた場合、表示されたコントロールの Play を押します。サーバープロンプトは、有用な場合に短い進捗説明を先に話すよう求めます。モデルは省略することもあり、聞こえたとしても照会成功の証明にはなりません。
イベントパネルは応答状態、呼び出しID、出力確認を表示します。キー、生音声、文字起こしは表示しません。モデルはツールを使わない場合も、1つまたは複数使う場合もあります。response.done が status: completed のとき、コントローラーは すべて の出力項目から完了済みの function_call 項目を探し、それぞれの call_id が存在して一意か確かめます。許可リスト内の照会を複数同時にも実行し、元のIDごとに1つの function_call_output を送ります。照会失敗はその呼び出しのエラー結果になり、ほかの結果は残ります。コントローラーは割り当てた各出力項目IDについて conversation.item.done を待ってから、1度だけ response.create を送ります。全確認を待つのは、この例の保守的な順序設計であり、APIの規定ではありません。1応答に最大8呼び出し、割り込まれない1ターンに最大3回のツールラウンドというローカル制限があります。
確認するのは 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. ローカル検査を実行し、失敗を処理する
同じディレクトリーで実行します。
node --check server.mjs
node --check controller.mjs
node protocol-check.mjs
node browser-check.mjs2つのスクリプトは模擬イベントとブラウザーモックを使います。メッセージと関数が混じる応答、逆順に終わる2呼び出し、唯一の続行より前の全出力と確認、呼び出しごとのエラー分離、完了済みだけの実行、ラウンド上限、音声割り込み、遅れた許可後の解放、Stopの後処理を確かめます。API要求は行いません。合格してもネットワークのタイミング、実機挙動、音声品質、本番信頼性は測れません。
もし Start が失敗したら、画面の状態とローカルサーバーが受けた上流HTTP状態を読みます。権限、localhost:3000、サーバー環境キー、モデルアクセス、課金、制限、ネットワークを確認します。502は上流の拒否、ネットワーク障害、例の25秒の要求期限を示し得ますが、OpenAIが受信しなかった証明ではありません。接続しても音がない場合、入出力機器を確認して Play を押します。失敗、未完了、キャンセル済みの応答から一部の呼び出しは実行しません。出力確認が時間切れになると、不確かな二重続行を送らずセッションを閉じます。開始やネットワーク結果が不明なら、セッションを止め、マイク解放を確かめ、再試行前にプロジェクト使用量を確認します。中止した要求でも費用が発生している可能性があります。
費用と制限
OpenAIの モデルページと価格表 を2026年10月20日に確認しました。非キャッシュのテキスト入力/出力は100万トークン当たり4/24米ドル、音声入力/出力は32/64米ドルです。実際の料金はトークン、音声、繰り返す会話コンテキストによります。請求額、遅延、ライブのツール挙動、進捗の前置きは測定していません。マイク音声はOpenAIへ送られます。固定の架空関数は実業務統合や書き込みを許可するものではありません。共有展開には独自の利用者識別、制限、ツール認可、監視、審査が必要です。
出典と参考資料
- OpenAI WebRTCガイド:ブラウザーメディアトラック、遠隔再生、データチャネル、信頼するサーバー経由のSDP交換。このブラウザーやアカウントの試験ではありません。
- Realtimeの会話:完了応答の全出力、関数結果の順序、割り込み、60分の上限。特定モデルのツール呼び出しは保証しません。
- Realtimeのツール:アプリケーション側の関数呼び出しと
function_call_output。このローカルテストデータの成功は証明しません。 - クライアントとサーバーのイベントリファレンス:項目ID、確認、応答イベントの構造。全確認待ちは例のローカルな選択です。
- Realtimeのプロンプト作成:条件に応じた短い音声の前置きへの指針であり、聞こえる出力の保証ではありません。
- モデルアクセスとAPI価格:現在のアクセス階層とトークン単価。実際の請求は自分のプロジェクト使用量で確認してください。



