欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页

直播软件源码如何实现音视频同步(一)

程序员文章站 2022-06-10 10:39:13
...

音视频同步是播放器中比较复杂的一部分内容。前几次实验中的代码远不能满足要求,需要大幅修改。本次实验不在前几次代码上修改,而是基于 ffplay 源码进行修改。ffplay 是 FFmpeg 工程自带的一个简单播放器,尽管称为简单播放器,其代码实现仍显得过为复杂,本实验对 ffplay.c 进行删减,删掉复杂的命令选项、滤镜操作、SEEK 操作、逐帧插放等功能,仅保留最核心的音视频同步部分。

尽管不使用之前的代码,但播放器的基本原理和大致流程相同,前面几次实验仍具有有效参考价值。

1. 视频播放器基本原理


如下内容引用自 “雷霄骅,视音频编解码技术零基础学习方法”:

解协议
将流媒体协议的数据,解析为标准的相应的封装格式数据。视音频在网络上传播的时候,常常采用各种流媒体协议,例如 HTTP,RTMP,或是 MMS 等等。这些协议在传输视音频数据的同时,也会传输一些信令数据。这些信令数据包括对播放的控制(播放,暂停,停止),或者对网络状态的描述等。解协议的过程中会去除掉信令数据而只保留视音频数据。例如,采用 RTMP 协议传输的数据,经过解协议操作后,输出 FLV 格式的数据。

解封装
将输入的封装格式的数据,分离成为音频流压缩编码数据和视频流压缩编码数据。封装格式种类很多,例如 MP4,MKV,RMVB,TS,FLV,AVI 等等,它的作用就是将已经压缩编码的视频数据和音频数据按照一定的格式放到一起。例如,FLV 格式的数据,经过解封装操作后,输出 H.264 编码的视频码流和 AAC 编码的音频码流。

解码
将视频/音频压缩编码数据,解码成为非压缩的视频/音频原始数据。音频的压缩编码标准包含 AAC,MP3,AC-3 等等,视频的压缩编码标准则包含 H.264,MPEG2,VC-1 等等。解码是整个系统中最重要也是最复杂的一个环节。通过解码,压缩编码的视频数据输出成为非压缩的颜色数据,例如 YUV420P,RGB 等等;压缩编码的音频数据输出成为非压缩的音频抽样数据,例如 PCM 数据。

音视频同步
根据解封装模块处理过程中获取到的参数信息,同步解码出来的视频和音频数据,并将视频音频数据送至系统的显卡和声卡播放出来。

2. 简易播放器的实现-音视频同步

2.1 实验平台

1
2
3
4
实验平台:      openSUSE Leap 42.3  
                Microsoft Visual Studio 2017 (WIN10)  
FFmpeg 版本:   4.1  
SDL 版本:      2.0.9  

本工程支持在 Linux 和 Windows 平台上运行。
Linux 下 FFmpeg 开发环境搭建可参考 “FFmpeg 开发环境构建”。
Windows 下使用 Microsoft Visual Studio 2017 打开工程目录下 ffplayer.sln 文件即可运行。

2.2 源码清单

使用如下命令下载源码:

svn checkout https://github.com/leichn/ffplayer/trunk

ffplay 所有源码集中在 ffplay.c 一个文件中,ffplay.c 代码很长。本实验将 ffplay.c 按功能点拆分为多个文件,源文件说明如下:

1
2
3
4
5
6
7
8
9
player.c    运行主线程,SDL 消息处理
demux.c     解复用线程
video.c     视频解码线程和视频播放线程
audio.c     音频解码线程和音频播放线程
packet.c    packet 队列操作函数
frame.c     frame 队列操作函数
main.c      程序入口,外部调用示例
Makefile    Linux 平台下编译用 Makefile
lib_wins    Windows 平台下 FFmpeg 和 SDL 编译时库和运行时库

本来想将 ffplay.c 中全局使用的大数据结构 VideoState 也拆分分散到各文件中去,但发现各文件对此数据结构的引用关系错综复杂,很难拆分,因此作罢。

2.3 源码流程分析

源码流程和 ffplay 基本相同,不同的一点是 ffplay 中视频播放和 SDL 消息处理都是在同一个线程中(主线程),本工程中将视频播放独立为一个线程。本工程源码流程如下图所示:

ffplay 的源码流程可参考 “ffplay源码分析3-代码框架”。

2.4 音视频同步

音视频同步的详细介绍可参考 “ffplay源码分析4-音视频同步”,为保证文章的完整性,本文保留此节内容。与 “ffplay源码分析4-音视频同步” 相比,本节源码及文字均作了适当精简。

音视频同步的目的是为了使播放的声音和显示的画面保持一致。视频按帧播放,图像显示设备每次显示一帧画面,视频播放速度由帧率确定,帧率指示每秒显示多少帧;音频按采样点播放,声音播放设备每次播放一个采样点,声音播放速度由采样率确定,采样率指示每秒播放多少个采样点。如果仅仅是视频按帧率播放,音频按采样率播放,二者没有同步机制,即使最初音视频是基本同步的,随着时间的流逝,音视频会逐渐失去同步,并且不同步的现象会越来越严重。这是因为:一、播放时间难以精确控制,二、异常及误差会随时间累积。所以,必须要采用一定的同步策略,不断对音视频的时间差作校正,使图像显示与声音播放总体保持一致。

音视频同步的方式基本是确定一个时钟(音频时钟、视频时钟、外部时钟)作为主时钟,非主时钟的音频或视频时钟为从时钟。在播放过程中,主时钟作为同步基准,不断判断从时钟与主时钟的差异,调节从时钟,使从时钟追赶(落后时)或等待(超前时)主时钟。按照主时钟的不同种类,可以将音视频同步模式分为如下三种:
音频同步到视频,视频时钟作为主时钟。
视频同步到音频,音频时钟作为主时钟。
音视频同步到外部时钟,外部时钟作为主时钟。

本实验采用 ffplay 默认的同步方式:视频同步到音频。ffplay 中同步模式的定义如下:

1
2
3
4
5
enum {
    AV_SYNC_AUDIO_MASTER, /* default choice */
    AV_SYNC_VIDEO_MASTER,
    AV_SYNC_EXTERNAL_CLOCK, /* synchronize to an external clock */
};

2.4.1 time_base

time_base 是 PTS 和 DTS 的时间单位,也称时间基。

不同的封装格式time_base不一样,转码过程中的不同阶段time_base也不一样。

以 mpegts 封装格式为例,假设视频帧率为 25 FPS。编码数据包 packet(数据结构 AVPacket) 对应的 time_base 为 AVRational{1,90000}。原始数据帧 frame(数据结构 AVFrame) 对应的 time_base 为 AVRational{1,25}。在解码或播放过程中,我们关注的是 frame 的 time_base,定义在 AVStream 结构体中,其表示形式 AVRational{1,25} 是一个分数,值为 1/25,单位是秒。在旧的 FFmpeg 版本中,AVStream 中的 time_base 成员有如**释:

For fixed-fps content, time base should be 1/framerate and timestamp increments should be 1.

当前新版本中已无此条注释。

2.4.2 PTS/DTS/解码过程

DTS(Decoding Time Stamp, 解码时间戳),表示 packet 的解码时间。
PTS(Presentation Time Stamp, 显示时间戳),表示 packet 解码后数据的显示时间。

音频中 DTS 和 PTS 是相同的。视频中由于 B 帧需要双向预测,B 帧依赖于其前和其后的帧,因此含 B 帧的视频解码顺序与显示顺序不同,即 DTS 与 PTS 不同。当然,不含 B 帧的视频,其 DTS 和 PTS 是相同的。

解码顺序和显示顺序相关的解释可参考 “视频编解码基础概念”,选用下图说明视频流解码顺序和显示顺序:
直播软件源码如何实现音视频同步(一)

理解了含 B 帧视频流解码顺序与显示顺序的不同,才容易理解视频解码函数 video_decode_frame() 的处理过程:
avcodec_send_packet() 按解码顺序发送 packet。
avcodec_receive_frame() 按显示顺序输出 frame。
这个过程由解码器处理,不需要用户程序费心。

video_decode_frame() 是非常核心的一个函数,实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
// 从packet_queue中取一个packet,解码生成frame
static int video_decode_frame(AVCodecContext *p_codec_ctx, packet_queue_t *p_pkt_queue, AVFrame *frame)
{
    int ret;
    
    while (1)
    {
        AVPacket pkt;

        while (1)
        {
            // 3. 从解码器接收frame
            // 3.1 一个视频packet含一个视频frame
            //     解码器缓存一定数量的packet后,才有解码后的frame输出
            //     frame输出顺序是按pts的顺序,如IBBPBBP
            //     frame->pkt_pos变量是此frame对应的packet在视频文件中的偏移地址,值同pkt.pos
            ret = avcodec_receive_frame(p_codec_ctx, frame);
            if (ret < 0)
            {
                if (ret == AVERROR_EOF)
                {
                    av_log(NULL, AV_LOG_INFO, "video avcodec_receive_frame(): the decoder has been fully flushed\n");
                    avcodec_flush_buffers(p_codec_ctx);
                    return 0;
                }
                else if (ret == AVERROR(EAGAIN))
                {
                    av_log(NULL, AV_LOG_INFO, "video avcodec_receive_frame(): output is not available in this state - "
                            "user must try to send new input\n");
                    break;
                }
                else
                {
                    av_log(NULL, AV_LOG_ERROR, "video avcodec_receive_frame(): other errors\n");
                    continue;
                }
            }
            else
            {
                frame->pts = frame->best_effort_timestamp;
                //frame->pts = frame->pkt_dts;

                return 1;   // 成功解码得到一个视频帧或一个音频帧,则返回
            }
        }

        // 1. 取出一个packet。使用pkt对应的serial赋值给d->pkt_serial
        if (packet_queue_get(p_pkt_queue, &pkt, true) < 0)
        {
            return -1;
        }

        if (pkt.data == NULL)
        {
            // 复位解码器内部状态/刷新内部缓冲区
            avcodec_flush_buffers(p_codec_ctx);
        }
        else
        {
            // 2. 将packet发送给解码器
            //    发送packet的顺序是按dts递增的顺序,如IPBBPBB
            //    pkt.pos变量可以标识当前packet在视频文件中的地址偏移
            if (avcodec_send_packet(p_codec_ctx, &pkt) == AVERROR(EAGAIN))
            {
                av_log(NULL, AV_LOG_ERROR, "receive_frame and send_packet both returned EAGAIN, which is an API violation.\n");
            }

            av_packet_unref(&pkt);
        }
    }
}

本函数实现如下功能:
[1] 从视频 packet 队列中取一个 packet。
[2] 将取得的 packet 发送给解码器。
[3] 从解码器接收解码后的 frame,此 frame 作为函数的输出参数供上级函数处理。

注意如下几点:
[1] 含 B 帧的视频文件,其视频帧存储顺序与显示顺序不同。
[2] 解码器的输入是 packet 队列,视频帧解码顺序与存储顺序相同,是按 dts 递增的顺序。dts 是解码时间戳,因此存储顺序解码顺序都是 dts 递增的顺序。avcodec_send_packet() 就是将视频文件中的 packet 序列依次发送给解码器。发送 packet 的顺序如 IPBBPBB。
[3]. 解码器的输出是 frame 队列,frame 输出顺序是按 pts 递增的顺序。pts 是解码时间戳。pts 与 dts 不一致的问题由解码器进行了处理,用户程序不必关心。从解码器接收 frame 的顺序如 IBBPBBP。
[4]. 解码器中会缓存一定数量的帧,一个新的解码动作启动后,向解码器送入好几个 packet 后解码器才会输出第一个 packet,这比较容易理解,因为解码时帧之间有信赖关系,例如 IPB 三个帧被送入解码器后,B 帧解码需要依赖 I 帧和 P 帧,所以在 B 帧输出前,I 帧和 P 帧必须存在于解码器中而不能删除。理解了这一点,后面视频 frame 队列中对视频帧的显示和删除机制才容易理解。
[5]. 解码器中缓存的帧可以通过冲洗(flush)解码器取出。冲洗(flush)解码器的方法就是调用 avcodec_send_packet(..., NULL),然后多次调用 avcodec_receive_frame() 将缓存帧取尽。缓存帧取完后,avcodec_receive_frame() 返回 AVERROR_EOF。

如何确定解码器的输出 frame 与输入 packet 的对应关系呢?可以对比 frame->pkt_pos 和 pkt.pos 的值,这两个值表示 packet 在视频文件中的偏移地址,如果这两个变量值相等,表示此 frame 来自此 packet。调试跟踪这两个变量值,即能发现解码器输入帧与输出帧的关系。为简便,就不贴图了。