跨域 WebRTC 对讲 Demo

对应 WHEP: POST /{streamPath}/whep
启动时自动从设备 /webrtc/ice-servers 拉取
整体状态
idle
WHEP (设备→浏览器)
idle
WHIP (浏览器→设备)
idle
麦克风
未请求
扬声器
未连接
📖 WebRTC 对讲对接文档(开发者集成指南)

1. 对讲是什么

对讲由两条独立的 WebRTC 链路组成,可以只建一条,也可以同时建:

两条链路各自一个 RTCPeerConnection,信令用 HTTP POST 交换 SDP,遵循标准 WHEP/WHIP 协议。

2. 接口列表

所有接口都走设备地址 https://<device-ip>/webrtc/...

方法路径用途请求 / 响应
GET /webrtc/ice-servers 获取 ICE 配置(STUN,可选 TURN 凭据) 响应:{"iceServers":[...]}
POST /webrtc/{streamPath}/whep 建立下行(设备→浏览器) 请求体:offer SDP(Content-Type: application/sdp
响应体:answer SDP
响应头 Location:资源 URL
POST /webrtc/whip 建立上行(浏览器→设备) 同上
DELETE POST 响应的 Location URL 断开会话
streamPath 取值h.264(主码流)、h1.264h2.264(子码流)。 完整 WHEP 路径例如 /webrtc/h.264/whep

3. 完整对接流程

// 1. 拉取 ICE 配置(含 TURN 凭据,可选)
GET https://device/webrtc/ice-servers
<- {"iceServers":[{"urls":["stun:..."]}, ...]}

// 2. 并发建立 WHEP(下行)+ WHIP(上行)

// --- WHEP(设备 → 浏览器) ---
pc1 = new RTCPeerConnection({ iceServers, iceTransportPolicy: 'all' })
pc1.addTransceiver('video', { direction: 'recvonly' })
pc1.addTransceiver('audio', { direction: 'recvonly' })
await pc1.setLocalDescription(await pc1.createOffer())
await waitForIceGathering(pc1, 1500)   // 见 §5
resp = await fetch('https://device/webrtc/h.264/whep', {
  method: 'POST',
  headers: { 'Content-Type': 'application/sdp' },
  body: pc1.localDescription.sdp
})
whepResUrl = resp.headers.get('Location')
await pc1.setRemoteDescription({ type: 'answer', sdp: await resp.text() })
// ontrack 拿到 MediaStream → 挂到 <video>

// --- WHIP(浏览器 → 设备) ---
stream = await navigator.mediaDevices.getUserMedia({ audio: {...}, video: false })
pc2 = new RTCPeerConnection({ iceServers, iceTransportPolicy: 'all' })
sender = pc2.addTrack(stream.getAudioTracks()[0])
forcePcmaOnly(pc2.getTransceivers().find(t => t.sender === sender))  // ⚠ 见 §4
await pc2.setLocalDescription(await pc2.createOffer())
await waitForIceGathering(pc2, 1500)
resp = await fetch('https://device/webrtc/whip', {
  method: 'POST',
  headers: { 'Content-Type': 'application/sdp' },
  body: pc2.localDescription.sdp
})
whipResUrl = resp.headers.get('Location')
await pc2.setRemoteDescription({ type: 'answer', sdp: await resp.text() })

// 3. 等 ICE connected → 对讲可用

// 4. 挂断:先 close PC,再 DELETE 资源
pc1.close(); pc2.close()
fetch(whepResUrl, { method: 'DELETE' })
fetch(whipResUrl, { method: 'DELETE' })

4. 上行音频必须强制 PCMA(不强制 = 没声音)

上行(WHIP)只支持 PCMA/8000(G.711 A-law)。浏览器默认 offer Opus,虽然 answer 会被砍掉 Opus 只留 PCMA, 但浏览器的 RTPSender 不一定跟随切换,仍可能编码 Opus → 表现为"看起来连上了,设备端没声音"。

必须在 addTrack 之后、createOffer 之前调用 setCodecPreferences

function forcePcmaOnly(transceiver) {
  const caps = RTCRtpSender.getCapabilities('audio');
  const pcma = caps.codecs.filter(c => c.mimeType === 'audio/PCMA');
  if (pcma.length === 0) return; // 不支持 PCMA, 上行会失败
  transceiver.setCodecPreferences([...pcma]);  // 必须是 getCapabilities 返回的原始对象
}

// getUserMedia 也尽量贴近 G.711
await navigator.mediaDevices.getUserMedia({
  audio: { channelCount: 1, sampleRate: 8000,
           echoCancellation: true, noiseSuppression: true, autoGainControl: true },
  video: false
});

5. ICE 配置与连接

5.1 ice-servers 响应示例

{
  "iceServers": [
    { "urls": ["stun:stun.miwifi.com:3478"] },
    { "urls": ["stun:stun.chat.bilibili.com:3478"] }
    // 设备外网通时还会有 TURN (带 username/credential)
  ]
}

5.2 iceTransportPolicy

含义何时用
all(默认)host + srflx + relay常规场景
relay只走 TURN对称型 NAT / 强制中继。必须有 TURN 凭据,否则 ICE 必败

5.3 ICE 收集超时

不要死等 iceGatheringState === 'complete',弱网下 STUN 反射要 1-3 秒。给个上限(1500ms):

function waitForIceGathering(pc, timeoutMs = 1500) {
  return new Promise(resolve => {
    if (pc.iceGatheringState === 'complete') return resolve();
    let done = false;
    const finish = () => { if (!done) { done = true; resolve(); } };
    pc.onicecandidate = e => { if (!e.candidate) finish(); };
    setTimeout(finish, timeoutMs);
  });
}

5.4 连接类型(用 getStats 看)

类型含义同 LAN 时 RTT
host局域网直连2-5ms
prflx对端反射2-5ms(同 LAN 时与 host 等价)
srflxSTUN 反射的公网 IP10-50ms
relayTURN 中继50-200ms
同 LAN 看到 prflx 而不是 host 是正常的(Chrome mDNS 隐私机制导致),实际就是直连,不影响使用。

6. 必须 HTTPS + 必须先信任自签证书

6.1 安全上下文

getUserMedia 只在 https:// / localhost / file:// 下可用。检测:window.isSecureContext === true

6.2 自签证书必须先在同源标签接受一次

设备是自签证书。从跨域 fetch 发起的请求遇到证书错误会静默失败(不弹警告,直接 Failed to fetch)。 必须先让用户在同源标签访问一次设备首页,点"高级 → 仍要访问":

window.open('https://<device-ip>', '_blank');
// 用户接受后, 后续 fetch /webrtc/... 才不会失败

7. 常见问题

现象原因处理
视频能看到但设备没声音没强制 PCMA,浏览器仍编码 Opus按 §4 设置 setCodecPreferences,确认时序(addTrack 后、createOffer 前)
fetch Failed to fetch自签证书未信任按 §6.2 引导用户信任证书
强制 relay 后 ICE 失败没有 TURN 凭据改回 all
WHEP 建立 5-10 秒后才有画面设备首次连接需要协商 + 等关键帧正常,后续会快
页面打不开 / 麦克风拒绝非安全上下文必须 https://localhost

8. 本页参考实现(函数索引)

函数用途
fetchIceServersFromDevice()/webrtc/ice-servers,失败退回兜底 STUN
buildWhepUrl / buildWhipUrl拼接口 URL
buildResourceUrlLocation 头拼出 DELETE 用的资源 URL
forcePcmaOnly强制音频为 PCMA/8000
waitForIceGathering带 1500ms 超时的 ICE 收集等待
startWhep / startWhip两条链路的标准 WHEP/WHIP 流程
stopAllclose PC → DELETE 资源 → 释放麦克风

9. 调试

日志