
# WebRTC 接入（实验）

*实验性的浏览器 Opus 接入，含 ICE/TURN 与控制数据通道；默认生产路径为 WSS + PCM*

> ⚠️ **状态：实验**：WebRTC（实验）尚不是默认生产接入方式。优先使用 [WebSocket](/docs/websocket) 的 WSS + 16 kHz 单声道 PCM 链路。

WebRTC（实验）面向浏览器：浏览器可使用回声消除、降噪、抖动缓冲和拥塞控制，音频走 Opus（48 kHz，服务端统一转成 16 kHz PCM 进房间）。信令是一次性的（非 trickle）：客户端把 SDP offer POST 给 gateway，拿回 answer，无需额外的 WebSocket 信令通道。

> ℹ️ **本部署的网关地址**：`https://hearo-gw.apps.aisp24.com`（下方示例已代入，可直接复制使用）。

## 1. 获取 ICE 服务器

`GET https://hearo-gw.apps.aisp24.com/v1/ice`

返回 `{ "iceServers": [...] }`（标准 RTCIceServer 数组）：STUN 加上（若平台已配置）TURN 中继凭据。配了 TURN 时 gateway 自己会强制 relay 策略以穿透对称型 NAT。

```javascript
const { iceServers } = await fetch("https://hearo-gw.apps.aisp24.com/v1/ice")
  .then((r) => r.json());
const pc = new RTCPeerConnection({ iceServers });
```

> ℹ️ **自部署调优**：STUN 服务器用 `HEARO_STUN_URL` 配置（默认 Google STUN；国内网络访问不到它只会拖慢 ICE 收集，建议换成可达的 STUN 或设 `off` 关闭）；TURN 用 `HEARO_TURN_URL`/`HEARO_TURN_USERNAME`/`HEARO_TURN_CREDENTIAL`，或 Cloudflare Realtime（`HEARO_CF_TURN_KEY_ID`/`HEARO_CF_TURN_API_TOKEN`，由 gateway 代签短期凭据）。

## 2. 建立 PeerConnection 并交换 SDP

`POST https://hearo-gw.apps.aisp24.com/v1/webrtc/offer?name=<昵称>&lang=<语言>`

| 项 | 说明 |
|---|---|
| Authorization | 请求头，必填：`Bearer <CLIENT_TOKEN>` |
| name / lang | 查询参数，可选。房间昵称 / 你说的语言（供字幕） |
| 请求体 | JSON：`{"type":"offer","sdp":"<你的 offer SDP>"}` |
| 返回 | JSON：`{"type":"answer","sdp":"<answer SDP>"}`，ICE candidate 已内联（非 trickle） |

offer 里需要包含**一条音频轨（sendrecv）**和**一个名为 `control` 的数据通道**（名字必须是 `control`，其他名字会被忽略）。

```javascript
const stream = await navigator.mediaDevices.getUserMedia({
  audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true },
});
stream.getTracks().forEach((t) => pc.addTrack(t, stream));

// 控制通道：与 WebSocket 文本控制平面等价（speech_start / interrupt / roster ...）
const dc = pc.createDataChannel("control");
dc.onmessage = (e) => handleEvent(JSON.parse(e.data));

// 播放下行音频
pc.ontrack = (e) => { audioEl.srcObject = e.streams[0]; };

const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
// 非 trickle：给 ICE 收集留一小段时间（无需等到 complete——
// STUN 不可达时 complete 永远不来；host candidate 是即时的）
await new Promise((r) => {
  pc.onicegatheringstatechange = () => pc.iceGatheringState === "complete" && r();
  setTimeout(r, 2000);
});

const offerUrl = new URL("https://hearo-gw.apps.aisp24.com/v1/webrtc/offer");
offerUrl.searchParams.set("name", "浏览器访客");
offerUrl.searchParams.set("lang", "zh-CN");

const resp = await fetch(offerUrl, {
  method: "POST",
  headers: {
    Authorization: `Bearer ${token}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({ type: "offer", sdp: pc.localDescription.sdp }),
});
const answer = await resp.json();
await pc.setRemoteDescription({ type: "answer", sdp: answer.sdp });
```

服务端同样以“有界等待”收集自己的 ICE candidate 后返回 answer。

> ⚠️ WebRTC（实验）的客户端 Token **只能**放在 `Authorization: Bearer` 请求头中。任何 `token` query 参数都会被拒绝，即使请求同时携带了有效的 Bearer 头。

## 3. 控制与事件：走数据通道

WebRTC（实验）路径下，`control` 数据通道镜像了 WebSocket 的文本控制平面：同样的 `speech_start`/`interrupt`/`config`/`caption_start` 控制消息和 `roster`/`transcript`/`turn_*`/`caption` 事件（完整列表见 [WebSocket](/docs/websocket) 第 3、4 节）。音频走媒体轨（Opus），无需手动分帧，也没有 `audio` 头加二进制帧的配对。

进房时房间广播的初始 `roster` 快照会由 gateway 缓存，待数据通道打开后立即补发——所以通道一打开你就能拿到当前的完整成员列表。

> ℹ️ **WebRTC（实验）与 WebSocket 的对应关系**：把「二进制音频帧」换成「媒体轨」，把「文本帧」换成「数据通道消息」，其余协议（控制/事件、交互模式、邀请 AI）与 [WebSocket](/docs/websocket) 一致。
