基于 FFmpeg 的跨平台视频播放器简明教程(八):音画同步

系列文章目录

  1. 基于 FFmpeg 的跨平台视频播放器简明教程(一):FFMPEG + Conan 环境集成
  2. 基于 FFmpeg 的跨平台视频播放器简明教程(二):基础知识和解封装(demux)
  3. 基于 FFmpeg 的跨平台视频播放器简明教程(三):视频解码
  4. 基于 FFmpeg 的跨平台视频播放器简明教程(四):像素格式与格式转换
  5. 基于 FFmpeg 的跨平台视频播放器简明教程(五):使用 SDL 播放视频
  6. 基于 FFmpeg 的跨平台视频播放器简明教程(六):使用 SDL 播放音频和视频
  7. 基于 FFmpeg 的跨平台视频播放器简明教程(七):使用多线程解码视频和音频

文章目录

  • 系列文章目录
  • 前言
  • I 帧,P 帧,B帧
  • PTS 与 DTS
  • Timebase,时间基
    • Timebase 转换
  • 音画同步
    • 精确地纪录和获取时间
    • 纪录时间的时机
    • 音画同步具体算法
  • 总结
  • 参考


前言

在上篇文章 基于 FFmpeg 的跨平台视频播放器简明教程(七):使用多线程解码视频和音频 中,我们使用多个线程来做不同的事情,让整个播放器更加的模块化。

我们的播放器现在能够同时视频和音频了,但还不够,你会发现视频画面和音频会对不上,这是因为我们没有做音画同步。在现有的代码中,我们每隔 30ms 去播放一帧画面,而音频则是由系统的音频线程来负责驱动调用。这等于说,视频和音频各播各的,画面与声音失去了同步。因此,本章将讨论如何进行音画同步。

本文参考文章来自 An ffmpeg and SDL Tutorial - Tutorial 05: Synching Video 和 An ffmpeg and SDL Tutorial - Tutorial 06: Synching Audio。这个系列对新手较为友好,但 2015 后就不再更新了,以至于文章中的 ffmpeg api 已经被弃用了。幸运的是,有人对该教程的代码进行重写,使用了较新的 api,你可以在 rambodrahmani/ffmpeg-video-player 找到这些代码。

本文的代码在 ffmpeg_video_player_tutorial-my_tutorial05_01_clock.cpp。

I 帧,P 帧,B帧

I 帧(关键帧):也被称为内插帧。每一帧都是独立的图像,也就是说它不依赖于其他任何帧的图像信息。I帧类似于完整的图像,编解码器只需要本帧数据就可以完成解码。在视频播放时,I帧也是快进、快退、拖动的定位点,通常情况下,视频中的第一帧就是I帧。

P 帧(预测帧):P帧的数据包含与前一个I帧或P帧的差别,在编码时仅考虑了前向预测,也就是只与前一帧比较,来找出两帧之间的区别,然后只记录下这个差别。P帧依赖于前面的I帧或P帧。

B 帧(双向预测帧):B帧记录的是该帧与前后帧的差别,即考虑了双向的预测,可以理解为,B帧被插在I、P帧之间,通过前后帧进行预测、记录、解压。B帧依赖于前后的I帧或P帧。

需要所有这些帧的主要原因是为了压缩视频。I帧需要的数据量最多,但是不可能所有帧都为I帧,这样压缩率就很低,所以P帧和B帧诞生了,P帧和B帧只记录和参考帧的差异信息,所以数据量小,压缩率高,缺点是如果参考帧丢失,那么将导致无法解压出真实图像。总的来说,所有这些帧的组合能够使视频数据得以有效的压缩,同时保持视频质量。

PTS 与 DTS

假设现在有 4 帧画面,且没有 B 帧的情况,一种最常见的编码情况是:

I P0 P1 P0

在 “I P P P” 这种编码模式下,视频帧的解码过程较为简单,播放顺序和解码顺序是一样的,因为这种情况下没有B帧存在,P帧只依赖前面的I帧或P帧。

具体来说,解码过程如下:

  1. 首先解码第一帧I帧,I帧是关键帧,可以独立解码,不依赖任何其他帧。
  2. 然后解码第二帧P帧,此P帧只依赖于前一帧,前一帧是I帧,已经解码。
  3. 接着解码第三帧P帧,此P帧也只依赖于前一帧,前一帧是P帧,已经解码。
  4. 最后解码第四帧P帧,此P帧只依赖于前一帧,前一帧是P帧,已经解码。

所以,解码顺序和播放顺序一致,均为:I P P P。

如果有 B 帧呢?一种最常见的编码情况是:

I B0 B1 P0

在 “I B B P” 这种编码模式下,视频帧的解码顺序和播放顺序也是不一样的。B帧依赖前面和后面的帧,因此需要等待后一个参考帧(I或P帧)解析完成后,才能开始 B 帧的解码。

  1. 首先解码第一帧I帧,I帧是关键帧,可以独立解码,不依赖任何其他帧。
  2. 然后解码第四帧P帧,P帧只依赖于之前的I帧或P帧,这一步可以顺利进行。
  3. 解码完第四帧P帧后,才能开始解码第二帧和第三帧B帧。B帧需要依赖前一帧和后一帧,也就是需要依赖第一帧I帧和第四帧P帧。

所以,解码顺序为:I P B B。然而,播放顺序仍然为:I B B P。

因此,类似的,在包含B帧的情况下,解码顺序和播放顺序是有所不同的,这也是为什么在视频解码过程中需要对帧进行重新排序的原因。

我们使用 DTS(Decoding Time Stamp;解码时间戳)来表示解码的顺序,PTS(Presentation Time Stamp;呈现时间戳)来表示播放的顺序。

在 FFmpeg 中,解封装得到 AVPacket 后,每个 AVPacket 都有个成员变量叫做 dts;解码得到 AVFrame 后,每个 AVFrame 都有一个成员变量叫 pts。这两个变量对应着 DTS 和 PTS 的概念。

仍然以上面的 4 帧画面为例,在没有 B 帧的情况,经过编码后,它的码流应该是 Stream: I P0 P1 P2,解码顺序与播放顺序一致,因此:

		PTS: 1  2  3  4
		DTS: 1  2  3  4
     Stream: I P0 P1 P2

在有 B 帧的情况,经过编码后码流为 Stream: I P B0 B1,解码顺序是 DTS: 1 2 3 4,播放顺序是 PTS: 1 4 2 3,因此:

		PTS: 1  4  2  3
		DTS: 1  2  3  4
     Stream: I  P  B0 B1

Timebase,时间基

DTS 和 PTS 都是时间戳,那么时间的单位是什么?是秒(s)还是毫秒(ms)呢?其实都不是,DTS 和 PTS 是以时间基(timebase)为单位的,时间基是由编码格式决定的,通常它是一个如1/90000这样的分数。

为什么要引入 timebase,而不是固定使用 秒(s)或者毫秒(ms) 呢?引入timebase主要是为了提供更为灵活和精细的时间表示方法。虽然可以选择只用秒或毫秒作为单位,但这可能会在一些特殊的情况下引入不必要的误差或者损失精度。

在多媒体处理中,每种编码格式或者媒体流可能有其特定的帧率或者时钟频率,用一个固定的时间单位如秒或毫秒可能难以精确地表示这些特定的时间点或时长。比如有些视频可能是24帧/秒,有的可能是30帧/秒,有的可能是29.97帧/秒等。

而timebase是一个分数形式的时间单位,它的分子和分母都可以根据具体的编码格式或者媒体流来自由设定,可以精确适配不同的帧率和时钟频率,使得时间表示既可以非常精细,也可以非常灵活。例如,对于一个帧率为29.97fps的视频,我们可以设置timebase为1/30000,这样就能够非常精确地表示每一帧的时间。所以,引入timebase主要是为了在多媒体处理中提供一个既灵活又精确的时间表示方法。

Timebase 不如常见的秒或者毫秒那样直观。对于绝大多数人来说,"1/30000秒"涉及到的分数运算可能会比较难以理解,相比之下,"33.33毫秒"可能会更直观一些。

但是在多媒体编程中,由于需要处理各种不同的编码格式和媒体流,每种可能有其特定的帧率或者时钟频率,这就需要一个更灵活和精细的时间表示方法来适应这些差异,这就是引入timebase的原因。

对于程序来说,处理timebase只不过是一些简单的乘法和除法运算,而对于熟悉多媒体编程的开发者来说,他们也已经习惯了timebase这样的时间表示方法。

在FFmpeg中,timebase是一个分数,表示的是时间单位,用于进行时间戳与实际时间的转换。它由一个分子和一个分母构成。分子通常为1,分母通常等于媒体的帧率或者时钟频率。

  • 分子(numerator):通常为1,在方程中根据需要设定
  • 分母(denominator):通常将帧率或者时钟频率设定为分母
    以数字视频为例,如果一个视频的帧率是每秒30帧,那么它的timebase就是1/30,这个值表示的是每帧的时间长度。换算成秒,就是约0.0333秒。如果你有一个时间戳值,例如100,那么这个时间戳对应的实际时间就是100 * (1/30)= 约3.33秒。

再举一个数字音频的例子,如果音频的采样率是44100Hz,表示每秒钟有44100个音频样本,那么它的timebase就是1/44100。如果你有一个时间戳值,例如22050,那么这个时间戳对应的实际时间就是22050 * (1/44100)= 0.5秒。

需要注意的是,不同的流可能有不同的time_base。例如一个包含视频和音频的媒体文件,它的视频流的time_base可能是1/30(对应30fps的帧率),而音频流的time_base可能是1/44100(对应44100Hz的采样率)。所以在计算时间戳对应的实际时间时,需要使用正确的time_base。

或者换一种理解,在FFmpeg中,timebase以一个分数形式存在,分母表示将1秒拆分成的份额,而分子表示每一帧(或采样,或其他计数单位)占用的份额。例如,如果一个时间基准(timebase)是7/30,那么这意味着1秒被拆分成了30份,每一帧占用了7份。所以,每一帧对应的时间就是7 * 1/30 = 0.2333秒。

在大多数的实际应用中,timebase的分子通常为1,这是因为在大多数媒体格式中,每一帧(或其他的计数单位)通常都对应于一个单位的时间。这样的话,如1/24、1/30、1/60这样的timebase也就表示了每帧的时间,直接是1/24秒、1/30秒、1/60秒。

Timebase 转换

由于视频流和音频流使用的 timebase 是不同的,为了能够进行音画同步,我们首先要做的是将视频帧的 PTS 与音频帧的 PTS 都转换到同一个 Timebase 上,以便我们进行时间上的比较。

在FFmpeg中,AV_TIME_BASE是一个宏定义,其值为1000000。这是作为FFmpeg内部处理时间戳时的一个共同参考,用于统一不同的时间基准。

这个宏定义的原理是以微秒作为基础时间单位。这样,你可以用这个AV_TIME_BASE来将时间戳从微秒转换到FFmpeg内部使用的时间单位,反之亦然。例如,如果你有一个以秒为单位的时间量,你可以乘以AV_TIME_BASE将其转换为基于FFmpeg的时间单位。

例如,1秒 = 1 * AV_TIME_BASE,0.5秒 = 0.5 * AV_TIME_BASE。多了解这一点,可以帮助你更好地理解FFmpeg在处理时间戳时的一些机制和单位转换等问题。

需要注意的是,虽然FFmpeg内部用AV_TIME_BASE作为统一的时间处理标准,但是不同的流还是可能有各自的时间基准,这需要我们在处理时对时间戳进行相应的转换。

假设我们有一个视频流,帧率是30fps,所以视频流的timebase是1/30;我们还有一个音频流,样本率是44100Hz,所以音频流的timebase是1/44100。

假设我们现在有一个视频帧和一个音频样本,它们的PTS(显示时间戳)分别是v_pts和a_pts。首先,我们需要把v_pts和a_pts转换到以AV_TIME_BASE为单位的时间戳,也就是以微秒为单位的时间戳:

v_pts_us = v_pts * (1 / 30) * AV_TIME_BASE 
a_pts_us = a_pts * (1 / 44100) * AV_TIME_BASE

然后,我们就可以直接比较v_pts_us和a_pts_us,这样就能知道音频和视频哪个应该先播放。

如果v_pts_us < a_pts_us,那么这个视频帧应该先播放; 如果a_pts_us < v_pts_us,那么这个音频样本应该先播放。这样,就实现了音画同步。当然这是一个简化的示例,在真实的应用中,可能还需要更复杂的逻辑来处理AV同步,例如处理拖动播放条引起的seek操作,处理音频和视频的解码延迟等。

在 FFmpeg API 中,转换时间戳的函数是 av_rescale_q。该函数通过给定的 AVRational 结构体(表示时间基数的分数结构体)来重新缩放时间戳。你可以像下面这样使用它:

int64_t v_pts_us = av_rescale_q(v_pts, video_stream->time_base, AV_TIME_BASE_Q);

这样 av_rescale_q 就会将 v_pts 从 video_stream->time_base (视频流的时间基数)转换为 AV_TIME_BASE_Q(AV_TIME_BASE的时间基数,值为1/1000000)。同样的,你可以用这个函数转换音频时间戳:

int64_t a_pts_us = av_rescale_q(a_pts, audio_stream->time_base, AV_TIME_BASE_Q);

音画同步

有了前面的知识铺垫,相信你已经对 FFmpeg 中关于时间概念有所了解,这是进行音画同步编程的前提。

就目前的程序而言,视频和音频正在愉快地运行,根本不需要同步。如果一切正常,我们就不必担心这个问题。但你的电脑并不完美,很多视频文件也不完美。因此,我们有三种选择:将音频同步到视频、将视频同步到音频,或者将两者同步到外部时钟(如电脑)。现在,我们介绍的是将视频同步到音频。

精确地纪录和获取时间

我们首先要解决的第一个问题是:如何精确的纪录当前视频/音频流的时间。只有拿到了精确的时间,我们才能知道视频与音频之间的快慢关系。

在 ffmpeg_video_player_tutorial-my_tutorial05_01_clock.cpp 中我们封装了一个叫 Clock 的类,它负责纪录时间。

class Clock {
public:
  std::atomic<double> pts{0};          // clock base, seconds
  std::atomic<double> last_updated{0}; // last pts updated time
  std::atomic<double> pre_pts{0};
  std::atomic<double> pre_frame_delay{0};
};
  • pts,当前流的播放时间,单位 s
  • last_updated,最近 pts 更新的时间,单位 s
  • pre_pts,上次的 pts
  • pre_frame_delay,上次帧的延迟。在某些情况我们将使用这个变量作为当前帧的延迟。

使用下面这些函数来更新/获取时钟:


void setClockAt(Clock &clock, double pts, double time) {
  clock.pts = pts;
  clock.last_updated = time;
}

void setClock(Clock &clock, double pts) {
  setClockAt(clock, pts, (double)av_gettime() / 1000000.0);
}

double getClock(const Clock &c) const {
    double time = (double)av_gettime() / 1000000.0;
    return c.pts + time - c.last_updated;
}

setClock(Clock &clock, double pts) 更新当前 clock 的 pts,同时更新 last_updated 值,last_updated 也就是调用该函数时的系统时间。为什么需要 last_updated ?getClock 给出了答案。

getClock 获取当前流的播放时间。下图解释了 getClock 的计算逻辑

	     pts=30				  pts=60			   pts=90	  		   pts=120
last_updated=1000	 last_updated=1030	  last_updated=1090	  last_updated=1120
Thread0: |--------------------|--------------------|--------------------| 
                    |                                             |
                    |                                             |
Thread1: |----------|---------------------------------------------|-----| 
 				time=1015                                     time=1110
			      t0=30+(1015-1000)							    t1=90+(1110-1090)
			        =45                                           =110 

想象有两个线程,Thread 0(视频播放线程)和Thread 1。Thread 0定期播放视频帧,并更新视频流的时间。Thread 1在任何时间都可能访问视频流时钟以获取当前播放时间。

在时间点 t0,系统时间 time 为 1015,视频流时钟的 pts=30,last_updated=1000;经过的时间是 (time - last_updated) = 15,这表明更新 pts 距现在过去了15个时间单位,所以当前播放时间是 pts + 15 = 45。

在时间点 t1,系统时间 time 为 1110,视频流时钟的 pts=90,last_updated=1090;经过的时间是 (time - last_updated) = 20,这表明更新 pts 距现在过去了20个时间单位,所以当前播放时间是 pts + 20 = 110。

纪录时间的时机

在代码中,我们使用两个 clock 分别纪录视频和音频的时间

Clock audio_clock_t; // 纪录音频时间
Clock video_clock_t; // 纪录视频时间

那么应该在何时更新时钟的时间呢?

在视频流中,在渲染当前帧时,我们更新 video_clock_t 时钟,具体的,在 videoRefreshTimer 函数使用 ctx->videoSync 进行时钟更新

void videoRefreshTimer(void *userdata) {
	// ...
	auto real_delay = ctx->videoSync(video_frame);
	// ...
}

在音量流中,在播放当前音频帧时,我们更新 audio_clock_t 时钟,具体的,在 audioCallback 函数中使用 ctx->setClock 进行时钟更新:

void audioCallback(void *userdata, Uint8 *stream, int len){
	// ...
	play_ctx->setClock(play_ctx->audio_clock_t,
           frame->pts * av_q2d(audio_stream->time_base));
	// ...
}

当然,注意我们设置时钟时需要将 pts 转换到以秒(s)为单位,确保视频时钟和音频时钟时间单位一致。

音画同步具体算法

本文主要讲解如何将视频同步到音频,因此,在音频线程中,我们只需更新音频时钟,而无需进行任何同步操作。所有的音频视频同步操作都会在视频播放线程中进行。

简单来说,音视频同步的原理并不复杂。以每秒 25 帧(即每 40 毫秒刷新一帧)的视频为例。如果视频播放速度超过了音频,我们可以适当地延长下一帧的刷新时间,使视频播放速度慢一些,给音频一些“追赶”的时间,比如延长到 45 毫秒刷新一帧。相反,如果音频的播放速度快于视频,我们可以缩短下一帧的刷新时间,使视频播放快一些,以便能跟得上音频的速度。

具体计算视频下一帧刷新时间的代码在 videoSync 函数中,看代码:

int videoSync(AVFrame *video_frame) {
    auto video_timebase_d = av_q2d(decode_ctx->video_stream->time_base);

    auto pts = video_frame->pts * video_timebase_d;
    setClock(video_clock_t, pts);

    auto pts_delay = pts - video_clock_t.pre_pts;
    printf("PTS Delay:\t\t\t\t%lf\n", pts_delay);
    // if the obtained delay is incorrect
    if (pts_delay <= 0 || pts_delay >= 1.0) {
      // use the previously calculated delay
      pts_delay = video_clock_t.pre_frame_delay;
    }
    printf("Corrected PTS Delay:\t%f\n", pts_delay);

    // save delay information for the next time
    video_clock_t.pre_pts = pts;
    video_clock_t.pre_frame_delay = pts_delay;

    auto audio_ref_clock = getAudioClock();
    auto video_clock = getVideoClock();
    auto diff = video_clock - audio_ref_clock;
    printf("Audio Ref Clock:\t\t%lf\n", audio_ref_clock);
    printf("Audio Video Delay:\t\t%lf\n", diff);

    auto sync_threshold = std::max(pts_delay, AV_SYNC_THRESHOLD);
    printf("Sync Threshold:\t\t\t%lf\n", sync_threshold);

    if (fabs(diff) < AV_NOSYNC_THRESHOLD) {
      if (diff <= -sync_threshold) {
        pts_delay = 0;
      } else if (diff >= sync_threshold) {
        pts_delay = 2 * pts_delay; // [2]
      }
    }

    printf("Corrected PTS delay:\t%lf\n", pts_delay);

    return (int)std::round(pts_delay * 1000);
  }
};

上述代码片段是一个函数,用于视频同步。用于使用音频时钟作为参考来调整视频帧的展示时间,从而实现音画同步。

  1. 第一部分:计算出当前视频帧的表现时间 pts。影片序数(pts)是一个表示时间基数的特殊单位,表示在给定的时间标度(time base)下的时间。这里av_q2d 函数将 decode_ctx->video_stream->time_base 转化为双精度浮点数。将 pts设为当前音频时钟的时间。
  2. 第二部分:计算 pts_delay,它表示当前帧和上一帧之间的延迟。如果这被认为是无效的(即小于等于0或大于等于1.0),则会使用先前的帧延迟。这些信息存储起来供下一次使用。
  3. 第三部分:获取音频时钟 audio_ref_clock和视频时钟 video_clock,并计算出二者之间的差值diff,这表示音频和视频帧之间的延迟。
  4. 第四部分:计算出调整阈值 sync_threshold,当差值 diff 在阈值范围内时,不调整 pts_delay,音频和视频同步。若差值(音频和视频延时)过大,则根据其正负,使视频速度加快或放慢,实现音视频同步。
  5. 最后,返回 pts_delay 的值(以毫秒为单位),这个返回值将被用作等待时间,来决定何时展示下一帧。

在视频播放线程中,使用 videoSync 计算出下一帧的刷新等待时间后,使用 scheduleRefresh(ctx, real_delay); 设置一个定时器,让定时器在 real_delay 后发送一个事件让 SDL 去渲染下一帧画面。就这样,我们完成了音画同步。

总结

本文介绍了如何实现播放器的音画同步,首先介绍了 I/P/B 帧的区别,引出了 PTS 和 DTS 的概念;接着,介绍了在 FFmpeg 中的 timebase 的概念,让读者了解 FFmpeg 是如何描述时间的;然后,我们详细的描述了音画同步实施的具体要点,包括如何精确的纪录不同流的当前时间,在什么时间节点来更新时钟,以及音画同步的具体算法。

参考

  • An ffmpeg and SDL Tutorial - Tutorial 05: Synching Video
  • An ffmpeg and SDL Tutorial - Tutorial 06: Synching Audio
  • FFmpeg 音视频(DTS / PTS)
  • ffmpeg_video_player_tutorial-my_tutorial05_01_clock.cpp。

你可能感兴趣的:(音视频,ffmpeg,ffmpeg,音视频)