跳到主要内容

与传统视频系统互通

WebRTC 不是孤岛。任何严肃的实时音视频项目最终都要回答四类互通问题:

  1. 已有 SIP / VoIP 系统(FreeSWITCH / Kamailio / Asterisk / 企业 IPPBX)怎么接 Web 端?
  2. OBS、推流机、监控摄像头出来的 RTMP / RTSP 怎么进 WebRTC 房间?
  3. WebRTC 出来的流怎么直推到 RTMP / HLS / DASH 给 CDN 万人观看?
  4. 怎么和电话网(PSTN)/ 视频会议终端(H.323 / SIP MCU)打通?

本文给出每条路径的网关选型、协议映射、代码骨架与失败时的样子。

互通原则:协议网关只做协议转换,不做业务。能在网关层用现成软件解决的事,不要硬塞进 SFU。

1. 互通全景

2. 协议映射速查

维度WebRTCRTMPSIPWHIP/WHEP
信令自定TCP 握手SIP(UDP/TCP/TLS/WS)HTTP POST + SDP
媒体传输SRTP / DTLS / SCTPRTMP(FLV 容器)RTP(多为明文,可 SRTP)SRTP / DTLS
视频编码VP8/VP9/H.264/AV1H.264 / HEVCH.264 / H.263VP8/VP9/H.264/AV1
音频编码Opus / G.711AAC / MP3G.711 / G.722 / OpusOpus
NAT 穿透ICE无(要 LB)NAT-helper / SBCICE
拥塞控制TWCC / REMB无(依赖底层)TWCC
端到端延迟100-300ms1-3s50-200ms200-500ms
互通常用网关LiveKit / mediasoupSRS / nginx-rtmp / JanusFreeSWITCH / Kamailio / Janus / Jigasi任意 WHIP-Server

3. 方向一:WebRTC ↔ SIP(电话/会议互通)

3.1 为什么要做

  • 客服系统:浏览器接入后能拨打企业总机
  • 在线会议:传统会议终端(Polycom / Cisco SX)和 Web 端在同一个房间
  • 紧急通讯:浏览器拨 PSTN 电话

3.2 三种主流网关方案

网关强项弱项推荐场景
FreeSWITCH完整 PBX、Dialplan 灵活、商业部署多资源占用偏高,学习曲线陡中大型 VoIP 系统
KamailioSIP 信令代理、性能极强、扇出能力好不带媒体(要配 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>

浏览器侧用 JsSIPSIP.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 入实测延迟部署难度
SRS0.3-1s
Janus streaming + 自接⚠️(需脚本)0.5-1s
LiveKit Ingress0.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 帧
  • baseline profile 提高浏览器兼容性(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 静默退出
  • concat filter 处理人离开/加入,时间戳错乱

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
LiveKitCloud / Self-host
SRS 5+内置
Pion50 行示例
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 ↔ RTMPRTMP 没 SDP,要在网关侧伪造
Opus ↔ G.711transcoding,且改 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 → 浏览器 WebRTCH.264 baselineH.264否(同 codec 透传)
摄像机 H.265 → 浏览器H.265H.264是(重编)
WebRTC Opus → SIP G.711OpusG.711
WebRTC VP9 → CDN HLSVP9H.264
WebRTC AV1 → CDN HLSAV1H.264
WebRTC H.264 → RTMPH.264H.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. 权威资料