前面的推流/通话在理想网络下跑通了,但真实网络是「会抖、会丢、会变慢」的。地铁里卡成 PPT、WiFi 切 4G 断流、晚高峰卡顿——这些全靠 QoS 引擎兜底。本文用 Claude Code 写一个能扛 30% 丢包的弱网对抗引擎。
1、弱网的三个敌人
实时音视频的体验,九成取决于网络,一成取决于编码。三个敌人:
- 📉 抖动 (Jitter):包到达时间不稳定,忽快忽慢,直接播放会「一卡一卡」
- 🕳️ 丢包 (Loss):UDP 不重传,包丢了就是永久丢了,画面花屏、声音断音
- 📶 带宽波动 (Bandwidth):WiFi→4G 切换、网络拥塞,固定码率必然卡顿
网络质量 vs 用户感知:
RTT < 50ms, Loss < 1% → 流畅(无需处理)
RTT 50-200ms, Loss 1-10% → 偶尔卡(需要 jitter buffer + 自适应)
RTT > 200ms, Loss > 10% → 严重卡顿(需要全面 QoS)
Loss > 30% → 不可用(需要 FEC/重传兜底)
核心思路: 抖动靠「缓冲」吸收,丢包靠「恢复」(FEC/重传),带宽靠「自适应」。三者协同,缺一不可。
2、QoS 引擎架构
┌───────────────────────────────────────────────┐
│ QoS 引擎 │
│ │
│ 接收侧 发送侧 │
│ ┌──────────────┐ ┌──────────────┐ │
│ │ Jitter Buffer │ │ 码率自适应 │ │
│ │ (吸收抖动) │◄─────│ (探测带宽) │ │
│ └──────┬───────┘ RTCP │ ┌──────────┐ │ │
│ │ 反馈 │ │ FEC 编码 │ │ │
│ ┌──────▼───────┐ │ │ (冗余包) │ │ │
│ │ 丢包恢复 │ │ └──────────┘ │ │
│ │ FEC解码/NACK │ │ │ NACK 重传 │ │ │
│ └──────┬───────┘ │ └──────────┘ │ │
│ │ └──────────────┘ │
│ ┌──────▼───────┐ │
│ │ 解码上屏 │ │
│ └──────────────┘ │
└───────────────────────────────────────────────┘
3、Jitter Buffer:吸收网络抖动
3.1、Prompt
帮我写一个音视频的抖动缓冲 (Jitter Buffer)。
【功能】
- 接收乱序到达的 RTP 包,按序号排序
- 估算网络抖动 (jitter),动态调整缓冲目标时长
- 缓冲时间过长时丢帧追赶(不要无限延迟)
- 提供「取出可播放包」的接口
【抖动估算】
- 用 RFC 3550 的 jitter 计算公式
- 目标缓冲 = 基础延迟 + 4 × jitter(覆盖 99.9% 抖动)
- 上下限约束:最小 40ms,最大 500ms
【要求】中文注释,双端一致逻辑
3.2、Jitter Buffer 实现
// JitterBuffer.kt (Android)
// 抖动缓冲:吸收网络抖动,输出平滑的包序列
import java.util.TreeMap
import kotlin.math.abs
class JitterBuffer(
private val minBufferMs: Long = 40, // 最小缓冲
private val maxBufferMs: Long = 500, // 最大缓冲
private val baseDelayMs: Long = 60 // 基础延迟
) {
// 按序号排序的包缓存
private val packets = TreeMap<Long, RtpPacket>()
// 抖动估算状态 (RFC 3550)
private var lastTransit = 0L
private var jitter = 0.0 // 单位:时间戳单位
private var clockRate = 90000L // 视频 90kHz
// 缓冲目标
@Volatile var targetBufferMs: Long = baseDelayMs
private set
/** 接收一个包 */
fun onPacketReceived(packet: RtpPacket) {
// 1. RFC 3550 jitter 估算
val transit = packet.arrivalMs - packet.timestamp * 1000 / clockRate
val d = abs(transit - lastTransit)
lastTransit = transit
// jitter = jitter + (|D| - jitter) / 16
jitter = jitter + (d - jitter) / 16.0
// 2. 动态调整目标缓冲
val jitterMs = jitter.toLong()
targetBufferMs = (baseDelayMs + 4 * jitterMs)
.coerceIn(minBufferMs, maxBufferMs)
// 3. 入队(TreeMap 自动按序号排序)
packets[packet.sequence] = packet
}
/** 取出可播放的包(按序) */
fun popPlayable(playbackMs: Long): RtpPacket? {
val first = packets.firstEntry() ?: return null
// 判断最早包是否「等待够了」:缓冲时长达到目标
val bufferedMs = playbackMs - first.value.arrivalMs
if (bufferedMs < targetBufferMs) {
return null // 还没缓冲够,等待
}
// 缓冲够了,弹出最早的包
return packets.pollFirstEntry()?.value
}
/** 缓冲过长时追赶:丢弃超过 2 倍目标的包 */
fun dropIfTooSlow(playbackMs: Long) {
while (packets.size > 1) {
val first = packets.firstEntry()
val bufferedMs = playbackMs - first.value.arrivalMs
if (bufferedMs > targetBufferMs * 2) {
packets.pollFirstEntry() // 丢帧追赶,牺牲画质换低延迟
} else break
}
}
data class RtpPacket(
val sequence: Long, // RTP 序号
val timestamp: Long, // RTP 时间戳
val arrivalMs: Long, // 到达时刻 (ms)
val payload: ByteArray
)
}
Jitter Buffer 的权衡: 缓冲越大越平滑,但延迟越高。
4 × jitter是个经验值,覆盖 99.9% 的抖动;实时通话宁可牺牲一点平滑,也要把延迟压下来。
4、丢包恢复:FEC 前向纠错
丢包了怎么办?两个思路:FEC(发送冗余,丢了自己算出来)和 NACK(发现丢了,请求重传)。
4.1、FEC:异或冗余编码
#!/usr/bin/env python3
"""
fec_xor.py
Claude Code 生成的 XOR-FEC 演示:每 N 个包生成 1 个冗余包
丢任意 1 个包都能恢复
"""
def xor_fec_encode(packets: list[bytes], group_size: int = 4) -> list[bytes]:
"""
XOR-FEC 编码
每组 group_size 个包,生成 1 个冗余包(逐字节异或)
冗余包放在组末尾
"""
output = []
for i in range(0, len(packets), group_size):
group = packets[i:i + group_size]
if not group:
continue
# 冗余包 = 组内所有包的逐字节 XOR
max_len = max(len(p) for p in group)
redundant = bytearray(max_len)
for p in group:
for j, b in enumerate(p):
redundant[j] ^= b
output.extend(group) # 原始包
output.append(bytes(redundant)) # 冗余包
return output
def xor_fec_decode(received: list[bytes | None], group_size: int = 4) -> list[bytes]:
"""
XOR-FEC 解码:received 中 None 表示丢失的包
每组丢 1 个包时,用剩余包 + 冗余包恢复
"""
recovered = []
for i in range(0, len(received), group_size + 1):
group = received[i:i + group_size + 1]
if len(group) < group_size + 1:
break
redundant = group[-1] # 最后一个是冗余包
data_packets = group[:-1]
# 统计丢失数量
lost_indices = [idx for idx, p in enumerate(data_packets) if p is None]
if len(lost_indices) == 0:
recovered.extend(data_packets) # 全收到,直接通过
elif len(lost_indices) == 1:
# 恰好丢 1 个:用冗余包 XOR 其余包恢复
lost_idx = lost_indices[0]
reconstructed = bytearray(redundant)
for idx, p in enumerate(data_packets):
if idx != lost_idx and p is not None:
for j, b in enumerate(p):
reconstructed[j] ^= b
recovered.insert(len(recovered), bytes(reconstructed))
# 补回其余包(保持顺序)
for idx, p in enumerate(data_packets):
if idx != lost_idx and p is not None:
recovered.append(p)
else:
# 丢 2 个以上:无法恢复
recovered.extend([p for p in data_packets if p is not None])
return recovered
# 演示
if __name__ == "__main__":
import random
packets = [f"RTP-packet-{i}".encode() for i in range(8)]
encoded = xor_fec_encode(packets, group_size=4)
print(f"原始 {len(packets)} 包 → FEC 后 {len(encoded)} 包(+25% 冗余)")
# 模拟丢包:随机丢 1 个包,用 None 标记
received = [p if random.random() > 0.12 else None for p in encoded]
recovered = xor_fec_decode(received, group_size=4)
print(f"丢包后恢复 {len(recovered)} 个包")
FEC 的代价: 每 4 个包加 1 个冗余 = 25% 冗余带宽。丢包率低时(<5%)用 NACK 重传更省带宽;丢包率高时(>10%)FEC 更合适。WebRTC 里的 Opus 还有「带内 FEC」——把上一帧的低码率版本塞进当前帧,语音场景白嫖纠错能力。
4.2、NACK:选择性重传
// NackRequester.kt (Android)
// 选择性重传:检测到序号空洞 → 发送 NACK 请求
class NackRequester(
private val sendNack: (List<Long>) -> Unit // 回调:发送 NACK 到对端
) {
private val received = mutableSetOf<Long>()
private var lastSequence = -1L
private val nackHistory = mutableMapOf<Long, Long>() // seq -> 上次请求时间
fun onPacketReceived(sequence: Long) {
received.add(sequence)
// 检测序号空洞(丢包)
if (lastSequence >= 0 && sequence > lastSequence + 1) {
val missing = (lastSequence + 1 until sequence).toList()
requestRetransmit(missing)
}
lastSequence = maxOf(lastSequence, sequence)
}
private fun requestRetransmit(missing: List<Long>) {
val now = System.currentTimeMillis()
val toRequest = missing.filter { seq ->
// 每个包最多重传 3 次,间隔 100ms
val last = nackHistory[seq] ?: 0
val count = nackCount(seq)
count < 3 && now - last > 100
}
if (toRequest.isNotEmpty()) {
toRequest.forEach { seq ->
nackHistory[seq] = now
nackCountMap[seq] = nackCount(seq) + 1
}
sendNack(toRequest)
}
}
private val nackCountMap = mutableMapOf<Long, Int>()
private fun nackCount(seq: Long) = nackCountMap[seq] ?: 0
}
5、码率自适应:带宽探测与降档
网络变差时,最快见效的手段是「降码率」。核心是一个基于丢包率的状态机。
5.1、基于丢包率 + RTT 的自适应算法
// BitrateController.swift (iOS)
// 码率自适应:基于丢包率和 RTT 的 AIMD 算法
final class BitrateController {
enum State { case increase, hold, decrease }
private(set) var currentBitrate: Int
private let minBitrate: Int
private let maxBitrate: Int
private var state: State = .increase
private var consecutiveLossReports = 0
init(minBitrate: Int = 200_000, maxBitrate: Int = 4_000_000, initial: Int = 1_000_000) {
self.minBitrate = minBitrate
self.maxBitrate = maxBitrate
self.currentBitrate = initial
}
/// 每次收到 RTCP 接收报告时调用
/// - Parameters:
/// - lossFraction: 丢包率 (0~1)
/// - rttMs: 往返时延
func onFeedback(lossFraction: Double, rttMs: Double) -> Int {
// 1. 丢包率高 → 降码率
if lossFraction > 0.10 {
state = .decrease
consecutiveLossReports += 1
// 丢包越严重,降得越狠(乘性下降)
let factor = max(0.5, 1.0 - lossFraction)
currentBitrate = max(minBitrate, Int(Double(currentBitrate) * factor))
}
// 2. RTT 高但没丢包 → 带宽接近饱和,保持
else if rttMs > 300 {
state = .hold
consecutiveLossReports = 0
}
// 3. 网络良好 → 试探性加码率(加性增长)
else {
consecutiveLossReports = 0
if state == .decrease || state == .hold {
// 降档后先观察一段时间,避免抖动
state = .hold
} else {
// AIMD:每次 +8% 试探
currentBitrate = min(maxBitrate, Int(Double(currentBitrate) * 1.08))
}
}
return currentBitrate
}
/// 快速切网(WiFi→4G)时立即重置到保守值
func onNetworkChange() {
currentBitrate = minBitrate
state = .increase
consecutiveLossReports = 0
}
}
AIMD 的精髓: 加性增(慢慢加,每次 +8%),乘性减(丢包就砍半,快速止损)。这是 TCP 拥塞控制验证了几十年的智慧,实时音视频直接照搬。
6、踩坑记录
| # | 问题 | 现象 | 根因 | 修复 |
|---|---|---|---|---|
| 1 | 缓冲永远不满 | 一直「加载中」 | jitter 估算初值 0,target 太小 | 初始 target 设为 baseDelay,jitter 用滑动平均收敛 |
| 2 | 视频越播越卡 | 延迟累积到 2 秒 | 缓冲只增不减,抖动后不追赶 | 加 dropIfTooSlow,缓冲 >2 倍目标时丢帧 |
| 3 | FEC 冗余过高 | 上行带宽翻倍 | 固定 group_size=4 不随丢包率变 | 丢包 <5% 时关 FEC 用 NACK,>10% 才开 FEC |
| 4 | 码率振荡 | 码率在高低间剧烈跳 | 降档后立即升档,没观察期 | 降档后强制 hold 500ms~1s 再考虑升 |
| 5 | WiFi 切 4G 断流 | 切换瞬间黑屏 | 码率仍按 WiFi 高带宽发 | 监听网络切换,立即重置到 minBitrate |
| 6 | NACK 风暴 | 丢包时上行被 NACK 塞满 | 每个丢包都立即 NACK,不限制 | 限制重传次数(3次)+ 间隔(100ms)+ 批量聚合 |
7、QoS 效果对比
| 场景 | 无 QoS | 加 Jitter Buffer | 加 FEC/NACK | 加码率自适应 |
|---|---|---|---|---|
| 10% 丢包 | 花屏严重 | 花屏依旧 | ✅ 可看 | ✅ 流畅 |
| 50ms 抖动 | 卡顿 | ✅ 平滑 | ✅ 平滑 | ✅ 平滑 |
| 带宽 4M→1M | 卡死 | 卡死 | 卡死 | ✅ 自动降档 |
| RTT 400ms | 延迟大 | 延迟大 | 延迟大 | ✅ 降低码率 |
结论: 单点优化只能解决一类问题。抖动缓冲管抖动、FEC/NACK 管丢包、码率自适应管带宽,三者叠加才是完整的 QoS。这也是 WebRTC 的内核思路。
学习和提升音视频开发技术,欢迎你加入我们的知识星球

版权声明:本文内容转自互联网,本文观点仅代表作者本人。本站仅提供信息存储空间服务,所有权归原作者所有。如发现本站有涉嫌抄袭侵权/违法违规的内容, 请发送邮件至1393616908@qq.com 举报,一经查实,本站将立刻删除。