3. 音画同步核心概念

时间戳这东西,说白了就是RTP包的"时间身份证"。我在做WebRTC开发的头两年,踩过最多的坑就是时间戳不对齐。音频在跑、视频也在跑,但画面和声音就是差那么一拍,用户直接骂"这什么破视频会议"。

这一章,咱们就把RTP时间戳彻底讲透。你搞懂了它,音画同步就成功了一半。

3.1 为什么音频和视频的时间戳频率不一样?

先问一个问题:为什么音频和视频不能用同一个时钟频率?

答案其实很简单——采样方式不同。

音频是连续采样的。你想想看,麦克风每秒采集16000个样本点,每个样本就是一个点。所以音频的RTP时间戳,天然就跟着采样率走。比如8kHz采样,时间戳每秒增加8000;16kHz采样,每秒增加16000。

视频不一样。摄像头不是"连续采样",而是"逐帧捕获"。一帧画面就是一个完整的图像。视频的RTP时间戳,通常用90kHz的固定时钟。为什么是90kHz?

90kHz的由来:这是从MPEG时代传下来的老规矩。90kHz能被常见的帧率整除(24、25、30、50、60),而且精度够用。1/90秒 ≈ 11.1微秒,足够区分每一帧的时间位置。

我在项目中遇到过有人想改成48kHz给视频用,结果帧率一算全是小数,播放器直接懵了。嗯,别乱改。

3.2 音频RTP时间戳的生成与解析

音频时间戳的生成,核心就一句话:时间戳 = 采样数。

举个例子。你用Opus编码,采样率48kHz,每20ms生成一帧音频。那么:

  • 每帧的样本数 = 48000 × 0.02 = 960个样本
  • 第一帧时间戳 = 0
  • 第二帧时间戳 = 960
  • 第三帧时间戳 = 1920
  • ……以此类推

代码实现也很直接:

// 音频时间戳生成示例(C++风格伪代码)
uint32_t audio_timestamp = 0;
const int samples_per_frame = 960; // 48kHz, 20ms

void OnAudioFrameEncoded() {
    RTPPacket packet;
    packet.set_timestamp(audio_timestamp);
    audio_timestamp += samples_per_frame;
    // 发送packet
}

解析的时候更简单。收到RTP包,取出时间戳,除以采样率,就得到绝对时间(秒)。

我的习惯:在接收端维护一个 last_audio_ts,每次收到新包计算差值。如果差值不是960的整数倍,说明中间丢包了。这时候要做插值或者静音填充,别让播放器卡死。

3.3 视频RTP时间戳的生成与解析

视频时间戳用90kHz时钟。生成逻辑和音频类似,但单位不同。

假设你采集30fps的视频:

  • 每帧间隔 = 1/30 ≈ 33.33ms
  • 每帧的时间戳增量 = 90000 / 30 = 3000
  • 第一帧时间戳 = 0
  • 第二帧时间戳 = 3000
  • 第三帧时间戳 = 6000

代码示例:

// 视频时间戳生成示例
uint32_t video_timestamp = 0;
const int ts_increment = 3000; // 30fps

void OnVideoFrameCaptured() {
    RTPPacket packet;
    packet.set_timestamp(video_timestamp);
    video_timestamp += ts_increment;
    // 发送packet
}

这里有个坑——实际采集时间可能不准。摄像头驱动有时候会跳帧,或者采集时间不均匀。我曾经遇到过某款USB摄像头,标称30fps,实际采集间隔忽大忽小。如果硬按3000递增,播放端算出来的时间全是错的。

避坑指南:不要用"帧率计算值"作为时间戳增量。应该用真实的采集时间。比如你从系统时钟拿到当前时间 now_us,然后计算 timestamp = (now_us * 90) / 1000。这样即使帧率波动,时间戳也是准确的。

3.4 音频和视频时间戳的映射关系

现在问题来了:音频用采样率计数,视频用90kHz时钟,两者怎么对齐?

答案就是——NTP时间。

WebRTC的每个RTP包,除了时间戳,还会携带对应的NTP时间(通过RTCP SR包传递)。NTP是全局统一的绝对时间,单位是秒。有了NTP,就能把音频时间戳和视频时间戳映射到同一个时间轴上。

映射公式:

// 音频:时间戳 → 绝对时间
double audio_time = audio_rtp_timestamp / (double)audio_sample_rate;

// 视频:时间戳 → 绝对时间
double video_time = video_rtp_timestamp / 90000.0;

// 有了NTP校准后,更精确的做法:
// audio_ntp_time = ntp_base + (audio_rtp_timestamp - audio_ts_base) / sample_rate
// video_ntp_time = ntp_base + (video_rtp_timestamp - video_ts_base) / 90000

我习惯在接收端维护一个映射表:

媒体类型 时间戳单位 时钟频率 NTP映射方式
音频 采样数 采样率(如48000) RTCP SR中的NTP + RTP时间戳对
视频 90kHz时钟计数 90000 RTCP SR中的NTP + RTP时间戳对

3.5 时间戳解析的常见陷阱

做时间戳解析,有几个坑我几乎每次培训都要讲:

  1. 回绕问题:RTP时间戳是32位无符号整数。音频48kHz下,32位能撑多久?48000 × 2^32 ≈ 2.6e12采样,约24小时。视频90kHz下,90000 × 2^32 ≈ 3.8e14,约13小时。超过这个时间,时间戳会回绕到0。处理方式:用无符号减法,或者维护一个扩展的高位计数器。
  2. 乱序包:网络里RTP包可能乱序到达。解析时不能假设时间戳递增。我一般先按序列号排序,再处理时间戳。
  3. 多路流的时间戳独立:音频流和视频流的时间戳起点是独立的。你不能直接比较audio_ts和video_ts的大小,必须先通过NTP转换到同一时间轴。

核心原则:永远不要直接比较音频RTP时间戳和视频RTP时间戳。先转成NTP绝对时间,再比较。这是音画同步的第一条铁律。

3.6 时间戳生成与解析的完整流程

我把整个流程画了一张图,方便你理解:

RTP时间戳生成与解析流程 RTP时间戳生成与解析流程 发送端 音频采集(48kHz) 视频采集(30fps) 音频编码(20ms/帧) 视频编码(H264/VP8) 时间戳生成 ts += 960 时间戳生成 ts += 3000 RTP打包发送 RTP打包发送 接收端 RTP接收解析 时间戳解析 NTP时间映射 音画同步播放 关键点 • 音频ts按采样数递增 • 视频ts按90kHz递增 • 通过NTP统一时间轴

这张图把发送端和接收端的完整流程都串起来了。你注意看中间那个NTP映射的环节——这是音画同步的枢纽。

3.7 实战:一个时间戳对齐的代码片段

最后,给你一个我在项目中实际用过的对齐逻辑。这段代码处理音频和视频时间戳的映射:

// 时间戳对齐工具类(简化版)
class TimestampAligner {
public:
    // 音频时间戳 → NTP时间(秒)
    double AudioRtpToNtp(uint32_t rtp_ts, uint32_t sample_rate) {
        // 需要RTCP SR中的NTP基准
        if (!ntp_base_valid_) return 0.0;
        double offset = (rtp_ts - audio_ts_base_) / (double)sample_rate;
        return ntp_base_ + offset;
    }
    
    // 视频时间戳 → NTP时间(秒)
    double VideoRtpToNtp(uint32_t rtp_ts) {
        if (!ntp_base_valid_) return 0.0;
        double offset = (rtp_ts - video_ts_base_) / 90000.0;
        return ntp_base_ + offset;
    }
    
    // 更新NTP基准(从RTCP SR中调用)
    void UpdateNtpBase(double ntp_time, uint32_t audio_ts, uint32_t video_ts) {
        ntp_base_ = ntp_time;
        audio_ts_base_ = audio_ts;
        video_ts_base_ = video_ts;
        ntp_base_valid_ = true;
    }
    
private:
    double ntp_base_ = 0.0;
    uint32_t audio_ts_base_ = 0;
    uint32_t video_ts_base_ = 0;
    bool ntp_base_valid_ = false;
};

使用建议:每次收到RTCP SR包时,调用 UpdateNtpBase 更新基准。如果长时间没收到SR(比如网络抖动),可以用上一次的基准继续计算,但误差会逐渐累积。我一般设置一个超时阈值,超过5秒没收到SR就强制等待。

好了,RTP时间戳的核心内容就这些。你理解了频率差异、生成逻辑和解析方法,后面讲音画同步算法时就能直接上手了。