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 的转换系数就不同。所以一定要在运行时动态获取转换系数,不要写死。
屏幕录制的时间戳策略可以总结为三种模式:
- 事件触发模式:有画面更新才采集,时间戳用事件发生时间。适合远程桌面、协作工具。
- 固定帧率模式:不管画面变不变,按固定帧率采集。适合录屏教学视频、游戏录制。
- 混合模式:平时按低帧率采集(比如 5fps),检测到画面变化时切换到高帧率(比如 30fps)。适合兼顾性能和流畅度的场景。
我个人比较推荐混合模式。在项目中,我通常设置一个「静默帧率」为 5fps,当画面变化面积超过屏幕的 10% 时,临时提升到 30fps,持续 2 秒后如果画面静止再降回去。这样既保证了交互流畅度,又不会浪费 CPU 和带宽。
4.4 三种采集源的时间戳对比
为了让你看得更清楚,我把三种采集源的时间戳策略放在一起对比:
| 采集源 | 驱动方式 | 时间戳基准 | 常见问题 | 推荐策略 |
|---|---|---|---|---|
| 摄像头 | 固定帧率 | 驱动回调时间 | 帧率抖动、时间戳跳变 | CLOCK_MONOTONIC + 回调首行记录 |
| 麦克风 | 连续流 | Buffer 中心时间 | 采样率偏差、驱动时间不准 | 回调时间 - 半帧时长 |
| 屏幕录制 | 事件驱动 | 系统通知时间 | 采集延迟、时间戳转换 | 事件触发 + 混合帧率 |
4.5 采集端时间戳整体流程
下面这张图展示了三种采集源的时间戳生成流程,以及它们如何汇聚到统一的时间轴:
从这张图可以看得很清楚:摄像头、麦克风、屏幕录制各自有独立的时间戳生成策略,但最终都要汇聚到统一时间轴上。这个统一时间轴,就是我们后面做音画同步、多流对齐的基础。
一个实用的建议:在实际工程中,我建议你在采集端就给每一帧数据打上三个时间戳:硬件时间戳(驱动层)、系统时间戳(应用层收到时)、处理时间戳(送入编码器时)。这样一旦出现对齐问题,你可以通过对比这三个时间戳快速定位是哪个环节出了偏差。
好了,采集端时间戳的策略就讲到这里。记住一句话:时间戳的精度,决定了整个流媒体系统的对齐上限。采集端多花点心思,后面能省下十倍的时间去排查问题。