由于目前市面上关于移动端的音视频开发书籍极少,因此当得知<<音视频开发进阶指南>>上市后,我就立马买了,然后如饥似渴废寝忘食的读了一遍。首先,我觉得这本书写的很好,循序渐进的一步步带我们从0开始到开发出一个相对成熟的音视频应用。但是这本书也很"坑"(为什么说"坑"?因为据我估计,初次看本书的同学,肯定坚持不下去,因为中间任何一步断了就会导致看书进行不下去),本文的目的就是填平这些坑,确保能顺利的把本书读完。
本文请配合demo阅读,demo地址为AudioVideoDemo文件夹。
接下来我会把每章觉得重点的地方都记录下来。本文是基于iOS端的。
第1章 音视频基础概念
通常说的音频的裸数据格式就是脉冲编码调制(Pulse Code Modulation, PCM )数据。
常见的无损压缩:WAV编码。PCM加上描述信息
常见的有损压缩:MP3编码。AAC编码。
YUV和RGB的转化:
典型场景:在iOS平台中使用摄像头采集出YUV数据后,上传显卡成为一个纹理ID,这个时候就需要做YUV到RGB的转换。
在iOS的摄像头采集出一帧数据之后(CMSampleBufferRef),我们可以在其中调用CVBufferGetAttachment来获取YCbCrMatrix,用于决定使用哪一个矩阵进行转换。
常见的视频编码:MPEG-4 , H.264
编码概念: IPB帧。
I帧: 帧内编码帧。I帧压缩可去掉视频的空间冗余信息。
P帧: 前向预测编码帧,也称预测帧。
B帧:双向预测内插编码帧,也称双向预测帧。P帧与B帧是为了去掉时间冗余信息。
PTS和DTS。 DTS,Decoding Time Stamp,解码时间戳,用于视频的解码,PTS,呈现时间戳,用于解码阶段视频的同步和输出。
根据不同的业务场景,适当地设置gop_size,得到更高质量的视频
第2章 移动端环境搭建
LAME 是最好的MP3编码器,编码高品质MP3的最好也是唯一的选择
FDK_AAC是用来编码和解码AAC格式音频文件的开源库
X264是一个开源的H.264/MPEG视频编码函数库,是最好的有损压缩编码器之一。一般输入是视频帧的YUV表示,输出是编码之后的H264数据包。
项目实践 : 将PCM文件通过LAME编码为MP3文件。
填坑 : 本章作者给的几个脚本都是有问题的,不能直接交叉编译出想要的库,所以了解下交叉编译的概念就好,不用太纠结,自己去网上找写好的交叉编译脚本(脚本地址)。我们的目的就是编译出目的库,所以不要被错误的脚本卡住了。
第3章 FFmpeg的介绍与使用
FFmpeg基础知识:8个静态库,8个模块
AVUtil: 核心工具库,最基础的模块
AVFormat : 文件格式和协议库,最重要的模块之一
AVCodec : 编解码库,最重要的模块之一,但是不会默认添加像FDK-AAC等库,而是像平台一样,可以将其他的第三方Codec以插件的方式添加进来,为开发者提供统一的接口
AVFilter : 音视频滤镜库,提供了音视频特效处理
AVDevice : 输入输出设备库
SwResample : 用于音频重采样,可以对数字音频进行声道数、数据格式、采样率等多种基本信息的转换
SWScale : 对图像进行格式的转换,如将YUV的数据转换为RGB
下面介绍一些重要的结构体,
AVFormatContext : 就是对容器或媒体文件的抽象,包含了多路流(音频流、视频流、字幕流),对流的抽象就是AVStream,在每一路流中都有描述这路流的编码格式,对编解码格式以及编解码器的抽象就是AVCodecContext与AVCodec,对于编码器或者解码器的输入输出部分,也就是压缩数据以及原始数据的抽象就是AVPacket与AVFrame。
FFmpeg中的bit stream filter,用于应对某些格式的封装转换行为。
H264的bit stream filter常常应用于视频解码过程中,特别是各个平台上提供的硬件编码器时,一定会用到它。
项目实践 : 把一个视频文件解码成单独的音频PCM文件和视频YUV文件。在这个基础实践中熟悉FFmpeg的使用步骤。
FFmpeg的使用步骤(FFmpeg用多了其实不难)
1.引用FFmpeg相应模块的头文件
extern "C" {
#include "libavcodec/avcodec.h"
#include "libavformat/avformat.h"
#include "libavutil/avutil.h"
#include "libavutil/samplefmt.h"
#include "libavutil/common.h"
#include "libavutil/channel_layout.h"
#include "libavutil/opt.h"
#include "libavutil/imgutils.h"
#include "libavutil/mathematics.h"
#include "libswscale/swscale.h"
#include "libswresample/swresample.h"
};
2.注册协议、格式与编解码器
av_register_all();
3.打开媒体文件源,并设置超时回调
AVFormatContext *formatCtx = avformat_alloc_context();
AVIOInterruptCB int_cb = {interrupt_callback, (__bridge void *)(self)};
formatCtx->interrupt_callback = int_cb; //设置超时回调
avformat_open_input(&avFormatContext, audioFile, NULL, NULL); //打开媒体文件
avformat_find_stream_info(avFormatContext, NULL); //检查在文件中的流的信息,其实就是填充avFormatContext
4.寻找各个流,并且打开对应的解码器
坑点:本章的配套demo未处理视频,只处理了音频,所以第一次看的时候我懵了,跟书本根本对应不起来。
// 本demo并没有提供视频文件,所以只处理了音频,未处理视频(这是跟书里面不同的地方)
AVStream * audioStream = avFormatContext->streams[stream_index];// 找到音频或者视频流(注意找序号,有可能是数组)
formatCtx->streams[i]->codec->codec_type //找出流的类型
avCodecContext = audioStream->codec; //找出解码格式
AVCodec * avCodec = avcodec_find_decoder(avCodecContext->codec_id); //根据解码格式找到对应的解码器
avcodec_open2(avCodecContext, avCodec, NULL);// 打开解码器
5.初始化解码后数据的结构体
5.1构建音频的格式转换对象以及音频解码后数据的存放的对象
if (avCodecContext->sample_fmt != AV_SAMPLE_FMT_S16) // 如果不等于AV_SAMPLE_FMT_S16这种可以FFmpeg可以直接使用的格式,就需要转换。
{
// swr_alloc_set_opts是初始化上下文方法,传入原始音频的格式(包括声道,采样率,表示格式)和目标音频的格式(包括声道,采样率,表示格式)
// 就可以得到空的重采样上下文
SwrContext *swrContext = swr_alloc_set_opts(NULL, av_get_default_channel_layout(codecCtx->channels), AV_SAMPLE_FMT_S16, codecCtx->sample_rate, av_get_default_channel_layout(codecCtx->channels), codecCtx->sample_fmt, codecCtx->sample_rate, 0, NULL);
// 创建空的音频AVFrame(An AVFrame filled with default values)
_audioFrame = avcodec_alloc_frame();
}
5.2构建视频的格式转换对象以及视频解码后数据的存放的对象
// 如果不等于PIX_FMT_YUV420P这种可以FFmpeg可以直接使用的格式,就需要转换。
_pictureValid = avpicture_alloc(&_picture,
PIX_FMT_YUV420P,
_videoCodecCtx->width,
_videoCodecCtx->height) == 0;
if (!_pictureValid)
return NO;
// sws_getCachedContext是获取上下文方法,传入原始视频的格式(包括视频宽,高,表示格式)和目标视频的格式(包括视频宽,高,表示格式)
// 就可以得到空的重采样上下文
_swsContext = sws_getCachedContext(_swsContext,
_videoCodecCtx->width,
_videoCodecCtx->height,
_videoCodecCtx->pix_fmt,
_videoCodecCtx->width,
_videoCodecCtx->height,
PIX_FMT_YUV420P,
SWS_FAST_BILINEAR,
NULL, NULL, NULL);
// 创建空的视频AVFrame(An AVFrame filled with default values)
_videoFrame = avcodec_alloc_frame();
6.读取流内容并且解码
打开解码器后,可以读取一部分流中的数据(AVPacket),将它作为解码器的输入,解码器将其解码为原始数据(裸数据AVFrame)
AVPacket packet;
BOOL finished = NO;
while (!finished)
{
if (pktStreamIndex ==_videoStreamIndex) //视频的解码
{
if (av_read_frame(_formatCtx, &packet) < 0) {
break;
}
avcodec_decode_video2(_videoCodecCtx, _videoFrame,&gotframe,&packet);
if (gotframe)
{
frame = [self handleVideoFrame];//视频具体处理见下面的7
}
}
else if (pktStreamIndex == _audioStreamIndex)
{
avcodec_decode_audio4(_audioCodecCtx, _audioFrame,&gotframe,&packet);
if (gotframe)
{
AudioFrame * frame = [self handleAudioFrame];//音频具体处理见下面的7
}
}
7.处理解码之后的裸数据
解码之后就会得到裸数据,音频就是PCM数据,视频就是YUV数据,下面将其处理成我们需要的格式并且进行写文件
7.1处理音频PCM数据
- (AudioFrame *) handleAudioFrame
{
void * audioData;
NSInteger numFrames;
if (swrContext) {
const int ratio = 2;
const int bufSize = av_samples_get_buffer_size(NULL,
numChannels, pAudioFrame->nb_samples * ratio,
AV_SAMPLE_FMT_S16, 1);
if (!swrBuffer || swrBufferSize < bufSize) {
swrBufferSize = bufSize;
swrBuffer = realloc(swrBuffer, swrBufferSize);
}
byte *outbuf[2] = { (byte*) swrBuffer, NULL };
// swr_convert 填充AVFrame到swrContext上下文中
numFrames = swr_convert(swrContext, outbuf,
pAudioFrame->nb_samples * ratio,
(const uint8_t **) pAudioFrame->data,
pAudioFrame->nb_samples);
audioData = swrBuffer;
} else {
audioData = pAudioFrame->data[0];
numFrames = pAudioFrame->nb_samples;
}
}
7.2 处理视频YUV数据
- (VideoFrame *) handleVideoFrame
{
VideoFrame *frame = [[VideoFrame alloc] init];
if(_videoCodecCtx->pix_fmt == AV_PIX_FMT_YUV420P || _videoCodecCtx->pix_fmt == AV_PIX_FMT_YUVJ420P){
frame.luma = copyFrameData(_videoFrame->data[0],
_videoFrame->linesize[0],
_videoCodecCtx->width,
_videoCodecCtx->height);
frame.chromaB = copyFrameData(_videoFrame->data[1],
_videoFrame->linesize[1],
_videoCodecCtx->width / 2,
_videoCodecCtx->height / 2);
frame.chromaR = copyFrameData(_videoFrame->data[2],
_videoFrame->linesize[2],
_videoCodecCtx->width / 2,
_videoCodecCtx->height / 2);
}
else
{
// 处理图像数据。
// sws_scale将输出的_videoFrame(AVFrame类型)转换成为AVPicture,再从AVPicture提取对应的数据封装到自定义的结构体(VideoFrame)中
sws_scale(_swsContext,
(const uint8_t **)_videoFrame->data,
_videoFrame->linesize,
0,
_videoCodecCtx->height,
_picture.data,
_picture.linesize);
frame.luma = copyFrameData(_picture.data[0],
_picture.linesize[0],
_videoCodecCtx->width,
_videoCodecCtx->height);
frame.chromaB = copyFrameData(_picture.data[1],
_picture.linesize[1],
_videoCodecCtx->width / 2,
_videoCodecCtx->height / 2);
frame.chromaR = copyFrameData(_picture.data[2],
_picture.linesize[2],
_videoCodecCtx->width / 2,
_videoCodecCtx->height / 2);
}
}
8.关闭所有资源
代码略
有可能还是看不懂这章的代码,没关系,到了第5章你就懂了(坑点:很多内容必须要等到看到后面几章才明白)
第4章 移动平台下音视频渲染
iOS平台下利用AudioUnit来渲染音频
项目实践 : 使用AudioUnit完成一个音频播放的功能的步骤:创建音频会话、构建一个AudioUnit,并给AudioUnit设置参数(再介绍AudioUnit的分类),最后构建一个AUGraph完成音频播放
1.创建音频会话,并设置合适的参数(使用AVAudioSession 代码略)
2.构建AudioUnit
(1)构造AUGraph
NewAUGraph(&mPlayerGraph);
(2).构建AudioComponentDescription
AudioComponentDescription ioDescription;
ioDescription.componentType = kAudioUnitType_Output; // 后面详细介绍
ioDescription.componentSubType = kAudioUnitSubType_RemoteIO; // 与上面对应
ioDescription.componentManufacturer = kAudioUnitManufacturer_Apple;
(3).添加Node
AUGraphAddNode(mPlayerGraph, &ioDescription, &mPlayerIONode);
(4).打开Graph, 只有真正的打开了Graph才会实例化每一个Node
AUGraphOpen(mPlayerGraph);
(5)获取出某个node的AudioUnit
AUGraphNodeInfo(mPlayerGraph, mPlayerIONode, NULL, &mPlayerIOUnit);
3.AudioUnit的通用参数设置
// 配置AudioStreamBasicDescription
AudioStreamBasicDescription asbd;
bzero(&asbd, sizeof(asbd));
asbd.mSampleRate = _sampleRate;
asbd.mFormatID = kAudioFormatLinearPCM;
asbd.mFormatFlags = kAudioFormatFlagsNativeFloatPacked | kAudioFormatFlagIsNonInterleaved;
asbd.mBitsPerChannel = 8 * bytesPerSample;
asbd.mBytesPerFrame = bytesPerSample;
asbd.mBytesPerPacket = bytesPerSample;
asbd.mFramesPerPacket = 1;
asbd.mChannelsPerFrame = channels;
使用AudioStreamBasicDescription给AudioUnit设置属性
AudioUnitSetProperty(_ioUnit, kAudioUnitProperty_StreamFormat, kAudioUnitScope_Output, inputElement,
&streamFormat, sizeof(streamFormat));
4.连接起Node
方式一:直接连接
AUGraphConnectNodeInput(_auGraph, _convertNode, 0, _ioNode, 0);
方式二:回调
AURenderCallbackStruct callbackStruct;
callbackStruct.inputProc = &InputRenderCallback;
callbackStruct.inputProcRefCon = (__bridge void *)self;
5.最后播放
CAShow(_auGraph);
AUGraphInitialize(_auGraph);
AUGraphStart(_auGraph);
安卓和iOS都利用OpenGL ES来渲染视频
注意: 因为OpenGL不负责窗口管理及上下文环境管理,所以需要各平台(如iOS平台)自己提供OpenGL ES的上下文环境和窗口管理
OpenGL ES实践
基础知识
Vertex Shader(顶点着色器)替换顶点处理阶段 attribute,用于经常更改的信息,只能用于顶点着色器 (varying,用于修饰从顶点往片元传递的变量)
gl_Position
Fragment Shader(片元着色器,又称像素着色器)替换片元处理阶段 uniform,用于不经常更改的信息,可以用于2种着色器
gl_FragColor
窗口创建的步骤
1.创建窗口
glCreateProgram();
2.创建Vertex Shader和Fragment Shader
(1)创建shader
GLuint shader = glCreateShader(type);
(2)加载资源
glShaderSource(shader, 1, &sources, NULL);
(3)编译
glCompileShader(shader);
3.关联着色器到窗口
glAttachShader(filterProgram, vertShader);
4.链接
glLinkProgram(filterProgram);
5.使用窗口
glUseProgram(filterProgram);
上下文环境搭建
1.编写View类继承UIView,重写layerClass,返回CAEAGLLayer
+ (Class) layerClass
{
return [CAEAGLLayer class];
}
2.然后在initWithFrame中获取layer并且强制转为CAEAGLLayer,并设置色彩模式等参数
CAEAGLLayer *eaglLayer = (CAEAGLLayer*) self.layer;
eaglLayer.opaque = YES;
eaglLayer.drawableProperties = [NSDictionary dictionaryWithObjectsAndKeys:
[NSNumber numberWithBool:FALSE], kEAGLDrawablePropertyRetainedBacking,
kEAGLColorFormatRGBA8, kEAGLDrawablePropertyColorFormat,
nil];
3.在线程中构造上下文,并且绑定
_context = [[EAGLContext alloc] initWithAPI:kEAGLRenderingAPIOpenGLES2];
[EAGLContext setCurrentContext:_context];
4.创建帧缓冲区
glGenFramebuffers(1, &_displayFramebuffer);
创建绘制缓冲区
glGenRenderbuffers(1, &_renderbuffer);
绑定帧缓冲区到渲染管线
glBindFramebuffer(GL_FRAMEBUFFER, _displayFramebuffer);
绑定绘制缓冲区到渲染管线
glBindRenderbuffer(GL_RENDERBUFFER, _renderbuffer);
为绘制缓冲区分配存储区,此处将CAEAGLLayer的绘制存储区作为绘制缓冲区的存储区
[_context renderbufferStorage:GL_RENDERBUFFER fromDrawable:(CAEAGLLayer*)self.layer];
获取绘制缓冲区的像素宽度
glGetRenderbufferParameteriv(GL_RENDERBUFFER, GL_RENDERBUFFER_WIDTH, &_backingWidth);
获取绘制缓冲区的像素高度
glGetRenderbufferParameteriv(GL_RENDERBUFFER, GL_RENDERBUFFER_HEIGHT, &_backingHeight);
将绘制缓冲区绑定到帧缓冲区
glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_RENDERBUFFER, _renderbuffer);
呈现renderBuffer,完成
[_context presentRenderbuffer:GL_RENDERBUFFER];
绘制的过程
1)规定窗口的大小
glViewport(0, _backingHeight - _backingWidth - 75, _backingWidth, _backingWidth);
2)使用显卡绘制程序
glUseProgram(filterProgram);
3)设置物体坐标
GLfloat imageVertices[] = {
-1.0f, -1.0f,
1.0f, -1.0f,
-1.0f, 1.0f,
1.0f, 1.0f,
};
glVertexAttribPointer(filterPositionAttribute, 2, GL_FLOAT, 0, 0, imageVertices);
glEnableVertexAttribArray(filterPositionAttribute);
4)设置纹理坐标
GLfloat noRotationTextureCoordinates[] = {
0.0f, 1.0f,
1.0f, 1.0f,
0.0f, 0.0f,
1.0f, 0.0f,
};
glVertexAttribPointer(filterTextureCoordinateAttribute, 2, GL_FLOAT, 0, 0, noRotationTextureCoordinates);
glEnableVertexAttribArray(filterTextureCoordinateAttribute);
5)指定将要绘制的纹理对象并且传递给对应的Fragment Shader
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_2D, _inputTexture);
glUniform1i(filterInputTextureUniform, 0);
6)执行绘制操作
glDrawArrays(GL_TRIANGLE_STRIP, 0, 4);
7)最后删除纹理对象
glDeleteTextures(1, &_inputTexture);
小tip : GPUImage是一个开源的图像处理工具,它是基于OpenGL ES实现的。
第5章 实现一款视频播放器
架构设计: 解码模块,音频播放模块,视频播放模块,音视频同步,中控系统。
5.1 架构设计
输入:本地磁盘的一个媒体文件(可能flv、mp4、avi、mov等格式)或者网络上的媒体文件(可能是http、rtmp、hls等协议)
输出:视频中的音频放到扬声器让人听到,视频画面渲染到屏幕让人看到,同时确保声音画面同步。
输入模块:由媒体文件解析得到音频流和视频流,然后将这两路流都解码为裸数据,然后为音视频各建立一个队列将裸数据存储起来。
音频输出和视频输出模块:去音频视频队列中拿出裸数据,然后分别进行音视频的渲染。
音视频同步模块:负责音视频同步
调度器模块:负责将以上模块组装起来
具体模块说明:
AudioFrame:音频帧,这里面记录了音频的数据格式以及这一帧的具体数据、时间戳等信息;
VideoFrame:视频帧,记录了视频的格式以及这一帧的具体的数据、宽、高以及时间戳等信息;
AudioFrameQueue:音频队列,主要用于存储音频帧,为它的客户端代码音视频同步模块提供压入和弹出操作,由于解码线程和声音播放线程会作为生产者和消费者同时访问这个队列中的元素,所以这个队列要保证线程安全性;(本项目中是一个可变数组)
VideoFrameQueue:视频队列,主要用于存储视频帧,为它的客户端代码音视频同步模块提供压入和弹出操作,由于解码线程和视频播放线程会作为生产者和消费者同时访问这个队列中的元素,所以这个队列保证线程安全性;(本项目中是一个可变数组)
VideoDecoder:输入模块,职责在前面已经分析了,由于还没有确定具体的技术实现,所以这里根据前面的分析写了三个实例变量,一个是协议层解析器,一个是格式解封装器,一个是解码器,并且它主要向AVSynchronizer暴露接口:打开文件资源(网络或者本地)、关闭文件资源、解码出一定时间长度的音视频帧。
AudioOutput:音频输出模块,由于在不同平台有不同的实现,所以这里真正的声音渲染API为Void类型,但是音频的渲染要放在单独的一个线程(不论是平台API自动提供的线程,还是我们主动建立的线程)中进行,所以这里有一个线程的变量,在运行过程中会调用注册过来的回调函数来获取音频数据;
VideoOutput:视频输出模块,虽然这里统一使用OpenGL ES来渲染视频,但是前面也讲过,OpenGL ES的具体实现在不同平台也会有自己的上下文环境,所以这里采用了Void类型的实现,当然,必须由我们主动开启一个线程来作为OpenGL ES的渲染线程,它会在运行过程中调用注册过来的回调函数获取视频数据;
AVSynchronizer:音视频同步模块,组合输入模块及音频队列和视频队列,这里面主要对它的客户端代码VideoPlayerController这个调度器提供接口,包括:开始、结束,以及最重要的获取音频数据和获取对应时间戳的视频帧。此外,它也会维护一个解码线程,并且根据音视频队列里面的元素数目来继续或者暂停这个解码线程的运行;
VideoPlayerController:调度器,内部维护音视频同步模块、音频输出模块、视频输出模块,向客户端代码暴露接口:开始播放、暂停、继续播放、停止播放接口;向音频输出模块和视频输出模块暴露两个获取数据的接口;
第6章 音视频的采集与编码
6.1 音频的采集
iOS端使用AudioUnit采集音频
6.2 视频画面的采集
ELImageProgram : 把OpenGL的Program的构建、查找属性、使用等操作以面向对象的形式封装起来的类。
ELImageTextureFrame:用于将纹理对象和帧缓存对象的创建、绑定、销毁等操作以面向对象的方式封装起来的类。
ELImageContext:OpenGL ES渲染操作必须执行在绑定了OpenGL上下文的线程中,而且由于其对于客户端代码的调用,需要在调用线程和OpenGL ES的线程之间进行频繁的切换,所以提供一个静态方法可以获得具有OpenGL上下文的渲染线程,让渲染操作可以直接在该线程中执行。
ELImageInput:协议。方法一:设置输入纹理对象。方法二:进行渲染操作。
ELImageOutput:需要向后级节点输出纹理对象的节点的类。
6.3 音频的编码
6.3.1 libfdk_aac编码AAC
使用libfdk_aac软编码将PCM文件编码为AAC文件