POST /{streamPath}/whep
对讲由两条独立的 WebRTC 链路组成,可以只建一条,也可以同时建:
两条链路各自一个 RTCPeerConnection,信令用 HTTP POST 交换 SDP,遵循标准 WHEP/WHIP 协议。
所有接口都走设备地址 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 |
断开会话 | 无 |
h.264(主码流)、h1.264、h2.264(子码流)。
完整 WHEP 路径例如 /webrtc/h.264/whep。
// 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' })
上行(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
});
{
"iceServers": [
{ "urls": ["stun:stun.miwifi.com:3478"] },
{ "urls": ["stun:stun.chat.bilibili.com:3478"] }
// 设备外网通时还会有 TURN (带 username/credential)
]
}
| 值 | 含义 | 何时用 |
|---|---|---|
all(默认) | host + srflx + relay | 常规场景 |
relay | 只走 TURN | 对称型 NAT / 强制中继。必须有 TURN 凭据,否则 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);
});
}
| 类型 | 含义 | 同 LAN 时 RTT |
|---|---|---|
host | 局域网直连 | 2-5ms |
prflx | 对端反射 | 2-5ms(同 LAN 时与 host 等价) |
srflx | STUN 反射的公网 IP | 10-50ms |
relay | TURN 中继 | 50-200ms |
prflx 而不是 host 是正常的(Chrome mDNS 隐私机制导致),实际就是直连,不影响使用。getUserMedia 只在 https:// / localhost / file:// 下可用。检测:window.isSecureContext === true。
设备是自签证书。从跨域 fetch 发起的请求遇到证书错误会静默失败(不弹警告,直接 Failed to fetch)。
必须先让用户在同源标签访问一次设备首页,点"高级 → 仍要访问":
window.open('https://<device-ip>', '_blank'); // 用户接受后, 后续 fetch /webrtc/... 才不会失败
| 现象 | 原因 | 处理 |
|---|---|---|
| 视频能看到但设备没声音 | 没强制 PCMA,浏览器仍编码 Opus | 按 §4 设置 setCodecPreferences,确认时序(addTrack 后、createOffer 前) |
fetch Failed to fetch | 自签证书未信任 | 按 §6.2 引导用户信任证书 |
| 强制 relay 后 ICE 失败 | 没有 TURN 凭据 | 改回 all |
| WHEP 建立 5-10 秒后才有画面 | 设备首次连接需要协商 + 等关键帧 | 正常,后续会快 |
| 页面打不开 / 麦克风拒绝 | 非安全上下文 | 必须 https:// 或 localhost |
| 函数 | 用途 |
|---|---|
fetchIceServersFromDevice() | 拉 /webrtc/ice-servers,失败退回兜底 STUN |
buildWhepUrl / buildWhipUrl | 拼接口 URL |
buildResourceUrl | 从 Location 头拼出 DELETE 用的资源 URL |
forcePcmaOnly | 强制音频为 PCMA/8000 |
waitForIceGathering | 带 1500ms 超时的 ICE 收集等待 |
startWhep / startWhip | 两条链路的标准 WHEP/WHIP 流程 |
stopAll | close PC → DELETE 资源 → 释放麦克风 |
window.__intercomState.whepPc.getStats() 直接看候选统计。chrome://webrtc-internals/ 看完整 SDP / 候选 / ICE 图。curl -sk https://<device-ip>/webrtc/ice-servers | jq .