🎬 第十三章 WebRTC网页直播
📄 一句话概括

整个页面就是九个动作,前五步自己准备,第六步送出去,后三步接住。 answer 只做三处改动,但有两处不能动,动了就黑屏。

📝 本节和前面几节的关系

13.3.2 与 13.3.3 把信令与页面服务装好了,这一节把它们串起来。 下一章换成综合案例,把前面十一章全用上。

一、九个动作

不管页面写得多花,核心永远是这九步。左边是 API,右边是它干的事:

步骤 调用的 API 它干的事


1 getUserMedia 拿到音视频轨道 2 new RTCPeerConnection 建一个连接对象 3 addTrack 把轨道塞进去 4 createOffer 生成自己的 SDP 5 setLocalDescription 记下来,同时开始收候选 6 经信令发出去 对面拿到 offer 7 setRemoteDescription 认出对面的 SDP 8 createAnswer 生成回应 9 ontrack 轨道到了,接进 video

前五步是推的一方自己准备,第 6 步把 offer 送出去,后三步是收的一方接住。 中间那一步不在浏览器里,这也是为什么必须有信令服务器。

还有一个细节容易漏:setLocalDescription 之后候选是「陆续」出来的。 icegatheringstate 从 gathering 变成 complete 才收齐。 急着发出去只会发一个空候选表,剩下的要靠 onicecandidate 一条条补发。 这个「陆续补发」的做法叫 trickle,几乎所有现成方案都是这么做的。

二、拆一份 offer

沙箱里没有浏览器,抓不到真的 offer, 所以下面这份是按 Chrome 的真实格式誊抄的:字段一个不少、顺序一致、 PT 号也是 Chrome 真会给的那几个(111 给 opus、96 给 VP8、97 给 H264)。

媒体 PT 号 编码数 编码清单


audio 111 63 2 111=opus、63=red video 96 97 98 3 96=VP8、97=H264、98=rtx

注意 PT 号:111、63、96、97、98。这些号是发起方随手定的,不是标准号。 唯一固定的是 0 到 95 那一段(96 以上才留给动态分配)。

所以看 SDP 永远要顺着 a=rtpmap 查表,不能记「96 就是 VP8」。 这个习惯很重要:同一台机器上开两次浏览器,PT 号都可能不一样。 写代码时如果硬编码了某个号,换一台机器就跑不通。

三、answer 只改三处,但两处不能动

answer 不是照抄,SRS 在里面做了三处改动,还有一处故意不动:

行 offer answer


a=setup actpass active a=sendonly/recvonly sendonly recvonly a=group:BUNDLE BUNDLE 0 1 BUNDLE 0 1

sendonly 变 recvonly 最好理解:推的人说「我给」,收的人说「我要」。

setup 从 actpass 变 active 就有点绕: actpass 的意思是「我主动被动都行,你定」; SRS 回 active,等于接下这个活——DTLS 握手由服务器先发起。 如果两边都回 active,握手会僵住,谁也等不到对方。 这是自建信令时最常见的死锁,现象是连接一直停在 connecting。

BUNDLE 那行两边一样,都是 0 1。 它的意思是「音视频挤在同一个端口上走」,靠 mid 号区分。 如果不 BUNDLE,一路流就要一对端口,观众一多端口就不够用了。

再看编码那一栏:offer 给了 3 个视频编码,answer 只要 1 个。 被丢掉的是 96(VP8)与 98(rtx)。 98 的 apt=97 说明它是 97 的重传通道,丢掉它意味着这条路没有重传。 所以这套配置下一旦丢包就是花屏,不会补。

四、两条铁律:顺序不能换,号码不能改

第一,answer 的 m 段必须和 offer 一一对应、顺序相同。 这不是约定,是 SDP 规范——m 段是靠位置匹配的,mid 只是辅助。 把 answer 里 audio 和 video 两段对调,浏览器会认为「音频那条我要的是 H264」。

第二,每个编码的 PT 号由 offer 方定,answer 只能沿用,不能改号。 如果 SRS 把 video 的 97 改回 96,浏览器会去 96 的 rtpmap 里找, 而 answer 里 96 又没定义,结果就是「收到了包但不知道怎么解」。

这两条合起来解释了一类很难查的故障: SDP 语法完全合法、ICE 也连上了、DTLS 也通了,就是黑屏。 查这种问题不要看日志,直接把两边的 SDP 并排贴出来逐行对。 日志只会告诉你「连接成功」,它不会告诉你「协商错了」。

五、动手 Demo:创建基于 WebRTC 的网页视频会话——九个步骤与一次协商

先拆开一份双 m 段的 offer,抠出两条 m 段的编码表。 再和 answer 逐项对比,最后算一次会话总共交换多少字节。

def sdp_media(text):
    """把 SDP 拆成 (全局行, [每条 m 段的 (m 行, 其他行)])。"""
    head, media, cur = [], [], None
    for line in text.splitlines():
        if line.startswith("m="):
            cur = (line, [])
            media.append(cur)
        elif cur is None:
            head.append(line)
        else:
            cur[1].append(line)
    return head, media

def pt_table(lines):
    """从一条 m 段里抠出 PT 号与编码名的对照。"""
    out = []
    for line in lines:
        if line.startswith("a=rtpmap:"):
            num, rest = line[9:].split(" ", 1)
            out.append((num, rest))
    return out

head, media = sdp_media(OFFER)
print("全局", len(head), "行,m 段", len(media), "条")
for mline, body in media:
    kind = mline.split(" ")[0][2:]
    pts = pt_table(body)
    print(kind, "PT", [a for a, _b in pts],
          "编码", [b.split("/")[0] for _a, b in pts])

# 和 answer 比,看砍掉了哪些
o = dict((m.split(" ")[0][2:], pt_table(b)) for m, b in media)
a = dict((m.split(" ")[0][2:], pt_table(b)) for m, b in sdp_media(ANSWER)[1])
drop = [x for x in o["video"] if x[0] not in [y[0] for y in a["video"]]]
print("视频砍掉", drop)

实跑输出:

--------------------------------------------------------------------
创建基于 WebRTC 的网页视频会话——九个步骤与一次协商
--------------------------------------------------------------------
**① 整个页面就是九个动作**

  **不管页面写得多花,核心永远是这九步。左边是 API,右边是它干的事:**

  步骤     调用的 API                它干的事
  -------------------------------------------------------------
  1        getUserMedia              拿到音视频轨道
  2        new RTCPeerConnection     建一个连接对象
  3        addTrack                  把轨道塞进去
  4        createOffer               生成自己的 SDP
  5        setLocalDescription       记下来,同时开始收候选
  6        经信令发出去              对面拿到 offer
  7        setRemoteDescription      认出对面的 SDP
  8        createAnswer              生成回应
  9        ontrack                   轨道到了,接进 video

  **前五步是推的一方自己准备,第 6 步把 offer 送出去,后三步是收的一方接住。**
  **中间那一步(经信令发出去)不在浏览器里,这也是为什么必须有服务器。**
  **注意 `setLocalDescription` 之后要等 `icegatheringstate` 变化才能拿到完整候选,**
  **急着发出去只会发一个空候选表,这叫 trickle,后面那条候选得单独补发。**

**② 真拆一份浏览器的 offer**

  **沙箱里没有浏览器,抓不到真的 offer,**
  **所以下面这份是按 Chrome 的真实格式誊抄的:字段一个不少、顺序一致、**
  **PT 号也是 Chrome 真会给的那几个(111 给 opus、96 给 VP8、97 给 H264)。**
  **先把两条 m 段各自的编码表抠出来:**

  **全局部分 6 行,m 段 2 条。**

  媒体      PT 号        编码数     编码清单
  -------------------------------------------------------------
  audio     111 63       2          111=opus、63=red
  video     96 97 98     3          96=VP8、97=H264、98=rtx

  **注意 PT 号:111、63、96、97、98。这些号是发起方随手定的,不是标准号。**
  **唯一固定的是 0 到 95 那一段(96 以上才留给动态分配)。**
  **所以看 SDP 永远要顺着 `a=rtpmap` 查表,不能记「96 就是 VP8」。**

**③ 真 diff 一次 offer 与 answer**

  **answer 不是照抄,SRS 在里面做了三处改动,还有一处故意不动。这里逐项比:**

  项目             offer     answer     变化
  ---------------------------------------------------
  audio 编码数     2         1          砍掉 1 个
  video 编码数     3         1          砍掉 2 个

  **视频这边,offer 给了 3 个编码,answer 只要了 1 个。**
  **被丢掉的是:96(VP8)、98(rtx)。**

  **这里有个细节值得盯:被丢掉的 `98` 是 rtx,它的 `apt=97` 说明它是 97 的重传通道。**
  **SRS 只要 97 不要 98,意味着这条路上一旦丢包就是花屏,没有重传。**
  **这也是为什么 WebRTC 在弱网下画质掉得比 RTMP 快——不是它不行,是这套配置没开重传。**

  **另外几行的对照,重点看哪一行动了、哪一行没动:**

  行                       offer          answer
  ------------------------------------------------------
  a=setup                  actpass        active
  a=sendonly/recvonly     sendonly       recvonly
  a=group:BUNDLE           BUNDLE 0 1     BUNDLE 0 1

  **`sendonly` 变 `recvonly` 最好理解:推的人说「我给」,收的人说「我要」。**
  **`setup` 从 `actpass` 变 `active` 就有点绕:**
  **`actpass` 的意思是「我主动被动都行,你定」;**
  **SRS 回 `active`,等于接下这个活——DTLS 握手由服务器先发起。**
  **如果两边都回 `active`,握手会僵住,谁也等不到对方。**
  **这是自建信令时最常见的死锁,现象是连接一直停在 connecting。**

  **BUNDLE 那行两边一样,都是 `0 1`。**
  **它的意思是「音视频挤在同一个端口上走」,靠 mid 号区分。**
  **如果不 BUNDLE,一路流就要一对端口,观众一多端口就不够用了。**

**④ 两条铁律:顺序不能换,号码不能改**

  **第一,answer 的 m 段必须和 offer 一一对应、顺序相同。**
  **这不是约定,是 SDP 规范——m 段是靠位置匹配的,`mid` 只是辅助。**
  **把 answer 里 audio 和 video 两段对调,浏览器会认为「音频那条我要的是 H264」。**

  **第二,每个编码的 PT 号由 offer 方定,answer 只能沿用,不能改号。**
  **如果 SRS 把 video 的 97 改回 96,浏览器会去 96 的 rtpmap 里找,**
  **SRS 的 answer 里 96 又没定义,结果就是「收到了包但不知道怎么解」。**

  **这两条合起来解释了一类很难查的故障:**
  **SDP 语法完全合法、ICE 也连上了、DTLS 也通了,就是黑屏。**
  **查这种问题不要看日志,直接把两边的 SDP 并排贴出来逐行对。**

**⑤ 一次会话到底交换了多少字节**

  项目                       字节     条数
  --------------------------------------------
  offer(含两条 m 段)       1324     1
  answer                     951      1
  候选(trickle 逐条发)     354      3
  合计                       2629     5

  **合计 2629 字节,五条消息。**
  **其中 87% 是那两份 SDP——因为里面全是重复的 ice-ufrag、fingerprint。**
  **而 ICE 自己跑一次连通性检查,一个包就 100 字节出头,要试几十上百个组合。**
  **所以建立连接的耗时里,真正花在信令上的很少,大头在 ICE 试路。**

  **这也解释了一个现象:**
  **同一对浏览器,在同城机房 100 毫秒就出画,跨运营商要 3 秒。**
  **差的不是 SDP 传输,是候选配对要试多少条路、每条路等多久超时。**
  **想快就多给候选(TURN 兜底),想省钱就少给(只留 host 与 srflx)。**
  **WebRTC 的体验从来不是「代码写得好不好」,是「路修得够不够」。**

offer 的全局部分 6 行,m 段 2 条,一条 audio 一条 video。 audio 有 2 个编码(111 是 opus、63 是 red),video 有 3 个(96/97/98)。 和 answer 一比,视频砍掉两个:96 的 VP8、98 的 rtx。 所以 SRS 只收 H264,而且不开重传。 一份 offer 1324 字节、一份 answer 951 字节,加三条候选一共 2629 字节。 其中 87% 是那两份 SDP,因为里面全是重复的 ice-ufrag 与 fingerprint。 而 ICE 自己跑一次连通性检查,一个包就 100 字节出头,要试几十上百个组合。 所以建连耗时的大头在 ICE 试路,不在信令。

30 秒实验

  1. 把 answer 里两条 m 段对调,猜会怎样——浏览器认为音频那条要的是 H264,协商失败,黑屏。
  2. 把 answer 里 video 的 97 改成 96,猜会怎样——96 在 answer 里没定义,收到包也解不了。
  3. 让双方都回 setup:active,猜连接停在哪——一直停在 connecting,DTLS 谁也不先发起,互相等。

常见误区

  1. 「PT 96 固定就是 VP8」——号码是发起方定的,必须查 rtpmap。
  2. 「answer 可以只回它要的那条 m 段」——必须一一对应,顺序也要一致。
  3. 「信令传得慢所以连接慢」——大头在 ICE 试路,信令才几 KB。
自测三问(点开对答案) 1. `setLocalDescription` 之后候选什么时候齐? —— **`icegatheringstate` 变成 complete**,之前只能靠 trickle 补发。 2. `setup` 从 actpass 变 active 意味着什么? —— **DTLS 握手由服务器先发起**,浏览器等它。 3. 为什么 rtx 被丢掉会影响弱网画质? —— **rtx 是重传通道**,没有它丢包就直接花屏。

相关笔记