简介:这份资源围绕G.711音频编码与RTP实时传输的封装实现展开,面向从事VoIP开发、实时音频流传输或网络协议学习的开发者与相关专业学生。包内共4个文件,以C语言源码、SDP会话描述文件、G.711音频样本及说明文档为主,压缩包约2.16MB,体量轻便,便于快速上手调试。核心代码演示了如何读取G.711编码的音频数据,按RTP头部结构生成包含序列号、时间戳等字段的数据包,再经UDP发送,并配合SDP文件在VLC中完成接收与播放验证。读者可借此理解G.711的μ-law与A-law编码差异、RTP封装流程以及SDP在会话建立中的作用,获得一套可直接编译运行的实时音频传输参考实现,适合作为VoIP或流媒体项目的入门范例与排错对照。目前已有1486人学习下载。
1. 从一段“能听但别细听”的语音说起:G711 封装 RTP 到底在做什么
如果你抓过 VoIP 或者对讲机的包,大概率见过一种情况:Wireshark 里 RTP 流能正常解码出声音,但一听全是电流底噪,或者断断续续像机器人。十有八九,问题不在编解码器本身,而在 G711 到 RTP 的封装环节。G711 是 ITU-T 定义的最古老也最稳的语音编码标准之一,分 A-law 和 μ-law 两个分支,采样率固定 8kHz,每样本 8 bit,码率恒定为 64 kbps。它不压缩、不算复杂,就是一条“把模拟语音采样后直接映射成 8 位码字”的直路。而 RTP 负责给这些码字加上时间戳、序列号和 SSRC,让接收端能按正确顺序、正确节奏还原成连续语音。把 G711 塞进 RTP,本质上是解决“裸码流没有时间基准、没有顺序标识、没有会话区分”这三个问题。适合谁看?做嵌入式语音网关的、写 SIP 客户端的、调对讲协议栈的,以及所有被“声音能出来但不对”折磨过的人。下面按我实际拆包和写封包代码的顺序,把这件事从头到尾走一遍。
2. G711 编码与 RTP 封包的底层账:先算清 160 字节怎么来的
2.1 采样、量化与 A-law/μ-law 的取舍
G711 的输入是 8kHz 采样、16 bit 线性 PCM。编码器要做的是把 16 bit 压成 8 bit,同时尽量保留语音可懂度。A-law 和 μ-law 的区别在于量化曲线:A-law 在小信号区线性度稍好,μ-law 在北美和日本更常见。两者码率一样,封装到 RTP 时 Payload Type 不同——静态 PT 里 8 是 A-law(PCMA),0 是 μ-law(PCMU)。选哪个不是技术优劣问题,是互通问题:对端 SIP 信令里写的是 PCMU,你封成 PCMA,声音出来就是噪声。我一般先看 SDP 里的m=audio行,确认a=rtpmap再决定编码分支。
编码本身可以用查表法,也可以用位运算。以 A-law 为例,核心是把 13 bit 的线性值映射成 8 bit 的符号位、段落码和段内码。下面这段 Python 是常见的查表实现,用于把 16 bit PCM 转成 A-law 字节流:
import numpy as np def pcm16_to_alaw(pcm_data): # pcm_data: int16 numpy array, 8kHz mono # 查表法:先构建 65536 项的映射表,实际工程中可预生成 def linear_to_alaw(sample): sign = 0x80 if sample >= 0 else 0x00 if sample >= 0: sample = sample else: sample = -sample - 1 if sample > 32767: sample = 32767 # 找到最高有效位位置,确定段落 if sample < 256: seg = 0 step = 16 else: seg = 0 temp = sample >> 8 while temp > 1: temp >>= 1 seg += 1 step = 16 << seg # 段内量化 quant = (sample - (step if seg > 0 else 0)) // (step // 16) if seg > 0 else sample // 16 if quant > 15: quant = 15 alaw = sign | (seg << 4) | quant return alaw ^ 0x55 # A-law 偶数位取反 return np.array([linear_to_alaw(int(s)) for s in pcm_data], dtype=np.uint8)这段代码的逻辑是:先取符号位,再根据幅度确定段落号 seg,最后在段内做 16 级量化。^ 0x55是 A-law 规定的交替位翻转,防止全零码流。参数上,输入必须是 8kHz 单声道 int16,如果你手里是 16kHz 或双声道,必须先重采样和降混,否则 RTP 时间戳会翻倍,接收端播放速度直接快一倍。
2.2 RTP 头里每个字段对 G711 意味着什么
RTP 固定头 12 字节,对 G711 来说关键字段就四个:序列号、时间戳、SSRC、Payload Type。序列号每发一个 RTP 包加 1,用来检测丢包和乱序。时间戳的增量等于本包携带的采样点数,G711 是 8kHz,一个包通常装 160 个样本,对应 20ms,所以时间戳每次加 160。SSRC 是会话内随机生成的 32 位标识,同一路语音流必须保持一致。Payload Type 在静态映射里 PCMA=8、PCMU=0,如果走动态 PT,需要在 SDP 里用a=rtpmap:96 PCMA/8000声明。
下面是一个手工构造 RTP 头的 Python 片段,把 G711 码流按 20ms 打包:
import struct import random def build_rtp_header(seq, timestamp, ssrc, pt=8, marker=0): # V=2, P=0, X=0, CC=0 first_byte = 0x80 second_byte = (marker << 7) | (pt & 0x7f) header = struct.pack('!BBHII', first_byte, second_byte, seq & 0xffff, timestamp & 0xffffffff, ssrc & 0xffffffff) return header def packetize_g711(alaw_bytes, ssrc, start_seq=0, start_ts=0, ptime=20): # ptime=20ms, 8kHz -> 160 samples per packet samples_per_packet = 8000 * ptime // 1000 packets = [] seq = start_seq ts = start_ts for i in range(0, len(alaw_bytes), samples_per_packet): chunk = alaw_bytes[i:i + samples_per_packet] if len(chunk) < samples_per_packet: break # 最后不足一包通常丢弃或补静音 header = build_rtp_header(seq, ts, ssrc, pt=8) packets.append(header + chunk.tobytes()) seq += 1 ts += samples_per_packet return packetsstruct.pack的格式!BBHII对应网络字节序的 1+1+2+4+4 字节。marker位在 G711 里通常只在静音抑制后的第一个包置 1,普通连续流置 0。ssrc用random.randint(0, 0xffffffff)生成一次即可,不要每包都变。ptime决定每包样本数,20ms 是默认值,改成 10ms 则每包 80 样本,时间戳增量也变 80,接收端 jitter buffer 行为会不同。
2.3 一个完整的封包流程与验证方法
把上面两步串起来:读 PCM 文件 → 转 A-law → 按 20ms 切片 → 加 RTP 头 → 发 UDP。验证时我习惯先用 Wireshark 抓本地回环,看 RTP 流的Payload Type是否和 SDP 一致,再看Time delta是否稳定在 20ms 左右。如果 delta 忽大忽小,说明发送线程被阻塞或者时间戳算错了。另一个土办法是把 RTP payload 单独导出成.raw,用 Audacity 以 8kHz A-law 导入,能直接听出编码是否正确。这一步能排掉大部分“声音不对”的玄学问题。
3. 从 PCM 到 UDP 的完整链路:发送端与接收端怎么对齐
3.1 发送端:采样线程与发送线程的节奏控制
实际工程里,PCM 来源可能是声卡、文件或者另一个编解码器。发送端最常见的翻车点是“采样线程和发送线程各跑各的”,导致时间戳和实际采集节奏对不上。我一般用一个环形缓冲区:采样线程按 8kHz 往里写 int16,发送线程每 20ms 取 160 个样本,转 A-law 后封包发出。取不到 160 个就等,取多了就丢最旧的。下面是一个简化的双线程模型:
import threading import time import collections class G711Sender: def __init__(self, ssrc, ptime=20): self.buf = collections.deque() self.ssrc = ssrc self.ptime = ptime self.seq = 0 self.ts = 0 self.lock = threading.Lock() self.running = True def push_pcm(self, pcm_int16): with self.lock: self.buf.extend(pcm_int16) def send_loop(self, sock, dest): samples_per_packet = 8000 * self.ptime // 1000 while self.running: with self.lock: if len(self.buf) >= samples_per_packet: chunk = [self.buf.popleft() for _ in range(samples_per_packet)] else: chunk = None if chunk: alaw = pcm16_to_alaw(np.array(chunk, dtype=np.int16)) header = build_rtp_header(self.seq, self.ts, self.ssrc, pt=8) sock.sendto(header + alaw.tobytes(), dest) self.seq = (self.seq + 1) & 0xffff self.ts = (self.ts + samples_per_packet) & 0xffffffff time.sleep(self.ptime / 1000.0)time.sleep的精度在普通操作系统上只有毫秒级,长时间跑会漂移。更稳的做法是用time.perf_counter()算下一次发送的绝对时间,或者直接用音频库的回调。seq和ts的回绕处理用掩码& 0xffff和& 0xffffffff,不要用取模,掩码更快且语义清晰。
3.2 接收端:jitter buffer 与丢包隐藏的边界
接收端收到 RTP 包后,先按序列号排序,再按时间戳还原播放节奏。jitter buffer 深度是双刃剑:太浅,网络抖动直接导致断音;太深,延迟增加,对讲场景里超过 200ms 就没法对话了。G711 本身没有丢包恢复能力,丢一包就是 20ms 的静音或噪声。常见做法是丢包时用前一包的最后一个样本重复填充,或者直接插静音。下面是一个最小接收端逻辑:
def receive_loop(sock, jitter_depth=3): buffer = {} expected_seq = None while True: data, _ = sock.recvfrom(2048) if len(data) < 12: continue first, second, seq, ts, ssrc = struct.unpack('!BBHII', data[:12]) payload = data[12:] if expected_seq is None: expected_seq = seq buffer[seq] = (ts, payload) # 按序输出 while expected_seq in buffer: ts, payload = buffer.pop(expected_seq) play_alaw(payload) # 送入声卡或解码 expected_seq = (expected_seq + 1) & 0xffff # 丢包检测:如果 buffer 里最小 seq 已经超过 expected_seq 太多 if len(buffer) > jitter_depth: # 跳过丢失的包,用静音填充 expected_seq = (expected_seq + 1) & 0xffffjitter_depth设 3 意味着容忍 3 个包的乱序。play_alaw是伪函数,实际要转回 PCM 再写声卡。注意expected_seq的回绕:从 65535 到 0 时,(expected_seq + 1) & 0xffff能正确归零,但比较大小的时候不能直接用>,要用序列号差值模 65536 来判断新旧。
3.3 用 SDP 协商把 Payload Type 锁死
RTP 本身不关心 PT 含义,但 SIP/SDP 协商决定了收发双方用哪个编码。一个典型的 SDP 音频段长这样:
m=audio 49170 RTP/AVP 8 0 96 a=rtpmap:8 PCMA/8000 a=rtpmap:0 PCMU/8000 a=rtpmap:96 telephone-event/8000 a=ptime:20m=行里的 8 和 0 是静态 PT,96 是动态 PT 用于 DTMF。a=ptime:20告诉对端每包 20ms。如果对端 SDP 里只有 0 没有 8,你发 PCMA 过去就是噪声。我一般会在代码里解析 SDP,取第一个匹配的音频编码,而不是硬编码 PT。这一步不做,后面所有封包逻辑都白搭。
4. 避坑与排查:G711 over RTP 最常见的五类翻车
4.1 现象:能听到声音但全是“沙沙”底噪
原因通常是 A-law 和 μ-law 搞反了。PCMA 和 PCMU 的码流在时域上看起来都是 8 bit,但解码曲线不同,用错解码器就会把量化噪声放大成宽带噪声。解决方法是回到 SDP 确认a=rtpmap,或者用 Wireshark 的 RTP 分析看Payload Type字段。如果 PT=8 但解码器选的是 PCMU,改过来即可。
4.2 现象:声音播放速度明显偏快或偏慢
时间戳增量算错了。G711 固定 8kHz,20ms 包对应 160 个样本,时间戳加 160。如果你按 16kHz 算成 320,接收端会以为每包是 40ms,播放时就会慢一半。反过来,如果采样率是 16kHz 但没重采样就按 8kHz 封包,声音会快一倍。排查时看 RTP 头的 timestamp 差值,和a=ptime是否匹配。
4.3 现象:每隔几秒卡一下,像周期性丢包
发送线程被阻塞了。常见于在发送循环里做日志写盘、内存分配或者锁竞争。time.sleep的漂移累积到一定程度,jitter buffer 就会耗尽。解决方法是把发送线程优先级提高,或者用音频回调驱动发送,避免在发送路径上做任何重操作。另外检查seq回绕时有没有用有符号比较,错误比较会导致接收端认为所有包都是旧的而丢弃。
4.4 现象:Wireshark 能解码但声卡没声音
RTP 包发出去了,但接收端没把 payload 送进声卡。检查play_alaw是否真的被调用,以及声卡采样率是否设成了 8kHz。很多声卡默认 44.1kHz 或 48kHz,直接写 8kHz A-law 数据会被重采样成怪声。正确做法是先 A-law 解码回 16 bit PCM,再用声卡支持的采样率播放,或者让声卡以 8kHz 打开。
4.5 现象:DTMF 按键音变成刺耳噪声
DTMF 通常用 RFC 2833 的 telephone-event 走独立 PT(如 96),不能混在 G711 音频流里。如果你把 DTMF 频率直接生成 PCM 再 G711 编码,经过量化后频率会偏移,接收端识别不出来。正确做法是音频流走 PCMA/PCMU,DTMF 事件走a=rtpmap:96 telephone-event/8000,两者用不同 PT 区分。
5. 进阶:用 Python 做端到端回环验证与抓包对照
把发送端和接收端跑在同一台机器上,用回环地址发 UDP,是最快的验证方式。我一般会写一个测试脚本:生成 1kHz 正弦波 PCM,编码成 A-law,按 20ms 封包发到127.0.0.1:5004,接收端收包后解码回 PCM,存成 WAV。然后用 Audacity 对比原始正弦波和回环后的波形,看频率是否还是 1kHz,幅度是否在量化误差范围内。如果频率偏了,查时间戳;如果幅度不对,查 A-law 查表。
下面是一个端到端回环的骨架代码:
import socket import numpy as np import wave def generate_sine(freq=1000, duration=1.0, rate=8000): t = np.linspace(0, duration, int(rate * duration), endpoint=False) return (np.sin(2 * np.pi * freq * t) * 16000).astype(np.int16) def alaw_to_pcm16(alaw_bytes): # 简化的 A-law 解码,实际工程用查表 out = [] for b in alaw_bytes: b ^= 0x55 sign = b & 0x80 seg = (b >> 4) & 0x07 quant = b & 0x0f if seg == 0: val = (quant << 4) + 8 else: val = ((quant << 4) + 8 + 256) << (seg - 1) out.append(-val if sign else val) return np.array(out, dtype=np.int16) # 发送 pcm = generate_sine() alaw = pcm16_to_alaw(pcm) ssrc = 0x12345678 packets = packetize_g711(alaw, ssrc, start_seq=0, start_ts=0, ptime=20) sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) for pkt in packets: sock.sendto(pkt, ('127.0.0.1', 5004)) # 接收(简化:直接收固定数量) recv_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) recv_sock.bind(('127.0.0.1', 5004)) recv_sock.settimeout(2.0) received = [] try: while True: data, _ = recv_sock.recvfrom(2048) received.append(data[12:]) except socket.timeout: pass # 解码并写 WAV all_alaw = b''.join(received) pcm_out = alaw_to_pcm16(all_alaw) with wave.open('loopback.wav', 'wb') as wf: wf.setnchannels(1) wf.setsampwidth(2) wf.setframerate(8000) wf.writeframes(pcm_out.tobytes())这段代码里alaw_to_pcm16是解码方向,和编码方向互为逆运算。packetize_g711和前面的发送逻辑一致。跑完后用 Audacity 打开loopback.wav,如果看到 1kHz 正弦波且没有明显断裂,说明封包、时间戳、序列号、编解码全链路是通的。如果波形有周期性缺口,检查packetize_g711里最后不足一包的处理——我一般直接丢弃,因为补静音会引入额外延迟。
抓包对照是另一道保险。在回环测试的同时用 Wireshark 抓lo接口,过滤rtp,看Seq是否连续、Time差值是否稳定在 20ms、Payload Type是否等于 8。如果 Wireshark 显示Malformed Packet,多半是 RTP 头版本位或扩展位设错了。我习惯在每次改完封包代码后,先跑一遍回环加抓包,确认这三项再上真实网络。从那以后我每次调语音链路都强制走一遍“正弦波回环 + Wireshark 对照”,能省掉大量靠耳朵猜的时间。希望帮到你。
本文还有配套的精品资源,点击获取