音视频学习笔记七——从0开始的播放器之数据播放

395 阅读9分钟

题记:通过上一篇可以得到解码数据,本章介绍如何展示这些数据——即图像渲染与声音播放,可以结合音视频Demo或ffplay进行理解。

数据播放

解码后的数据一般有三类,字幕、音频和视频数据。本章分别介绍播放流程,字幕、图像渲染与声音播放。

字幕

字幕流的处理前文介绍的比较少,因为字幕的处理流程相对独立,并且影响不大。字幕分为软字幕与硬字幕。硬字幕数据已经与视频数据融合,成为视频图像的一部分,不需要额外处理。这里讨论的软字幕,指字幕数据与视频数据分开存储,播放时根据需要动态叠加到视频上。

字幕介绍

打开一个ASS文件(下载tears_of_steel中文字幕),可以使用文本打开,可以看到如下内容(包含显示时间和文字)。 字幕ass.jpg 由于没有找到合适的包含字幕流资源(大多数是硬字幕),自己使用FFmpeg命令合成了一个tears_of_steel_with_subtitles.mp4文件:

ffmpeg -i tears_of_steel.mov -i TearsofStee.2012.Chs.ass -c:v copy -c:a copy -c:s mov_text tears_of_steel_with_subtitles.mp4

解码&播放

对于字幕的解码,解码相对简单,不需要视频和音频send、receive流程。只需要使用函数:

  • avcodec_decode_subtitle2解码字幕数据包(AVPacket),并将解码结果存储在 AVSubtitle 结构中
    • avctx: 解码器上下文信息。
    • sub: 指向 AVSubtitle 结构的指针,用于存储解码后的字幕数据。
    • got_sub_ptr: 用于指示是否成功解码出字幕。解码成功,该值会被设置为非零。
    • avpkt: 指向 AVPacket 的指针

AVSubtitle就是解码后的数据,为了统一处理ffplay把它封装到了Frame中,统一放入了队列。主要结构如图

typedef struct AVSubtitle {
    uint16_t format;             // 字幕格式类型,
    uint64_t pts;                // 显示时间戳(以 AV_TIME_BASE 为单位)
    uint64_t end_display_time;   // 字幕显示的结束时间(以毫秒为单位)
    unsigned int num_rects;      // 字幕区域的数量
    AVSubtitleRect **rects;      // 指向字幕区域的指针数组
    int64_t start_display_time;  // 字幕显示的起始时间(以毫秒为单位)
} AVSubtitle;

其中AVSubtitleRect的结构如下

typedef struct AVSubtitleRect {
    int x, y, w, h;             // 字幕区域的(仅用于位图字幕)
    AVPicture pict;             // 位图数据(仅用于位图字幕)
    enum AVSubtitleType type;   // 字幕类型
    char *text;                 // 文本字幕内容(仅用于文本字幕)
    char *ass;                  // ASS 格式字幕内容(仅用于 ASS 字幕)
} AVSubtitleRect;

由于合成时使用的是ASS结构,程序中的截图如下

字幕ass信息.jpg 这些字幕信息保存在FrameQueue中,在刷新视频时,ffplay通过video_image_display叠加到视频帧上,具体会根据 format判断字幕的具体格式(文本、位图或ASS),文本通过SDL或Android 的 Canvas 或 iOS 的 CoreText渲染到视频帧上,位图通过位图数据叠加到视频帧上。Demo中暂未处理字幕部分。

音频

音视频解码后数据被封装在AVFrame中,iOS原生播放可以参考iOS端PCM播放,要实现跨平台播放一般需要集成SDL。

音频展示.jpg

SDL集成

iOS可以在Podfile上添加

pod 'SDL2', '3.2.0'

或者到SDL官网下载,集成到iOS或android上。如果遇到SDL_main冲突问题,记得在引用头文件前定义一下#define SDL_MAIN_HANDLED SDL播放相关函数:

  • SDL_SetMainReady 不需要标准 main() 函数作为程序入口点的环境中,移动端集成需要。
  • SDL_Init&SDL_Quit 初始化SDL音频子系统 & 清理SDL
  • 打开音频设备 SDL_OpenAudio/SDL_OpenAudioDevice
  • 暂停/播放 SDL_PauseAudio/ SDL_PauseAudioDevice 1暂停 0播放
  • 关闭音频设备 SDL_CloseAudio/SDL_CloseAudioDevice

其中打开音频设备SDL_OpenAudio中,需要传递设备参数SDL_AudioSpec,这里需要设置合适的设备参数,如下(实际上ffplay或DemoSDL_AudioSpec wanted_spec, spec代码段根据设备选择)

SDL_memset(&desired_spec, 0, sizeof(desired_spec));
desired_spec.freq = 44100;          // 采样率(例如 44100 Hz)
desired_spec.format = AUDIO_S16SYS; // 采样格式(例如 16 位有符号整数)
desired_spec.channels = 2;          // 声道数(例如立体声)
desired_spec.samples = 1024;        // 音频缓冲区大小(样本数)
desired_spec.callback = audio_callback; // 音频回调函数
desired_spec.userdata = NULL;       // 用户数据
  • 参数中callbackuserdata就是填充数据的回调,callback参数
    • userdata C风格的传递参数形式
    • stream 音频缓冲区,需要将PCM数据填充到该缓冲区。
    • len 音频缓冲区的长度(字节数)

音频播放通过这个回调,将需要播放的数据填充到stream中,就可以持续播放视频了。这里可以看到有个len的参数,也就是需要填充的区域是有要求的长度,使得需要保存一个Cache的逻辑。先来看看如何获取数据吧。

播放数据

解码后的数据放在FrameQueue中,先来看一下AVFrame的音频部分。

  • 音频AVFrame中,相关主要字段:
    • format 音频采样格式,表示存储方式和位深度,'P'在这里代表Planar
      • AV_SAMPLE_FMT_FLT: 32 位浮点数。
      • AV_SAMPLE_FMT_S16: 16 位有符号整数。
      • AV_SAMPLE_FMT_S32: 32 位有符号整数。
      • AV_SAMPLE_FMT_FLTP: 平面格式的 32 位浮点数。
      • AV_SAMPLE_FMT_S16P: 平面格式的 16 位有符号整数
    • linesize 指向长度为8的数组的指针,对应data的长度
    • data 指向长度为8的uint8_t数组的指针,如果交错格式如AV_SAMPLE_FMT_S16,声道的数据交错存储在 data[0],LRLRLR...,如果是平面格式如AV_SAMPLE_FMT_S16P,数据在data[0]-》LLL, data[1]-》RRR
    • sample_rate 采样率,表示每秒的采样次数。
    • channels 音频的声道数
    • channel_layout 声道布局,表示每个声道的空间分布
    • nb_samples 每一帧采样点数,
    • pts&duration 当前帧的开始时间和持续时长,需要结合stream->time_base得到时间。

frame的数据格式和SDL_AudioSpec格式不一定是相同的,这是需要一个转换函数:

  • swr_convert 用于音频重采样
    • s: SwrContext 指针,音频重采样器的上下文。
    • out: 输出缓冲区的指针数组。每个元素指向一个声道的输出数据。
    • `out_count: 输出缓冲区中每个声道的样本数。
    • in: 输入缓冲区的指针数组。每个元素指向一个声道的输入数据。
    • in_count: 输入缓冲区中每个声道的样本数。

这里的SwrContext参数,需要构建一个转换的上下文并初始化swr_init(swr_ctx)SwrContext配置in、out相关参数如下,in就是当前frame的参数,out就是前一节SDL_AudioSpec的参数。

struct SwrContext *swr_alloc_set_opts(struct SwrContext *s, int64_t out_ch_layout, enum AVSampleFormat out_sample_fmt, int out_sample_rate, int64_t in_ch_layout, enum AVSampleFormat in_sample_fmt, int in_sample_rate, int log_offset, void *log_ctx);

经过转换的out数据,就可以copy到上一小节的callbackstream中了,当然这里还有几个问题:

  • 长度问题,frame转化的out数据长度不一定和callback中的len相等
    • 如果数据不够填充,继续读取下一帧frame,如果没有frame了,填充0
    • 如果转化的数据超过了stream的容量,把剩余数据记录下来保存到audio_buf
    • 下一次填充先填充audio_buf数据,再读取frame数据
  • 同步问题,常见播放以音频主时钟,但其他主时钟方式需要调整音频播放速度
    • swr_set_compensation设置音频重采样过程中使用的补偿机制,下一章会介绍
  • 音量问题
    • 如果有设置视频音量,SDL_MixAudioFormat进行声音混合替代直接copy

这样就可以播放出声音了。

图像

视频展示.jpg 图像的展示相对来说更直观一些,如[音视频Demo](https://github.com/honghewang/OpenPlayer)在`OPDecoderView`中直接使用了`sws_scale`转为了rgba数据,再转化成图片就可以渲染了,但这显示性能方面会差一下。桌面端通常调用SDL相关函数(如 `SDL_UpdateTexture` 和 `SDL_RenderCopy`)将处理后的帧渲染到窗口上,移动端通常使用OpenGL渲染这些数据。先看一下`AVFrame`的数据:
  • 视频AVFrame中,相关主要字段:
    • format 视频格式,常见格式
      • AV_PIX_FMT_YUV420P: YUV 4:2:0 平面格式。
      • AV_PIX_FMT_YUV422P: YUV 4:2:2 平面格式。
      • AV_PIX_FMT_RGB24: 24 位 RGB 格式。
      • AV_PIX_FMT_BGRA: 32 位带 Alpha 通道的 BGRA 格式
    • width & height: 图像宽高
    • linesize 注意这里和音频不同,每个维度上的行宽,这里和width也不同,可能有字节对齐原因。
    • data 存储视频帧的像素数据的指针数组,
      • 如对于 YUV 格式
      • data[0] 存储 Y 分量(亮度)
      • data[1] 存储 U 分量(色度)
      • data[2] 存储 V 分量(色度)
    • pts&duration 当前帧的开始时间和持续时长,需要结合stream->time_base得到时间。
    • key_frame 是否为关键帧
    • color_range 颜色值范围
      • AVCOL_RANGE_MPEG: 限制范围(16-235 用于 Y,16-240 用于 U/V)。
      • AVCOL_RANGE_JPEG: 全范围(0-255)。
    • colorspace 颜色空间
      • AVCOL_SPC_BT709: BT.709 标准。
      • AVCOL_SPC_BT470BG: BT.470BG 标准。

也就是在视频播放,展示Frame数据即可,线程处理如下: 视频播放frame.jpg

  • 视频保持循环
    • 判断是否结束,如果播放结束,退出线程
    • 判断是否暂停状态,如果是暂停,直接进入休眠,等待操作
    • 判读是否当前帧是否已经播完,
      • 未完,保持当前帧,计算剩余时间,设置休眠时间等于剩余时间
      • 播完,从FrameQueue中取到新帧(最近时间的帧),根据帧的duration 设置休眠。

而更新Frame时,移动端需要在各自平台上渲染,注意这里iOS段不需要到主线程处理,在本线程更新即可,android未验证。对于视频的每一种格式,对应一种OpenGL渲染方式(这里也可以用sws_scale转换,相当于CPU转换,性能会下降一些)。

最常见的格式AV_PIX_FMT_YUV420P,处理如下,data[0]、data[1]、data[2]分别转换成3个纹理,再根据YUV到RGB的公式转换即可,OpenGL着色器如下:

顶点着色器

precision highp float;
varying   highp vec2 vv2_texcoord;
attribute highp vec4 av4_position;
attribute highp vec2 av2_texcoord;
void main() {
    vv2_texcoord = av2_texcoord;
    gl_Position  = av4_position;
}

片元着色器(BT.709标准),如果是BT.601格式需要调整参数

uniform highp sampler2D samplerY;
uniform highp sampler2D samplerU;
uniform highp sampler2D samplerV;
varying highp vec2      vv2_texcoord;
void main()
{
    highp float y = texture2D(samplerY, vv2_texcoord).r;
    highp float u = texture2D(samplerU, vv2_texcoord).r - 0.5;
    highp float v = texture2D(samplerV, vv2_texcoord).r - 0.5;
    highp float r = y +             1.402 * v;
    highp float g = y - 0.344 * u - 0.714 * v;
    highp float b = y + 1.772 * u;
    gl_FragColor = vec4(r, g, b, 1.0);
}

关于OpenGL渲染,本节讲的比较粗糙,后续章节会有关于OpenGL的专门介绍。