弱网 QoS 引擎(抖动缓冲 / 丢包恢复 / 码率自适应)

前面的推流/通话在理想网络下跑通了,但真实网络是「会抖、会丢、会变慢」的。地铁里卡成 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 倍目标时丢帧
3FEC 冗余过高上行带宽翻倍固定 group_size=4 不随丢包率变丢包 <5% 时关 FEC 用 NACK,>10% 才开 FEC
4码率振荡码率在高低间剧烈跳降档后立即升档,没观察期降档后强制 hold 500ms~1s 再考虑升
5WiFi 切 4G 断流切换瞬间黑屏码率仍按 WiFi 高带宽发监听网络切换,立即重置到 minBitrate
6NACK 风暴丢包时上行被 NACK 塞满每个丢包都立即 NACK,不限制限制重传次数(3次)+ 间隔(100ms)+ 批量聚合

7、QoS 效果对比

场景无 QoS加 Jitter Buffer加 FEC/NACK加码率自适应
10% 丢包花屏严重花屏依旧✅ 可看✅ 流畅
50ms 抖动卡顿✅ 平滑✅ 平滑✅ 平滑
带宽 4M→1M卡死卡死卡死✅ 自动降档
RTT 400ms延迟大延迟大延迟大✅ 降低码率

结论: 单点优化只能解决一类问题。抖动缓冲管抖动、FEC/NACK 管丢包、码率自适应管带宽,三者叠加才是完整的 QoS。这也是 WebRTC 的内核思路。

学习和提升音视频开发技术,欢迎你加入我们的知识星球

弱网 QoS 引擎(抖动缓冲 / 丢包恢复 / 码率自适应)

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

(0)

相关推荐