播放器架构和简单实现

这是2020年10-12月我学习播放器时的记录,在21年5月发在博客上,现在搬到

本文讲述如何实现一个播放器

主要内容包括一个音视频基础、简单播放器的pipeline结构原理和实现:FFMpeg简单使用,iOS音视频渲染相关内容。

音视频基础

真是够基础的了,大部分人可以跳过

要实现一个播放器,首先需要知道音视频是如何播放、如何存储和传输的。

对于文字、数字来说,编码似乎很简单,无非是找到一个对照表,使得能用一个计算机的表示对应上一个需要被表示的值,例如,可以用七位二进制0110000表示数字0(ASCII),可以用四位十六进制554a表示中文"啊"(Unicode)。

对于音视频来说,更和他们的物理特性相关。

要编码和存储视频,实际上只需要编码和存储不同的图像,因为视频实际上是由不同的图像在时间上叠加得到的。而要编码图像,常用的做法是分别标识一幅图不同位置的颜色——例如,均匀地在图片上采样1024*768个点,分别存储他们的颜色(如RGB模型),可以近似地认为我们得到了一幅图像的编码。如果在时间上均匀的采样一段视频为不同的图像,比如10秒视频采样为300幅图像(30fps),再在播放时按照每幅图像持续33ms显示,就可以近似地认为我们得到了一个视频的编码。

音频也是类似的。声音是一个波(想象一下正弦曲线、余弦曲线以及他们的叠加,当然,我们不能得到声音的方程),如果按照一定的时间间隔采样每个点的大小,也可以近似地认为我们得到了一个声音(的声学特性),在硬件上,如果按照这样的时间间隔、大小比例去驱动振膜,就可以得到类似的声音(音色很可能不会相同),这也是电话的原理。

当然,音视频的编码又和文字、数字有很大的不同:文字、数字的编码和文字数字是可以一一对应的,而我们编码的视频、音频只能说是真实世界当中的近似,已经无法转换回原本的模样了。这是因为,文字、数字本身就是离散的,而音频、视频都是连续的,而(当前的)计算机只能处理离散的数据,从连续到离散,采样得越精细(提高fps、提高像素密度、提高音频采样点,提高音量的表示位数)越真实,但永远都损失了一部分信息。

这是第一步,编码;当然,编码也有很多种。例如,对于音频,前面所说的这种最简单的就是PCM编码,在此之外,做一些编码效率、增益以及预测压缩等又有AAC、opus等。对于图片的编码也可以有带压缩的JPEG,而视频在此之外还可以去除空间的冗余,有HEVC、AVCC等(我们常说的H.264、H.265)。

但我们网上冲浪常见到的“音视频”,并不会单独只是音频和画面,而是既有声音又有画面,而假如仅有前一步的音频和视频编码,对于我们的播放和传输稍有不便:此处有两个文件,分别编码画面和声音,当我们播放到正好第10s时,需要从第一个文件读到第N帧画面,需要从第二个文件中读出并播放第M+k个采样点,想象网络传输/存储时,控制两个文件以及同步有多不便。

因此,我们需要将音频和视频封装到一个文件中,比如,文件第一部分是第1-30帧画面,第二部分是0-1秒的音频...如此交错,播放该“视频”(含音频)时只需要读文件、缓冲一部分内容就可以很简单地完成音(频)画(面)同步。例如我们常说的AVI(Audio Video Interleaved,音频视频交错格式)就是一种封装格式。亦有不同的封装格式完成不同的目的,例如flv(最初是for flash),播放简单,因此流传甚广。

到此,我们已能完成一个本地的音视频存储和播放了,但对于点/直播网络上的视频还需要一个传输协议。当然,你可能会说,直接套个TCP来传输文件不就可以了么?所以,为什么不试试HTTP来持续分发一个文件呢。国内由于历史原因,众多直播网站都选择HTTP+FLV的方式(FLV over HTTP);又或者,设想当网络出现抖动时,如何更方便地通过切换清晰度来提升用户体验呢,这个时候一系列提供分片功能的协议就胜出了(如HLS);又或者,直播的内容生产者上行视频时,偏爱RTMP协议,这或许是由于OBS最初就支持它的原因。

总之,音视频的基础就是理解为何在处理音视频的过程中需要做 编码/解码、封装/解封装,以及挑选传输协议。

播放器Pipeline

由前面的基础可以得知,播放器由于不涉及视频的生产过程,需要做的工作就是传输解封装解码,以及最后渲染画面和声音。同时对于一款播放器来说,需要支持不同的文件、网络格式,不同的封装格式、不同的编码格式、甚至在不同平台上实现渲染,因此较通用的做法是将整个播放的核心过程实现为一个流水线,流水线上的每一个模块可插拔,由此可根据当前所要播放的视频具体格式来组装流水线。

简单的播放器架构

先看图中最小的几个模块

downloader:实现传输功能并将读到的数据传递给后续的模块。对于本地文件,提供一个file-downloader的版本,作用仅仅是简单的打开并读取文件;而对于一个rtmp-downloader,就需要做一些rtmp协议解析的工作(一般使用librtmp来完成),并把rtmp协议所运载的flv数据传递给后续模块。

demuxer:实现前述所说的解封装功能,解析封装协议得到编码后的数据,区分音频和视频类型,分别喂给音频解码器和视频解码器。

decoder:实现解码功能,分为CPU实现的软解(通用计算)和特定硬件实现的硬解。软解兼容性好,而硬解效率高能耗较低(对于移动平台尤为重要):常见的模式是优先使用软解,软解失败后切硬解,这就要求decoder其实实现的是组合模式(has-a),内部组合两种解码器。

render:实现渲染音视频功能,该模块和特定平台实现高度相关(前面的硬件解码器也和平台相关),如果要实现一个跨平台的播放器,那么需要实现不同的render,而前面的downloader、demuxer、decoder可以复用(需要其依赖库选择跨平台的,例如网络库使用libcurl)。

以上为播放器最核心的四个部件,已经能实现基本的功能,但我们还可以添加一些模块:

controller:控制模块。持有以上四个模块,同时按需组装,根据当前需要播放的url类型和播放参数来取用不同类型的downloader、demuxer、decoder、render。也可以把controller整个认为是对外提供服务的播放器。

eventBus:提供一个事件处理和线程同步模型。为了性能起见、也很容易,将以上downloader、demuxer、decoder、render实现为四个独立的线程,每个模块与下个模块间靠一个队列来同步(生产-消费);但控制每个模块start/stop/pause/resume和每个模块回传给controller一些信息时需要回到同一个线程当中,这个线程由eventBus来实现,并完成一些event-handler机制。

另外,如果使用FFMpeg库来解封装,它已提供了从网络/文件打开并读取封装(即downloader的作用),因此项目中只有一个单独的ffmpeg·demuxer来替代downloader和dmuxer。毕竟,自己实现协议的解析太麻烦,而且并没有什么价值。

对于整个播放器的架构就讨论到这,以下是代码和相关API部分

FFMpeg+SDL实现简单播放器

以下部分是我之前写的《实现一个简单播放器》

代码在这里:https://github.com/Styx-S/SFPlayer

环境搭建

  • VisualStudio 2017
  • SDL2
  • FFMpeg

VisualStudio这里就不再多言,我使用的是VisualStudio2017,安装上c++相关的支持。

SDL2是一个跨平台库,封装了(OpenGL、Direct3D)对音频、键盘、鼠标、图形等的低级访问,这里我们用它来进行画面的渲染,和声音的播放。

安装编译SDL2比较简单,在官网下载https://www.libsdl.org/release/SDL2-2.0.12.zip,打开路径下VisualC里的SDL.sln工程,编译即可在Debug\X64路径下找到SDL的库文件(SDL*.lib, SDL*.dll),将他们和SDL的include路径下的头文件一起拿出来放到我们第三方库的位置。

FFMpeg大家应该都比较了解,使我们无需理解细节就可以完成音视频的相关处理(封装/解封装,编解码,滤镜,格式转换)等操作,许多视频编辑压制工具箱也是ffmpeg命令行的封装,而ffmpeg命令行工具又是对livavformat、libavcodec等底层库的封装,我们这里会直接使用这几个库。

Windows下编译ffmpeg比较麻烦,因为需要安装configure那一套编译工具链。

需要首先安装msys2,然后在msys2中使用包管理工具安装编译工具链

pacman -S make gcc diffutils pkg-config

然后这里有一套很复杂的操作:

  1. 把msys路径下msys64/usr/bin/link.exe重命名为其他名字
  2. 下载yasm,把其中的yasm*.exe改名为yasm.exe放到msys64/usr/bin路径下
  3. 把msys64/msys2_shell.cmd中rem set MSYS2_PATH_TYPE=inherit一行的rem给删掉
  4. 首先在命令行(cmd)中运行VS的脚本,我的vs2017路径是“C:\Program Files (x86)\Microsoft Visual Studio\2017\Community\VC\Auxiliary\Build\vcvars64.bat”
  5. 随后在当前命令行下打开msys “msys2_shell.cmd -mingw64”

现在终于能在msys2的终端当中编译ffmpeg了

# 进入ffmpeg路径,这里我使用4.3版本
git branch -b release/4.3 origin/release/4.3
# 不进行太多裁剪,指定安装路径放到一个文件夹里,到时候好拖出去
./configure  --toolchain=msvc --arch=x86 --enable-yasm --enable-asm --enable-shared --disable-static --prefix="ffmpeg_build"
# 编译
make -j6
# 安装
make install

在当前路径ffmpeg_build文件夹里,将include里的头文件、lib里的库文件同样拷到我们之前放SDL的地方。

接下来可以创建一个VS工程(C++命令行),进行第三方库的设置,右键解决方案里当前Target,进入属性。

选择C/C++ -> 常规,在附加包含目录中添加第三方库的头文件路径,我这里是third_party\ffmpeg\include和third_party\sdl2\include

选择链接器 -> 输入,在附加依赖项中添加第三方库lib文件,这里包括ffmpeg的几个和SDL的几个(..\third_party\ffmpeg\lib\windows\avcodec.lib ... ..\third_party\sdl2\lib\windows\SDL2.lib ..\third_party\sdl2\lib\windows\SDL2main.lib)。

现在整个环境搭建完成,可以开始写代码了。

解码播放流程

由于使用了FFmpeg+SDL,接下来当好一个调包侠就可以了,这里分步骤介绍一下他们的API,省略异常处理等流程,完整的代码可以参见这个git仓库

读文件/流(解封装)
// 相关的变量放在AVFormatContext这个结构体里面
AVFormatContext *format_context_;
// 使用给定的url初始化这个结构体,url可以是一个本地的文件,也可以是常见的网络上的多媒体资源
avformat_open_input(&format_context_, url.c_str(), NULL, NULL)
// 探测具体的流信息
avformat_find_stream_info(format_context_, NULL)
// AVPacket是封装数据包,从文件中直接读出来的数据放到这个里面
AVPacket *packet = av_packet_alloc();
// 这样就可以读出来一个数据包了,一般是配合一个while循环,并检测函数的返回值,确定是否读到EOF末尾
av_read_frame(format_context_, packet);
解码

这里只写视频,音频类似。

// 从format_context_ -> streams 里找到一个视频流,根据stream->codecpar->codec_type == AVMEDIA_TYPE_VEDIO来判断.
// 使用该视频流的解码参数来初始化一个解码器
AVCodec *codec = avcodec_find_decoder(vudioStream->codecpar->codec_id);
// 使用解码器初始化解封装上下文
AVCodecContext *video_codec_context_ = avcodec_alloc_context3(codec);
// 注意,这里还需要对解封装上下文里面一些参数进行设置,否则不能正常解码
avcodec_parameters_to_context(video_codec_context_, videoStream->codecpar)
    video_codec_context_->framerate = av_guess_frame_rate(format_context_, videoStream, NULL);
avcodec_open2(video_codec_context_, codec, NULL);

// 然后使用上一步读出来的AVPacket进行解码 AVFrame就是解码后的数据
AVFrame *frame = av_frame_alloc();
avcodec_send_packet(video_codec_context_, packet);
avcodec_receive_frame(video_codec_context_, frame);

格式转换

这里使用SDL2渲染画面用的纹理图片是YV12,也就是需要把解码后的视频帧转换成YUV420P才能正常的显示

SwsContext *video_sws_context_ = NULL;
// 创建一个格式转换上下文,这里宽度和高度不变,只改变格式
video_sws_context_ = sws_getCachedContext(video_sws_context_,
            video_codec_context_->width,
            video_codec_context_->height,
            video_codec_context_->pix_fmt,
            video_codec_context_->width,
            video_codec_context_->height,
            AV_PIX_FMT_YUV420P,
            SWS_BILINEAR,
            NULL, NULL, NULL);
// 旧版本的API是使用一个AVPicture来承接转码后的数据,但是AVPicture相关的API在新版本ffmpeg已经废弃了,所以这里直接使用uint8_t []和int []来装YUV420P数据
uint8_t *dst_data[4];
int dst_linesize[4];
// 分配存储空间
av_image_alloc(dst_data, dst_linesize, video_codec_context_->width, video_codec_context_->height, AV_PIX_FMT_YUV420P, 1);
// 转换格式
sws_scale(video_sws_context_, frame->data, frame->linesize, 0, video_codec_context_->height, dst_data, dst_linesize);
渲染画面

SDL的使用方式比较简单,主要是创建窗口,创建渲染器,创建纹理

// 创建窗口,虽然咱们是一个命令行程序,但是也能看到窗口了
SDL_Window *window_ = SDL_CreateWindow("SFPlayer", SDL_WINDOWPOS_UNDEFINED, SDL_WINDOWPOS_UNDEFINED, width, height, 0);
// 创建窗口对应的渲染器
SDL_Renderer *render_ = SDL_CreateRenderer(window_, -1, SDL_RENDERER_ACCELERATED || SDL_RENDERER_PRESENTVSYNC);
// 创建对应的贴图
SDL_Texture *texture_ = SDL_CreateTexture(render_, SDL_PIXELFORMAT_YV12, SDL_TEXTUREACCESS_TARGET, width, height);

// 这个时候拿到前面的YUV420P数据就可以绘制了
SDL_SetRenderDrawColor(render_, 0x11, 0x11, 0x11, 0xff);
SDL_RenderClear(render_);
SDL_UpdateYUVTexture(texture_, NULL, dst_data[0], dst_linesize[0], dst_data[1], dst_linesize[1], dst_data[2], dst_linesize[2]);
SDL_RenderCopy(render_, texture_, NULL, NULL);
SDL_RenderPresent(render_);

编写CMake

步骤1中最后需要在工程中进行一些依赖的设置,由于使用的FFmpeg+SDL都是跨平台的,我们很容易就可以把这个播放器用在iOS、Android中,这个时候需要在Xcode、AndroidStudio建立工程、同样进行这些设置是十分繁琐的

可以使用CMake来描述这些依赖的过程。之前我也没编写过Cmake脚本,这里学习了下,可能有些地方写的不好(还没找到最佳实践)

cmake_minimum_required(VERSION 3.12)

project (SFPlayer)

if(CMAKE_CL_64)
    set(PLATFORM_64 TRUE)
    message("64bit platform")
else()
    set(PLATFORM_64 FALSE)
    message("32bit platform")
endif()

### FFMpeg ###
set(FFMPEG_DIR "${SFPlayer_SOURCE_DIR}/third_party/ffmpeg")
set(FFMPEG_INCLUDE_DIR "${FFMPEG_DIR}/include")
if(WIN32)
    set(FFMPEG_LIBS_DIR "${FFMPEG_DIR}/lib/windows")
endif()
set(FFMPEG_LIBS ${FFMPEG_LIBS_DIR}/avcodec.lib ${FFMPEG_LIBS_DIR}/avdevice.lib ${FFMPEG_LIBS_DIR}/avfilter.lib ${FFMPEG_LIBS_DIR}/avformat.lib ${FFMPEG_LIBS_DIR}/avutil.lib ${FFMPEG_LIBS_DIR}/swresample.lib ${FFMPEG_LIBS_DIR}/swscale.lib)


### SDL2 ###
set(SDL2_DIR "${SFPlayer_SOURCE_DIR}/third_party/sdl2")
set(SDL2_INCLUDE_DIR "${SDL2_DIR}/include")
if(WIN32)
    if(PLATFORM_64)
        set(SDL2_LIBS_DIR "${SDL2_DIR}/lib/windows")
    else()
        set(SDL2_LIBS_DIR "${SDL2_DIR}/lib/windows/x86")
    endif()
endif()
set(SDL2_LIBS ${SDL2_LIBS_DIR}/SDL2.lib ${SDL2_LIBS_DIR}/SDL2main.lib)

### include ###
set(SFPlayer_SRC_DIR ${SFPlayer_SOURCE_DIR}/src)
set(SFPlayer_INCLUDE_DIR ${SFPlayer_SOURCE_DIR}/include)

include_directories(${SFPlayer_INCLUDE_DIR})
include_directories(${SFPlayer_INCLUDE_DIR}/input)
include_directories(${SFPlayer_INCLUDE_DIR}/demxuer)
include_directories(${SFPlayer_INCLUDE_DIR}/decoder)
include_directories(${SFPlayer_INCLUDE_DIR}/render)

include_directories(${SFPlayer_SRC_DIR})
include_directories(${SFPlayer_SRC_DIR}/render)

include_directories(${FFMPEG_INCLUDE_DIR})
include_directories(${SDL2_INCLUDE_DIR})



### Source ###
file(GLOB SOURCES 
    "src/*.cpp" 
    "src/**/*.cpp" 
    "src/*.h"
    "src/**/*.h" 
    "include/*.h"
    "include/**/*.h")


add_executable(${PROJECT_NAME} ${SFPlayer_SOURCE_DIR}/examples/windows/main.cpp ${SOURCES})

### Link ###
link_directories(${FFMPEG_LIBS_DIR})
link_directories(${SDL2_LIBS_DIR})
target_link_libraries(${PROJECT_NAME}
    ${FFMPEG_LIBS}
    ${SDL2_LIBS})

if(${CMAKE_CXX_COMPILER_ID} STREQUAL "MSVC")
set_property(DIRECTORY PROPERTY VS_STARTUP_PROJECT "SFPlayer")
endif()

主要用到了这些命令,记录下

命令 作用
project() 声明工程
set() 声明变量
if() else() endif() 条件语句
message() 打印消息
include_directories() 指定头文件路径
file() 将所有匹配的所有文件路径添加到变量中
add_executable() 声明一个可执行程序(编译目标)
link_directories() 指定库搜索路径
target_link_libraries() 链接对应的库
set_property() 设置vs默认的启动项目,(如果不设置,vs中会尝试运行ALL_BUILD项目,会有问题)

播放音频

播放音频的流程如下(一些初始化的工作这里不再提了)

  1. av_read_frame 根据stream_index判断是音频流,随后送音频解码
  2. av_send_packet/av_receive_frame 解码
  3. swr_convert或swr_convert_frame 重采样
  4. SDL_Audio 播放

其中1、2步与前面一篇文章里视频帧处理流程没啥区别,关键的就在于3、4步,如果格式和数据长度没计算好的话,就会导致播出来的声音不对。

使用FFmpeg进行解码和重采样,仍然是使用AVFrame这个结构体,会用到其中音频相关的数据大概有这些:

AVFrame *frame;
frame->data;  // 存储音频数据
frame->linesize; // 
frame->channels;    // 音频数据有几个声道
frame->channel_layout; // 声道布局
frame->nb_samples;  // 采样点
frame->sample_rate; // 采样率Hz

对于data和linesize两个字段和视频Frame类似,就是用来存储数据的。对于音频,如果是planer(平面)布局,每个声道数据分别存储在frame->data[0]、frame->data[1]...里,否则的话全部存储在frame->data[0]里;linesize指示frame->data对应的维度分配的空间有多大,只代表空间(size),不代表存储多少数据(length)

原来多媒体技术大概学过存储、播放音频的原理,也就是用采样将模拟数据(波形)变为数字数据;sample_rate就是代表这个采样率,即一秒采集了多少个点,而nb_samples则是该帧数据中含有多少个采样点。其实可以得知这一frame中所含音频的时长。

channels代表的是声道数,例如可能单声道、双声道、多声道;而channel_layout代表的是声道布局,这个词可能有点难理解,我们看一下这个字段对应的头文件

#define AV_CH_FRONT_LEFT             0x00000001
#define AV_CH_FRONT_RIGHT            0x00000002
#define AV_CH_FRONT_CENTER           0x00000004
#define AV_CH_LOW_FREQUENCY          0x00000008
#define AV_CH_BACK_LEFT              0x00000010
#define AV_CH_BACK_RIGHT             0x00000020
//......
#define AV_CH_LAYOUT_MONO     (AV_CH_FRONT_CENTER)
#define AV_CH_LAYOUT_STEREO   (AV_CH_FRONT_LEFT|AV_CH_FRONT_RIGHT)
#define AV_CH_LAYOUT_2POINT1  (AV_CH_LAYOUT_STEREO|AV_CH_LOW_FREQUENCY)
#define AV_CH_LAYOUT_2_1      (AV_CH_LAYOUT_STEREO|AV_CH_BACK_CENTER)
//......

可以看到,声道布局就是指的是有哪些对应的声道,例如双声道的默认声道布局值就是4,就是中间;不过这里我们不用过多的考虑这个,对于普通的音频,默认的双声道也就可以了。

使用SDL播放音频时,我们需要设置音频播放的信息和提供数据的callback,大概长这样:

SDL_AudioSpec spec;
        spec.freq = 44100;                      // 频率
        spec.format = AUDIO_S16SYS;     // 音频格式
        spec.channels = 2;                      // 声道数
        spec.silence = 0;
        spec.samples = 1024;                    // 一次提供多少个采样点
        spec.callback = ReadAudioFrameCallback; // callback函数
        spec.userdata = this;       // 回调callback时会传递这个指针
// 开始播放音频
SDL_OpenAudio(&spec, NULL) < 0)
SDL_PauseAudio(0);

其中freq、format、channels、samples需要严格和音频数据对应,即需要和我们FFmpeg解出来的那些音频对应。

看起来好像只需要在第一次解到音频数据时获取那些值然后设置到SDL,开始播放就可以了,为什么要有“重采样”这一步呢,前面说到,音频多个声道的数据可能是Planer模式LLLLL(data[0])RRRRR(data[1]),而阅读SDL_Audio的头文件,它要求双声道时传入的数据为LRLRLRLR,因此我们需要通过重采样来兼容这一步。

重采样的流程为

  1. 初始化重采样上下文
  2. 使用swr_convert或swr_convert_frame进行处理
// 初始化重采样上下文
audio_swr_context_ = swr_alloc_set_opts(NULL,               // 复用,这里新建一个,所以传空
            audio_codec_context_->channel_layout,       // 输出的通道布局
            AV_SAMPLE_FMT_S16,                                          // 输出的format格式
            audio_codec_context_->sample_rate,          // 输出的采样率
            audio_codec_context_->channel_layout,       // 输入的通道布局
            audio_codec_context_->sample_fmt,               // 输入的format格式
            audio_codec_context_->sample_rate,          // 输入的采样率
            0,                  //log相关,不传
            NULL);          //log相关,不传
swr_init(audio_swr_context_);

由于SDL_Audio可以处理不同的采样率和通道布局(其实是不在乎通道布局,这里注意如果声道数大于2,那么输出的通道布局应该设置为av_get_default_channel_layout(2),即目标要求为双通道),所以这两方面输出与输入一致就好。

输出的format格式为AV_SAMPLE_FMT_S16,指的是一个采样点使用16位有符号数这一数据结构来装,那么一个采样点的数据大小为2个字节;还有一个format叫AV_SAMPLE_FMT_S16P,即planar模式,不是SDL所需要的,需要注意。

AVFrame *srcFrame; // 前面解码获取的音频数据
int output_samples = swr_get_out_samples(audio_swr_context_, srcFrame->nb_samples);
std::shared_ptr frame = std::make_shared(MediaType::audio); // 这是我自己定义的用于存储数据的结构体
frame->audio_data_size = output_samples * 2 * 2; 
frame->audio_data = (uint8_t *)av_malloc(frame->audio_data_size); // 分配存储空间

swr_get_out_samples用于计算重采样后会输出多少个采样点,因为上一次重采样可能会剩余一些数据所以要通过这个来计算;分配空间大小为采样点 * 通道数 * 单个点数据大小;最后通过以下方式即可完成重采样过程

// 使用swr_convert来重采样
memset(frame->audio_data, 0x00, frame->audio_data_size);
swr_convert(audio_swr_context_, &frame->audio_data, output_samples, (const uint8_t **)srcFrame->data, srcFrame->nb_samples);

也可以使用swr_convert_frame来进行重采样,区别就是,swr_convert的目标是一个裸的data指针,同时需要我们自己来计算大小并分配空间,像上面做的那样;而swr_convert_frame的目标是一个AVFrame,省去了分配空间的过程,这里可以根据我们的需要来选择,差别不大。

// 使用swr_convert_frame来重采样
AVFrame *frame = av_frame_alloc();
frame->channel_layout = srcFrame->channel_layout;
frame->frame_->sample_rate = audio_codec_context_->sample_rate;
frame->frame_->format = AV_SAMPLE_FMT_S16;
swr_convert_frame(audio_swr_context_, frame->frame_, srcFrame);

这里比较重要的一点就是:按照swr_convert_frame头文件的说明,需要由我们来设置目标frame的这三个字段,但是我比较疑惑的是,这三个信息应该是在重采样上下文中有的才对;我没有试过如果不设置会怎么样,这里存疑。

使用swr_convert_frame来进行重采样,有一个比较坑的地方就是,目标frame的linesize并不代表音频的实际数据长度:注意前面说的,由于frame->data是由内部来分配的,而linesize本身确实只表示data的可用大小,不代表数据大小,这里可能由于对齐的原因导致将data分配得比实际需要的数据大,如果在后面播放的时候使用linesize作为length就会导致播放了无用的数据产生异常(在我这里的例子是实际上音频的大小应该是4096,但是linesize却为4224)

下面为前面SDL设置的callback回调,做的事比较简单

// 这是一个类静态方法
void SDLAudioRender::ReadAudioFrameCallback(void *udata, Uint8 *stream, int len) {
        SDLAudioRender *render = (SDLAudioRender *)udata;
        SDL_memset(stream, 0, len);
        SDL_MixAudio(stream, frame->audio_data, frame->audio_data_size, SDL_MIX_MAXVOLUME);
}

如果是不常调这种c语言API的朋友,也看出来了前面AudioSpec里userdata的用处,即可以完成c++函数传递到c然后访问对象中数据的作用。

C++11并发

之前没用过c++11的线程模型,这里感觉简单实用还挺方便的,介绍给没用过的朋友

这个播放器中我主要使用了下面几个标准库

#include            // 线程库
#include             // 互斥量、以及锁等

std::thread;        // 创建一个线程
std::mutex;             // 创建一个互斥量
std::lock_guard;    // 使用互斥量来上锁,锁会在这个变量作用域结束时释放
std::unique_lock;   // 调用lock方法上锁,锁会在作用域结束时释放
std::condition_variable;    // 条件变量,用于多线程同步

下面给几个例子:

std::thread的使用

// std::shared_ptr worker_;
// 创建线程
std::shared_ptr worker_ = std::make_shared([this](){
  while(running_) {
    // 逻辑
  }
}
// 结束线程
running_ = false;
worker_->join();
worker_ = nullptr;

std::mutex std::lock_guard std::unique_lock std::condition_variable结合使用:

std::mutex mutex_;              // 一个互斥锁
std::condition_variable cond_;  // 条件变量

void Write() {
  std::lock_guard lock(mutex_); // lock_guard被创建时就调用了mutex_.lock()
  // 临界区 在这里对临界资源做写操作
  
  cond_.notify_all();           // 阻塞在当前条件上的所有线程会被唤醒  可以notify_one()会唤醒随机一个线程
  
  // lock_guard生命周期结束,自动调用mutex.unlock()
}

void Read() {
  std::unique_lock lock(mutex_); 
  cond_.wait(lock, ([this]() {
            return true;        // 如果可以获得锁,且满足条件,就会调用lock.lock(); 否则阻塞,等待当前条件变量被唤醒
  }));
  
  // 生命周期结束,自动调用mutex.lock()
}

可以看到,很方便就完成了创建多个线程,以及多个线程的协同工作,这里我要给c++11点个赞。

音画同步

现在整个播放器的工作流程如下:

  1. 从foramt_context中读取packet,将packet塞入下一级(demuxer 线程)
  2. 根据packet的stream_index判断是音频还是视频帧,分别走视频解码逻辑和音频解码逻辑(decode audio & decoder video 线程)
  3. 解出的音频帧塞入队列中,等待回调时取数据播放音频 (其实这里工作在音频API相关的线程里,不过不是由我们手动创建)
  4. 解出的视频帧塞入队列中,定期上屏显示(render 线程)

音视频数据在编码时其实打上了相应的时间戳,对应在最后AVFrame的pts里(显示时间戳),例如pts=1000(ms)的音频意味着它应当在影片1s时被播放,而1s时其实也理当有一个画面(可能没有pts正好为1000的数据,但是有1000附近的,这也是我们实际上在1s时看到的画面)。

如果某个地方出了问题,导致没能在画面和音频对应的时间同时播放他们,就会让用户感受到音画不同步。

很容易想到,音画同步有三种同步方式

  1. 音频去同步视频
  2. 视频去同步音频
  3. 各自同步主时钟

由于音频的变速播放、甚至丢弃一段音频对于用户来讲感知比较明显,所以一般不会使用方式一;由于音频的时长效应比较明显(一帧音频的时长其实就是下一帧pts和这帧pts的差值),所以方式三的实现与方式二类似,这里讨论一下视频同步音频。

视频同步音频,也有很多种方式,这里讨论比较简单的一种:

  1. 音频正常播放,在将音频提交给系统播放时记录当前音频的pts
  2. 视频渲染以某种定时的间隔触发(例如60fps的回调,或渲染完每帧后sleep一个较小的时间),触发时在当前缓存的视频中寻找一个和即将播放音频的pts最接近的帧上屏(在这之前的帧都丢弃)。

整段逻辑非常简单,同时可以一直正常工作,但是会有这样一个问题:有可能丢弃了过多的画面

考虑一下在高刷新率设备上播放高fps视频,此时假如音频仍然与之前一样(44100Hz,一帧采样点为1024,那么一秒需要43帧,即fps为43),那么音频的pts可能的取值在1s内会有43次,按照刚才的逻辑,尽管设备回调了120次请求画面,但我们根据离音频pts最近的原则来选取画面,也只能选取43帧,丢弃了过多的画面(在60fps下会丢弃16帧),尽管一般不会有这么高刷新率的视频,但足以说明这一个算法是不完备的。

同时,使用FFmpeg解码视频进行音画同步,需要注意默认情况下frame->pts并不代表着它的显示时间,也不意味着毫秒,需要进行下述处理

// 解码前需要将packet的时间基从流的(stream->time_base)转换到解码上下文的
av_packet_rescale_ts(packet->packet_, audio_stream_timebase_, audio_codec_context_->time_base);
// 解码
avcodec_send_packet(audio_codec_context_, packet->packet_);
avcodec_receive_frame(audio_codec_context_, srcFrame);
// 解码后将pts转换到毫秒
av_q2d(video_codec_context_->time_base) * srcFrame->pts * 1000

你可能感兴趣的:(播放器架构和简单实现)