3. 音视频同步原理
时间戳,说白了就是音画同步的「锚」。锚不稳,船就晃。我在做第一个 RTSP 推流项目时,就踩过这个坑——画面和声音各走各的,观众看着像在看译制片,嘴型永远对不上。
这一节,我们聊聊时间戳怎么生成才靠谱。不是简单调个 API 就完事,里面门道不少。
3.1 系统时钟 vs 媒体时钟
先搞清楚两个概念:系统时钟和媒体时钟。很多人混着用,结果出问题了。
| 时钟类型 | 来源 | 特点 | 适用场景 |
|---|---|---|---|
| 系统时钟 | 操作系统 wall-clock | 可被 NTP 调整、可能回跳 | 日志记录、绝对时间标记 |
| 媒体时钟 | 采集设备驱动或编码器 | 单调递增、与帧率绑定 | 音画同步、RTP 时间戳 |
系统时钟,就是你的电脑时间。它有个致命问题——会被 NTP 同步调整。你想想看,推流推到一半,系统时间突然往回跳了 200 毫秒,RTP 时间戳也跟着回退,接收端直接懵了。
媒体时钟则不同。它通常来自硬件设备驱动,比如摄像头每 33ms 产生一帧,这个间隔是物理世界决定的,不受 NTP 影响。我建议:RTP 时间戳必须用媒体时钟,系统时钟只用来做日志和调试。
核心原则:RTP 时间戳 = 媒体时钟采样时刻,不是系统时间。
3.2 单调递增时间戳
什么叫单调递增?就是时间戳只能变大,不能变小。听起来简单,但实现起来容易翻车。
我曾经在一个 Android 项目里直接用 System.currentTimeMillis() 做时间戳。结果用户把手机时间调慢了 1 分钟,时间戳直接倒流,接收端解码器崩溃了三次。后来我改成 System.nanoTime(),问题才解决。
各平台单调时钟 API 如下:
| 平台 | 推荐 API | 单位 | 说明 |
|---|---|---|---|
| Linux | clock_gettime(CLOCK_MONOTONIC) |
纳秒 | 系统启动后单调递增 |
| macOS/iOS | mach_absolute_time() |
纳秒 | 需转换到纳秒 |
| Windows | QueryPerformanceCounter() |
计数 | 需配合频率转换 |
| Android | System.nanoTime() |
纳秒 | 基于 CLOCK_MONOTONIC |
注意:单调时钟在设备休眠时可能暂停。如果你需要跨休眠周期的时间戳,得自己处理补偿逻辑。
3.3 跨平台时间戳实现
跨平台推流,最头疼的就是时间戳统一。我在做一个跨 iOS/Android/Windows 的推流 SDK 时,踩了不少坑。下面给出一套我验证过的方案。
3.3.1 统一时间基
RTP 时间戳的基准是 90kHz。为什么是 90kHz?因为 90000 能被常见的视频帧率(24、25、30、50、60)整除。音频则用采样率,比如 44100 或 48000。
代码实现思路:
// 伪代码:统一时间戳生成
class MediaTimestampGenerator {
int64_t startTime; // 单调时钟起始点
int64_t lastTimestamp; // 上一个时间戳
// 视频时间戳(90kHz 基)
uint32_t generateVideoTimestamp(int64_t monotonicNs) {
int64_t elapsed = monotonicNs - startTime;
uint32_t ts = (uint32_t)(elapsed * 90 / 1000000); // ns → 90kHz
if (ts <= lastTimestamp) {
ts = lastTimestamp + 1; // 强制单调递增
}
lastTimestamp = ts;
return ts;
}
// 音频时间戳(采样率基)
uint32_t generateAudioTimestamp(int sampleCount, int sampleRate) {
static uint32_t audioTs = 0;
audioTs += (uint32_t)((double)sampleCount / sampleRate * 90000);
return audioTs;
}
}
我的习惯:视频和音频的时间戳基统一用 90kHz,这样在 RTCP SR 包中计算 NTP 对应关系时更方便。
3.3.2 音画时间戳对齐
光有单调递增还不够,音画时间戳必须对齐到同一个时间轴。我常用的方法是:
- 推流开始时,记录单调时钟的起始点
startTime - 视频帧和音频包都基于
当前单调时间 - startTime计算时间戳 - 视频用 90kHz 基,音频用采样率基,但都映射到同一个物理时间轴
举个例子:
// 对齐示例
int64_t now = getMonotonicNs();
int64_t elapsedUs = (now - startTime) / 1000; // 微秒
// 视频帧时间戳(90kHz)
uint32_t videoTs = (uint32_t)(elapsedUs * 90 / 1000);
// 音频帧时间戳(48kHz 采样率)
// 假设音频帧有 1024 个采样
uint32_t audioTs = (uint32_t)(elapsedUs * 48 / 1000);
这样,视频的 90kHz 时间戳和音频的 48kHz 时间戳都指向同一个物理时刻。接收端解码时,就能准确对齐。
3.4 时间戳生成流程图
下面这张图,是我做项目时画的,帮你理清整体流程:
3.5 避坑指南
最后,分享几个我踩过的坑:
- 别用 gettimeofday():它可能被 NTP 调整,导致时间戳回跳。我吃过这个亏。
- 注意 32 位溢出:RTP 时间戳是 32 位无符号整数,90kHz 基下大约 13 小时溢出一次。长时间推流需要处理回绕。
- 音频时间戳别用帧计数:直接用采样数累加,不要依赖系统时间。否则采样率漂移会让你崩溃。
- 跨平台测试:Windows 的
QueryPerformanceCounter在多核 CPU 上可能乱跳,记得绑定核心或做校准。
一个小技巧:调试时,把时间戳和系统时间一起打日志。如果发现时间戳差值超过 1ms,说明有问题,赶紧查。