Markdown 版本
AI-translated from English; not yet reviewed by a fluent editor.
# 用 OpenAI Realtime 建立本機瀏覽器語音對話
> 透過 WebRTC 用 OpenAI Realtime 建立本機瀏覽器麥克風對話。完整伺服器及頁面程式碼將 API 金鑰保留在伺服器,並示範檢查播放、停止麥克風及處理故障。
By BIG CHANGE Editorial
Published: 2026-10-19T19:20:34.863Z
Updated: 2026-10-19T19:20:34.863Z
Canonical: https://bigchange.ai/blog/openai-realtime-browser-voice-webrtc-guide

AI-generated conceptual illustration by BIG CHANGE; no actual OpenAI interface, browser test or heard reply is depicted.
OpenAI Realtime API 讓瀏覽器透過 WebRTC 傳送麥克風音訊並播放語音回答。本指南用 `gpt-realtime-2.1` 建立一段本機對話。API 金鑰保留在電腦上的 Node 伺服器。你需要有模型存取權且啟用計費的 OpenAI API 專案;ChatGPT 訂閱不支付 API 用量。BIG CHANGE 檢查了目前文件及範例程式碼,但未開啟付費工作階段或聽到回答。
## 關鍵變化
- **改變了什麼:** OpenAI 文件介紹了瀏覽器 WebRTC 即時對話路徑:本機伺服器保管專案金鑰,將瀏覽器連線提議換成應答。
- **為何重要:** 開發者可先在本機頁面檢查從麥克風到語音回答的完整路徑,再設計更大的語音產品。專案負責人控制金鑰,並支付 API 用量。
- **應注意什麼:** 本機通訊成功不代表已適合正式環境。共用版本需要呼叫者身分驗證及支出控制;取消或逾時的要求可能已送達 OpenAI。
## 開始之前
安裝 Node.js 22.6 或更新版本,使用有正常麥克風及喇叭的目前瀏覽器。瀏覽器麥克風存取需要安全環境;`http://localhost` 適用於此本機範例。準備具備 `gpt-realtime-2.1` 存取權且啟用計費的 OpenAI API 專案金鑰。先檢查專案速率與支出限制。模型頁面列明不支援 Free 層級。透過常用祕密管理工具或本機 shell 設定,將金鑰留在伺服器程序環境中。不要放進 `index.html`、提交至 `.env` 檔案,或貼入對話紀錄。[OpenAI WebRTC 指南](https://developers.openai.com/api/docs/guides/voice-webrtc)介紹了可信任伺服器交換;[模型頁面](https://developers.openai.com/api/docs/models/gpt-realtime-2.1)列出目前存取條件與價格。
此處使用 Realtime API 的統一 `/v1/realtime/calls` 路由。WebRTC 指南也介紹了透過 `/v1/live/sessions` 使用 GPT-Live,但它採用不同的工作階段協定。以下路由與酬載須配套使用。範例繫結自己的電腦;本機 Host 和 Origin 檢查無法驗證共用服務使用者身分。
1. 為示範建立空目錄,並在該目錄安裝 Express:
```bash
npm init -y
npm install express
```
安裝會建立 `package.json` 並加入網頁伺服器相依項。將接下來兩個檔案存於同一目錄。程式碼使用 Node 內建的 `fetch`、`FormData` 和 `AbortSignal.timeout`;檢查過的設定要求 Node.js 22.6 或更新版本。
2. 將以下內容儲存為 `server.mjs`。它接收瀏覽器的 SDP 提議(擬議 WebRTC 連線的描述),再與 Realtime 工作階段設定一起傳給 OpenAI。標準 API 金鑰只在此伺服器讀取。
```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.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: "Be a concise, helpful voice assistant. Do not claim access to external tools."
}));
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"));
```
伺服器繫結至 `127.0.0.1:3000`,只接受本機頁面的 host 與 POST origin,再將 SDP 應答傳回瀏覽器。25 秒上游要求上限是本範例的選擇。逾時或瀏覽器中斷不代表 OpenAI 未接受要求,因此啟動結果不明時,重試前檢查用量。
3. 將以下內容儲存為 `index.html`。頁面在按下 **Start voice** 後要求麥克風權限、向本機伺服器傳送提議,並播放傳入音訊。**Stop** 會關閉連線並停止麥克風軌道,包括仍在建立連線時。30 秒建立計時器也是範例選擇,並非 API 保證。
```html
<!doctype html>
<html lang="en">
<meta charset="utf-8">
<title>Local Realtime voice check</title>
<button id="start">Start voice</button>
<button id="stop" disabled>Stop</button>
<p id="status" role="status">Idle</p>
<audio id="reply" autoplay controls></audio>
<script type="module">
const start = document.querySelector("#start");
const stop = document.querySelector("#stop");
const status = document.querySelector("#status");
const reply = document.querySelector("#reply");
let current;
function closeSession(session = current, message = "Stopped") {
if (!session) return;
session.closed = true;
clearTimeout(session.timer);
session.abort.abort();
session.mic?.getTracks().forEach(track => track.stop());
if (session.pc) session.pc.onconnectionstatechange = null;
session.dc?.close();
session.pc?.close();
if (current === session) {
current = null;
reply.srcObject = null;
start.disabled = false;
stop.disabled = true;
status.textContent = message;
}
}
start.onclick = async () => {
const session = { abort: new AbortController(), closed: false };
current = session;
start.disabled = true;
stop.disabled = false;
status.textContent = "Requesting microphone";
session.timer = 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) reply.srcObject = event.streams[0]; };
pc.onconnectionstatechange = () => {
if (session.closed) return;
status.textContent = `Connection: ${pc.connectionState}`;
if (pc.connectionState === "connected") clearTimeout(session.timer);
if (["failed", "closed", "disconnected"].includes(pc.connectionState)) {
closeSession(session, "Connection ended; microphone stopped. Start a new session.");
}
};
pc.addTrack(mic.getAudioTracks()[0], mic);
const dc = session.dc = pc.createDataChannel("oai-events");
dc.onmessage = message => {
if (session.closed) return;
try {
const event = JSON.parse(message.data);
if (event.type === "session.created") status.textContent = "Session ready: speak when connected";
if (event.type === "response.done") {
status.textContent = `Response status: ${event.response?.status || "unknown"}; verify audible playback`;
}
if (event.type === "error") closeSession(session, `Realtime error: ${event.error?.message || "unknown"}; stopped`);
} catch { closeSession(session, "Invalid event; stopped"); }
};
dc.onclose = () => { if (!session.closed) closeSession(session, "Event channel closed; stopped"); };
dc.onerror = () => closeSession(session, "Event channel failed; stopped");
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
if (session.closed) return;
// The official WebRTC example submits the generated offer directly.
const response = await fetch("/session", {
method: "POST",
headers: { "Content-Type": "application/sdp" },
body: offer.sdp,
signal: session.abort.signal
});
if (!response.ok) throw new Error(await response.text());
const answer = await response.text();
if (session.closed) return;
await pc.setRemoteDescription({ type: "answer", sdp: answer });
if (!session.closed && pc.connectionState !== "connected") 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>
```
對等連線或事件通道故障,以及離開頁面時,瀏覽器也會關閉工作階段。麥克風權限可能在停止或逾時後才獲准;延遲出現的軌道會被停止。狀態列顯示 `response.done` 及其回報狀態。該事件本身不表示回答可聽見,也不表示取消或失敗的回應成功。
4. 從包含兩個檔案的目錄啟動伺服器,並先在該伺服器程序環境設定 `OPENAI_API_KEY`:
```bash
node server.mjs
```
開啟 `http://localhost:3000`。應看到 **Start voice**、**Stop**、**Idle** 狀態及音訊播放器。按下 **Start voice**,允許麥克風存取,再說一個簡短且無敏感內容的問題。OpenAI [Realtime 對話指南](https://developers.openai.com/api/docs/guides/realtime-conversations)說明預設語音活動偵測可將說話後的停頓轉成回應。要確認執行成功,檢查瀏覽器顯示麥克風使用中、連線達到 `connected`、出現 `session.created` 事件、聽得到語音,而且 `response.done` 回報回應完成。按下 **Stop**,確認瀏覽器麥克風指示燈熄滅。這些檢查須由你執行;BIG CHANGE 未執行此付費瀏覽器工作階段。
## 檢查失敗時
若麥克風權限被拒或未顯示輸入裝置,檢查瀏覽器權限與裝置,再使用本機 `localhost` URL 或 HTTPS。若頁面回報 `/session` 故障,查看伺服器端 HTTP 狀態,確認金鑰存在、專案模型權限、計費、速率限制及端點。伺服器刻意不印出金鑰或 OpenAI 回應本文。502 也可能是網路故障或範例的 25 秒期限,無法判斷結果不明的上游要求是否已獲接受。
若信令成功但連線失敗,檢查頁面連線狀態、網路限制及傳回的 SDP 應答。連線正常卻無聲時,檢查麥克風輸入及所選輸出裝置。瀏覽器可能封鎖自動播放:在音訊控制項按 **Play**,必要時停止並啟動新工作階段。任何故障都應使用 **Stop** 並確認麥克風指示燈熄滅。要求結果不明時,反覆啟動前先檢查專案用量。
## 費用、隱私與限制
OpenAI 列出的 `gpt-realtime-2.1` 非快取文字輸入/輸出價格為**每百萬 tokens $4/$24**,音訊輸入/輸出為**每百萬 tokens $32/$64**,查核於 2026 年 10 月 19 日。費用取決於使用的 tokens,包括可能攜帶先前對話脈絡的後續輪次。使用前檢查[模型價格與限制](https://developers.openai.com/api/docs/models/gpt-realtime-2.1)及專案實際用量;本指南無法只依經過時間估算帳單。[Realtime 對話指南](https://developers.openai.com/api/docs/guides/realtime-conversations)規定工作階段最長為 60 分鐘。
工作階段期間,麥克風音訊會傳給 OpenAI。僅使用有權傳送的語音,避免在測試中使用私人或第三方音訊。本機頁面沒有登入或個別使用者預算控制。共用服務需要已驗證身分、限速的 `/session` 存取及由伺服器產生的安全識別碼;本機 Host 和 Origin 檢查並不足夠。範例不呼叫外部工具或撥打電話。未測量延遲、可靠性、語音品質、節省或正式部署結果。
## 來源與延伸閱讀
- [OpenAI WebRTC 指南](https://developers.openai.com/api/docs/guides/voice-webrtc):統一 Realtime 伺服器交換、瀏覽器音訊路徑及獨立 GPT-Live 協定。
- [OpenAI Realtime 對話指南](https://developers.openai.com/api/docs/guides/realtime-conversations):工作階段事件、輪次行為、回應狀態與時長限制。
- [OpenAI `gpt-realtime-2.1` 模型頁面](https://developers.openai.com/api/docs/models/gpt-realtime-2.1):模型存取權、token 價格與速率限制。執行範例時應重新查核價格及存取條件。
## Sources
- [OpenAI WebRTC 指南](https://developers.openai.com/api/docs/guides/voice-webrtc) — Realtime 統一 calls 介面、可信任金鑰、SDP、瀏覽器音訊及獨立 GPT-Live 協定。
- [OpenAI Realtime 對話](https://developers.openai.com/api/docs/guides/realtime-conversations) — 工作階段事件、VAD、回應狀態及 60 分鐘時長。
- [OpenAI gpt-realtime-2.1 模型](https://developers.openai.com/api/docs/models/gpt-realtime-2.1) — 模型特定 token 價格與存取條件。
- [OpenAI 語音成本最佳化](https://developers.openai.com/api/docs/guides/voice-latency-cost) — 脈絡及每個回應的用量。
- [OpenAI API 與 ChatGPT 計費](https://help.openai.com/en/articles/9039756-managing-billing-for-chatgpt-and-the-api-platform) — 各自獨立的計費安排。