ffmpeg开发播放器学习笔记 - 音视频同步

该节是ffmpeg开发播放器学习笔记的第六节《音视频同步》

一般来说,视频同步指的是视频和音频同步,也就是说播放的声音要和当前显示的画面保持一致。想象以下,看一部电影的时候只看到人物嘴动没有声音传出;或者画面是激烈的战斗场景,而声音不是枪炮声却是人物说话的声音,这是非常差的一种体验。


image.png

✅ 第一节 - Hello FFmpeg
✅ 第二节 - 软解视频流,渲染 RGB24
✅ 第三节 - 认识YUV
✅ 第四节 - 硬解码,OpenGL渲染YUV
✅ 第五节 - Metal 渲染YUV
✅ 第六节 - 解码音频,使用AudioQueue 播放
🔔 第七节 - 音视频同步
📗 第八节 - 完善播放控制
📗 第九节 - 倍速播放
📗 第十节 - 增加视频过滤效果
📗 第十一节 - 音频变声

该节 Demo 地址: https://github.com/czqasngit/ffmpeg-player/releases/tag/Audio-And-Video-Sync

实例代码提供了Objective-CSwift两种实现,为了方便说明,文章引用的是Objective-C代码,因为Swift代码指针看着不简洁。

该节最终效果如下图:

image

目标

  • 音视频同步的背景以及产生不同步的原因
  • 音视频同步的处理方案及选择
  • 编码实现音视频同步

音视频同步的背景以及产生不同步的原因

在视频流和音频流中已包含了其以怎样的速度播放的相关数据,视频的帧率(Frame Rate)指示视频一秒显示的帧数(图像数);音频的采样率(Sample Rate)表示音频一秒播放的样本(Sample)的个数。可以使用以上数据通过简单的计算得到其在某一Frame(Sample)的播放时间,以这样的速度音频和视频各自播放互不影响,在理想条件下,其应该是同步的,不会出现偏差。如果用上面那种简单的计算方式,慢慢的就可能 会出现音视频不同步的情况。要不是视频播放快了,要么是音频播放快了。这就需要一种随着时间会线性增长的量,视频和音频的播放速度都以该量为标准,播放快了就减慢播放速度;播放快了就加快播放的速度。所以,视频和音频的同步实际上是一个动态的过程,同步是暂时的,不同步则是常态。以选择的播放速度量为标准,快的等待慢的,慢的则加快速度,是一个你等我赶的过程。

音视频同步的处理方案及选择

处理音视频同步的方案通常有以下三种:

1.视频时钟同步到音频时钟

以音频时钟为标准时钟,音频自然播放。视频帧播放时判断当前视频帧播放结束后的时间与当前的音频时钟时间对比,如果视频当前帧播放完时间比音频时钟时间早,则让当前视频播放线程暂时时间差,以保证播放完后与音频时钟同步。如果当前视频帧播放完时间比音频时间晚,则丢弃当前视频帧读取下一帧再判断,以保证播放完后与音频时钟同步。

2.音频时钟同步到视频时钟

以视频时钟为标准,视频自然播放。同步逻辑则与第1点的同步逻辑一致:即音频快了就暂停音频播放线程等待时间差,慢了则丢弃当前音频帧。以保证当前音频帧播放完与视频帧时钟同步。

3.以外部时钟为准,音频与视频时钟同时同步到外部时钟。

同步逻辑与1、2点一致。需要注意的是外部时钟应尽量使用毫秒时钟以确保同步的精准。

同步方案选择

以上3种方案都可以实现音频与视频的同步处理,但怎么选择更适合的方案呢?

  • 人的眼睛与耳朵对图像与声音的敏感程度不一样,当画面偶尔缺少一帧或者几帧时人的眼睛可能不太容易察觉。这是因为画面的连贯性比较强,两帧画面之前的差异有时候很小,眼睛比耳机敏感度更低。当声音发生一变化,比如缺失了一点声音或者声音异常的,人的耳朵马上就察觉到了。
  • 在大多数平台上声音的播放开销都比渲染画面小。声音的数据处理过程更简单,数量量也更小。声音线程播放声音卡顿的概率很小。
  • 在macOS/iOS平台上,以AudioQueue播放为例,声音的播放缓存对象是重复利用的,而这个利用则是由实际播放声音的具体线程来回调的。不同于视频每一帧的渲染,声音的暂停与丢弃相比视频实现成本更高。

综合上以的三点,本文选择第1点同步方案<strong>视频时钟同步到音频时钟</strong>

编码实现音视频同步

音频视频同步基础

FFmpeg里有两种时间戳:DTS(Decoding Time Stamp)和PTS(Presentation Time Stamp)。 顾名思义,前者是解码的时间,后者是显示的时间。要仔细理解这两个概念,需要先了解FFmpeg中的packet和frame的概念。

FFmpeg中用AVPacket结构体来描述解码前或编码后的压缩包,用AVFrame结构体来描述解码后或编码前的信号帧。 对于视频来说,AVFrame就是视频的一帧图像。这帧图像什么时候显示给用户,就取决于它的PTS。DTS是AVPacket里的一个成员,表示这个压缩包应该什么时候被解码。 如果视频里各帧的编码是按输入顺序(也就是显示顺序)依次进行的,那么解码和显示时间应该是一致的。可事实上,在大多数编解码标准(如H.264或HEVC)中,编码顺序和输入顺序并不一致。 于是才会需要PTS和DTS这两种不同的时间戳。

基本概念:

I帧 :帧内编码帧 又称intra picture,I 帧通常是每个 GOP(MPEG 所使用的一种视频压缩技术)的第一个帧,经过适度地压缩,做为随机访问的参考点,可以当成图象。I帧可以看成是一个图像经过压缩后的产物。

P帧: 前向预测编码帧 又称predictive-frame,通过充分将低于图像序列中前面已编码帧的时间冗余信息来压缩传输数据量的编码图像,也叫预测帧;

B帧: 双向预测内插编码帧 又称bi-directional interpolated prediction frame,既考虑与源图像序列前面已编码帧,也顾及源图像序列后面已编码帧之间的时间冗余信息来压缩传输数据量的编码图像,也叫双向预测帧;

PTS:Presentation Time Stamp。PTS主要用于度量解码后的视频帧什么时候被显示出来

DTS:Decode Time Stamp。DTS主要是标识读入内存中的bit流在什么时候开始送入解码器中进行解码。

在没有B帧存在的情况下DTS的顺序和PTS的顺序应该是一样的。

IPB帧的不同:

I帧:自身可以通过视频解压算法解压成一张单独的完整的图片。

P帧:需要参考其前面的一个I frame 或者B frame来生成一张完整的图片。

B帧:则要参考其前一个I或者P帧及其后面的一个P帧来生成一张完整的图片。

两个I帧之间形成一个GOP,在x264中同时可以通过参数来设定bf的大小,即:I 和p或者两个P之间B的数量。
通过上述基本可以说明如果有B frame 存在的情况下一个GOP的最后一个frame一定是P。

DTS和PTS的不同:

DTS主要用于视频的解码,在解码阶段使用.PTS主要用于视频的同步和输出.在display的时候使用.在没有B frame的情况下.DTS和PTS的输出顺序是一样的.

2.音频与视频数据缓冲对象增加时钟数据

@interface FFQueueAudioObject : NSObject
@property (nonatomic, assign, readonly)float pts;
@property (nonatomic, assign, readonly)float duration;
- (instancetype)initWithLength:(int64_t)length pts:(float)pts duration:(float)duration;
- (uint8_t *)data;
- (int64_t)length;
- (void)updateLength:(int64_t)length;
@end
@interface FFQueueVideoObject : NSObject
@property (nonatomic, assign)double unit;
@property (nonatomic, assign)double pts;
@property (nonatomic, assign)double duration;
- (instancetype)init;
- (AVFrame *)frame;
@end

分别在上面的音频与视频缓冲对象上增加变量ptsduration

  • pts: 当前帧播放或显示的时间。
  • duration: 当前帧播放或显示持续的时长。音频帧内包括多个音频数据包,而视频则可以通过FPS计算得到每一帧的显示持续时长。

3.视频时钟同步到音频时钟

pthread_mutex_lock(&(self->mutex));
/// 读取当前的音频时钟时间
double ac = self->audio_clock;
pthread_mutex_unlock(&(self->mutex));
FFQueueVideoObject *obj = NULL;
/// 统计路过的视频帧数量
int readCount = 0;
/// 首先读取一帧视频数据
obj = [self.videoFrameCacheQueue dequeue];
readCount ++;
/// 计算当前视频帖播放结束时的时间点
double vc = obj.pts + obj.duration;
if(ac - vc > self->tolerance_scope) {
    /// 视频太慢,丢弃当前帧继续读取下一帧
    /// 这里认为读取下一帧或者更下一帧不会造成视频缓冲队列枯竭,所以未做等待处理
    /// 因为时时同步能形成的时间差比较有限
    while (ac - vc > self->tolerance_scope) {
        FFQueueVideoObject *_nextObj = [self.videoFrameCacheQueue dequeue];
        if(!_nextObj) break;
        obj = _nextObj;
        vc = obj.pts + obj.duration;
        readCount ++;
    }
} else if (vc - ac > self->tolerance_scope) {
  /// 视频太快,暂停一下再接着渲染显示当前视频帧
    float sleep_time = vc - ac;
    usleep(sleep_time * 1000 * 1000);
} else {
  /// 在误差范围之后, 不需要处理
}

tolerance_scope为可允许的误差值,即音频与视频时间差小于这个数据则认为是同步的。这是因为要达到绝对的时间一致性是不可能的,在计算时间的过程中有精度的丢失。

  • 获取当前音频时钟的时间(该时间为当前音频帧播放结束后的时间)
  • 读取一帧视频帧,计算出该视频帧播放完之后的时间
  • 判断音频时间与视频时间的差值,进行同步处理

到此,音视频同步的完成了,现在再去看视频就不会发现嘴巴与声音不一致的问题了🏄🏄🏄🏄🏄🏄。

总结

  • 了解了音视频同步的背景以及产生不同步的原因
  • 了解音视频同步的处理方案及合理的选择了视频时钟同步到音频时钟的方案
  • 编码实现音视频同步

更多内容请关注微信公众号<<程序猿搬砖>>