4. 时钟与同步原理

采集端的时间戳,是整个流媒体时间体系的起点。起点歪了,后面再怎么对齐都是白搭。我见过太多项目,编码、传输、渲染都做得很好,但画面和声音就是对不上,最后查下来,问题就出在采集端的时间戳上。

这一章,我们就把摄像头、麦克风、屏幕录制这三个场景的时间戳生成策略掰开揉碎讲清楚。

4.1 摄像头采集时间戳生成

摄像头采集,说白了就是每隔固定时间从传感器拿一帧图像。这个「固定时间」由帧率决定,比如 30fps 就是每 33.33ms 一帧。但实际硬件不会那么准时,所以时间戳的生成策略就很重要了。

核心原则:用硬件驱动层的时间,别用应用层的时间。

我个人习惯的做法是,在摄像头驱动回调里,直接取系统单调时钟(CLOCK_MONOTONIC)的时间戳。为什么不用 CLOCK_REALTIME?因为系统时间可能被 NTP 调整,导致时间戳跳变。你想想看,如果采集过程中系统时间被往回拨了 1 秒,那后面所有的时间戳逻辑都会乱掉。

推荐做法:

  • 使用 clock_gettime(CLOCK_MONOTONIC, &ts) 获取时间戳
  • 在驱动回调函数的第一行就记录时间戳,不要等图像数据拷贝完再记
  • 将时间戳与帧数据绑定,一起送入编码器

我曾经在一个项目中,发现采集到的视频帧时间戳偶尔会重复。查了两天才发现,是驱动回调里用了 gettimeofday(),而系统刚好在跑 NTP 同步。从那以后,我所有采集端代码都统一用 CLOCK_MONOTONIC。

代码示例(C 语言风格):

// 摄像头采集回调
void on_camera_frame(uint8_t* data, int size) {
    struct timespec ts;
    clock_gettime(CLOCK_MONOTONIC, &ts);
    uint64_t timestamp_ns = ts.tv_sec * 1000000000ULL + ts.tv_nsec;
    
    // 将时间戳和帧数据打包
    frame_t* frame = frame_new(data, size, timestamp_ns);
    encoder_push(frame);
}

4.2 麦克风采集时间戳生成

音频采集和视频采集有个本质区别:音频是连续流,视频是离散帧。麦克风一次回调可能拿到 1024 个采样点,这 1024 个点对应的时间跨度是多少?取决于采样率。比如 48kHz 采样率,1024 个采样就是 1024 / 48000 ≈ 21.33ms。

音频时间戳的关键:要算清楚第一个采样点的时间。

我建议的做法是,在音频回调中记录当前时间,然后减去半个 buffer 的时长,作为这包音频数据的「中心时间戳」。为什么是中心?因为音频数据是一个时间窗口,用窗口中心点代表整包数据,误差最小。

采样率 每帧采样数 帧时长 中心时间戳偏移
44100 Hz 1024 23.22 ms 11.61 ms
48000 Hz 1024 21.33 ms 10.67 ms
48000 Hz 960 20.00 ms 10.00 ms
16000 Hz 320 20.00 ms 10.00 ms

小技巧:如果音频和视频来自同一个设备(比如手机),它们的时钟源是同一个硬件晶振,时间戳天然同步。但如果是独立 USB 麦克风和独立摄像头,它们的时钟是独立的,这时候就需要做跨时钟域的时间戳映射了。这个我们后面章节会讲。

嗯,这里要注意一个坑:有些音频 API 返回的时间戳是驱动层已经算好的,比如 WASAPI 的 GetCurrentPadding 返回的偏移量。这时候直接用它提供的时间戳就好,不要自己再算一遍,否则会引入二次误差。

4.3 屏幕录制时间戳策略

屏幕录制和摄像头采集不太一样。摄像头是固定帧率,屏幕录制是「有变化才采集」。你想想看,如果桌面一动不动,录个 30fps 的流,30 帧全是重复画面,浪费带宽。

屏幕录制的核心策略:基于事件驱动,而非时间驱动。

常见的做法是,操作系统检测到屏幕区域有更新(比如窗口移动、鼠标点击),就触发一次采集。这时候生成的时间戳,应该用触发事件的时间,而不是采集完成的时间。

我做过一个远程桌面项目,一开始用的是采集完成时间,结果发现鼠标点击和画面更新之间总有几十毫秒的延迟。后来改成用系统通知事件的时间戳,延迟一下子就降下来了。

避坑指南:我曾经在 macOS 上用 CGDisplayStream 做屏幕录制,发现它回调里带的时间戳是 mach_absolute_time(),需要转换成纳秒。但不同 macOS 版本转换方式不一样,High Sierra 和 Mojave 的转换系数就不同。所以一定要在运行时动态获取转换系数,不要写死。

屏幕录制的时间戳策略可以总结为三种模式:

  1. 事件触发模式:有画面更新才采集,时间戳用事件发生时间。适合远程桌面、协作工具。
  2. 固定帧率模式:不管画面变不变,按固定帧率采集。适合录屏教学视频、游戏录制。
  3. 混合模式:平时按低帧率采集(比如 5fps),检测到画面变化时切换到高帧率(比如 30fps)。适合兼顾性能和流畅度的场景。

我个人比较推荐混合模式。在项目中,我通常设置一个「静默帧率」为 5fps,当画面变化面积超过屏幕的 10% 时,临时提升到 30fps,持续 2 秒后如果画面静止再降回去。这样既保证了交互流畅度,又不会浪费 CPU 和带宽。

4.4 三种采集源的时间戳对比

为了让你看得更清楚,我把三种采集源的时间戳策略放在一起对比:

采集源 驱动方式 时间戳基准 常见问题 推荐策略
摄像头 固定帧率 驱动回调时间 帧率抖动、时间戳跳变 CLOCK_MONOTONIC + 回调首行记录
麦克风 连续流 Buffer 中心时间 采样率偏差、驱动时间不准 回调时间 - 半帧时长
屏幕录制 事件驱动 系统通知时间 采集延迟、时间戳转换 事件触发 + 混合帧率

4.5 采集端时间戳整体流程

下面这张图展示了三种采集源的时间戳生成流程,以及它们如何汇聚到统一的时间轴:

采集端时间戳生成流程 摄像头采集 固定帧率 30fps 麦克风采集 连续流 48kHz 屏幕录制 事件驱动采集 CLOCK_MONOTONIC 回调时间 - 半帧时长 系统通知时间戳 统一时间轴(纳秒精度) 所有采集源时间戳归一化到同一时钟域 输出:带时间戳的媒体帧 说明:三种采集源使用不同的时间戳生成策略,但最终都归一化到统一时间轴 后续的编码、传输、渲染都基于这个统一时间轴进行对齐

从这张图可以看得很清楚:摄像头、麦克风、屏幕录制各自有独立的时间戳生成策略,但最终都要汇聚到统一时间轴上。这个统一时间轴,就是我们后面做音画同步、多流对齐的基础。

一个实用的建议:在实际工程中,我建议你在采集端就给每一帧数据打上三个时间戳:硬件时间戳(驱动层)、系统时间戳(应用层收到时)、处理时间戳(送入编码器时)。这样一旦出现对齐问题,你可以通过对比这三个时间戳快速定位是哪个环节出了偏差。

好了,采集端时间戳的策略就讲到这里。记住一句话:时间戳的精度,决定了整个流媒体系统的对齐上限。采集端多花点心思,后面能省下十倍的时间去排查问题。