简介:围绕G.711音频封装RTP传输的实践资料包,面向数字音频通信、VoIP与实时流媒体方向的开发者及学习者,帮助打通从G.711编码数据到RTP包封装、再到VLC接收播放的完整链路。包内共4个文件,以C语言源码、SDP会话描述文件、G.711音频样本和说明文档为主,压缩包约2.16MB,分别对应封装逻辑实现、媒体会话参数描述、测试素材与使用说明。已有1486人学习下载,具备一定参考热度。读者可结合源码理解RTP头部中序列号、时间戳与载荷类型的组织方式,掌握将G.711数据写入RTP载荷并通过UDP发送的过程,同时借助SDP配置在VLC中验证接收与播放效果;说明文档和音频样本便于快速复现实验、排查封装与播放环节的问题,适合用作实时音频传输入门实践与课程实验参考。
1. 64 kbps 的语音包,为什么能把整条链路拖进一周的排查
G711 的编码器十几行查表代码,RTP 固定头 12 个字节,把两者接起来看着是最没有技术含量的活。可遇到的真实情况往往是:一套语音系统上线后出现断续、吞字、越说越怪,九成以上的现场问题不在压扩表,而在 RTP 的两个计数器——16 位序号和 32 位时间戳。它们一旦和打包节奏脱钩,故障表现就不是“完全听不见”,而是“能听见但听着难受”,这种半故障状态最难定位,抓包看上去一切都正常。搞清楚 g711 怎么被装进 RTP,本质上是搞清楚采样时钟怎么变成网络上的节拍,以及节拍错位之后听感会怎么变形。
2. RTP 头里真正决定语音质量的字段:PT、序号、时间戳、SSRC
RTP 头是 12 字节固定部分加可选的 CSRC 列表和扩展头。对 G711 这种恒定速率、无帧结构的编码来说,真正参与决策的只有四个字段:负载类型 PT、序号 seq、时间戳 timestamp、同步源 SSRC。剩下的 V/P/X/CC/M 里,M 位在语音场景有用,其它几个在纯音频单流里基本是常量。这一章把这四个字段各自的职责边界划清楚,后面所有打包和收包的实现都是围绕它们展开的。
2.1 负载类型 PT:0 和 8 不是同一件事
RTP 头第二个字节低 7 位是 PT。RFC 3551 给出的静态负载类型表里,G711 占了两格:0 是 PCMU,也就是 μ-law;8 是 PCMA,也就是 A-law。两格挨得很近,含义却完全不同。μ-law 的编码思路是把 14 位线性采样压到 8 位,用对数曲线在小信号区给更细的量化台阶;A-law 是 13 位线性压到 8 位,段边界和量化台阶位置都不一样。两套压扩表不通用。
把 A-law 的字节流用 μ-law 的表去解,得到的不是“声音小一点”或者“有点失真”,而是明显的白噪声、金属感、完全听不清内容。这个现象很容易被误判成网络丢包或者时钟漂移,实际只是 SDP 里m=audio 4000 RTP/AVP 8写的是 PCMA,而发送端代码里 PT 字段填了 0。
注意:PT 是 7 位字段,取值范围 0 到 127。96 到 127 属于动态负载类型,需要靠 SDP 的
a=rtpmap行协商,比如a=rtpmap:96 PCMA/8000。静态类型 0 和 8 虽然可以省掉 rtpmap,但 SDP 里仍然要写对数字。
另一个容易忽略的点是同一路流中途切换 PT。有些实现为了做静音抑制,在有声段发 PT=8、在静音段发 PT=13(舒适噪声 CN)。接收端如果只按第一个包的 PT 固定解码器,遇到 CN 包就会把一小段噪声数据当成语音解出来,听感上是每隔几秒冒出一声“嘶”。正确的做法是按包读 PT,再分派到对应的处理分支。
2.2 序号与时间戳:两套不同节拍,不能互相推导
序号和时间戳是这整套机制里最容易被混为一谈的两个字段。它们都是递增的,都在回绕,看起来像是同一个东西的两种表示。实际职责完全不同。
序号是 16 位,每发一个 RTP 包加 1,不管这个包里装了多少采样。它的语义是“这是第几个包”,服务于传输层:接收端靠它判断有没有丢包、有没有乱序、需不需要重排。时间戳是 32 位,按采样时钟递增,增量等于这个包里携带的采样点个数。它的语义是“这段音频在媒体时间轴上的位置”,服务于播放层:接收端靠它决定什么时候把这段采样送进声卡、和另一路音频怎么对齐。
关键在于:这两个字段之间没有固定的换算关系。发一个 20 ms、160 采样的包,序号加 1、时间戳加 160;如果中间因为 VAD 判断静音停发了 3 个包,恢复发送时序号只加 1,时间戳却要加 4×160=640。如果实现里图省事,用序号乘固定步长去推时间戳,静音抑制一开就立刻翻车——对端听到的是一段被压缩的音频,语速变快、音调变高。
时间戳的增量单位是采样点,不是毫秒,也不是字节。8000 Hz 采样率下,20 ms 对应 160 个采样点,G711 每采样点 1 字节,所以这个包恰好是 160 字节载荷。这三个数字碰巧在 20 ms 这个点上相等,很容易让人写出timestamp += len(payload)这种在 20 ms 下永远正确、换到 30 ms 就悄悄出错的代码。真正安全的写法是让时间戳只跟采样计数挂钩,跟字节数和墙上时钟都无关。
2.3 SSRC 与 CSRC:多路混流时的身份与来源
SSRC 是 32 位同步源标识,一个 RTP 会话里每一路独立的媒体流有一个 SSRC。它的取值应当是随机生成的,并且要能检测冲突。听起来是个纯粹的样板字段,但在两类场景里会直接导致故障。
第一类是 SSRC 冲突。两台设备各自随机生成 SSRC,理论上可能撞到同一个值。接收端看到同一个 SSRC 下序号突然跳变、时间戳突然错位,会当成一次严重丢包或者时钟跳变处理,表现是周期性爆音。成熟的实现会在会话建立阶段做一次比对,发现冲突就重新生成并通知对端。
第二类是混音场景里的 CSRC。当中间有一个混音单元把多路 G711 流混成一路时,输出流的 SSRC 是混音单元自己的,而参与混音的原始 SSRC 会被列进 CSRC 列表,CC 字段记录列表长度。这个列表的作用是让接收端知道“这段音频里有哪些人在说话”,用于会议室界面上的说话人指示。纯点对点通话里 CC 通常是 0,头部就是干净的 12 字节。
3. 20 ms 是怎么定下来的:采样点数、载荷长度与时间戳步长的换算链
打包间隔这个参数,几乎所有语音项目里都会出现,但真正说得清为什么是 20 ms 的人不多。它不是协议规定的,RTP 允许任意打包长度;它是一组工程约束下的折中结果。这一章把从采样率到最终带宽的整条换算链摆出来,再给一个状态极简的打包器实现。
3.1 从 8000 Hz 到 160 字节:一条不能断的换算链
G711 的采样率固定 8000 Hz,每个采样点编码成 8 位,所以裸码率是 8000×8 = 64000 bps,也就是 64 kbps。这条链往下走:
第一环,确定打包间隔 ptime,单位毫秒。第二环,算出这个间隔内的采样点数:samples = 8000 × ptime / 1000,20 ms 得 160。第三环,因为 G711 每采样点 1 字节,载荷长度恰好等于采样点数,160 字节。第四环,时间戳增量等于采样点数,160。
四环里最容易断的是第二环和第四环。如果音频采集侧送上来的是 16 位线性 PCM(每采样点 2 字节),在编码成 G711 之前必须先做一次 2:1 的字节压缩,如果代码里直接把 PCM 缓冲区长度当载荷长度,包就会大一倍,而对端按 G711 解出来只有一半是有效音频,另一半是错位的字节,听感是严重的沙哑。
链路里如果还有重采样,比如设备原生采集 16 kHz,需要降到 8 kHz,那么“采样点数”这个量必须在重采样之后重新计算。常见的错误是在 16 kHz 域算出 320 个采样点,然后直接拿这个数当 8 kHz 域的时间戳增量,结果时间戳走得比实际快一倍。
3.2 打包间隔的带宽账:10 ms 和 40 ms 差在哪
每一路 RTP 流除了载荷,还要背 IP 头 20 字节、UDP 头 8 字节、RTP 固定头 12 字节,IPv4 下合计 40 字节固定开销。这个开销按包计,不按字节计,所以包越短、开销占比越高。
| ptime | 载荷字节 | 协议头(IPv4) | 每包总字节 | 每秒包数 | 单路带宽 | 头开销占比 |
|---|---|---|---|---|---|---|
| 10 ms | 80 | 40 | 120 | 100 | 96 kbps | 33.3% |
| 20 ms | 160 | 40 | 200 | 50 | 80 kbps | 20.0% |
| 30 ms | 240 | 40 | 280 | 33.3 | 74.7 kbps | 14.3% |
| 40 ms | 320 | 40 | 360 | 25 | 72 kbps | 11.1% |
| 60 ms | 480 | 40 | 520 | 16.7 | 69.3 kbps | 7.7% |
从表里能看出两个趋势。往短了走,带宽涨得很快但延迟低,10 ms 比 20 ms 多花 20% 带宽,换来的是端到端延迟少 10 ms。往长了走,带宽趋近 64 kbps 的裸码率,但每多 10 ms 打包间隔就多 10 ms 的固有延迟,而且单个包丢失造成的声音缺口也更大。
20 ms 之所以成为事实标准,是因为它同时满足三个约束:一是总延迟(采集 + 打包 + 网络 + 抖动缓冲 + 解码)能压在 150 ms 以内,人对这个量级的延迟不敏感;二是丢一个 20 ms 的包,听感上是一个很短的“咔”,用简单的补偿就能糊过去;三是开销占比 20% 在可接受范围内。
真正做产品的时候这个值要按场景调。对讲机、游戏语音这类强交互场景会压到 10 ms 甚至更低;单向广播、录音回传这类不关心延迟的场景会拉到 40 ms 或 60 ms 省带宽。改这个参数时,时间戳步长、抖动缓冲深度、丢包补偿窗口都要跟着改,只改一个地方是埋雷。
3.3 一个能跑的打包器:状态只保留三个变量
打包器的核心状态其实只有三个:序号、时间戳、talkspurt 标志。其余都是从配置推出来的常量。下面是一个最小实现,重点看时间戳是怎么累加的。
import struct SAMPLE_RATE = 8000 PT_PCMU = 0 PT_PCMA = 8 RTP_VERSION = 2 def build_rtp(seq, timestamp, ssrc, payload, pt, marker=False): """组装 RTP 固定头 + 载荷。G711 不使用填充位和扩展头,头长恒为 12 字节。""" first_byte = (RTP_VERSION << 6) # V=2,P=0,X=0,CC=0 second_byte = (0x80 if marker else 0x00) | (pt & 0x7F) # '!BBHII' 大端打包:1+1+2+4+4 = 12 字节,正好是 RTP 固定头长度 header = struct.pack( '!BBHII', first_byte, second_byte, seq & 0xFFFF, timestamp & 0xFFFFFFFF, ssrc & 0xFFFFFFFF, ) return header + payload class G711Packer: def __init__(self, ssrc, pt=PT_PCMA, ptime_ms=20): self.ssrc = ssrc self.pt = pt # 打包间隔换算成采样点数,这是整条链路唯一的时间基准 self.samples_per_packet = SAMPLE_RATE * ptime_ms // 1000 self.seq = 0 self.timestamp = 0 self.talkspurt_start = True def pack(self, g711_bytes): if len(g711_bytes) != self.samples_per_packet: raise ValueError('payload length must equal samples_per_packet') pkt = build_rtp( self.seq, self.timestamp, self.ssrc, g711_bytes, self.pt, marker=self.talkspurt_start, # 一段连续语音的首包置 M 位 ) self.seq = (self.seq + 1) & 0xFFFF # 时间戳只跟采样点数挂钩,与发送时刻的墙上时钟无关 self.timestamp = (self.timestamp + self.samples_per_packet) & 0xFFFFFFFF self.talkspurt_start = False return pktbuild_rtp里的!BBHII是 Python struct 的大端格式串,依次对应两个 1 字节字段、一个 2 字节序号、两个 4 字节字段,拼出来正好 12 字节。& 0xFFFF和& 0xFFFFFFFF的作用是在自增时完成回绕,别指望语言层面的整数溢出兜底。
G711Packer.pack里最关键的一句是self.timestamp + self.samples_per_packet。它不读系统时间,不看网络情况,也不看这一包实际什么时候发出去。整个 RTP 时间轴是一条纯粹由采样计数驱动的直线,网络抖动只会改变包的到达时刻,不会改变包上标注的媒体位置。反过来,如果按time.time()去算时间戳,音频时钟和网络时钟的微小频差会被逐包放大,接收端为了对齐只能做重采样,听感上就是每隔几秒一次的轻微“咯噔”。
marker位的用法也值得说一句。语音场景里它标记“一段连续讲话的开始”,接收端收到 M=1 的包会重置抖动缓冲、清空丢包状态。如果每包都置 M,接收端会不停地重置缓冲,声音会断断续续;如果从不置 M,接收端在长时间静音之后的第一段话会被缓冲延迟吃掉开头一两个字。
4. 排查:语音能通但不对的五种典型症状
打包逻辑写完、链路打通、能听见声音,这只是开始。真正耗时的是“通但不正常”这一类问题。下面五条是这类项目里出现频率最高的,按现象、原因、解决三段来写。
4.1 周期性咔哒声,每隔几秒响一下
现象:双方都能听清对方说话,但背景里稳定地每隔 3 到 8 秒出现一次轻微的“咔”,音量不大,用耳机听特别明显。
原因:时间戳不是按采样计数递增,而是取了系统时间乘以 8000 再取整。系统时钟和音频设备的采样时钟来自不同的晶振,频差通常在几十 ppm 量级。发送端每包标注的时间戳和对端实际播放的采样数逐渐错开,累积到一定程度接收端就要做一次“追赶”或“丢弃”,那一下就是咔哒声。
解决:把时间戳的来源换成累计采样计数器,只在编码器实际产出采样时递增。如果采集侧本身有硬件时钟漂移,就在接收端的抖动缓冲里做缓慢的伸缩调整,把纠偏摊到几百毫秒里,而不是一次性跳变。
4.2 对端听到的是快放或慢放,音调也变了
现象:内容能听清,但语速明显不对,男声变尖或者变低沉,类似老式磁带快放。
原因:时间戳步长的单位算错了。常见的两种错法:一是按字节数递增,16 位 PCM 转 G711 之后忘了除以 2,步长翻倍;二是按毫秒数递增,20 ms 的包时间戳加了 20 而不是 160。步长偏大,接收端认为每个包覆盖的媒体时间更长,播放时就会压缩,听感是快放加升调。
解决:把时间戳步长的计算收敛到一处,用samples_per_packet = sample_rate * ptime_ms / 1000这个式子,别在多个地方各算一遍。写完之后用抓包工具看连续两个包的 timestamp 差值,这个差值必须严格等于采样点数,多一个少一个都是错的。
4.3 连续跑几天之后突然没声音,重启就好
现象:设备上电后一切正常,跑五六天之后通话突然静音,重启进程立刻恢复。日志里没有任何异常。
原因:32 位时间戳回绕没处理。8000 Hz 下 2³² 个采样点对应 536870 秒,约 6.21 天。如果代码里用有符号 32 位整数存时间戳,或者用if ts > last_ts这种朴素比较判断新旧,回绕的那一刻就会被误判成“这个包比上一个包旧得多”,整包被丢弃,之后每个包都被丢,直到进程重启。
解决:时间戳和序号都做无符号存储,比较时用模运算的差值语义,具体写法见下一章。这类 bug 的隐蔽性在于测试周期通常不会跨过 6 天,只能靠代码审查或者构造回绕用例来发现。
4.4 两边抓包都有数据,但互相听不到
现象:在两端同时抓包,都能看到对方发来的 RTP 包,序号连续、载荷长度正确,就是没声音。
原因:三种可能,按概率从高到低排。一是 PT 不匹配,一边发 PCMA 标 8、另一边按标 0 解;二是 SDP 协商的端口和实际发包端口不一致,抓包能看到但接收端的 socket 没绑在那个端口上;三是 SSRC 冲突,两路流的 SSRC 撞了,接收端把它们当成同一路处理,序号在两条独立的递增序列之间来回跳,被判成严重乱序全部丢弃。
解决:先看 RTP 头的 PT 字段和 SDP 里的 rtpmap 是否一致,再看收发端口是不是同一个会话里协商出来的那一对,最后看两端的 SSRC 是否相同。SSRC 冲突在点对点场景里概率不高,但一旦出现,现象和丢包极其相似,容易查很久。
4.5 丢一个包,后面拖出几百毫秒的杂音
现象:偶尔丢一个 RTP 包,听感不是短暂的一下缺口,而是接着一段明显的破碎声,长度远超一个包的时长。
原因:接收端没有按序号重排,来一个包就往播放缓冲里塞一个。丢包之后到达的包可能是乱序的,塞进去就造成了音频片段前后错位,错位的两段音频接在一起,听起来就是一段拖长的杂音。更糟的情况是接收端把乱序的包当成新数据覆盖了已经排好序的位置。
解决:接收端维护一个按序号索引的小窗口,收到包先入窗口,按序号顺序出队。出队时如果发现序号跳跃,才判定为丢包并触发补偿。窗口深度取 2 到 3 个包就够,太深了延迟会上去。
5. 接收侧的复原:序号回绕、乱序判定与抖动缓冲窗口
发送侧把节拍定准了,接收侧的任务是把网络打乱的节拍重新拉直。这一侧要处理三件事:怎么在 16 位序号回绕之后还能正确判断新旧、抖动缓冲等多久、丢包了拿什么填。
5.1 16 位序号的回绕比较:为什么不能直接用大于号
序号到了 65535 之后回到 0,直接用a > b判断“a 比 b 新”在跨越回绕点时会得出完全相反的结论。正确做法是把两个序号相减,把结果按有符号 16 位解释。
#include <stdint.h> /* 判断序号 a 是否严格晚于 b。 * 差值按 int16_t 解释,等价于模 2^16 的环序比较; * 只要重排窗口不超过 32767 个包,结论就是正确的。 */ static inline int seq_after(uint16_t a, uint16_t b) { return (int16_t)(a - b) > 0; } /* 两个序号之间的“距离”,用于判断丢了多少包。 */ static inline uint16_t seq_gap(uint16_t a, uint16_t b) { return (uint16_t)(a - b); }(int16_t)(a - b)这步转换是关键。C 语言里无符号减法先按模 2¹⁶ 回绕,再解释成有符号数,得到的就是“a 在 b 之后的偏移量”,超过半圈则为负。32767 个 20 ms 包约等于 11 分钟,任何合理的重排窗口都远小于这个量级,所以判断是安全的。
时间戳回绕用同一套思路,只是把位宽换成 32 位、类型换成int32_t。需要注意的是时间戳的“半圈”是 2³¹ 个采样点,换算下来约 74 小时,比序号的窗口宽得多,但仍然要显式处理,不能靠运气。
5.2 抖动缓冲:等待窗口如何决定延迟与吞字的平衡
抖动缓冲的作用是吸收网络到达时间的波动:包到达有快有慢,但播放必须匀速。缓冲深度就是这个“快慢差”的容忍上限。
深度太小,网络稍微抖一下缓冲就见底,播放线程取不到数据,听感是吞字、卡顿。深度太大,端到端延迟线性上升,双向通话超过 200 ms 就会出现明显的“抢话”感,两个人同时开口时互相打断。
常见做法是自适应。RFC 3550 附录里给了一个到达间隔抖动的估计式,用指数滑动平均跟踪:
/* RFC 3550 A.8 的抖动估计,单位与时间戳一致(采样点) */ static void update_jitter(rtp_session_t *s, int32_t arrival_ts, uint32_t rtp_ts) { int32_t d = (arrival_ts - (int32_t)rtp_ts) - s->transit; if (d < 0) d = -d; s->transit = arrival_ts - (int32_t)rtp_ts; s->jitter += ((double)d - s->jitter) / 16.0; /* 增益 1/16 */ }transit是“到达时刻减媒体时间戳”的相对传输时延,抖动就是它的变化量。增益 1/16 意味着大约 16 个包的时间常数,20 ms 包下约 320 ms,对语音场景足够灵敏又不至于被单个异常包带偏。
算出来的 jitter 值直接映射成缓冲深度:深度取jitter × 2 + 一个包时长是比较稳的经验值。20 ms 包、jitter 估到 15 ms 时,缓冲深度约 50 ms,端到端延迟增加 50 ms,仍在可接受区间。
5.3 丢包与 PLC:G711 没有帧概念,补偿只能靠猜
G711 是逐采样点的压扩编码,没有帧头、没有帧内预测、没有冗余信息。这意味着一个包丢了,接收端拿不到任何可以用于恢复的边信息,只能靠上下文猜。
工程上常见的补偿手段有三档。最轻的是静音填充,直接把缺口填 0,听感是明显的“断一下”,但计算量最小。中间档是波形延续,用丢包前最后一个基音周期(8 kHz 下成年男声约 80 到 120 个采样点)循环填满缺口,再叠一个淡出包络,听感上能把缺口糊过去。最重的是发送端冗余,把上一包的 G711 载荷复制一份附在当前包后面,代价是带宽翻倍,只在丢包率高的链路上用。
判定丢包的时机也有讲究。收到序号跳跃时不能立刻认定丢包,因为可能只是乱序,晚一点那个包就到了。稳妥的做法是等一个抖动缓冲的时长,如果窗口里该位置仍然是空的,才触发补偿。这段等待时间直接吃掉了抖动缓冲的余量,所以补偿窗口和缓冲深度要一起调。
6. 设计取舍:RTP 为什么把时钟和序号拆成两套,而不是合成一套
很多人第一次看 RTP 头会有一个疑问:既然序号和时间戳都在递增,为什么不合成一个字段,省下 2 个字节?这个问题往回推,能推到 RTP 整个设计的出发点。
序号跟踪的是“包”,时间戳跟踪的是“媒体”。这两件事在网络里会以不同的方式脱钩。开启静音抑制后,包还在发,但媒体时间跳过了几百毫秒;做丢包重传时,同一个媒体时刻可能对应两个包;做前向纠错时,一个包里可能同时装着当前采样和之前采样的冗余。任何一次脱钩,用单一字段都无法同时表达。
| 维度 | 序号 seq | 时间戳 ts |
|---|---|---|
| 位宽 | 16 位 | 32 位 |
| 递增条件 | 每发一个 RTP 包 | 每个采样点 |
| 服务对象 | 传输层:丢包、乱序、重排 | 播放层:节奏、去抖、同步 |
| 静音抑制时 | 正常 +1 | 按实际采样跳变 |
| 重传包 | 新包新序号 | 沿用原包时间戳 |
| 回绕周期(20 ms 包 / 8 kHz) | 约 21.8 分钟 | 约 6.21 天 |
这张表里最值得看的是“重传包”那一行。同一个媒体片段被重发时,时间戳必须保持不变,否则接收端会认为这是一段新音频,播放节奏就乱了;而序号必须是新的,否则接收端分不清这是重复包还是网络层的重包。两个字段各自承担一件事,才能同时表达“这是同一段声音”和“这是一个新包”。
反过来的设计也不是没人试过。有的私有协议用一个 32 位字段同时编码包序号和媒体时刻,在点对点、无静音抑制、无重传的简单场景下工作得很好。但只要加一个功能——VAD、FEC、重传、混音——这个字段就得拆成两半,拆完发现还不如一开始就分开。这就是协议设计里常见的路径:把耦合在一起的语义尽早解耦,代价是多几个字节,收益是后面每加一个功能都不用改基础结构。
验证这套机制是否实现正确,我一般会做三件事。一是抓包看连续 100 个包的 seq 和 timestamp 差值,seq 必须恒为 1,timestamp 必须恒等于采样点数,中间有静音段的话 timestamp 的跳变要能对上实际的静音时长。二是构造时间戳回绕用例,把初始值设到 2³²−1000,跑几十个包看接收端是否正常。三是人为打乱发送顺序,看接收端的重排窗口能不能把它们还原成正确顺序,同时不影响延迟。
从那以后我每次写打包逻辑,都会先把samples_per_packet这个值单独算出来、单独打印一遍,再和抓包里的时间戳差值做一次对账,对不上就不往下走。这个习惯拦下过好几次“20 ms 下碰巧正确、换 30 ms 就错”的隐蔽 bug。希望帮到你。
本文还有配套的精品资源,点击获取