[webrtc] encoder 1/fps time_base 量化导致活跃期 RTP 时间膨胀,缓冲累积 10+ 秒 #25

Closed
opened 2026-06-20 22:42:07 +08:00 by dailz · 0 comments
Owner

现象

#24 修复后(PTS 已正确传播),浏览器 jitter buffer 在活跃期间仍然累积,导致:

  1. jitterBufferDelay(chrome://webrtc-internals):

    • 静态期:10-20 ms
    • 活跃期:1000+ ms (用户移鼠标时)
  2. 10 秒尾随现象(关键新症状):

    • 用户在服务端大幅移动鼠标 10 秒
    • 停止移动后,客户端继续显示鼠标移动 10 秒
    • 表明浏览器累积了约 10 秒的帧缓冲

之前 #24 只解决了静态期 PTS,活跃期问题仍在。

根因分析

1. encoder time_base = 1/fps 的精度限制

WebRTC encoder 初始化用 enc.set_time_base(ff::Rational::new(1, fps as i32))(src/avhw.rs:1810 等)。30fps 时每个 PTS tick = 33ms。

2. compute_capture_pts 的整数除法量化

src/state_portal.rs:581:

let ticks_i128 = (relative_ns.saturating_mul(i128::from(self.args.fps))) / NS_PER_SEC;

KWin 在活跃期间以 60fps 送帧(每 16.7ms):

真实时间 relative_ns 计算 (ns × 30 / 1e9) ticks
T=0 0 0 0
T=16.7ms 16_700_000 501_000_000 / 1e9 0(整数除法)
T=33.3ms 33_300_000 999_000_000 / 1e9 0
T=50ms 50_000_000 1_500_000_000 / 1e9 1
T=66.7ms 66_700_000 2_001_000_000 / 1e9 2
T=83.3ms 83_300_000 2_499_000_000 / 1e9 2

多帧映射到同一 tick。state_portal.rs:584-588 的 monotonicity guard 强制顺序化:

if pts <= last {
    pts = last.checked_add(1).unwrap_or(last);
}

结果:0, 1, 2, 3, 4, 5, ...(顺序)。

3. 致命错位:真实时间 vs RTP 时间

1 秒真实时间 @ 60fps 捕获 = 60 帧
60 帧 sequential RTP(每帧间隔 3000 = 33ms)
RTP 时间跨度:60 × 33ms = 1.98 秒 RTP 时间

1 真实秒变成 2 RTP 秒

浏览器按 RTP 速率(半速)播放 → 帧到达比播放快 → 缓冲累积。

用户移鼠标 10 秒(60fps 捕获):
  真实时间:10 秒
  RTP 时间:20 秒
  浏览器累积缓冲:10 秒

用户停止后,服务端立即停止发送(damage 停止 + hash dedup),但浏览器慢慢排空累积的 10 秒缓冲。这正是用户报告的现象

4. 为什么静态期没问题

静态期间每 600ms 一帧,远超 33ms 粒度。compute_capture_pts 返回准确的 18 ticks 跳变,RTP 跳变 54000,精确反映 600ms 间隔。浏览器看到真实时间 = RTP 时间,jitter 极小。

数学验证(test8 数据)

  • 会话:98.6s,1920 帧,平均 19.5 fps
  • 静态期(用户不动):每 600ms 一帧,RTP 跳变 54000(正确)
  • 活跃期(用户移鼠标):每 16.7ms 一帧,RTP 跳变 3000(量化,被强制顺序化)

如果 50% 时间活跃(60fps 捕获) + 50% 时间静态(1.6fps):

  • 活跃 49s × 60fps = 2940 帧(但编码器只能产 30fps,实际编码 1470 帧)
  • 静态 49s × 1.6fps = ~80 帧
  • 总编码 ~1550 帧(接近实际 1920)

活跃期间每帧 RTP 间隔 3000(33ms),但真实到达间隔是 33ms(因编码器 30fps 上限)。
等等,这看起来 RTP 和真实时间匹配?

不对。问题在于单帧内的 PTS 计算:

  • KWin 送来 60fps 帧(每 16.7ms)
  • compute_capture_pts 用 PipeWire 真实 ns 计算 ticks
  • 因 1/fps 量化,ticks 被压缩
  • monotonicity 把它们 spread 出来,导致 RTP 间隔 > 真实到达间隔

具体:test8 有 2067 个 RTP=3000 跳变,61 个 RTP=54000 跳变。

  • 2067 × 33ms = 68s RTP 时间花在"active"帧上
  • 61 × 600ms = 37s RTP 时间花在"static"帧上
  • 总 RTP 时间 = 105s,真实 98.6s

RTP 时间 > 真实时间 = 浏览器播放慢于真实 = 缓冲累积。验证假设。

(误差可能因为部分帧的 PTS 计算确实接近真实,但整体趋势 RTP 时间膨胀约 7%。长时间活跃会话会放大这个差异。)

修复方案

主修复:encoder time_base 改成 90kHz

src/avhw.rs:1810(WebRTC 路径的 create_software_h264_encoder):

// 旧:
enc.set_time_base(ff::Rational::new(1, fps as i32));

// 新:
// 90kHz 时钟与 RTP 直接对齐,避免 1/fps 粒度量化。
// 在 60fps 捕获下,1/fps time_base 会让 16.7ms 间隔的两帧映射到同一 tick,
// 被 monotonicity guard 强制顺序化,导致 RTP 时间膨胀、浏览器缓冲累积。
// 90kHz 提供 11μs 粒度,完全足够 WebRTC 可变帧率。
enc.set_time_base(ff::Rational::new(1, 90_000));

compute_capture_pts 返回 90kHz ticks

src/state_portal.rs:581:

// 旧:
let ticks_i128 = (relative_ns.saturating_mul(i128::from(self.args.fps))) / NS_PER_SEC;

// 新:
// 90kHz 时钟:1 秒 = 90000 ticks,1 ns = 90/1e9 ticks
const RTP_CLOCK_HZ: i128 = 90_000;
let ticks_i128 = (relative_ns.saturating_mul(RTP_CLOCK_HZ)) / NS_PER_SEC;

rtp_timestamp_from_pts_ticks 简化

src/webrtc.rs 现有实现:

// 旧:
pub fn rtp_timestamp_from_pts_ticks(pts_ticks: i64, fps: u32) -> u32 {
    let fps_safe = (fps.max(1) as u64).max(1);
    let pts_u64 = (pts_ticks.max(0) as u64).min(u64::MAX / TICKS_PER_SECOND);
    pts_u64.saturating_mul(TICKS_PER_SECOND) / fps_safe as u32
}

新签名(去掉 fps 参数,因为 PTS 已经是 90kHz):

// 新:
pub fn rtp_timestamp_from_pts_ticks(pts_ticks: i64) -> u32 {
    pts_ticks.max(0) as u32  // 直接是 RTP 时间戳
}

或者直接 inline 到 write_h264_frame,删除独立函数。

MP4 路径不动

MP4 用 create_software_h264_muxer(另一个 encoder 构造器),保持 1/fps time_base。理由:

  • 文件不需要实时播放
  • 顺序 PTS 配合 1/fps time_base 是 VFR 录制的简化版
  • 改了会影响文件播放速度

期望效果

场景 修复前 修复后
静态期 jitterBufferDelay 10-20 ms 10-20 ms(不变)
活跃期 jitterBufferDelay 1000+ ms < 100 ms
停止操作后尾随 10+ 秒 < 200 ms(几乎立即停止)
总体感知延迟 1-2 秒 < 300 ms(接近实时)

复现

wl-webrtc --port 56666 -v --stats > /tmp/test.log 2>&1
# 浏览器连接,chrome://webrtc-internals 观察
# 1. 静态 30 秒(jitterBufferDelay 应该 10-20 ms)
# 2. 大幅移动鼠标 10 秒(jitterBufferDelay 飙到 1000+ ms)
# 3. 停止移动(客户端继续显示鼠标 10 秒)

关联

  • #24 (CLOSED):修复了 PTS 传播链路,但用 1/fps time_base 仍有量化问题
  • #23 (CLOSED):VBV + 码率 cap + PLI 限流,仍在工作
  • #15 (CLOSED):stall 范式转变,仍在工作
  • #18 (CLOSED):filler 删除,仍在工作
  • #20 (OPEN):frame_age 统计仍然没修(应该独立 commit)

严重度

Critical —— 即使所有之���的修复都生效,产品在用户活跃操作时仍有 1-2 秒延迟和 10 秒尾随。严重影响可用性。

## 现象 在 #24 修复后(PTS 已正确传播),浏览器 jitter buffer 在**活跃期间仍然累积**,导致: 1. **`jitterBufferDelay`**(chrome://webrtc-internals): - 静态期:10-20 ms ✅ - **活跃期:1000+ ms** ❌(用户移鼠标时) 2. **10 秒尾随现象**(关键新症状): - 用户在服务端大幅移动鼠标 10 秒 - **停止移动后,客户端继续显示鼠标移动 10 秒** - 表明浏览器累积了约 10 秒的帧缓冲 之前 #24 只解决了静态期 PTS,活跃期问题仍在。 ## 根因分析 ### 1. encoder time_base = 1/fps 的精度限制 WebRTC encoder 初始化用 `enc.set_time_base(ff::Rational::new(1, fps as i32))`(`src/avhw.rs:1810` 等)。30fps 时每个 PTS tick = 33ms。 ### 2. compute_capture_pts 的整数除法量化 `src/state_portal.rs:581`: ```rust let ticks_i128 = (relative_ns.saturating_mul(i128::from(self.args.fps))) / NS_PER_SEC; ``` KWin 在活跃期间以 60fps 送帧(每 16.7ms): | 真实时间 | relative_ns | 计算 (ns × 30 / 1e9) | ticks | |---|---|---|---| | T=0 | 0 | 0 | 0 | | T=16.7ms | 16_700_000 | 501_000_000 / 1e9 | **0**(整数除法) | | T=33.3ms | 33_300_000 | 999_000_000 / 1e9 | **0** | | T=50ms | 50_000_000 | 1_500_000_000 / 1e9 | 1 | | T=66.7ms | 66_700_000 | 2_001_000_000 / 1e9 | 2 | | T=83.3ms | 83_300_000 | 2_499_000_000 / 1e9 | 2 | 多帧映射到同一 tick。`state_portal.rs:584-588` 的 monotonicity guard 强制顺序化: ```rust if pts <= last { pts = last.checked_add(1).unwrap_or(last); } ``` 结果:0, 1, 2, 3, 4, 5, ...(顺序)。 ### 3. 致命错位:真实时间 vs RTP 时间 ``` 1 秒真实时间 @ 60fps 捕获 = 60 帧 60 帧 sequential RTP(每帧间隔 3000 = 33ms) RTP 时间跨度:60 × 33ms = 1.98 秒 RTP 时间 ``` **1 真实秒变成 2 RTP 秒**。 浏览器按 RTP 速率(半速)播放 → 帧到达比播放快 → 缓冲累积。 ``` 用户移鼠标 10 秒(60fps 捕获): 真实时间:10 秒 RTP 时间:20 秒 浏览器累积缓冲:10 秒 ``` 用户停止后,服务端立即停止发送(damage 停止 + hash dedup),但浏览器慢慢排空累积的 10 秒缓冲。**这正是用户报告的现象**。 ### 4. 为什么静态期没问题 静态期间每 600ms 一帧,远超 33ms 粒度。`compute_capture_pts` 返回准确的 18 ticks 跳变,RTP 跳变 54000,精确反映 600ms 间隔。浏览器看到真实时间 = RTP 时间,jitter 极小。 ## 数学验证(test8 数据) - 会话:98.6s,1920 帧,平均 19.5 fps - 静态期(用户不动):每 600ms 一帧,RTP 跳变 54000(正确) - 活跃期(用户移鼠标):每 16.7ms 一帧,RTP 跳变 3000(量化,被强制顺序化) 如果 50% 时间活跃(60fps 捕获) + 50% 时间静态(1.6fps): - 活跃 49s × 60fps = 2940 帧(但编码器只能产 30fps,实际编码 1470 帧) - 静态 49s × 1.6fps = ~80 帧 - 总编码 ~1550 帧(接近实际 1920) 活跃期间每帧 RTP 间隔 3000(33ms),但真实到达间隔是 33ms(因编码器 30fps 上限)。 **等等,这看起来 RTP 和真实时间匹配?** 不对。问题在于**单帧内的 PTS 计算**: - KWin 送来 60fps 帧(每 16.7ms) - compute_capture_pts 用 PipeWire 真实 ns 计算 ticks - 因 1/fps 量化,ticks 被压缩 - monotonicity 把它们 spread 出来,导致 RTP 间隔 > 真实到达间隔 具体:test8 有 2067 个 RTP=3000 跳变,61 个 RTP=54000 跳变。 - 2067 × 33ms = 68s RTP 时间花在"active"帧上 - 61 × 600ms = 37s RTP 时间花在"static"帧上 - 总 RTP 时间 = 105s,真实 98.6s **RTP 时间 > 真实时间 = 浏览器播放慢于真实 = 缓冲累积**。验证假设。 (误差可能因为部分帧的 PTS 计算确实接近真实,但整体趋势 RTP 时间膨胀约 7%。长时间活跃会话会放大这个差异。) ## 修复方案 ### 主修复:encoder time_base 改成 90kHz `src/avhw.rs:1810`(WebRTC 路径的 `create_software_h264_encoder`): ```rust // 旧: enc.set_time_base(ff::Rational::new(1, fps as i32)); // 新: // 90kHz 时钟与 RTP 直接对齐,避免 1/fps 粒度量化。 // 在 60fps 捕获下,1/fps time_base 会让 16.7ms 间隔的两帧映射到同一 tick, // 被 monotonicity guard 强制顺序化,导致 RTP 时间膨胀、浏览器缓冲累积。 // 90kHz 提供 11μs 粒度,完全足够 WebRTC 可变帧率。 enc.set_time_base(ff::Rational::new(1, 90_000)); ``` ### compute_capture_pts 返回 90kHz ticks `src/state_portal.rs:581`: ```rust // 旧: let ticks_i128 = (relative_ns.saturating_mul(i128::from(self.args.fps))) / NS_PER_SEC; // 新: // 90kHz 时钟:1 秒 = 90000 ticks,1 ns = 90/1e9 ticks const RTP_CLOCK_HZ: i128 = 90_000; let ticks_i128 = (relative_ns.saturating_mul(RTP_CLOCK_HZ)) / NS_PER_SEC; ``` ### rtp_timestamp_from_pts_ticks 简化 `src/webrtc.rs` 现有实现: ```rust // 旧: pub fn rtp_timestamp_from_pts_ticks(pts_ticks: i64, fps: u32) -> u32 { let fps_safe = (fps.max(1) as u64).max(1); let pts_u64 = (pts_ticks.max(0) as u64).min(u64::MAX / TICKS_PER_SECOND); pts_u64.saturating_mul(TICKS_PER_SECOND) / fps_safe as u32 } ``` 新签名(去掉 fps 参数,因为 PTS 已经是 90kHz): ```rust // 新: pub fn rtp_timestamp_from_pts_ticks(pts_ticks: i64) -> u32 { pts_ticks.max(0) as u32 // 直接是 RTP 时间戳 } ``` 或者直接 inline 到 write_h264_frame,删除独立函数。 ### MP4 路径不动 MP4 用 `create_software_h264_muxer`(另一个 encoder 构造器),保持 `1/fps` time_base。理由: - 文件不需要实时播放 - 顺序 PTS 配合 1/fps time_base 是 VFR 录制的简化版 - 改了会影响文件播放速度 ## 期望效果 | 场景 | 修复前 | 修复后 | |---|---|---| | 静态期 jitterBufferDelay | 10-20 ms | 10-20 ms(不变) | | 活跃期 jitterBufferDelay | 1000+ ms | **< 100 ms** | | 停止操作后尾随 | 10+ 秒 | **< 200 ms**(几乎立即停止) | | 总体感知延迟 | 1-2 秒 | **< 300 ms**(接近实时) | ## 复现 ```bash wl-webrtc --port 56666 -v --stats > /tmp/test.log 2>&1 # 浏览器连接,chrome://webrtc-internals 观察 # 1. 静态 30 秒(jitterBufferDelay 应该 10-20 ms) # 2. 大幅移动鼠标 10 秒(jitterBufferDelay 飙到 1000+ ms) # 3. 停止移动(客户端继续显示鼠标 10 秒) ``` ## 关联 - **#24 (CLOSED)**:修复了 PTS 传播链路,但用 1/fps time_base 仍有量化问题 - **#23 (CLOSED)**:VBV + 码率 cap + PLI 限流,仍在工作 - **#15 (CLOSED)**:stall 范式转变,仍在工作 - **#18 (CLOSED)**:filler 删除,仍在工作 - **#20 (OPEN)**:frame_age 统计仍然没修(应该独立 commit) ## 严重度 **Critical** —— 即使所有之���的修复都生效,产品在用户活跃操作时仍有 1-2 秒延迟和 10 秒尾随。严重影响可用性。
dailz added the area/encoderarea/webrtcpriority/criticaltype/bug labels 2026-06-20 22:42:07 +08:00
dailz closed this issue 2026-06-20 22:59:52 +08:00
Sign in to join this conversation.