与传统视频系统互通
WebRTC 不是孤岛。任何严肃的实时音视频项目最终都要回答四类互通问题:
- 已有 SIP / VoIP 系统(FreeSWITCH / Kamailio / Asterisk / 企业 IPPBX)怎么接 Web 端?
- OBS、推流机、监控摄像头出来的 RTMP / RTSP 怎么进 WebRTC 房间?
- WebRTC 出来的流怎么直推到 RTMP / HLS / DASH 给 CDN 万人观看?
- 怎么和电话网(PSTN)/ 视频会议终端(H.323 / SIP MCU)打通?
本文给出每条路径的网关选型、协议映射、代码骨架与失败时的样子。
互通原则:协议网关只做协议转换,不做业务。能在网关层用现成软件解决的事,不要硬塞进 SFU。
1. 互通全景
2. 协议映射速查
| 维度 | WebRTC | RTMP | SIP | WHIP/WHEP |
|---|---|---|---|---|
| 信令 | 自定 | TCP 握手 | SIP(UDP/TCP/TLS/WS) | HTTP POST + SDP |
| 媒体传输 | SRTP / DTLS / SCTP | RTMP(FLV 容器) | RTP(多为明文,可 SRTP) | SRTP / DTLS |
| 视频编码 | VP8/VP9/H.264/AV1 | H.264 / HEVC | H.264 / H.263 | VP8/VP9/H.264/AV1 |
| 音频编码 | Opus / G.711 | AAC / MP3 | G.711 / G.722 / Opus | Opus |
| NAT 穿透 | ICE | 无(要 LB) | NAT-helper / SBC | ICE |
| 拥塞控制 | TWCC / REMB | 无 | 无(依赖底层) | TWCC |
| 端到端延迟 | 100-300ms | 1-3s | 50-200ms | 200-500ms |
| 互通常用网关 | LiveKit / mediasoup | SRS / nginx-rtmp / Janus | FreeSWITCH / Kamailio / Janus / Jigasi | 任意 WHIP-Server |
3. 方向一:WebRTC ↔ SIP(电话/会议互通)
3.1 为什么要做
- 客服系统:浏览器接入后能拨打企业总机
- 在线会议:传统会议终端(Polycom / Cisco SX)和 Web 端在同一个房间
- 紧急通讯:浏览器拨 PSTN 电话
3.2 三种主流网关方案
| 网关 | 强项 | 弱项 | 推荐场景 |
|---|---|---|---|
| FreeSWITCH | 完整 PBX、Dialplan 灵活、商业部署多 | 资源占用偏高,学习曲线陡 | 中大型 VoIP 系统 |
| Kamailio | SIP 信令代理、性能极强、扇出能力好 | 不带媒体(要配 RTPEngine) | 高并发 SIP 信令 |
| Janus(SIP plugin) | 与 WebRTC 同源代码、配置简单 | 媒体能力比 FS 弱 | WebRTC 主栈临时挂 SIP 互通 |
| Asterisk | 老牌、生态成熟 | RTP 处理性能一般 | 已有 Asterisk 资产的项目 |
| Jigasi(Jitsi) | 直接挂 Jitsi Meet | 仅服务 Jitsi 系 | 用 Jitsi Meet 套件的团队 |
3.3 协议映射的关键点
SIP INVITE ──> PeerConnection.createOffer
SIP 200 OK <── PeerConnection.setLocalDescription/createAnswer
SIP ACK ──> iceConnectionState=connected
SIP BYE ──> PeerConnection.close()
DTMF (RFC4733/SIP INFO) ─> RTCDataChannel 或 telephone-event payload
注意点:
- WebRTC 的 SDP 默认是 BUNDLE + rtcp-mux + DTLS-SRTP,传统 SIP 终端通常不支持这些
- 网关要做 SDP 重写:拆 BUNDLE、关 rtcp-mux、SRTP↔SRTP-or-RTP、ICE↔无 ICE(用 SBC IP 直连)
- 编解码不一定一致,常见 transcoding:Opus ↔ G.711μ/A
3.4 FreeSWITCH 配置:让 WebRTC 端拨打 SIP 分机
<!-- conf/sip_profiles/external.xml 关键片段 -->
<param name="ws-binding" value=":7443"/>
<param name="wss-binding" value=":7443"/>
<param name="apply-candidate-acl" value="rfc1918.auto"/>
<param name="rtp-ip" value="auto-nat"/>
<param name="ext-rtp-ip" value="$${external_rtp_ip}"/>
<param name="dtls-srtp" value="true"/>
<param name="rtcp-mux" value="true"/>
<param name="ice-rtp" value="true"/>
<param name="liberal-dtmf" value="true"/>
<param name="codec-prefs" value="opus,PCMU,PCMA,G722,telephone-event"/>
<!-- conf/dialplan/default.xml: 浏览器走 WSS 进来,拨 1xxx 转分机 -->
<extension name="webrtc-to-sip">
<condition field="destination_number" expression="^(1\d{3})$">
<action application="set" data="rtp_secure_media=optional"/>
<action application="set" data="bypass_media=false"/>
<action application="set" data="hangup_after_bridge=true"/>
<action application="bridge" data="user/$1@$${domain}"/>
</condition>
</extension>
浏览器侧用 JsSIP 或 SIP.js 注册到 FreeSWITCH 的 WSS:
import JsSIP from 'jssip';
const socket = new JsSIP.WebSocketInterface('wss://fs.example.com:7443');
const ua = new JsSIP.UA({
uri: 'sip:1001@fs.example.com',
password: 'webrtc-secret',
sockets: [socket],
session_timers: false,
register: true,
});
ua.on('newRTCSession', (e) => {
const session = e.session;
session.on('confirmed', () => console.log('connected'));
session.on('ended', () => console.log('ended'));
});
ua.start();
ua.call('sip:1002@fs.example.com', {
mediaConstraints: { audio: true, video: false },
rtcOfferConstraints: { offerToReceiveAudio: true, offerToReceiveVideo: false },
});
3.5 与 Kamailio + RTPEngine 配合(高并发信令)
Kamailio 只代理 SIP 信令,媒体走 RTPEngine。架构:
# kamailio.cfg 关键片段
loadmodule "websocket.so"
loadmodule "rtpengine.so"
modparam("rtpengine", "rtpengine_sock", "udp:127.0.0.1:22222")
route[NATMANAGE] {
if (proto == WS || proto == WSS) {
set_advertised_address("kamailio.example.com");
rtpengine_manage("ICE=force replace-origin replace-session-connection trust-address SDES-off DTLS=passive");
}
}
要点:
DTLS=passive让 RTPEngine 当被叫端发 DTLS 握手replace-origin/replace-session-connection重写 SDP 让 Web 看到的是 RTPEngine 公网 IP- WebRTC 想拨 SIP,关 SRTP 必须经过媒体层 transcoding
3.6 Janus SIP 插件方案(最轻量)
// 浏览器侧用 Janus 客户端
const sip = janus.attach({
plugin: 'janus.plugin.sip',
success: (handle) => {
handle.send({
message: {
request: 'register',
username: 'sip:1001@sip.example.com',
secret: 'webrtc-secret',
proxy: 'sip:sbc.example.com:5060',
},
});
},
onmessage: (msg, jsep) => {
if (msg.result?.event === 'registered') {
handle.send({
message: {
request: 'call',
uri: 'sip:1002@sip.example.com',
},
media: { audioSend: true, audioRecv: true, videoSend: false, videoRecv: false },
});
}
if (jsep) handle.handleRemoteJsep({ jsep });
},
});
3.7 失败的样子
- 没配
ext-rtp-ip/external_rtp_ip,SDP 里给出 192.168.x.x,WebRTC 端 ICE 全 timeout - 浏览器和 SIP 终端 codec 不交集(浏览器没 G.722,SIP 终端没 Opus),FreeSWITCH 默默 transcoding,CPU 涨爆
- DTMF 用错通道:浏览器发 RFC4733,PBX 等 SIP INFO,按键失效
rtp-ip auto-nat在 docker 里不生效,必须手填- 没开
liberal-dtmf,按键事件被吞 - WSS 证书不是公网信任的 CA,浏览器拒连
4. 方向二:OBS / RTMP → WebRTC
4.1 为什么要做
- OBS / 推流机已是直播标准
- 物理摄像机大多输出 RTSP / RTMP
- 主播工作流不愿意为 WebRTC 改
4.2 网关选型
| 网关 | RTMP 入 | RTSP 入 | WHIP 入 | 实测延迟 | 部署难度 |
|---|---|---|---|---|---|
| SRS | ✅ | ✅ | ✅ | 0.3-1s | 中 |
| Janus streaming + 自接 | ⚠️(需脚本) | ✅ | ✅ | 0.5-1s | 中 |
| LiveKit Ingress | ✅ | ❌ | ✅ | 0.5-1s | 低 |
| OBS WHIP(OBS 30+) | — | — | ✅ 直推 | 0.2-0.5s | 极低 |
| mediasoup + 自写 ffmpeg → PlainTransport | ✅(需自接) | ✅ | ⚠️ | 0.5-1s | 高 |
4.3 SRS 作为 RTMP↔WebRTC 网关
SRS 5.x 起原生支持双向 RTMP/WebRTC 网关,并且实现了 WHIP/WHEP。
# srs.conf
listen 1935;
http_api { enabled on; listen 1985; }
rtc_server {
enabled on;
listen 8000;
candidate $CANDIDATE;
}
vhost __defaultVhost__ {
rtc {
enabled on;
rtmp_to_rtc on; # RTMP 推流自动转 WebRTC 订阅
rtc_to_rtmp on; # WebRTC 推流自动转 RTMP 订阅
keep_bframe off; # WebRTC 不喜欢 B 帧
}
http_remux { enabled on; mount [vhost]/[app]/[stream].flv; }
}
OBS 端推流:rtmp://srs.example.com/live/room-001
浏览器订阅:webrtc://srs.example.com/live/room-001(SRS 提供 player)
// 浏览器订阅 SRS 的 WebRTC 流
const pc = new RTCPeerConnection();
pc.addTransceiver('audio', { direction: 'recvonly' });
pc.addTransceiver('video', { direction: 'recvonly' });
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
const res = await fetch('https://srs.example.com/rtc/v1/play/', {
method: 'POST',
body: JSON.stringify({ api: 'play', streamurl: 'webrtc://srs.example.com/live/room-001', sdp: offer.sdp }),
});
const { sdp } = await res.json();
await pc.setRemoteDescription({ type: 'answer', sdp });
4.4 OBS 直接 WHIP(最优解)
OBS 30+ 内置 WHIP 推流,完全跳过 RTMP:
OBS → 设置 → 服务 → WHIP
Server: https://ingress.example.com/whip/room-001
Bearer Token: <jwt>
服务端 WHIP 端点的最小实现(Go + Pion,参见 SFU 横向对比文档第 5.2 节):
func whipHandler(w http.ResponseWriter, r *http.Request) {
if !verifyToken(r.Header.Get("Authorization")) {
http.Error(w, "unauthorized", 401)
return
}
offer, _ := io.ReadAll(r.Body)
pc, _ := webrtc.NewPeerConnection(webrtc.Configuration{
ICEServers: []webrtc.ICEServer{{URLs: []string{"stun:stun.l.google.com:19302"}}},
})
pc.AddTransceiverFromKind(webrtc.RTPCodecTypeVideo,
webrtc.RTPTransceiverInit{Direction: webrtc.RTPTransceiverDirectionRecvonly})
pc.AddTransceiverFromKind(webrtc.RTPCodecTypeAudio,
webrtc.RTPTransceiverInit{Direction: webrtc.RTPTransceiverDirectionRecvonly})
pc.OnTrack(func(t *webrtc.TrackRemote, _ *webrtc.RTPReceiver) {
// 把 RTP 喂进 SFU 的 PlainTransport / Pipe
forwardToSFU(t)
})
pc.SetRemoteDescription(webrtc.SessionDescription{Type: webrtc.SDPTypeOffer, SDP: string(offer)})
answer, _ := pc.CreateAnswer(nil)
gather := webrtc.GatheringCompletePromise(pc)
pc.SetLocalDescription(answer)
<-gather
w.Header().Set("Content-Type", "application/sdp")
w.Header().Set("Location", "/whip/session/"+sessionID)
w.WriteHeader(http.StatusCreated)
w.Write([]byte(pc.LocalDescription().SDP))
}
4.5 RTSP 摄像头入 WebRTC
# 把 RTSP 摄像头转 WHIP 喂进 LiveKit
ffmpeg -rtsp_transport tcp -i rtsp://user:pass@cam:554/stream1 \
-c:v libx264 -preset veryfast -tune zerolatency -profile:v baseline \
-g 60 -keyint_min 60 -sc_threshold 0 \
-bf 0 \
-c:a libopus -b:a 64k -ar 48000 \
-f whip "https://livekit.example.com/whip?room=cam-1&token=$TOKEN"
要点:
-bf 0关 B 帧baselineprofile 提高浏览器兼容性(Safari 老版本对 main 不友好)- RTSP 用 TCP 防止 UDP 丢包
- 摄像机不输出 Opus,要 transcoding 到 Opus 才能在 WebRTC 房间播
5. 方向三:WebRTC → RTMP / HLS(出向直播)
5.1 为什么要做
- WebRTC 互动 + CDN 扇出是万人直播的标准架构
- 已有播放器只支持 HLS / DASH
5.2 路径
5.3 LiveKit Egress 推 RTMP
egressClient.StartRoomCompositeEgress(ctx, &livekit.RoomCompositeEgressRequest{
RoomName: "live-001",
Layout: "speaker",
Output: &livekit.RoomCompositeEgressRequest_Stream{
Stream: &livekit.StreamOutput{
Protocol: livekit.StreamProtocol_RTMP,
Urls: []string{"rtmp://cdn.example.com/live/live-001"},
},
},
})
5.4 mediasoup + FFmpeg 自合流
参考 录制方案.md 的方案三。要点:
- 转推链路独立部署,不与录制共享 FFmpeg 进程
- RTMP 推流必须
-reconnect 1 -reconnect_streamed 1 -reconnect_delay_max 10 - LL-HLS 需 fmp4 段,segment 1s,partial 0.2s
5.5 失败的样子
- 没关 B 帧,CDN 切片首帧时间不稳定
- GOP 设 250 帧(默认),首屏要等 8s
- RTMP 推流断连,FFmpeg 静默退出
- 用
concatfilter 处理人离开/加入,时间戳错乱
6. 方向四:WHIP / WHEP
6.1 标准定位
- WHIP(WebRTC-HTTP Ingestion Protocol):HTTP 单包推流接入,IETF RFC 9725
- WHEP(WebRTC-HTTP Egress Protocol):HTTP 单包订阅,IETF Internet-Draft
WHIP/WHEP 把 WebRTC 信令降级为 一次 HTTP POST,让 OBS / FFmpeg / 推流机能直接对接 WebRTC 端点。
6.2 协议流程
6.3 哪些东西支持
| 端点 | WHIP 推 | WHEP 订 | 备注 |
|---|---|---|---|
| OBS 30+ | ✅ | ❌ | 服务设置选 WHIP |
| FFmpeg 7+ | ✅ | ✅ | -f whip / -f whep |
| GStreamer 1.22+ | ✅ | ✅ | whipsink / whepsrc |
| LiveKit | ✅ | ✅ | Cloud / Self-host |
| SRS 5+ | ✅ | ✅ | 内置 |
| Pion | ✅ | ✅ | 50 行示例 |
| Cloudflare Stream | ✅ | ✅ | 商业 |
6.4 WHIP 鉴权
最常见是 Bearer Token:
POST /whip HTTP/1.1
Authorization: Bearer eyJhbGciOiJIUzI1NiIs...
Content-Type: application/sdp
v=0
o=- 4611734...
服务端用 JWT 校验,并把 room、user、过期时间塞 claim:
type WhipClaims struct {
Room string `json:"room"`
User string `json:"user"`
Action string `json:"action"` // publish / subscribe
jwt.RegisteredClaims
}
func verifyWhipToken(authz string) (*WhipClaims, error) {
raw := strings.TrimPrefix(authz, "Bearer ")
token, err := jwt.ParseWithClaims(raw, &WhipClaims{}, func(t *jwt.Token) (any, error) {
return whipSecret, nil
})
if err != nil || !token.Valid {
return nil, errors.New("invalid token")
}
return token.Claims.(*WhipClaims), nil
}
6.5 WHEP 拉流(最小浏览器示例)
async function play(url: string, token: string): Promise<MediaStream> {
const pc = new RTCPeerConnection();
pc.addTransceiver('audio', { direction: 'recvonly' });
pc.addTransceiver('video', { direction: 'recvonly' });
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
const resp = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/sdp',
Authorization: `Bearer ${token}`,
},
body: offer.sdp,
});
if (resp.status !== 201) throw new Error(`whep failed: ${resp.status}`);
const sdp = await resp.text();
await pc.setRemoteDescription({ type: 'answer', sdp });
const stream = new MediaStream();
pc.getReceivers().forEach((r) => r.track && stream.addTrack(r.track));
return stream;
}
7. 方向五:电话网(PSTN)
7.1 为什么要做
- 客服 / 双向回拨
- 紧急联络
- 国际语音桥
7.2 链路
Browser ─SIP/WSS─> FreeSWITCH ─SIP─> SIP Trunk Provider ─SS7/SIP─> 电信网络
要点:
- 不要自建 SS7 / SIP-T,找运营商或 CPaaS(Twilio / 阿里通信 / 容联七陌)
- 公网走 SIP Trunk 必须走 SBC(Session Border Controller)做拓扑屏蔽 + 反 toll fraud
- WebRTC 必须 transcoding 到 G.711μ/A,PSTN 不接受 Opus
- 录音、计费、合规要在 PBX 侧落库
7.3 失败的样子
- 没限速,被拨测/盗打打爆话费
- 主叫号码(CLID)不合规,运营商不放行
- 国际拨打没装 IDD 前缀,永远拨不通
- 编解码协商失败,PSTN 端只听到杂音
8. SDP 重写:网关的命脉
互通做不出来 90% 是 SDP 没写对。常见重写场景:
| 场景 | 改动 |
|---|---|
| WebRTC ↔ 老 SIP | 拆 BUNDLE、关 rtcp-mux、删 ICE 行、改 a=setup |
| WebRTC ↔ RTMP | RTMP 没 SDP,要在网关侧伪造 |
| Opus ↔ G.711 | transcoding,且改 a=rtpmap / a=fmtp |
| H.264 baseline ↔ H.264 main | 重编 / 重协商 profile-level-id |
| H.265 ↔ H.264 | 浏览器 WebRTC 主流不支持 H.265,必须转码 |
原始 WebRTC SDP(节选):
m=video 9 UDP/TLS/RTP/SAVPF 96 97 98
a=rtcp-mux
a=group:BUNDLE 0 1
a=ice-ufrag:abcd
a=fingerprint:sha-256 ...
老 SIP 终端期望:
m=video 49170 RTP/AVP 96
a=rtpmap:96 H264/90000
a=fmtp:96 profile-level-id=42e01f
网关在收到 WebRTC Offer 后,要重新生成上面这段,并把媒体送到 49170 这个端口。FreeSWITCH mod_sofia 自动做了一部分;自写网关要照着 RFC 4566 / 8866 严格处理。
9. 编解码兼容矩阵
| 场景 | 入 | 出 | 是否需 transcoding |
|---|---|---|---|
| OBS H.264 → 浏览器 WebRTC | H.264 baseline | H.264 | 否(同 codec 透传) |
| 摄像机 H.265 → 浏览器 | H.265 | H.264 | 是(重编) |
| WebRTC Opus → SIP G.711 | Opus | G.711 | 是 |
| WebRTC VP9 → CDN HLS | VP9 | H.264 | 是 |
| WebRTC AV1 → CDN HLS | AV1 | H.264 | 是 |
| WebRTC H.264 → RTMP | H.264 | H.264 | 否 |
经验:能不重编就不重编。一次重编引入 1-3s 延迟、20-50% CPU。
10. 一份"互通联调脚本"
故障定位时按这个顺序检查:
# 1. 信令是否通
curl -i -X POST https://ingress.example.com/whip \
-H 'Authorization: Bearer xxx' \
-H 'Content-Type: application/sdp' \
--data-binary @offer.sdp
# 2. ICE 是否通
sudo tcpdump -ni eth0 'udp port 3478' -w turn.pcap
# 3. RTP 是否在送
sudo tcpdump -ni eth0 'udp portrange 40000-49999' -c 100 -A
# 4. SIP 协商日志(FreeSWITCH)
fs_cli -x "sofia profile external siptrace on"
fs_cli -x "console loglevel debug"
# 5. RTMP 推流帧率
ffprobe -v error -show_entries stream=r_frame_rate \
rtmp://cdn.example.com/live/room-001
11. 反模式
| 反模式 | 后果 | 替代 |
|---|---|---|
| 在 SFU 里直接做 SIP 协议解析 | 业务核心被电信级协议拖累 | 独立 SIP Gateway |
| 用 RTMP 做"准实时互动" | 1-3s 延迟,对话错位 | 互动走 WebRTC,观众走 RTMP |
| 让 OBS 推 RTMP 进 WebRTC,再转 RTMP 出去 | 两次重编、链路长 | OBS 直接 WHIP |
| 把 WHIP 端点暴露在公网无鉴权 | 被滥用推垃圾流 | Bearer Token + 房间维度限速 |
| 让浏览器直连 PSTN | 不可能,且监管不允许 | 必须经 SBC + SIP Trunk |
| WebRTC ↔ SIP 不做 transcoding 兜底 | 编解码不匹配静默失败 | 默认 G.711 兜底 |
| 跨地域接 SIP Trunk 不留延迟预算 | 国际拨打回声/卡顿 | 选就近落地 + 部署区域 SBC |
| 把 RTSP 直接喂给浏览器 | 浏览器不解 RTSP | 走网关转 WebRTC |
| 一份 SDP 给所有终端 | 老 SIP 端崩溃 | 网关层做 SDP 重写 |
| 网关进程 OOM 后无监控 | 互通悄无声息断 | RED 指标 + 主动拨测 |
12. 落地清单
- 已确认互通方向是单向还是双向
- SIP 互通是 WebSocket 还是 UDP/TCP,证书是否就绪
- 网关层有独立的 SDP 重写逻辑,且写了单测
- OBS 推流首选 WHIP,RTMP 仅兜底
- WHIP/WHEP 端点强制 Token 鉴权
- PSTN 走 SBC + SIP Trunk,反 toll fraud 配额生效
- 编解码不一致时默认走 transcoding,不假设对端能力
- 互通服务有独立的 dashboard:RTP 流量 / SIP 错误码 / Codec 分布
- 主动拨测:每 5 分钟一通"端到端"探测呼叫
- 与电信侧有 SLA 与故障联系人
13. 权威资料
- IETF RFC 9725(WHIP): https://www.rfc-editor.org/rfc/rfc9725
- IETF Draft(WHEP): https://datatracker.ietf.org/doc/draft-ietf-wish-whep/
- IETF RFC 3261(SIP): https://www.rfc-editor.org/rfc/rfc3261
- IETF RFC 8866(SDP): https://www.rfc-editor.org/rfc/rfc8866
- FreeSWITCH WebRTC 文档: https://developer.signalwire.com/freeswitch/FreeSWITCH-Explained/Configuration/WebRTC/
- Kamailio 官方文档: https://www.kamailio.org/wiki/
- RTPEngine: https://github.com/sipwise/rtpengine
- Janus SIP plugin: https://janus.conf.meetecho.com/docs/sip.html
- SRS WebRTC 文档: https://ossrs.io/lts/zh-cn/docs/v5/doc/webrtc
- LiveKit Ingress / Egress: https://docs.livekit.io/home/ingress/overview/
- OBS WHIP 公告: https://obsproject.com/forum/threads/obs-30-0-release-notes.169527/
- 核对日期:2026-06-22