播放器开发--A/V同步(1)
前面我们已经把解码后的 VideoFrame 和 AudioFrame 送入了各自的 FrameQueue。如果直接不管时间信息就去渲染播放,会发生什么?
视频和音频会各自以最大速度输出——画面飞速闪过,声音也失去了正常节奏。因为它们只是”有数据就消费”,没有人在协调节奏。
A/V 同步要解决的核心问题就是:让视频和音频以正确的速度、在正确的时间点呈现,并且两者保持对齐。
怎么对齐?我们需要一把”尺子”——时间戳。
时间戳
每个 AVFrame 和 AVPacket 都携带着时间信息:
- DTS(Decoding Time Stamp):解码时间戳。告诉解码器这一帧应该在什么时候被解码。大多数情况下 DTS 和 PTS 相同,但存在 B 帧时 DTS 会早于 PTS。
- PTS(Presentation Time Stamp):显示时间戳。告诉播放器这一帧应该在什么时候显示到屏幕上。
- Timebase(时间基):PTS/DTS 的数值单位。PTS 本身是一个整数,需要乘以 time_base 才能得到以秒为单位的时间。
换算公式:
1 | |
例如 time_base = 1/1000(即 av_q2d 返回 0.001),PTS = 500,那么这一帧应该在 第 0.5 秒 播放。
A/V 同步主要关注 PTS。DTS 由 FFmpeg 内部在解码时使用,播放器的同步逻辑不需要关心它。
同步策略
同步需要一个主时钟(Master Clock)作为参考基准。主流方案有三种:
1. 音频为主时钟(Audio Master)——最常用
音频时钟由声卡晶振驱动,播放速度极其稳定。同时人耳对声音的抖动(Jitter)极其敏感,而眼睛对视频偶尔跳帧或重复帧的感知相对迟钝。
做法:
- 用当前音频播放位置作为主时钟
- 视频帧 PTS 跟主时钟对比:
- 视频太快 → 休眠等待
- 视频太慢 → 丢弃落后帧,追赶进度
本播放器采用的就是这个策略。
2. 视频为主时钟(Video Master)
以视频渲染速度为基准。问题是:如果为了对齐去拉伸或压缩音频(重采样),会导致声音变调(”电音感”),体验很差。通常只在特殊硬件环境使用。
3. 外部时钟(External Clock)
直接参考系统时钟。缺点是系统时钟可能漂移,且无法感知音视频输出设备的物理延迟。一般作为无音频流时的 fallback。
我会按 时间基获取 → 时钟设计与实现 → 音频驱动时钟 → 视频同步到时钟 的顺序讲解。本篇先覆盖前两部分。
时间基的获取与传递
Timebase 存储在文件的流信息中,需要从 Demuxer → PlayerController → Decoder 一路传递。
Demuxer 端:取出流的时间基
1 | |
PlayerController 端:传给解码器
1 | |
Decoder 端:保存供解码线程使用
1 | |
_streamTimeBase 会在 decodeLoop() 中赋给每个解码出的 AVFrame:
1 | |
这样帧从解码器出来时 PTS 和时间基是配对的,后续任何模块拿到这个帧都能用 av_q2d(time_base) * pts 算出准确的秒数。
注意:
AVRational是一个分数结构{num, den},使用时需要av_q2d()转为double,等价于(double)num / den。
时间的计算 —— Clock 类
时间基解决的是”PTS 怎么换算成秒”,但播放器还需要一个运行时的钟来跟踪”当前播放到第几秒了”。这个钟就是 Clock 类。
1 | |
设计思路
Clock 不自己”走”,它依赖外部定期调用 setTime() 来更新。但两次 setTime() 之间可能有间隔(比如外部每 5ms 更新一次),而 getTime() 可能随时被调用。所以 Clock 的设计是”快照 + 外推”:
_current+_lastUpdateTime构成一个快照:记录”在某个 wall-clock 时刻,播放到了第 X 秒”getTime()调用时,用当前 wall-clock 减去_lastUpdateTime,把流逝的时间加到_current上,得到外推的当前播放位置
这样就得到了连续、平滑的时间读数,而不是跳变的离散值。
setTime(double time)
1 | |
同时更新当前时间和基准时间点,构成一个新的快照。AudioOutput 每写入一批数据到声卡就会调用一次。
getTime()
1 | |
暂停时直接返回 _current,不累加 wall-clock 流逝时间——因为播放已经停了。
pause()
1 | |
暂停时先做一次”结算”——把从上次更新到这瞬间的时间差加进 _current,然后再冻结。如果跳过这一步,getTime() 在暂停后会丢失这段间隔内的播放进度。
resume()
1 | |
恢复时只需要把 _lastUpdateTime 重置为当前时刻,_current 不变。之后 getTime() 会从 _current 开始继续外推。
线程安全
Clock 会被两个线程同时访问:
| 线程 | 操作 | 频率 |
|---|---|---|
| AudioOutput 消费线程 | setTime()(写入) |
每次写入声卡后(高频) |
| Qt 渲染线程(VideoSurface) | getTime()(读取) |
每帧 synchronize() 时 |
所有读写操作都用 std::mutex 保护,_mutex 声明为 mutable 是因为 getTime() 是 const 方法但仍需要加锁。
接下来
上面讲了时间戳的概念、时间基的传递链、Clock 的设计与实现。一个关键的问题还没有回答:谁在调用 setTime() 驱动时钟? 答案是 AudioOutput 的消费线程——它在往声卡写入 PCM 数据的同时持续更新时钟。而 VideoSurface 的 synchronize() 方法读取 clock.getTime(),决定当前帧是显示、等待还是丢弃。
这两部分将在下一节 “音频驱动时钟与视频同步” 中详细展开。
这次的插图来自画师mmAir