G711 over RTP封装实战:载荷、时间戳步进与抖动缓冲排查
简介:面向实时音频通信与网络协议练习者的G.711封装RTP传输实践包,重点解决G.711编码音频如何按RTP头结构打包、经UDP发送并由VLC接收播放的最小链路问题。包内共4个文件,涵盖C语言实现源码、SDP会话描述、G.711音频样本与使用说明,压缩包约2.16MB,体量轻便,便于对照理解序列号、时间戳、负载插入和连接参数等关键环节。已有1486人学习,说明该主题在实时音频流实验中具有一定关注度。适用于课程实验、协议分析与音视频传输调试场景,也可作为网络电话入门的对照范例。读者可借此搭建可运行的发送端,观察RTP包构造与VLC播放效果,并结合说明梳理两种律制编码、语音质量、UDP传输和SDP会话协商之间的配合关系,为排查收包异常、同步偏差及扩展RTP功能提供可复用参考。
1. 从一段发闷的通话说起:G711 装进 RTP 传输前必须想清楚的三个值
接通了,能听见,但声音发闷,每隔 20 毫秒还咔一下。这是几年前拆一个语音对讲模块时最典型的一幕。问题不在编解码器本身——G711 只有 8 位、8 kHz、恒定 64 kbps 这点本事,简单到几乎没有调参空间;真正出问题的是 G711 封装成 RTP 传输这一段:净荷里到底该放压扩后的字节还是线性 PCM、时间戳按什么步进、序号在 SSRC 不变的前提下怎么不跳变。这一段做错,后面接什么播放器都救不回来。
这份资源解决的正是这一段:把 G711 的 A-law / μ-law 码流按 RTP 载荷格式切开打包、按固定节奏发出去、在收端拆包还原。适合正在做 SIP 对讲、VoIP 终端、录音回放、嵌入式音频网关的人,也适合想搞明白 RTP 头里那几个字段到底谁在用的开发者。下面按「码流怎么对应 → 怎么封 → 怎么拆 → 哪儿翻车 → 怎么验」走一遍。
2. 码流与载荷的对应关系:PCMA/PCMU 的载荷类型、时间戳步进和打包周期
2.1 先看清 G711 出手的是什么:8 位压扩码,不是线性 PCM
G711 干的事情只有一件:把 13 位(A-law)或 14 位(μ-law)的线性采样,用折线段量化的方式压成 1 个字节。也就是说,编码器输出的一字节,就是一个采样点,8000 个采样点就是 8000 字节,一秒净荷恒定 64 kbps。
这个「一样本一字节」的性质决定了后面所有打包逻辑:净荷长度永远等于样本数,20 毫秒就是 160 个样本、160 字节,不需要任何帧头、不需要长度前缀、不需要分隔符。RTP 的包边界本身就是帧边界。
A-law 和 μ-law 在 RTP 里有各自的静态载荷类型,不需要 SDP 里额外的 rtpmap 协商参数,但 SDP 中仍然要把对应的 PT 列出来。
| 项目 | PCMA(G.711 A-law) | PCMU(G.711 μ-law) |
|---|---|---|
| RTP 静态载荷类型 | 8 | 0 |
| 采样率 | 8000 Hz | 8000 Hz |
| 每样本位数 | 8 bit | 8 bit |
| 净荷速率 | 64 kbps | 64 kbps |
| 20 ms 净荷长度 | 160 字节 | 160 字节 |
| 静音(空闲)码字 | 0xD5 | 0xFF |
| 常见使用区域 | 欧洲、中国等 | 北美、日本等 |
静音码字这一行值得单独记一下。做静音检测或者补包时,如果你随手填 0x00,对端解出来不是静音,而是一个幅度不小的直流偏置,听感上就是持续的「嗡」。填错静音码字属于那种查半天查不出、一听就听出来的问题。
A-law 和 μ-law 之间可以互转,但那是二次量化,转一次掉一点信噪比。整条链路上只要有一处无谓的 A↔μ 转换,就等于白扔一档音质。常见做法是:SDP 协商阶段两个 PT 都报,谁发谁定,收端按收到的 PT 解码,全程不转。
2.2 RTP 头里真正要用到的六个字段
12 字节固定头,G711 场景下大部分字段是常量,真正需要每包算的只有三个。
| 字段 | 位宽 | G711 场景取值 | 说明 |
|---|---|---|---|
| V(版本) | 2 | 2 | 固定 |
| P(填充) | 1 | 0 | G711 净荷天然字节对齐,不需要填充 |
| X(扩展) | 1 | 0 | 无扩展头 |
| CC(CSRC 数) | 4 | 0 | 单声道点对点,无混音源 |
| M(标记) | 1 | 见下 | 会话首包、静音期后首包置 1 |
| PT(载荷类型) | 7 | 0 或 8 | PCMU / PCMA |
| 序号 | 16 | 每包 +1 | 16 位回绕,不跳号 |
| 时间戳 | 32 | 每包 +160 | 时钟频率 8000 Hz |
| SSRC | 32 | 会话内固定 | 随机生成,中途不能变 |
时间戳是坑最多的地方。RTP 的时间戳单位不是秒、不是毫秒、不是字节数,而是「采样点数」。G711 的时钟频率是 8000,所以一个 20 毫秒的包,时间戳步进是 8000 × 0.02 = 160。如果哪天把打包周期改成 30 毫秒,步进就要变成 240,同时净荷变成 240 字节——这两个数必须同步改,只改一个就会出现「音调对但节奏不对」或者「节奏对但音调不对」。
序号的 16 位回绕也是同类问题。65535 之后回到 0,收端如果直接用seq - last_seq判断连续,回绕那一下会算出一个 -65535 的巨大间隙,抖动缓冲会误判成大规模丢包然后清空重来。
注意:M 位不是「每包都置 0」就完事的。会话第一个包、以及静音抑制结束后的第一个包,都应该置 1,收端靠它判断「这里可以重置播放节奏」。
2.3 打包周期怎么定:10/20/30/40 毫秒的带宽与时延账
G711 净荷速率固定,但加上 IP(20 字节)、UDP(8 字节)、RTP(12 字节)共 40 字节的头部之后,实际占用的带宽跟打包周期强相关。包打得越密,头部占比越高。
| 打包周期 | 每包样本数 | 净荷字节 | 包速率 | 单包总长 | 含头总速率 |
|---|---|---|---|---|---|
| 10 ms | 80 | 80 B | 100 pps | 120 B | 96 kbps |
| 20 ms | 160 | 160 B | 50 pps | 200 B | 80 kbps |
| 30 ms | 240 | 240 B | 33.3 pps | 280 B | 74.7 kbps |
| 40 ms | 320 | 320 B | 25 pps | 360 B | 72 kbps |
从 10 毫秒到 40 毫秒,带宽从 96 kbps 降到 72 kbps,省了 25%。但代价是单包时延从 20 毫秒涨到 80 毫秒(往返算两次),而且丢一个包就是 40 毫秒的声音缺口。
我的取值习惯是:局域网、对讲机这类对时延敏感的场景用 20 毫秒;跨公网、带宽紧张的用 30 毫秒;只有在明确的卫星链路或者极窄带信道上才考虑 40 毫秒。10 毫秒一般不划算——多花的 16 kbps 换来的 10 毫秒时延,在实际网络抖动面前基本被淹掉。
单包总长的上限要盯一眼 MTU。40 毫秒包 360 字节,加上以太网头也远低于 1500,不会分片。但如果做多帧捆包(把两个 20 毫秒帧塞进一个包),时间戳步进要改成 320,净荷 320 字节,一样安全;捆到 8 帧就是 1280 字节净荷加 40 字节头,在 PPPoE 环境下就危险了。
2.4 输入不是 8 kHz 怎么办:重采样加压扩的两步换算
现实里拿到的音频往往是 16 kHz、16 bit 的 PCM。直接丢进 RTP 净荷是最常见的翻车方式之一:对端按 8 bit 解,长度翻倍、播放速度减半、内容全是噪声。
正确的顺序是先降到 8 kHz,再做压扩。降采样必须先低通再抽取,否则 4 kHz 以上的成分会混叠回来。
import audioop # 注意:Python 3.13 起标准库已移除该模块,老版本或自备实现可用 # pcm16k: 16 kHz / 16 bit / 单声道 的原始字节流 # 第一步:重采样到 8 kHz(ratecv 内部带简单滤波,语音场景够用) pcm8k, _ = audioop.ratecv(pcm16k, 2, 1, 16000, 8000, None) # 参数依次为:输入数据、样本字节宽度(2=16bit)、声道数、输入采样率、输出采样率、状态 # 第二步:16 bit 线性 -> A-law(输出一样本一字节) alaw = audioop.lin2alaw(pcm8k, 2) assert len(alaw) == len(pcm8k) // 2这段代码有两个地方要看清楚。ratecv的第二个参数是样本宽度字节数,不是位深,16 bit 就填 2,填 16 会直接抛异常。lin2alaw的第二个参数同理也是样本宽度,输出长度正好是输入的一半,这个断言可以当成一道自检:如果长度不对,说明前面的样本宽度或声道数填错了。
对音质有要求的场景,ratecv的滤波滚降不够陡,4 kHz 附近会有轻微混叠。替代做法是自己写一个 63 阶左右的 FIR 低通(截止 3.4 kHz),滤波后再每隔一个点取一个。这个在嵌入式上要算一下 MIPS,一般 Cortex-M4 级别的片子跑 8 kHz 单声道没问题。
3. 封装落地:用 12 字节头和 160 字节净荷把 G711 打成 RTP 包
3.1 手工构造 12 字节 RTP 头
路由器和交换机不关心负载是什么,但收端关心头部每一个字节。手写一遍头部打包,比调库更容易理解后面出问题该看哪儿。
import struct, socket, time, random PT = 8 # 8=PCMA,0=PCMU SSRC = random.getrandbits(32) # 会话内固定,进程重启才变 SEQ0 = random.getrandbits(16) # 起始序号随机化,避免明文预测 TS0 = random.getrandbits(32) # 起始时间戳同样随机化 def rtp_header(pt, seq, ts, ssrc, marker=0): """构造 12 字节 RTP 固定头,无填充、无扩展、无 CSRC""" b0 = 0x80 # V=2, P=0, X=0, CC=0 b1 = ((marker & 0x01) << 7) | (pt & 0x7F) # M 位 + 7 位载荷类型 return struct.pack('!BBHII', b0, b1, seq & 0xFFFF, ts & 0xFFFFFFFF, ssrc & 0xFFFFFFFF)struct.pack的格式串!BBHII里,!表示网络字节序(大端)。这一点不能省——RTP 规定所有多字节字段按网络字节序传输,在小端机器上不加!打出来的包,收端解出的序号和时间戳是高低位颠倒的,表现是「能收到包但序号乱跳」。
0x80这个常量把版本号 2 写死在最高两位,剩下的 P/X/CC 全零。以后如果要加 CSRC 或者扩展头,改的就是这个字节和后面的偏移量。
SSRC、起始序号、起始时间戳都随机化,不是可有可无的讲究。固定起始值在有多个终端同时上线的环境里会造成 SSRC 冲突,两路流在收端被当成同一路合并,听感上是两个人的声音交替出现。
3.2 按 20 毫秒切帧与时间戳步进
封装的节奏控制比头部构造更容易出问题,因为它的错误是累积的。
FRAME_MS = 20 CLOCK = 8000 # G711 时钟频率,固定 8000 SAMPLES = CLOCK * FRAME_MS // 1000 # 160 个样本 PAYLOAD = SAMPLES # G711 一样本一字节 = 160 字节 def send_stream(sock, dst, g711_frames, marker_first=True): seq, ts = SEQ0, TS0 next_t = time.monotonic() first = marker_first for payload in g711_frames: if len(payload) != PAYLOAD: # 长度不对宁可跳过也不要发 continue pkt = rtp_header(PT, seq, ts, SSRC, marker=1 if first else 0) + payload sock.sendto(pkt, dst) first = False seq = (seq + 1) & 0xFFFF ts = (ts + SAMPLES) & 0xFFFFFFFF next_t += FRAME_MS / 1000.0 delay = next_t - time.monotonic() if delay > 0: time.sleep(delay) else: next_t = time.monotonic() # 落后过多就重新对齐,不补发关键点有三个。
第一,ts的步进用的是SAMPLES而不是len(payload)。这两个值在 G711 下恰好相等,所以很多人写ts += len(payload)也能跑通,但一旦换成 G.722 或者加了冗余编码,样本数和字节数就不再一一对应,这个写法会立刻出错。从一开始就写SAMPLES,是给自己留后路。
第二,& 0xFFFF和& 0xFFFFFFFF的掩码不能省。Python 的整数是无界的,不掩码的话序号会一路涨到几百万,struct.pack('H', ...)直接抛struct.error。
第三,节奏用next_t累加而不是每轮sleep(0.02)。后者每次都会带上调度误差,误差单向累积,一分钟后时间戳和真实时间就错开一大截,收端的抖动缓冲会被迫反复调整。累加式写法把误差限制在单个周期内,落后太多时重新对齐而不是疯狂补发——补发会造成瞬间的带宽尖峰,比丢几帧更糟。
3.3 净荷从哪儿来:编码器输出直接对接
发送循环拿到的g711_frames必须是已经压扩好的字节流,按 160 字节切好。这个切分动作在上游做,不要在发送循环里做,因为发送循环承担着实时性责任,任何多余的内存操作都会让节奏抖动。
常见的流水线是:采集回调拿到 16 bit PCM → 重采样到 8 kHz → 压扩成 G711 → 塞进一个环形缓冲 → 发送线程按 20 毫秒从环形缓冲里取 160 字节。环形缓冲的容量建议 200 毫秒左右,太小会被调度抖动掏空,太大则把时延堆起来。
如果采集侧的一次回调不是 160 个样本(比如声卡给的是 441 个样本一块),就必须在环形缓冲这一层做重切分,不能把一个回调块直接当成一个 RTP 包发出去。变了长的包会破坏时间戳步进的等距性,收端的抖动缓冲会一直处于追赶状态。
3.4 多帧捆包与 MTU 边界
有些场景为了压头部开销会把多个帧捆进一个 RTP 包。做法很简单:净荷拼成 320 字节,时间戳步进改成 320,序号照样 +1。收端不需要知道捆了几帧,它只看时间戳差值就能算出一共有多少样本。
代价是丢包影响放大一倍。20 毫秒一个包丢一个缺口 20 毫秒,40 毫秒一个包丢一个缺口就是 40 毫秒。在有丢包的链路上,捆包是拿抗丢包能力换带宽,值不值要看实测的丢包率。
MTU 边界要算清楚:以太网 1500 字节减去 IP 头 20、UDP 头 8、RTP 头 12,净荷上限 1460 字节,也就是 182 个 8 毫秒样本、9 个 20 毫秒帧。PPPoE 环境还要再减 8 字节。跨越 NAT 或者隧道时,实际可用 MTU 可能只有 1400 出头。留一点余量,捆包不超过 8 帧比较稳妥。
提示:判断有没有发生分片,看抓包里 IP 层的 Fragment Offset 和 MF 位,比看应用层表现快得多。分片的直接后果是丢一片就整包作废,而很多实现只统计 RTP 层丢包,看起来丢包率不高但声音一直在断。
4. 收端还原:序号回绕、抖动缓冲与丢包补偿的实现细节
4.1 解析头部与序号回绕的正确判断
收端第一步是把 12 字节头拆开,并按 CC 和 X 位正确跳过 CSRC 列表和扩展头。虽然 G711 点对点场景里这两位通常是 0,但一旦中间经过了混音服务器或者网关,扩展头就会出现,写死data[12:]当净荷就会把扩展头当成音频数据播出去,听感是开头一小段刺耳的噪声。
def parse_rtp(data): if len(data) < 12: return None b0, b1, seq, ts, ssrc = struct.unpack('!BBHII', data[:12]) if (b0 >> 6) != 2: # 版本必须是 2 return None cc = b0 & 0x0F # CSRC 个数 ext = (b0 >> 4) & 0x01 # 扩展头标志 pad = (b0 >> 5) & 0x01 # 填充标志 off = 12 + cc * 4 if ext: if len(data) < off + 4: return None ext_words = struct.unpack('!H', data[off + 2:off + 4])[0] off += 4 + ext_words * 4 # 扩展头长度以 4 字节为单位 end = len(data) - (data[-1] if pad else 0) # 末尾填充字节数由最后一字节给出 if end <= off: return None return {'pt': b1 & 0x7F, 'm': b1 >> 7, 'seq': seq, 'ts': ts, 'ssrc': ssrc, 'payload': data[off:end]}三个偏移量要盯住:cc * 4因为每个 CSRC 是 4 字节;ext_words * 4因为扩展头长度字段的单位是 32 位字而不是字节;填充长度由最后一个字节给出,所以要先把最后一字节读出来再从尾部扣掉。
序号回绕的判断标准写法是先算差值再折回有符号区间:
def seq_delta(seq, base): """返回相对 base 的有符号差值,自动处理 16 位回绕""" d = (seq - base) & 0xFFFF return d - 0x10000 if d > 0x8000 else d差值大于 0x8000 就认为是「回退」,减掉 65536。这样 65535 → 0 算出来是 +1,0 → 65535 算出来是 -1,抖动缓冲的排序逻辑就不用为回绕单独写分支了。
4.2 抖动缓冲:按时间戳排序,不按到达顺序
网络包的到达顺序不可信。抖动缓冲的核心是攒够一定深度再开始播,播放时按序号(等价于按时间戳)取,缺的补、多的丢。
class JitterBuffer: def __init__(self, depth=3, clock=8000, frame_ms=20): self.depth = depth # 目标缓冲包数,3 包 = 60 ms self.base_seq = None self.buf = {} # seq -> payload self.clock = clock self.frame_samples = clock * frame_ms // 1000 def push(self, seq, payload): if self.base_seq is None: self.base_seq = seq d = (seq - self.base_seq) & 0xFFFF if d > 0x8000: # 比播放进度还早的迟到包,丢掉 return if d > 2000: # 超出窗口上限,视为新会话重新对齐 self.base_seq = seq self.buf.clear() return self.buf[seq] = payload def pop(self): """返回 (payload 或 None, 是否缺失)""" if self.base_seq is None or len(self.buf) < self.depth: return None, False # 还没攒够,不启动播放 payload = self.buf.pop(self.base_seq, None) self.base_seq = (self.base_seq + 1) & 0xFFFF return payload, payload is Nonedepth是这份缓冲里唯一需要调的参数,它直接等于引入的额外时延。3 包 60 毫秒适合局域网,公网建议 5~6 包。攒够之前pop返回None并且不推进base_seq,这一点很重要——如果没攒够就推进,会把前几个包全部判成丢失。
窗口上限 2000 是一个防呆值。正常情况下迟到的包差值只有几十,一旦出现几千的差值,说明对端换了 SSRC 或者重启了,这时候重新对齐比继续等更合理。
4.3 丢包补偿:重复上一帧加淡出
G711 没有帧间预测,丢一帧就是丢 20 毫秒,不会像某些带记忆的编码那样错误扩散。所以补偿策略可以很轻:重复最后一个好帧,每重复一次衰减一点幅度,连续丢超过 3 帧就填静音码字。
def conceal(last_payload, lost_run, silence_byte=0xD5): """last_payload: 上一个成功解码的 G711 帧;lost_run: 连续丢包计数(从1开始)""" if last_payload is None or lost_run > 3: return bytes([silence_byte]) * 160 # 超出补偿能力,直接静音 gain = 1.0 - 0.25 * lost_run # 每次衰减 25%,避免机械重复感 out = bytearray(len(last_payload)) for i, b in enumerate(last_payload): if b == silence_byte: out[i] = b continue # 只对幅度做衰减,保持压扩码的段号不变,避免解码端重新量化 seg = (b ^ 0x55) & 0x70 out[i] = b if gain > 0.5 else (b ^ 0x55) & 0x0F | seg | (b & 0x80) ^ 0x55 return bytes(out)最后那段按段号操作的取巧写法只是示意,实际工程里更省事的做法是先解码成线性 PCM、乘增益、再重新压扩——多一次量化,但代码可读性好得多,20 毫秒的量级上也听不出差别。
关键在于「连续丢 3 帧就停」。超过 60 毫秒的重复,听感上会变成一种奇怪的嗡鸣,比直接静音难听得多。
4.4 解码回 PCM 并落盘验证
从压扩码回到线性 PCM,标准库audioop在新版本里已经移除,自己写查表也就十几行。A-law 的解码逻辑是固定的:
def alaw2linear(a): a ^= 0x55 # 奇偶位取反,标准要求的预处理 t = (a & 0x0F) << 4 # 段内量化值左移 4 位 seg = (a & 0x70) >> 4 # 段号 0..7 if seg == 0: t += 8 elif seg == 1: t += 0x108 else: t += 0x108 t <<= seg - 1 return t if (a & 0x80) else -t # 最高位是符号位,1 为正前两段的偏移量8和0x108是折线表的固定常数,不是可调参数;第三段开始按段号左移,这是 A-law 折线段斜率的体现。写错这两个常数,解码出来的声音会有明显的「阶梯感」,安静段落尤其明显。
验证方式很朴素:把解码后的 16 bit PCM 按 8000 Hz、单声道写成一个 wav,用耳朵听。这个动作比看任何统计指标都快。听得出问题,再去抓包里找;听不出问题,说明链路基本可用。
5. 避坑与排查:G711 over RTP 最常见的五个翻车现场
5.1 能通,但全是沙沙声
现象:通话建立成功,双方都能收到包,但听到的是持续的白噪声,偶尔能辨认出人声轮廓。
原因:净荷里塞的是 16 bit 线性 PCM,或者虽然做了压扩但字节序搞反了。G711 一字节一样本,对端按 8 bit 解 16 bit 数据,等于把每个样本拆成两个、播放速度减半、内容全乱。
解决:抓一个包,看 RTP 净荷长度是不是正好等于时间戳差值(20 毫秒包应该是 160)。长度是 320 就说明没压扩。另外单独验一次编码器输出:把输入的正弦波压扩后再解码,波形应该基本还原;如果解出来是方波或者全零,问题在压扩那一步而不在 RTP 层。
5.2 声音节奏对但音调不对
现象:语速正常,人声变得又尖又细,或者又低又闷,像磁带快放慢放。
原因:采样率不匹配。发送侧按 16 kHz 采集、按 8 kHz 的标准压扩,或者接收侧按 16 kHz 播放 8 kHz 数据。RTP 头里的时间戳是对的,但实际音频内容的采样率跟协商的不一致。
解决:检查三处——SDP 里a=rtpmap:8 PCMA/8000的采样率是不是 8000;重采样那一步有没有真的执行(打断点看输出长度是不是输入的一半);播放端有没有按 8 kHz 打开设备。音调偏差刚好是两倍或一半,基本可以锁定在采样率,而不是时间戳。
5.3 单向无声,反向正常
现象:A 能听到 B,B 听不到 A,抓包看两个方向都有 RTP 流量。
原因:载荷类型不对称。一侧发的是 PCMA(PT 8),另一侧按 PCMU(PT 0)解。G711 的两种压扩码是完全不同的映射表,用错表解出来的不是噪声而是严重失真的、音量极小的声音,很容易被误判成「没有声音」。另一种可能是 SSRC 冲突,两路流被接收端合并成一路。
解决:抓包看两个方向的 PT 值是否一致,或者接收端是否按收到的 PT 动态选择解码器,而不是写死。SSRC 冲突看接收端的统计页面,如果收到的包数远大于发送端发出的包数,就是两路流混到一起了。
5.4 每隔十几秒卡一下,随后恢复
现象:通话大部分时间正常,但周期性出现一次明显的停顿或者「噗」的一声。
原因:发送节奏用固定sleep累积了漂移,时间戳逐渐偏离真实时间,收端抖动缓冲攒到上限后被迫丢包重对齐。另一种可能是抖动缓冲深度设得太小,网络稍有抖动就掏空。
解决:把发送循环改成next_t累加式(见 3.2),并把落后时的行为从「补发」改成「重新对齐」。抖动缓冲深度从 3 包提到 5 包试一次。如果周期性卡顿的间隔正好等于缓冲深度乘以帧长,那基本可以确认是缓冲被掏空。
5.5 按键音对方听不到,或者听成别的声音
现象:语音正常,但 DTMF 按键对方识别不出来,或者识别成错误的号码。
原因:把 DTMF 当成普通音频走 G711 带内传输。G711 的压扩对小信号是不均匀的,双音多频的两个频率分量经过压扩后谐波结构被改变,接收侧的检测器容易判错。同时语音编码器本身的非线性也会破坏 DTMF 的幅度关系。
解决:DTMF 走带外,用 RTP 的 telephone-event 载荷类型(通常是动态 PT,SDP 里通过a=rtpmap:101 telephone-event/8000协商),按键作为独立的事件包发送,语音流不受影响。如果链路两端有一端不支持带外,那就只能在带内传,但要保证这一段不做任何增益调整和降噪处理。
6. 抓包验证与落盘试听:两招确认封装真的对了
封装写完,我一般不急着接播放器,先做两件事。
第一件是把裸码流落盘试听。取一段发送侧的 G711 数据,按 8000 Hz、单声道、对应压扩类型写成 wav(A-law 的 wav 格式标签是 6,μ-law 是 7),直接用系统播放器打开。这一步能立刻区分「编码问题」和「传输问题」——如果落盘的声音干净,那就是 RTP 层的事;如果落盘就已经发闷、有咔哒声,跟网络一点关系都没有。
第二件是抓包做量化统计。用tshark把 RTP 字段拉成表格,重点看序号有没有跳变、时间戳差值是否恒等于 160:
# 提取 RTP 流的关键字段,按时间排序输出 tshark -r call.pcapng -Y "rtp" \ -T fields \ -e frame.time_relative \ -e ip.src \ -e rtp.seq \ -e rtp.timestamp \ -e rtp.p_type \ -e rtp.marker \ -e rtp.len \ | sort -n-Y "rtp"是显示过滤器,只留 RTP 包;-T fields配合-e输出成制表符分隔的纯文本,方便后面用脚本扫。rtp.len是净荷长度,正常应该恒等于 160。如果这一列出现了 320 或者 0,说明打包逻辑有分支没覆盖到。
拿到表格后,用几行脚本扫一遍异常:
prev_seq = prev_ts = None for line in open('rtp.tsv'): t, src, seq, ts, pt, m, ln = line.rstrip('\n').split('\t') seq, ts, ln = int(seq), int(ts), int(ln) if prev_seq is not None: d = (seq - prev_seq) & 0xFFFF if d != 1: print(f'{t}s 序号跳变: {prev_seq} -> {seq} (差 {d})') dt = (ts - prev_ts) & 0xFFFFFFFF if dt != 160: print(f'{t}s 时间戳异常: 步进 {dt}, 净荷 {ln}') prev_seq, prev_ts = seq, ts序号跳变定位丢包,时间戳步进异常定位打包逻辑。两者同时出错,通常是发送侧的帧切分和节奏控制没对齐。
还有一个容易被忽略的检查项:抓包文件里同一个 SSRC 是否只对应一条时间戳单调递增的流。如果同一 SSRC 下时间戳出现大幅回退,说明有设备在重启后复用了 SSRC,收端必须能识别并重新对齐,否则抖动缓冲会一直卡在「等一个永远不会来的包」的状态。
再补一个实操技巧:把tshark的 RTP 流分析打开,-z rtp,streams会给出一张按 SSRC 汇总的表,包含包数、丢包数、最大抖动、最大偏差。这张表适合快速判断「问题在不在 RTP 层」,比逐包看高效得多。但它的丢包统计依赖序号连续性,如果发送侧本身就在跳号,表里的数字会误导人——所以先跑上面的脚本确认序号是干净的,再看这张汇总表。
血泪经验是:不要一上来就怀疑网络。这类问题里,编码器输出格式不对、时间戳步进写错、载荷类型写死这三种情况占了大半,而它们全都能靠「落盘听三秒」和「看净荷长度是不是 160」当场定位。从那以后我每接一条语音链路,都强制先把裸码流落成 wav 听一遍,再去碰 RTP 头。希望帮到你。
本文还有配套的精品资源,点击获取