第11章 一个直播应用的构建

本章将会带着大家走进直播领域,而直播产品的实现也会像大多数产品的实现思路一样,首先进行场景的分析,然后在分析场景的过程中拆分出直播系统的各个模块,并且进行大致的技术选型,最终依次介绍这几个模块的具体实现手段,以及各个实现手段的优劣性,以供读者们根据自己的业务场景去选择具体的实现手段。

11.1 直播场景分析

在直播领域,大致上可以分为两种类型的直播,一种是非交互式直播,另外一种是交互式直播。非交互式直播典型的场景有:2015年9月的反法西斯阅兵直播,像某一些体育直播,这些直播因为都是非交互或者说交互性不太强,所以允许延迟(从视频中的主体发生实际的行为,到这个行为被用户观看到的时间)可以在10s或者10s以上,特点是:源(像阅兵直播、NBA直播、欧冠直播等)比较少,适合做多路转码(用户可以根据网络条件观看超清、高清,标清等多路视频);而交互式直播的典型场景有:秀场直播、游戏直播等,这些直播因为对主播和观众的互动性要求比较高,所以要求延迟要在5s以内,特点是:源(像美女主播、游戏主播)比较多,不太适合做多路转码,中间服务器只作为一个中转的角色。

直播中,传输的介质肯定是网络,网络中传播视频或者音频就需要使用对应的协议,而目前适合直播场景的常用协议有如下几种。

RTMP协议:长连接,低延时(3s左右),网络穿透性差;

HLS协议:http的流媒体协议,高延时(10s以上),跨平台性较好;

HDL协议:RTMP协议的升级版,低延时(2s左右),网络穿透性好。

RTP协议:低延时(1s以内),默认使用UDP作为传输协议。

所以我们应该按照自己的场景来选择协议,如果是非交互式场景下,选择HLS协议会更适合一些,但是交互式场景最好选择HDL协议或者RTMP协议,而RTP协议常用于视频会议中,或者直播场景的连麦中,不会直接应用于一对多的直播场景下。

接下来主要来看一下交互式直播场景下可以拆分为些模块,最基础最核心的应该是推流系统、拉流系统和流媒体服务器(Live Server),这三部分共同组成了整个直播系统的主播端和用户端之间在视频或者音频内容上的交互,整体流程是主播使用推流系统将采集的视频和音频进行编码,并最终发送到流媒体服务器上,而用户端使用拉流系统将流媒体服务器上的视频资源进行播放,整个过程是一种发布者/订阅者(Publisher/Subscriber)的模式,如图11-1所示。

图 11-1

但是作为一个完整的产品,仅仅有这三个模块又是远远不够的,最直观的就是缺少礼物系统(可让观众给喜欢的主播送礼物),礼物系统可以为App提供礼物动效的展示;既然观众能送礼物给用户,就需要有充值功能,所以就必须要有支付系统来提供用户充值、主播提现等服务;在直播过程中,主播想要和观众说话,观众在很短时间内就可以在视频中听到,但是观众想要和主播进行沟通交流,只能靠聊天系统来实现,聊天系统用来建立观众到主播的反馈通道;直播这种行为实际上是一种社交行为,而任何一个直播产品都应该是一种社交产品,所以还需要社交系统,为观众和主播提供长期有效的社交行为(比如,根据用户的关注关系给用户推送关注的主播开播了或者展示关注主播的开播列表以及关注主播的视频回放列表等);而图11-1中除了推流系统和拉流系统之外的四个系统,都需要与服务器进行交互,我们称之为Http Server,即服务器模块。经过上述分析,最终整体结构如图11-1所示。

在接下来的章节中,笔者会逐一介绍这几个系统,但是由于本书的范畴所限,支付系统以及Http server模块不再单独进行讲解,而社交系统又与产品所打造的社区有着密切关系,一般情况下社交系统包括但不限于以下的功能:

  • 第三方登录(包括微博、微信、QQ等);
  • 第三方分享(包括微博、微信、QQ等);
  • 手机号的登录与绑定;
  • 地理位置的使用;
  • 站内关系(关注与粉丝以及自己的Feed列表);
  • 推送策略以及用户端收到推送之后的跳转行为;
  • 后台系统,用于提供给客服、运营人员操作榜单以及推荐用户等功能。

由于其中的部分和本书的核心内容关系不大,所以也不会详细的分析。接下来看几个要重点分析的功能模块。

11.2拉流播放器的构建

本节将基于前面章节中所讲的视频播放器来构建用户端的拉流播放器,在第5章中已经详细地讲解了播放器的结构,它是使用FFmpeg的libavformat模块来处理协议层与解封装层的细节的,并且会使用FFmpeg的libavcodec模块来解码得到原始数据,最终使用OpenGL ES渲染视频,以及使用对应的API来渲染音频。

相比较于录播的实现,直播中的拉流播放器的使用时长(短则几十分钟,长则几个小时)会更长,所占用的CPU资源(观众界面还会有聊天的长连接、动画的展示等)也会更多,所以必须将第10章中讲解的硬件解码器集成进来。再者,由于网络直播中某一些主播由于环境光的因素,或者网络带宽的因素,导致视频不是很清楚,所以要在拉流播放器中加入一个后处理过程,以增加视频的清晰度。这里以增加对比度来作为这个后处理过程的实现,当然在实际的生产过程中可以再加上一些去块滤波器、锐化等效果器。硬件解码器的集成在第10章中已经有过讲解,所以本节将重点放在给播放器加入后处理过程这个工作上。

11.2.1 Android平台播放器增加后处理过程

首先,要想打开网络连接,必须知道媒体源的协议,也要在编译FFmpeg的过程中打开对应的网络协议,比如http、rtmp或者hls等协议。在第10章中讲解的硬件解码器中,解码器解码的目标是纹理ID,为此还建立了一个纹理对象的循环队列用于存储解码之后的纹理对象,整个解码器模块的运行流程如图11-2所示。

图 11-2

图11-2看起来可能稍微有点复杂,下面来逐一解释一下,首先是AVSync模块初始化解码器的过程,解码器会连接远程的流媒体服务器,如果打开连接失败则启用重试策略,重试几次,如果依然没能成功则提示给用户。如果连接成功,则开启Uploader线程,即最右侧橙色部分,Uploader线程是一个OpenGL ES线程,当解码器是软件解码器实例的时候,这个线程的职责就是将YUV数据上传到显卡上成为一个RGBA格式的纹理ID,而当解码器是硬件解码器实例的时候,这个线程的职责就是将硬件解码器解码出来的OES格式的纹理ID转换为RGBA格式的纹理ID。在实现Uploader这个模块的过程中肯定要设定一个父类,然后对应于两个子类(软件解码器与硬件解码器对应的Uploader)分别完成各自的职责。注意这里在为这个线程开辟OpenGL ES上下文的时候,需要和渲染线程共享上下文环境,这样OpenGL ES的对象在这两个线程中才可以共同使用。当Uploader线程开始运行以后,会进入一个循环,循环的一开始先阻塞(wait)住,等待解码线程发送Signal指令再去做上传纹理操作,之后进入下一次循环,也会先阻塞(wait)住,周而复始,完成整个纹理上传工作。

成功开启Uploader线程之后,初始化工作就结束了,等到AVSync模块中的解码线程开始工作后,解码器首先会调用FFmpeg的libavformat模块进行解析协议与解封装(Demuxer),然后调用具体实例(有可能是软件解码器,也有可能是硬件解码器)的解码方法。解码成功之后就给Uploader线程发送一个Signal指令,之后这个解码线程就Wait住,等待Uploader线程处理完这一帧视频帧之后解码线程再继续运行,如图11-2中间部分所示。

待Uploader线程接受到Signal指令之后,就会执行上传(转换)纹理的工作,而在转换成为一个RGBA格式的纹理对象之后,则调用回调函数(在初始化过程中AVSync传递进来的回调函数),来处理这一帧视频帧,并将这一帧视频帧拷贝到循环纹理队列中。虽然将纹理对象拷贝到循环纹理队列中的行为在Uploader线程中实现的,但是相关代码在AVSync中,这也是为了降低各个模块的耦合程度。所以这里就要在AVSync模块要加入视频特效处理器,每当解码器中解码出一帧纹理ID时,就交给视频特效处理器进行处理,待处理完毕之后再将这一帧纹理对象加入到循环纹理队列中。当然,在这里仅仅使用增加对比度作为特效处理器中的作用效果器,读者可以自己将去块滤波器以及锐化效果器也加入到特效处理器中,以增加后处理的效果。

要实现上述功能,需要新建一个LiveShowAVSync的类,继承自类AVSync,并重写父类中的三个回调方法,第一个就是当Uploader线程初始化成功之后的回调方法,代码如下:

void LiveShowAVSynchronizer::OnInitFromUploaderGLContext(EGLCore* eglCore,

int videoFrameWidth, int videoFrameHeight) {

videoEffectProcessor = new VideoEffectProcessor();

videoEffectProcessor->init();

int filterId = videoEffectProcessor->addFilter(0, 1000000 * 10 * 60 * 60,

PLAYER_CONTRAST_FILTER_NAME);

if(filterId >= 0){

videoEffectProcessor->invokeFilterOnReady(filterId);

}

glGenTextures(1, &mOutputTexId);

glBindTexture(GL_TEXTURE_2D, mOutputTexId);

glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);

glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);

glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);

glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);

glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, videoFrameWidth,

videoFrameHeight, 0, GL_RGBA, GL_UNSIGNED_BYTE, 0);

glBindTexture(GL_TEXTURE_2D, 0);

AVSynchronizer::OnInitFromUploaderGLContext(eglCore,

videoFrameWidth, videoFrameHeight);

}

如上述代码所示,由于这个回调函数的调用发生在Uploader线程中,并且Uploader线程已经准备好了OpenGL ES上下文,并且也绑定好了OpenGL ES上下文,所以这里就是初始化特效处理器的好时机,同时,还要初始化一个输出纹理ID,因为经过特效处理器处理过后要有一个纹理ID作为输出,我们设定mOutputTexId来接受处理结束之后的纹理ID。

接着来看如何处理一帧视频帧,重写父类的处理视频帧的方法:

void LiveShowAVSynchronizer::processVideoFrame(GLuint inputTexId, int width,

int height, float position){

GLuint outputTexId = inputTexId;

if(videoEffectProcessor){

videoEffectProcessor->process(inputTexId, position, mOutputTexId);

outputTexId = mOutputTexId;

}

AVSynchronizer::processVideoFrame(outputTexId, width, height, position);

}

代码也很简单,如果视频特效处理器存在的话,就拿处理器处理过的纹理ID(mOutputTexId)交由父类拷贝到循环纹理队列中去。最后一个需要重写的方法自然是销毁资源的方法,代码如下:

void LiveShowAVSynchronizer::onDestroyFromUploaderGLContext() {

if (NULL != videoEffectProcessor) {

videoEffectProcessor->dealloc();

delete videoEffectProcessor;

videoEffectProcessor = NULL;

}

if (-1 != outputTexId) {

glDeleteTextures(1, &outputTexId);

}

AVSynchronizer::onDestroyFromUploaderGLContext();

}

因为这个方法的调用也是发生在Uploader线程中的,所以也符合视频特效处理器的销毁方法需求,同时,还要删除掉分配的这个输出纹理ID,最后调用父类的销毁资源方法。

至此,就将后处理过程集成到了播放器中,这样就使得在网络状态下对视频的清晰度会有一个增强的效果。可以看到,这里只是新增了类而没有修改旧的代码就达到了增加一个功能的效果,可见最初有一个合理的架构设计是多么的重要。

除了这个功能的增加,我们还有一个比较重要的适配工作要做,读者可还记得在AVSync模块里定义了三个宏用来控制解码线程的启动和暂停,以及音视频对齐策略?之前定义的宏如下:

#define LOCAL_MIN_BUFFERED_DURATION 0.5

#define LOCAL_MAX_BUFFERED_DURATION 0.8

#define LOCAL_AV_SYNC_MAX_TIME_DIFF 0.05

而这三个值放在网络环境中就需要做适配了,否则就会出现视频的卡顿,并且会因为对齐而影响到视频的整体播放,更改之后的三个宏定义如下:

#define NETWORK_MIN_BUFFERED_DURATION 2.0

#define NETWORK_MAX_BUFFERED_DURATION 4.0

#define NETWORK_AV_SYNC_MAX_TIME_DIFF 0.3

使用这三个宏的给全局变量赋值的地方需要重写父类的initMeta方法,代码如下:

void LiveShowAVSynchronizer::initMeta() {

this->maxBufferedDuration = NETWORK_MAX_BUFFERED_DURATION;

this->minBufferedDuration = NETWORK_MIN_BUFFERED_DURATION;

this->syncMaxTimeDiff = NETWORK_AV_SYNC_MAX_TIME_DIFF;

}

这样一来,本地视频播放器就做好了播放网络视频的适配了,这个拉流播放器也就可以使用了,读者可以参考代码仓库中的源码进行分析,以便加深理解。

11.2.2 iOS平台播放器增加后处理过程

在iOS平台增加了硬件解码器的支持之后,并没有像Andorid平台一样在解码器端进行非常大的改造,而是在渲染端进行了适配,改动之后的结构如图11-3所示。

图 11-3

如图11-3所示,VideoPlayerController这个调度器会带着是否使用硬件解码器的参数来初始化VideoOutput,而在VideoOutput中有一个属性为frameCopier,VideoOutput会根据是否使用硬件解码器而初始化不同类型的FrameCopier,如果使用硬件解码器就实例化FastFrameCopier,而如果没有使用硬件解码器就实例化YUVFrameCopier。当VideoPlayerController从视频队列中取出一帧视频帧交给VideoOutput来渲染的时候,VideoOutput就会调用前面实例化好的FrameCopier将VideoFrame类型的视频帧转化为一个纹理ID,然后VideoOutput就会绑定到displayFrameBuffer上,最后使用DirectPassRender将纹理ID渲染到RenderBuffer上面,也就是渲染到了Layer上,从而让用户可以看到。而在上面的整个渲染过程中,我们要引入视频后处理效果,最好的插入点就是在FrameCopier之后,将FrameCopier处理完的纹理ID交给我们新定义的一个VideoEffectFilter来做视频特效的处理,最终处理结束之后的outputTexId再由原来的DirectPassRender渲染到Layer上面去,将这个新的节点引入到VideoOutput之后,整体结构如图11-4所示。

图 11-4

接下来,就来看一下如何具体地构建这个VideoEffectFilter,首先提供一个prepareRender的方法,完成OpenGL ES相关资源的初始化,要求VideoOutput在OpenGL ES线程中来调用这个方法,代码如下:

– (BOOL) prepareRender:(NSInteger) frameWidth height:(NSInteger) frameHeight;

{

_frameWidth = frameWidth;

_frameHeight = frameHeight;

[self genOutputFrame];

_processor = new VideoEffectProcessor();

_processor->init();

int filterId = _processor->addFilter(0, 1000000 * 10 * 60 * 60,

PLAYER_CONTRAST_FILTER_NAME);

if(filterId >= 0){

_processor->invokeFilterOnReady(filterId);

}

return YES;

}

其中genOutputFrame方法是生成这个类的输出纹理ID与FBO的,至于如何生成一个纹理ID和FBO,并且把这个纹理ID与FBO绑定起来前面已经讲过很多遍,这里就不在展示具体的代码了,接下来就是真正的渲染过程了,代码如下:

– (void) renderWithWidth:(NSInteger) width height:(NSInteger) height

position:(float)position;

{

glBindFramebuffer(GL_FRAMEBUFFER, _frameBuffer);

self.processor->process(_inputTexId, position, _outputTextureID);

glBindFramebuffer(GL_FRAMEBUFFER, 0);

}

最后就是释放为做后处理而分配的资源,代码如下:

– (void) releaseRender;

{

if(_outputTextureID){

glDeleteTextures(1, &_outputTextureID);

_outputTextureID = 0;

}

if (_frameBuffer) {

glDeleteFramebuffers(1, &_frameBuffer);

_frameBuffer = 0;

}

if (_processor) {

_processor->dealloc();

delete _processor;

_processor = NULL;

}

}

至此这个VideoEffectFilter类就构建完毕了。接着需要在VideoOutput这个类中将FrameCopier的输出给VideoEffectFilter作为输入,而把VideoEffectFilter的输出给DirectPassRender作为输入。这样渲染过程就集成进了后处理过程,当然目前的后处理过程仅仅是增强对比度的一个效果器,读者也可以将去块滤波效果器,锐化效果器加入到后处理过程中,这样会使得整体播放效果有很大提升。

另外,针对于视频源是网络流的特殊处理如下,第一点是要在VideoDecoder这个模块下给FFmpeg加入超时回调的方法,因为在本地磁盘文件的读取过程中基本不会出现超时的场景,但是由于网络环境过于复杂,所以这里要加入超时方法。在VideoDecoder类中,调用libavformat模块的打开连接之前,可给AVFormatContext设置超时回调函数,代码如下:

AVFormatContext *formatCtx = avformat_alloc_context();

AVIOInterruptCB int_cb = {interrupt_callback, (__bridge void *)(self)};

formatCtx->interrupt_callback = int_cb;

而其中设置的interrupt_callback这个静态方法的回调函数实现如下:

static int interrupt_callback(void *ctx)

{

__unsafe_unretained VideoDecoder *p = (__bridge VideoDecoder *)ctx;

const BOOL isInterrupted = [p detectInterrupted];

return isInterrupted;

}

而在这个静态函数中调用的detectInterrupted方法的实现很简单,就是判断当前时间戳和上一次接收到数据或者开始连接的时间间隔,如果大于超时时间(比如说15s),则返回YES,代表已经超时,否则返回NO。返回YES,则代表FFmpeg中阻塞的调用(比如:read_frame、find_stream_info等)会立即返回。

第二点对于网络流的适配是更改缓冲区大小,由于在本地播放器中设置了一个minBuffer和maxBuffer来作为控制解码线程的暂停和继续的条件,之前定义的宏如下:

#define LOCAL_MIN_BUFFERED_DURATION 0.5

#define LOCAL_MAX_BUFFERED_DURATION 1.0

而在网络中为了避免频繁的卡顿,还需要将控制解码线程暂停和运行的Buffer长度进行网络适配,新增宏定义如下:

#define NETWORK_MIN_BUFFERED_DURATION 2.0

#define NETWORK_MAX_BUFFERED_DURATION 4.0

在拉流播放器中就是用上述的这两个宏定义来确定AVSync模块的缓冲区大小。这样我们就完成了由本地播放器到网络拉流播放器的适配,读者可以参考代码仓库中的源码进行分析,以便于可以深入理解。

11.3 推流器的构建

本节来构建主播端使用的推流工具,当然,也是根据本书前面章节中的视频录制应用进行改动适配。在第7章中,已经构建了一个视频录制器,同时在第8章和第9章为这个视频录制器增添了音频效果处理器和视频效果处理器,对于一个录播应用来讲,已经比较完整了,但是对于直播应用,我们还需要做一些适配工作。首先一块来看一下整体结构,如图11-5所示。

图 11-5

从整体结构来看,录播和直播的区别仅仅在于最终Muxer(图中的Publisher)模块的输出不同,录播是向本地磁盘中输出,而直播是向网络中输出。而网络不同于磁盘的是,有可能会出现网络波动甚至网络阻塞,所以要针对网络这种场景做对应的适配工作。

接下来分析一下复杂的网络环境,读者可以参考图11-6,主播端第一步请求Http Server的开播接口,然后会得到域名形式的推流地址;得到推流地址之后主播端将去做域名解析,将域名解析为实际的IP地址和端口号;使用这个IP地址经过公有网络的各级路由器和交换机,最终找到实际的CDN厂商节点。从拿到IP地址开始,影响整个连接通道的因素就有很多,其中包括主播自己的出口网络、中间的链路状态,以及CDN厂商的机房的节点链路情况等。既然影响连接通道的因素这么多,如果都由开发者自己来解决,显然是不合理的, CDN厂商可以帮开发者解决掉除主播自己出口网速以外的其他部分,当然这也是CDN厂商存在的意义,但是任何一家CDN厂商也没有100%的服务保证性,并且,主播自己的出口网络可能是小运营商,也有可能是教育网络,或者主播有其他设备占用着网络出口带宽,所以不可控性就太多了。读者可以对比图11-6来分析整个网络情况。

图 11-6

综上所述,必须要做两件事情,才可以将视频录制器转换为一个可用的主播推流工具:第一件事情就是如果网络超时,要可以通知给用户重新开播或者切换网络环境;第二件事情是当网络出现抖动的时候,我们要做丢帧来保证整个视频直播的延迟时间,以维持整场直播的交互性。

首先来看第一件事情,网络超时的设置。超时设置应该在Muxer模块来完成,由于Muxer模块使用的是FFmpeg中libavformat模块的封装层和协议层,所以这里设置的超时代码和我们在拉流端设置超时的代码类似,即在分配出AVFormatContext之后,并在打开连接之前,设置超时回调,代码如下:

AVFormatContext* oc;

AVIOInterruptCB int_cb = { interrupt_cb, this };

oc->interrupt_callback = int_cb;

而静态函数interrupt_cb的实现如下:

int RecordingPublisher::interrupt_cb(void *ctx) {

RecordingPublisher* publisher = (RecordingPublisher*) ctx;

return publisher->detectTimeout();

}

这个静态函数中调用了这个RecordingPublisher类中的detectTimeout方法,而在detectTimeout方法中会针对当前时间戳减去上一次发送数据包的时间戳的值进行判断:如果大于我们设定的值(一般设置为5~15s)就返回1,代表超时,停止发送数据包;否则返回0,代表没有超时,可以由FFmpeg的协议层继续发送数据包。而在我们的发送线程中,可以判断,如果发生了超时,则可以回调客户端代码,让客户端代码给用户弹出提示,让用户可以重新开播或者切换网络重新开播。

下面来看网络出现抖动,或者在弱网环境下的丢帧策略。读者可以先参考图11-5,图中有两种队列分别是编码之前的原始数据的队列,和编码之后的编码队列,弱网丢帧策略常见的实现有两种:其中一种是丢弃原始数据队列中未编码的数据帧,另外一种是丢弃掉编码队列中的数据帧。这两种实现各有优缺点,但是无论哪一种实现方式都以“不影响音视频的对齐”为第一准则。接下来分析一下两种丢帧策略的优缺点,丢弃原始数据帧的丢帧策略优点是节省了这部分丢弃帧所占用编码器的资源,并且由于是丢弃的原始数据帧,所以可以在任意时刻丢弃任意的音频视频帧,缺点是增大了直播的延迟时间,因为要保持中间队列有一个阈值;丢弃编码之后数据帧的策略优点是减少了直播的延迟时间,缺点是丢弃的帧白白消耗了编码器资源,并且对于视频帧只要丢帧就丢掉一个GOP或者整个GOP的后半部分,否则会造成观众端不能正常观看视频。

不同的丢帧策略应用在不同的直播场景中,读者可以依据自己产品的场景来选择丢帧策略,笔者在实际开发过程中使用的丢帧策略是:对于视频丢弃的是编码之后的视频帧,对于音频丢弃的是编码之前的原始格式的音频帧。下面来看一下具体的实现。

对于编码后的视频帧进行丢帧,要丢弃只能丢弃一个完整的GOP(或者这个GOP后半部分非参考视频帧),或者这个GOP中剩余的视频帧,因为对于P帧需要参考前面的I帧与P帧才能被解码出来,对于B帧需要双向参考(但是在直播中一般不使用B帧,仅用I帧和P帧)。某一些策略会保留一个GOP中的I帧,但是I帧是一个GOP中容量最大的视频帧;而某一些策略是丢弃GOP中的后半部分的P帧,直到这个GOP中仅剩余I帧的时候,再把I帧丢弃掉。第二种策略其实是一种可取的策略,但是本书为了简单考虑,最终的丢帧策略是:要丢弃就丢掉一整个GOP(如果这个GOP已经发送出去了部分I帧和P帧,则丢掉这个GOP中剩余的视频帧)。如果读者想去实践仅丢弃GOP中的后半部分P帧策略,在后续代码中进行更改也很简单。

丢弃了视频帧之后,为了不影响音画的对齐效果,也应该丢弃掉同等时间的音频数据,但是丢弃的那些视频帧总时长是多少呢?我们不可以仅仅通过fps计算这些视频帧所代表的时长,而应该计算出视频帧每一帧持续的时间到底是多少,必须要精确计算。因为fps对于Camera的影响是在一定时间段范围内,所以在一定时间内连续的一段视频帧数目是可以被限定在这个fps之内的,但是对于某一段小时间段却不能保证满足fps的限制。那如何计算每一帧视频帧的精确时长呢?只能将Camera采集出来的视频帧都打上一个相对时间戳,在编码的时候,要在编码成功第二帧时才把第一帧的duration信息赋值,并把第一帧放入到编码后的视频队列中,代码如下:

bool LivePacketPool::pushVideoPacketToQueue(LiveVideoPacket* videoPacket) {

if (NULL != videoPacketQueue) {

//为了计算当前帧的Duration, 所以延迟一帧放入Queue中

if(NULL != tempVideoPacket){

int packetDuration = videoPacket->timeMills –

tempVideoPacket->timeMills;

tempVideoPacket->duration = packetDuration;

videoPacketQueue->put(tempVideoPacket);

}

tempVideoPacket = videoPacket;

}

return dropFrame;

}

代码中的tempVideoPacket是一个全局变量,代表durtion属性还没有被赋值的视频帧,在被赋值之后,它会被加入到视频队列中。接下来看看,如何丢弃一整个GOP或者GOP中没有发送出去的视频帧,并计算出丢弃的视频帧所占用的总时长,在丢弃之前要给整个队列上锁,执行完操作之后解锁,代码主体如下:

int LiveVideoPacketQueue::discardGOP() {

int discardVideoFrameDuration = 0;

LiveVideoPacketList *pktList = 0;

pthread_mutex_lock(&mLock);

//执行丢帧操作

pthread_mutex_unlock(&mLock);

return discardVideoFrameDuration;

}

执行丢帧操作的逻辑也比较简单,会先判断当前第一个元素是否是关键帧,如果是关键帧的话,则将布尔型变量isFirstFrameIDR设置为true,代码如下:

bool isFirstFrameIDR = false;

if(mFirst){

LiveVideoPacket * pkt = mFirst->pkt;

if (pkt) {

int nalu_type = pkt->getNALUType();

if (nalu_type == H264_NALU_TYPE_IDR_PICTURE){

isFirstFrameIDR = true;

}

}

}

然后循环队列中所有的视频帧,会判断视频帧类型。如果帧类型不是关键帧,则丢弃这一帧,并且把这一帧的时间长度加到丢弃帧时间长度的变量上;如果是关键帧,就先判断是否变量isFirstFrameID为true,如果是true则先置为false,然后丢弃这一帧并将帧长度加到丢弃帧时间长度的变量上,而如果是false,则代表已经删除了一个GOP,就应该退出了。代码如下:

LiveVideoPacketList *pktList = 0;

for (;;) {

if (mAbortRequest) {

discardVideoFrameDuration = 0;

break;

}

pktList = mFirst;

if (pktList) {

LiveVideoPacket * pkt = pktList->pkt;

int nalu_type = pkt->getNALUType();

if (nalu_type == H264_NALU_TYPE_IDR_PICTURE){

if(isFirstFrameIDR){

isFirstFrameIDR = false;

discardVideoFrameDuration += pkt->duration;

relesse(pktList);

continue;

} else {

break;

}

} else if (nalu_type == H264_NALU_TYPE_NON_IDR_PICTURE) {

discardVideoFrameDuration += pkt->duration;

relesse(pktList);

}

}

}

上述方法的调用端,就是当一个编码之后的视频帧要放入队列中之前,当然要判断一下当前视频队列的大小和设置Threshold(阈值)的关系,如果超过了阈值,则说明当前网络发生抖动或者处于弱网环境下,则执行丢帧逻辑。待丢弃完一个GOP之后,以这个丢弃掉视频帧的时间长度参数再去丢弃音频数据,而丢弃的音频帧是原始数据帧,又因为对于PCM队列中每一个元素都是固定长度(暂时设置的是40ms)的一个buffer,所以代码如下:

bool LivePacketPool::discardAudioPacket() {

bool ret = false;

LiveAudioPacket *tempAudioPacket = NULL;

int resultCode = audioPacketQueue->get(&tempAudioPacket, true);

if (resultCode > 0) {

delete tempAudioPacket;

tempAudioPacket = NULL;

pthread_rwlock_wrlock(&mRwlock);

totalDiscardVideoPacketDuration -= (40.0 * 1000.0f);

pthread_rwlock_unlock(&mRwlock);

ret = true;

}

return ret;

}

上述函数的实现比较简单,调用的地方就在原来的音频编码适配器(AudioEncodeAdapter)的getAudioPacket方法中。至此,我们就完成了丢帧策略的实现,主播端的推流工具就由视频录制器改造而成。但是基于此还有很多其他的改进空间,下一章中会继续完善。

11.4 第三方云服务介绍

在开发音视频的App的过程中,不得不和第三方的云服务打交道,因为这一些CDN厂商对于视频的带宽可以提供更加廉价的价格(相比较于IDC的带宽),而对于视频的访问速度也可以提供更加快速的访问通道。无论在录播场景下还是直播场景下,使用CDN厂商都要优于自己在IDC搭建一套存储服务与流媒体服务。

这里首先解释一下CDN的概念,CDN的全称是Content Delivery Network,其基本思路是尽可能避开互联网上有可能影响数据传输速度和稳定性的瓶颈和环节,使内容传输的更快、更稳定。一般实现手段都是在各处放置节点服务器,节点服务器呈树形结构,用户直接能访问到的是边缘(叶子)节点服务器,如果边缘节点服务器没有用户所访问的内容,就向它的上一级节点服务器去要数据,如果还没有的话(不同厂商提供的级数不同),就去开发者配置的文件实际地址(回源地址)去拉取;如果边缘节点有这个资源的话,那么就直接给到用户,并且在用户选择边缘节点的策略上,CDN厂商也会按照负载均衡、链路调度等服务安排用户就近获取内容,降低网络拥塞,提高用户访问的响应速度。而CDN去源站服务器获取源文件的这个过程称为回源,对于流媒体资源,回源率越小越好,否则源站服务器的I/O将不堪重负。而在日常工作中,不单单最终用户的视频作品会在CDN上存储,甚至我们的图片文件、音频文件,甚至前端工程师的js和css文件都可以存储在CDN上。当然在录播场景下,用户视频作品的存储以及访问,开发者肯定会用到某一些CDN厂商。而最终开发者或者公司和CDN一般会按照几部分服务进行计算资费,最主要的就是网络带宽的费用,这部分的费用要比使用我们自己机房的带宽费用便宜很多,至于存储、转码等服务,则依据开发者自己的使用情况而进行收费。

那么,在直播过程中,第三方的CDN厂商又可以给我们提供哪些服务呢?笔者根据自己接触的CDN厂商,总结了以下主要的几个服务。

直播转发服务:提供快速、稳定的直播转发服务,接受主播端推上来的视频流,并可以转发给所有的订阅者(观众端),一般CDN厂商对于视频会做一些关键帧缓存等优化,可以使得观众端最快速度的看到视频;

直播存档服务:在整个直播过程中的视频都可以被存储起来,以便于客户的产品可以沉淀下视频内容,在后续也可以进行观看这个视频;

直播转码服务:提供多协议、多分辨率、多码率的多路转码服务,一个产品的多个终端可能需求的拉流协议是不同的,比如网页上需要HLS协议的,而客户端需要HDL协议的,或者在一些大型直播以及一些专有的体育赛事直播中需要给用户提供超清、高清、标清等多路视频流;

视频抽帧服务:可以提供可配置时间内(1-10s)抽取一帧图像进行存储,可以提供给客户进行内容审核、实时预览等功能;

推流、拉流客户端SDK:可以提供给产品推流端和拉流端的视频基础服务,可以让客户花更多的时间在自己的产品和社交功能上,但是也有一些缺点,就是当和我们系统中的动画以及一些其他页面跳转等细节出现不兼容性问题的时候会比较麻烦,当然基于产品场景下,对于推流、拉流做相关优化也会很困难,所以有利也有弊,是否采用SDK,读者可以根据自己的业务场景以及产品阶段进行选择。

除了上述基础的服务之外,某一些CDN厂商也会提供其它可编程接口,离线处理接口等服务。而我们最常用的服务以及应用场景如下所示。

直播转发服务:在直播过程中作为中转服务器, 开发者自己的Http服务器会分配这个中间的地址,给推流端下发过去,推流端将视频流推送到这个中转服务器上,而观众端也会到中转服务器(和推流的服务器不一定相同,CDN厂商会提供最优的链路解决方案)上拉取直播流。另外在自己的Http服务器返回推流地址的时候,可以加入防盗链机制(推流地址会使用时间戳加密后进行验证)增加安全性;

直播存档服务:在直播过程中,我们的App会将主播端直播出来的视频实时转码为一个mp4文件存储到CDN上,以便后续可以产生视频回放服务;

直播转码服务:使用这个服务将视频实时转码为HLS的视频流,供网页播放服务使用,一般情况下不会使用到多分辨率以及多码率的服务,如果有一些特殊活动可以提前申请这样的服务;

视频抽帧服务:使用CDN厂商提供的这个服务,每隔5s拉取一帧图像进行展示,以供我们的内容审核人员针对一些违规视频进行处理。

合理地使用CDN厂商给我们提供的服务,可以提升开发效率,缩短开发周期,可以把我们有限的精力投入在产品的打磨上,让我们在自己的细分市场或者垂直领域快速地进行迭代。但是使用CDN厂商也有一定的弊端,比如CDN厂商提供了服务的稳定性,如果这家CDN厂商死掉了,那很有可能导致我们的产品处于不可用状态,所以在实际的开发中,要有多家CDN厂商备选,可以进行热切换,最彻底的方案就是我们自己再搭建一套系统,以便在所有第三方服务都挂掉的时候,也可以保持产品的可用性。具体读者的产品所提供的服务能达到哪一种层级,读者可以自己衡量与分析,笔者在这里只是给出可选的方案。

11.5 礼物系统的实现

对于一个直播App,礼物展示系统的实现是非常重要的,它实现的好坏直接会影响到整个产品的收入情况。我们在考虑礼物系统实现的时候,应该思考以下几个方面的问题:

  • 礼物系统性能怎么样;
  • 礼物系统将来的扩展性如何;
  • 当开发一个新动画的时候,开发成本多少,其中包括开发时间有多少,参与人员有哪几部分组成等。

下面来列举几种常用的实现手段:

第一种手段就是使用各个平台自身提供的API来实现动画,比如iOS平台使用CALayer动画来实现,Android平台使用自己的Canvas来实现。针对于这种实现手段,我们来分析一下以上几个问题,礼物系统的性能在iOS上没问题,在Android上可能要差一些;将来的扩展性并不会太好,将来可能会出现复杂的动画,比如类似碰撞检测的动画就很难实现;当开发一个新动画的时候,开发成本可以说是比较大的,因为需要两个客户端开发人员,还有设计人员,对于设计人员输出的图片尺寸可能也不一样,需要不断的和两端开发人员进行调试,以及适配各种机型。

第二种手段就是使用OpenGL ES来开发一套自己的动画引擎,前期开发成本巨大,需要兼容粒子系统(使用Particle Designer设计出来的配置文件可以直接运行到系统中),最好能兼容设计人员使用AE(全称是After Effect,是Adobe的一款专门设计视频特效的图形处理软件)产出的动画特效。礼物系统的性能没有问题,将来的扩展性主要看最开始自己的设计,但是遇到特殊情况,比如需要路径和碰撞检测的场景很难实现;对于开发成本,由于是跨平台的系统,需要的开发人员不多,但是需要精通OpenGL ES,也就是说,对开发人员的要求相对比较高,而和设计人员的沟通方面成本比较小。

第三种手段使用现有的一些游戏引擎来实现动画,比如Cocos2dX、libGDX等。这里就以最为流行的Cocos2dX为例来看上面提到的几个问题,Cocos2dX使用OpenGL ES来作为绘制引擎,所以效率方面没有问题;Cocos2dX是一款游戏引擎,所以在扩展性方面肯定是最强的,不论是碰撞检测,还是其他场景都比较容易实现;至于开发成本,由于开发语言是C++,所以比较简单,但是需要开发人员学习Cocos2dX的API,因为这是跨平台的一项技术,可见整体的开发成本并不算高,缺点就是引入Cocos2d引擎会增大App的体积。

其他手段,如Airbnb的工程师发布的Lottie项目,这个项目可更简单地为原生项目添加动画效果,它直接支持AE的动画特效,并且支持动画的热更新操作,可以有效地减小App的体积,且支持Andorid、iOS等平台,但是由于这一些技术业界使用的并不是太多,所以笔者也不在本书中详细介绍,等以后这一些技术更加成熟、稳定了之后,大家可以在一块交流。

下面将基于上述的分析为大家介绍如何使用Cocos2dX为应用增加动画特效。首先会介绍一下Cocos2dX的项目在Android和iOS设备上运行原理,然后会介绍Cocos2dX的关键API,最后利用这一些API实现一个动画。

11.5.1 Cocos2dX项目的运行原理

如何构建项目这里就不做过多的介绍了,大家可以根据官方文档来进行构建,本节重点介绍的是Cocos2dX项目在Android和iOS平台上如何运行,如果读者面前有开发环境的话,可以打开代码仓库中的Android工程或者iOS工程跟随笔者进行分析。

1. Android项目的运行

首先来看Android工程,这里要使用到Cocos2dX项目提供的jar包以及我们自己编写的so库。首先配置jar包,可在build.gradle中进行配置,至于so库,可暂且假设已经编译出来了,后续的关键API详解章节会详细介绍如何编译so库。前面笔者提到过Cocos2dX也是基于OpenGL ES引擎进行绘制的,所以在Android上需要使用GLSurfaceView作为Cocos2dX的绘制目标,而jar包里面提供的类Cocos2dxGLSurfaceView就是我们要使用的GLSurfaceView了。首先创建出这个View对象:

Cocos2dxGLSurfaceView glSurfaceView = new Cocos2dxGLSurfaceView(this);

然后给这个GLSurfaceView设置EGL的显示属性,并设置Renderer为jar包里面提供的专有类Cocos2dxRenderer:

mGLSurfaceView.setCocos2dxRenderer(new Cocos2dxRenderer());

再来看这个Cocos2dxRenderer内部具体的关键生命周期方法onSurfaceCreated,这个方法里面的第一行代码就调用了nativeInit方法,而这个Renderer的nativeInit方法最终会调用的Native层,这个类对应的Native层的源码文件是javaactivity-android.cpp,虽然不同的版本实现不同,但不论是在JNI_OnLoad方法中,还是在nativeInit方法中,都可以调用到以下这个方法:

cocos_android_app_init

这个方法会和so库中的main.cpp文件的实现连接起来,代码如下:

void cocos_android_app_init (JNIEnv* env, jobject thiz){

LOGD(“cocos_android_app_init”);

AppDelegate *pAppDelegate = new AppDelegate();

}

这样就可以让类Application的单例引用指向我们自己写的APPDelegate(继承自Application类)了。而在接下来的nativeInit方法中,会给Director设置GLView,代码如下:

director->setOpenGLView(glview);

这个GLView是一个接口,Cocos2dX在Android和iOS平台有各自的实现,分别完成一些平台相关的操作,比如viewPort、getSize、SwapBuffer等操作,面向接口编程的好处显而易见,正是因为这种设计才可以让Cocos2dX可以跨平台运行。在nativeInit方法中有一个最关键的调用,它能让整个引擎运行起来,方法如下:

cocos2d::Application::getInstance()->run();

上述的流程会让整个引擎委托给我们书写的类来完成动作。而具体在APPDelegate中是如何实现的,会在11.5.2节继续讲解。而在这个Renderer的生命周期方法onDrawFrame中会调用到nativeRender方法,而nativeRenderer方法中也会调用到Native层去,在Native层中可以看到如下调用:

cocos2d::Director::getInstance()->mainLoop();

而mainLoop方法是Director这个类中要绘制内部所有场景(Scene)的地方,这样就可以使整个动画不断地绘制出来。

综上所述,可依靠GLSurfaceView内部的渲染线程调用Renderer的方法onDrawFrame将整个渲染过程跑起来。至于上面提到的Cocos2dX的入口类Director,以及关键API,在后续的章节会继续介绍。

2. iOS项目的运行

将iOS项目运行起来之后,可以先找到源码文件main.m,这个文件是整个App的入口类,可以看到,这里面将AppController这个类作为整个App的生命周期方法的代理类。在这个类中首先声明了一个变量如下:

static AppDelegate s_sharedApplication;

声明这个变量的目的是让Cocos2dX的Application入口交由APPDelegate这个类,由于Application是单例模式设计的,而APPDelegate又继承自Application这个类,从而达到了委托给APPDelegate这个类的目的。下面看到这个类中的启动方法:

– (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:

(NSDictionary *)launchOptions;

在这个启动的方法中,首先取出Application,然后设置OpenGL上下文的属性,接下来利用提供的CCEAGLView构造出一个UIView,并将这个View以subView的方式加到ViewController中,最终给Director设置这个构造出来GLView,以及调用Application的run方法,代码如下:

cocos2d::GLView *glview = cocos2d::GLViewImpl::createWithEAGLView(eaglView);

cocos2d::Director::getInstance()->setOpenGLView(glview);

cocos2d::Application::getInstance()->run();

在这个run方法中就会调用到Cocos2dX引擎定义在AppDelegate中的生命周期方法了,然后在源码中可以发现run方法中最终会调用Director的startMainLoop:

[[CCDirectorCaller sharedDirectorCaller] startMainLoop];

在这个方法中,会使用CADisplayLink来做一个定时器,按照设置的fps信息调用OpenGL ES的渲染操作,而关键的OpenGL ES的操作都在CCEAGLView中实现,在Director中的关键操作(比如SwapBuffer)则会委托给GLView来完成,这样一来,它们就又到达了CCEAGLView这个类中,从而让整个Cocos2dX引擎实现了在iOS平台的运行。相比较于Android平台,iOS平台的运行原理是比较简单的,大家可以理解一下Cocos2dX是如何通过面向接口编程达到实现跨平台特性的。

11.5.2 关键API详解

本节将介绍Cocos2dX里面的关键API,不过,这里不再区分平台,而是使用一套跨平台的代码。对于Cocos2dX引擎来讲,最重要的是Director类,就像它的名字一样,它是整个游戏或者动画的导演,控制着内部所有场景(Scene)的渲染,可以进行显示、切换等操作,对于场景,大家可以将其理解为一个界面,类似于Android里面的Activity,或者iOS里面的ViewController,在这个场景中可以有很多图层(Layer),而每一个图层就类似于Photoshop中图层的概念,所有上述的这些对象共同构成了Cocos2dX引擎。

接下来读者可以看到上一节提到的AppDelegate中,这个AppDelegate就是Cocos2dX引擎委托给开发者的程序入口,而这个AppDelegate也必须要继承自cocos2d:: Application,并重写这里面的如下生命周期方法。

当应用程序启动的时候会调用方法:applicationDidFinishLaunching;

当应用程序进入后台的时候会调用方法:applicationDidEnterBackground;

当应用程序回到前台的时候会调用方法:applicationWillEnterForeground。

由于iOS平台不允许App进入后台之后还使用OpenGL ES进行渲染,并且进入后台之后也没必要在为用户展示动画,所以当应用程序进入后台的时候,应该调用停止动画的方法:

Director::getInstance()->stopAnimation();

调用了上述方法之后,Director中的渲染行为就不会再被触发,内部实现会把一个invalid的变量设置为true,而在mainLoop方法中,待判断出这个变量是true之后,Director就不会去渲染内部的场景了。而当App又重新回到前台的时候,则应该继续启用动画:

Director::getInstance()->startAnimation();

上述方法的内部实现又会把invalid这个变量设置为false,而在Director内部的mainLoop就会继续渲染内部的场景,用户就可以继续看到动画了。接下来看一下最重要的生命周期方法applicationDidFinishLaunching,这个方法是Cocos2dX引擎留给开发者设置参数与绘制操作等程序入口的地方。先来看一下设置Director的代码部分:

auto director = Director::getInstance();

director->setDisplayStats(false);

director->setAnimationInterval(1.0 / 45);

director->setClearColor(Color4F(0, 0, 0, 0));

首先第一步得到Director的实例,然后将显示fps状态的开关关闭掉,接下来设置fps,这里设置的是一秒钟45帧的帧率,最后一行代码是设置背景颜色,其实这个颜色是每次绘制的最开始使用glClearColor时所用的颜色。由于设备分辨率具有多样性,而设计人员在调整动画效果或者游戏效果的时候也只会设计一个标准分辨率下的效果,因此在适配不同分辨率的机器在Cocos2dX中的实现如下:

static cocos2d::Size designResolutionSize = cocos2d::Size(480, 320);

static cocos2d::Size smallResolutionSize = cocos2d::Size(480, 320);

static cocos2d::Size mediumResolutionSize = cocos2d::Size(1024, 768);

auto glview = director->getOpenGLView();

if(!glview) {

glview = GLViewImpl::create(“changba-cocos”);

director->setOpenGLView(glview);

}

glview->setDesignResolutionSize(designResolutionSize.width,

designResolutionSize.height, ResolutionPolicy::NO_BORDER);

Size frameSize = glview->getFrameSize();

if (frameSize.height > smallResolutionSize.height){

director->setContentScaleFactor(MIN(mediumResolutionSize.height/

designResolutionSize.height, mediumResolutionSize.width/

designResolutionSize.width));

}else{

director->setContentScaleFactor(MIN(smallResolutionSize.height/

designResolutionSize.height, smallResolutionSize.width/

designResolutionSize.width));

}

可以看到,首先会取出Director的绘制目标GLView,然后会给这个GLView设置进入原始的分辨率,并对比GLView的大小,给Director设置一个Scale系数,而Cocos2dX在实际的绘制过程中,会使用这个Scale系数进行屏幕分辨率的适配。

完成了对Director的设置过程之后,接下来就来实例化一个场景,让Director来显示这个场景。

auto scene = AnimatinScene::create();

director->runWithScene(scene);

可以看到这里面所有的类型都是auto类型的,代表是自动回收对象,而最后一行代码就是告诉Director运行HellWord这个场景。

对AppDelegate的介绍就到这里了,接下看一下HelloWord这个场景是如何来构建的。要创建一个场景必须继承自cocos2d::Scene,然后重写方法init,因为Scene里面默认的create方法是调用了init方法,所以只有重写init方法才可以在场景的创建过程中完成我们的逻辑。

bool AnimationScene::init() {

if (!Scene::init()) {

return false;

}

auto keyBoardLayer = KeyBoardLayer::create();

addChild(keyBoardLayer, 1, 1);

return true;

}

第一行代码就调用父类的init方法,然后创建出KeyBoardLayer,并调用addChild方法将这个Layer加到我们的场景中,而这个KeyBoardLayer就是设置的一个菜单Layer,上面可以增加动画按钮与退出按钮,点击不同的按钮有不同的行为。

接下来看KeyBoardLayer的内部实现。首先Layer要继承自cocos2d::Layer,然后要重写init方法,在init方法中需要调用父类的初始化方法,代码如下:

bool KeyBoardLayer::init() {

//1:调用父类的初始化

if (!Layer::init()){

return false;

}

Size visibleSize = Director::getInstance()->getVisibleSize();

Vec2 origin = Director::getInstance()->getVisibleOrigin();

//2:增加关闭按钮

//3:增加动画按钮

}

可以看到代码分为三部分,第一部分是调用父类的初始化方法,然后取出屏幕的宽度与起始点位置,以便于后续添加按钮来及算位置,接下来第二部分是给这个Layer增加一个关闭按钮,代码如下:

auto closeItem = MenuItemImage::create(“CloseNormal.png”, “CloseSelected.png”,

CC_CALLBACK_1(KeyBoardLayer::menuCloseCallback, this));

closeItem->setPosition(Vec2(origin.x + visibleSize.width –

closeItem->getContentSize().width/2 ,

origin.y + closeItem->getContentSize().height/2));

auto menu = Menu::create(closeItem, NULL);

menu->setPosition(Vec2::ZERO);

this->addChild(menu, 1);

可以看到,这里选择了两张图片分别作为这个菜单项的普通状态和选中状态,点击的监听方法是本类的menuCloseCallback方法,然后设置位置,并且加入到创建的Menu里面去,最终将这个Menu加入到这个Layer中,而menuCloseCallback方法的实现如下:

void KeyboardLayer::menuCloseCallback(Ref* pSender) {

Director::getInstance()->end();

}

可以看到,直接调用Director的end方法可结束整个Cocos2dX的绘制。那接下来,看Layer的init方法中的第三部分,添加一个动画按钮,代码如下:

auto animationBtn = Label::createWithTTF(“show”, “fonts/Marker Felt.ttf”, 10);

animationBtn->setAnchorPoint(Point(0, 0));

auto listener = EventListenerTouchOneByOne::create();

listener->setSwallowTouches(true);

listener->onTouchBegan = [] (Touch *touch, Event *event) {

if (event->getCurrentTarget()->getBoundingBox().

containsPoint(touch->getLocation())) {

Scene* scene = Director::getInstance()->getRunningScene();

AnimationScene* animation = (AnimationScene*) scene;

animation->showAnimation();

return true;

}

return false;

};

Director::getInstance()->getEventDispatcher()->

addEventListenerWithSceneGraphPriority(listener, animationBtn);

animationBtn->setPosition(Vec2(origin.x + 0, origin.y));

this->addChild(animationBtn, 1);

虽然这里称之为增加了一个按钮,实际上使用的是Cocos2dX提供的一个Label控件,首先加载一个字体,然后按照文字(show)与字体大小(10)创建出一个label,并创建一个监听事件,这个监听事件被触发的时候会取出当前Director运行的场景,并调用showAnimation方法,接下来将这个label绑定这个事件,最后给这个label设置位置,并且加到Layer中去。

至此关键的API也已经介绍得差不多了,而具体与动画相关的API将会在下一节进行介绍,并在下一节会实现一款动画。

11.5.3实现一款动画

本节会带着大家实现一个亲吻的动画展示,首先来看一下有所有帧组成的一张大图片,如图11-7所示。

图 11-7

可以看到,这张图片是将序列帧图像合并在一起得到的,接着来看一下将整张图片裁剪成为动画帧序列的plist配置文件,如图11-8所示。

图 11-8

这个plist配置文件描述了每一帧图片应该在整张图片的位置以及旋转角度,Cocos2dX引擎可以解析这个plist配置文件并结合原始图片,最终形成序列帧。而设计人员如何生成plist配置文件以及合并整张图片呢?答案是使用TexturePacker这个工具,这个配置文件也是可以被大部分的游戏引擎所解析的,其中包括Cocos2dX、libGDX、Unity3D等。当设计人员利用AE开发完动画之后,然后导出为png序列图,之后在使用TexturePacker制作成为plist配置文件与大图的形式,然后提供给开发者进行使用。接下来就看一下如何利用整张图片与这个配置文件完成动画的展示。

在Cocos2dX中,每一个能运动的物体都可以理解为一个精灵,那我们先使用Cocos2dX提供的精灵缓存类来解析出所有的序列帧,代码如下:

SpriteFrameCache * cache = SpriteFrameCache::getInstance();

cache->addSpriteFramesWithFile(“el_kiss.plist”);

将plist的配置文件解析完成之后,所有的帧序列都存在与缓存中了,接下来就利用名字创建出一个精灵对象,代码如下:

auto kissSprite = Sprite::createWithSpriteFrameName(“el_kiss00.png”);

int randomY = random(170.f, screenHeight – 80.f);

kissSprite->setPosition(screenWidth + 30, randomY);

kissSprite->setOpacity(255);

this->addChild(kissSprite);

可以看到代码中先使用第一张图片创建出了一个精灵对象,然后设置了位置与透明属性,由于这个位置是画到屏幕的右侧还要再加30个像素的地方,所以暂时看不到,最后将这个精灵加入到这个场景中。接下来就来为这个精灵安排动画,动画分为三部分,第一部分是从屏幕右侧移动到屏幕中间,第二部分是重复3次整个序列帧动画,第三部分是重新移出到屏幕右侧。下面先来看第一部分从屏幕右侧移动到屏幕中的看得见的位置,代码如下:

auto kissFirstStepMoveAction = MoveTo::create(0.3,

Vec2(screenWidth – 200, randomY));

auto kissFirstStepEaseOutAction = EaseOut::create(kissFirstStepMoveAction, 2);

auto kissFirstMoveTargetAction = TargetedAction::create(kissSprite,

kissFirstStepEaseOutAction);

代码中的第一行定义了一个移动的动画,相当于从原来的位置历经0.3s的时间移动到屏幕宽度减去200的位置(纵坐标不变,依然是randomY),然后使用EaseOut封装一下,主要是为了将匀速的运动变成非匀速的运动,最终使用TargetedAction在这个精灵上创建出这个动作。接着来看第二部分的动画,代码如下:

//1:在缓存中拿出所有精灵帧

Vector<SpriteFrame *>animFrames(11);

char str[100] = {0};

for(int i = 0;i < 11 ;i++) {

sprintf(str, “el_kiss%02d.png”,i);

SpriteFrame *frame = cache->getSpriteFrameByName(str);

animFrames.pushBack(frame);

}

//2:创建Animation

auto animation = Animation::createWithSpriteFrames(animFrames, 1.0 / 11, 3);

//3:构建Action

auto kissAnimationAction = Animate::create(animation);

auto kissAnimationTargetAction = TargetedAction::create(kissSprite,

kissAnimationAction);

可以明显看到该段代码分为三部分,第一部分在精灵缓存池中拿出所有的精灵帧放入到一个数组中,第二部分利用这个数组中的精灵帧和延迟时间以及循环次数创建出Animation对象,第三部分就利用这个Animation对象构造出动作。接着来看最后一部分的动画,代码如下:

auto kissLastStepMoveAction = MoveTo::create(0.2,

Vec2(screenWidth + 30, randomY));

auto kissLastStepEaseInAction = EaseIn::create(kissLastStepMoveAction, 2);

auto kissLastMoveTargetAction = TargetedAction::create(kissSprite,

kissLastStepEaseInAction);

其实这个和第一部分的动画正好相反,是向屏幕的右侧移动出去,使用EaseIn将匀速运动封装为非匀速运动。最后将这三部分动画封装到一个序列动作中,并在结束的时候将这个精灵对象移除掉,之后将这个序列动作放入到场景中执行,代码如下:

auto sequence = Sequence::create(kissFirstMoveTargetAction,

kissAnimationTargetAction, kissLastMoveTargetAction,

CallFunc::create(CC_CALLBACK_0(Node::removeFromParent, kissSprite)),

NULL);

this->runAction(sequence);

至此,showAnimation方法就实现好了,这个方法完成了动画的展示,读者可以参考代码仓库中的源码进行分析,以便于深入理解。

本节对于礼物系统的实现就结束了,当然,整个礼物系统还需要指令控制系统才能构建完成,准确来讲本节只是介绍了礼物的动画展示部分,那么在接下来的章节中,会实现一个聊天系统,礼物系统中送礼物的命令也会在聊天系统中作为一个指令发送出去。

11.6 聊天系统的实现

聊天系统也有很多手段可以实现,在直播App中使用的聊天系统不单单用于聊天,还会作为这个直播房间的指令控制系统。那指令控制系统都包含了哪些指令呢?比如主播要踢出某一个观众,或者要禁言某一个观众,观众给主播赠送了一个礼物等,都属于一条条的控制指令,其实真正的聊天内容也可以看做一个指令,就是聊天指令。而实现手段也有很多种,其中最常用的就是WebSocket协议,本节将详细介绍如何利用WebSocket来实现指令控制(或者说是聊天系统)。

WebSocket API是下一代客户端-服务器的异步通信方法,是HTML5规范中替代AJAX的一种新技术。现在,很多网站为了实现推送技术,所用的技术都是轮询。轮询是在特定的的时间间隔(如每1秒),由浏览器对服务器发出HTTP request,然后由服务器返回最新的数据给客户端的浏览器。这种传统的模式有很明显的缺点,即浏览器需要不断地向服务器发出请求,然而HTTP request的header是非常长的,里面包含的数据可能只是一个很小的值,这样会占用很多的带宽和服务器资源。而比较新的技术去做轮询的技术是Comet,使用了AJAX。这种技术虽然可达到双向通信,但依然需要发出请求,而且在Comet中,普遍采用了长链接,这也会大量消耗服务器带宽和资源。面对这种状况,HTML5定义了WebSocket协议,能更好的地节省服务器资源和带宽并且能实时通信。它实现了浏览器与服务器全双工通信(full-duplex)。在WebSocket API中,浏览器和服务器只需要要做一个握手的动作,浏览器和服务器之间就形成了一条快速通道。两者之间就直接可以进行数据互相传送。但是它不单单仅适用于Web端,在客户端使用起来也非常方便,一般使用在客户端会维护一个WebSocket对象,该对象可以调用发起连接、发送消息、关闭连接的方法,同时要为这个对象绑定以下四个事件。

onOpen:连接建立时触发;

onMessage:收到服务端消息时触发;

onError:连接出错时触发;

onClose:连接关闭时触发;

而在这四个事件中,开发者可以执行自己的操作,下面就分别来介绍如何在在Android和iOS客户端使用WebSocket技术实现简单的聊天系统。

11.6.1 Android客户端的WebSocket实现

Android客户端比较常用的WebSocketClient有:autobahn、AndroidAsync、Java-WebSocket,笔者使用的是autobahn,读者可以按照自己的使用场景来选择,其实原理都一样,现在就来看一下如何使用autobahn实现一个聊天系统。

首先来看实例化WebSocket对象与发起连接的实现,代码如下:

WebSocket mConnection = new WebSocketConnection();

String wsuri = “ws://echo.websocket.org”;

mConnection.connect(wsuri, new WebSocketConnectionHandler() {

@Override

public void onOpen() {

}

@Override

public void onClose(int code, String reason) {

}

@Override

public void onTextMessage(String payload) {

}

@Override

public void onBinaryMessage(byte[] payload) {

}

@Override

public void onRawTextMessage(byte[] payload) {

}

});

可以看到,在对地址”ws://echo.websocket.org”发起连接时,需要传递进去一个回调接口,当打开连接成功的时候会回调onOpen方法,用来提示用户已经连接成功,并且开发者也会开始发送Ping命令,以保持心跳连接;如果打开连接失败,回调onClose方法,开发者可以在这里尝试重试策略,或者提示用户连接失败;当收到消息的时候,如果是字符串类型的消息,则回调方法onTextMessage,如果是二进制类型的消息则回调onBinaryMessage方法。

接下来看一下发送Ping消息,直接调用WebSocket对象的sendPing方法,如果发送实际的消息就调用sendTextMessage方法,而在最终关闭连接的时候,调用disconnect方法就可以了。就这么简单,就可以利用autobahn库使用WebSocket完成聊天系统了,当然界面层还需要开发者自己去搭建。

11.6.2 iOS客户端的WebSocket实现

iOS客户端实现WebSocket使用得最多的就是SocketRocket这个第三方库,大家可以在github上下载到这个库,当然也可以直接在代码仓库中拿到源码。把这个库的目录拖到项目中去之后,还需要为项目添加一个libicucore.tbd的库,然后引入SRWebSocket的头文件,代码如下:

#import “SRWebSocket.h”

之后让自己的ViewController实现头文件中的协议SRWebSocketDelegate,这时需要重写以下方法:

– (void)webSocketDidOpen:(SRWebSocket *)webSocket {

}

– (void)webSocket:(SRWebSocket *)webSocket didFailWithError:(NSError *)error {

}

– (void)webSocket:(SRWebSocket *)webSocket didReceiveMessage:(id)message {

}

– (void)webSocket:(SRWebSocket *)webSocket didCloseWithCode:(NSInteger)code

reason:(NSString *)reason wasClean:(BOOL)wasClean {

}

协议里面定义的这四个方法,分别对应前面介绍的四个事件,它们分别在连接成功的时候回调方法webSocketDidOpen,可以提示给用户连接成功以及取消掉Loading框,并同时启动一个定时器,定时给服务器发送ping的消息,以保证自己处理存活状态;连接失败的时候回调方法webSocket:didFaileWithError,开发者可以进行重连策略,如果不进行重连,应该提示给用户连接失败;接受到消息的时候回调方法webSocket:didReceiveMessage。由于可以传递二进制的消息,所以开发者可以判断参数中的message类型,再去做自己的处理;并且可在连接最终关闭的时候回调方法webSocket:didCloseWithCode:reason:wasClean,开发者可以根据关闭原因去尝试重连,并记录错误原因。

而真正的实例化WebSocket对象以及发起连接操作也很简单,代码如下:

NSString *url = “ws://echo.websocket.org”;

SRWebSocket *webSocket = [[SRWebSocket alloc] initWithURLRequest:

[NSURLRequest requestWithURL:[NSURL URLWithString:url]]];

webSocket.delegate = self;

[webSocket open];

如果要发送消息,则调用WebSocket的send方法,最终退出界面的时候可以调用close方法来关闭掉连接。

当然,要想真正运行一个聊天室,还需要有一个服务器的支持,可以使用WebSocket开源网站提供的服务器地址:ws://echo.websocket.org。若要想自己搭建一个WebSocket服务器,可使用Java-WebSocket库写一个JavaSE的程序,用于将发布者的消息转发给所有的订阅者。如果想要真正部署到WebSever上,可以使用Tomcat容器来跑一个JavaEE的Servlet,这个Servlet可以使用javax.websocket包中WebSocket相关的类来构建一个转发程序,从而将发布者的消息转发给所有订阅者。在此就不展示了,读者可以去代码仓库中看到所有的源码。

11.7 本章小结

在实现了上述所有的模块之后,最终构建的直播系统如图11-10所示。

图 11-10

如图11-10所示,首先来看主播端(Anchor),主播要想开播先去请求调度中心的开播接口,调度中心会返回两个地址,一个是流媒体中心的地址,一个是指令控制中心的地址,然后主播会先去拿着流媒体中心地址去连接Live服务器,连接成功之后就可以推流了;接着拿着指令控制中心的地址去连接WebSocket服务器,连接成功之后就可以接受到观众进入房间、聊天、礼物等指令,也可以发送出禁言、踢人等指令。再来看看观众端(Audience),为了加快用户的首屏时间(点击进入某个主播的的房间开始,到看到主播的视频的时间称为首屏时间),在系统中所有展示房间的位置都会冗余这个主播的流媒体地址字段,所以不用请求HttpServer就可以直接观看这个主播的直播,但是为了可以继续和主播发生聊天、送礼物等交互行为,并且验证自己的身份是否合法(可能被主播禁言或者踢掉了),还要请求一下HttpServer拿到指令控制中心地址,然后去连接WebSocket服务器,如果连接成功就可以接收到指令以及发送对应的指令。这样通过这些模块的共同交互就构建出了一个可用的直播系统,但是要想让这个直播系统表现得更好,还有一些细节需要处理,比如在弱网环境下主播端的处理,加快拉流端的首屏时间,两端利用统计数据来帮助我们完善整个系统的迭代更新等,所以在接下来的章节中会讨论一下直播应用中的关键处理。

第8章 音频效果器的介绍与实践

前七章不仅了解了音视频的基础概念,还在Android和iOS平台完成了两个比较完整的应用,一个是视频播放器的应用,一个是视频录制应用,所以可以把前七章称之为基础篇或者说是入门篇。而从现在开始,将进入一个新的篇幅——提高篇,这部分内容旨在为基础篇中完成的两个应用添加一些必要的功能(比如添加音频滤镜、视频滤镜),做一些性能优化(比如硬件解码器的使用),实现一些公共基础库的抽象与构建(音频处理、视频处理的公共库)等。

本章将学习音频处理相关的知识,在第1章已介绍过一些音频背景与相关的基础知识,本章会在此基础上进行更加深入的讲解。此外,一些基本的乐理知识也会在本章中进行介绍与讲解。让我们马上开始吧!

8.1数字音频基础

第1章已经讲解过音频的模拟信号与数字信号的概念,本章所面对的都属于数字音频,所以本节会以更加直观的方式从各个角度来了解一下数字音频。在开始本节之前建议读者先下载一个音频编辑工具(如:Audacity、Audition、Cubase等),Audacity在我们的资源目录中提供了一个Mac版本的安装文件,如果读者使用的是MacOS环境,可以直接安装。由于本章内容中很多操作都基于Audacity工具操作的,所以先简单介绍一下Audacity。Audacity是一个集播放、编辑、转码为一体的一个工具软件,在平常的工作中,它是必不可少的一个工具。在本节开始之前,大家可以在本章资源目录中找出对应的音频文件pass.wav放到Audacity中,完成操作之后,直接映入眼帘的就是下面要讲的第一种表示形式,即波形图的表示。

8.1.1波形图

声音最直接的表示就是波形图,英文叫waveform。横轴是时间,纵轴根据表示的意义不同有多种格式,比如说有用dB表示的、有用相对值表示的等,但是可总体理解为强度的大小。下面先来看一下当笔者读出pass[pɑ:s]这个单词时,所产生的波形图,如图8-1所示。

图 8-1

当横轴的分辨率不够高的时候,波形图看起来就像图8-1一样。如果不是一个单词,而是一段话,其波形图就会有多个这样子的波形连接起来,而所有波形的轮廓可以叫做整个声音在时域上的包络(envelope),包络整体形状描述了声音在整个时间范围内的响度。一般来说,每一个音节对应一个这样的三角形,因为每一个音节通常都会包含一个元音,而元音听起来比辅音更加响亮(如图8-1中的0.05-0.18秒)。但是也有例外,比如:类似/s/的唇齿音持续时间比较长,也会形成一个比较长的三角形(如图8-1中的0.18-0.4秒);类似/p/的爆破音会在瞬时聚集大量能量,在波形上体现为一个脉冲(如图8-1中的0.02-0.05秒)。如果把横轴时间单位的分辨率提高,比如只观察20毫秒的波形,可以看到波形图的更精细的结构,如图8-2所示。

图 8-2

图8-2中的左边图片就是放大了0.08-0.10秒部分的波形图情况,这部分是元音,大家可以注意到这个波形是有周期性的,大约有3个周期多一点(每个周期大约是7ms左右),这也是所有浊音在时域上的特性。相反的,再来看一下8-2中的右边图片,该图放大了波形图中0.2-0.22秒部分的波形,这部分是清音部分,是没有任何周期性可言的,并且频率(过零率,即在横轴精度一致的的情况下的波形的疏密程度)比元音也高很多。

上面所讲的特性都是我们从波形图上可以直观看出来的,可以知道,波形图表示的其实就是随着时间的推移,声音强度变化的曲线,是最直观也最容易理解的一种声音的表示形式,也就是通常称所说的声音的时域表示。看完了声音的时域表示,再从另外一个维度来看一下声音是如何表现的,也就是它的频域表示——声音的频谱(spectrum)图的表示。

8.1.2频谱图

使用图8-1中0.08-0.11秒的这一段声音来做FFT,得到频谱展示图,如图8-3所示。

图 8-3

在解释频谱图之前先理解一下什么是FFT。FFT是离散傅立叶变换的快速算法,可以将一个时域信号变换为频域表示的信号,有些信号在时域上是很难看出什么特征的,但是如果变换到频域之后,就很容易看出特征了,这就是很多信号分析采用FFT变换的原因。所以我们将一小段波形做FFT之后取模,注意这里必须是一小段波形(一般情况下是20-50ms),如果这段波形表示的时间太长其实就没有意义了。对音频信号做FFT的时候,是把虚部设置为0,得到的FFT的结果是对称的,即音频采样频率是44100,那么从0-22050的频率分布和22050-44100的频率分布是一致的。下面基于此来理解一下图8-3,横轴是频率,表示范围就是0-22050,而纵轴表示的就是当前频率点能量的大小,我们直接能看到的就是频域的包络,如果把横轴表示的单位改为指数级(即把分布比较密集的地方使用更加精细的单位来表示),就可以显示出频域上能量分布的精细结构。图8-4表示了频域分布的精细结构。

图 8-4

从图8-4中可以看出,每隔170Hz左右就会出现一个峰,而这恰恰是我们在波形图(8-2左边的图片)中所看到的波形周期(为6ms左右)所对应的频率。从图中也可以看出语音不是一个单独的频率信号,而是由许多频率的信号经过简谐振动叠加而成的。图中的每一个峰叫做共振峰,第一个峰叫基音,其余的峰叫泛音,第一个峰的频率(也是相邻峰的间隔)叫作基频(fundamental frequency),也叫音高(pitch),常记作f0,对于人声来讲,声带发声之后会经过我们的口腔、颅腔等进行反射最终让别人听到,但是这里基频指的就是声带发出的最原始的声音所代表的频率。所以如果声带不发声的声音,比如唇齿音(/z/ /c/ /s/等)一般就无法检测出基频。

继续看图8-4的频谱图,该图有很多峰,每个峰的高度是不一样的,这些峰的高度之比决定了音色(timbre)。不过对于语音的音色来说,一般没有必要精确地描写每个峰的高度,而是用“共振峰”(formant)来描述的。共振峰指的是包络的峰,可以看到,第一个共振峰的的频率就是170Hz,第二个共振峰的频率为340Hz,第三个共振峰大约是510Hz,第四个共振峰是680Hz,第五个共振峰大约是850Hz,第六个共振峰是1020Hz左右,再往后边的共振峰相对于前面的这几个共振峰就弱了很多,所以一般前几个共振峰的形状决定了这个声音的音色。接着再看一下0.2-0.22秒波形的频谱图表示,如图8-5所示。

图 8-5

观察图8-5可以发现,在低频率部分几乎没有峰(1000Hz那里由于能量太小,可以忽略),第一个峰值都出现在5000Hz以上,这种情况下也就无法计算出基频来了,如果对应于人的发声部位,其实就是我们的声带不发声,这一类一般称之为清音。清音通常没有共振峰,也就没有基频,没有音高。

上面的频谱图只能表示一小段声音,而如果我们想观察一整段语音信号的频域特性,应该怎么办呢?这将涉及下一节介绍的语谱图,其实在第3章中讲解ffplay时在显示面板上绘制的就是语谱图。

8.1.3语谱图

我们可以把一整段语音信号截成许多帧,把它们各自的频谱“竖”起来(即用纵轴表示频率),用颜色的深浅来代替当前频率下的能量强度,再把所有帧的频谱横向并排起来(即用横轴表示时间),就得到了语谱图,它可以称为声音的时频域表示。语谱图读者可以理解为一个三维的概念,如果称横轴为X轴,那么表示的是时间;纵轴为Y轴,表示的是频率;还有一个Z轴,表示就是当前时间点,当前频率所代表的能量值(能量值越大,颜色越深)。使用Audacity软件打开pass.wav之后,在这一轨声音的左侧选择频谱图(在Audacity中语谱图称之为频谱图)的视图模式,来看一下这段声音的语谱图,如图8-6所示。

图 8-6

在图8-6中,横轴是时间,纵轴是频率,颜色越深的地方其实代表声音的能量越大。所以对应着图8-1的波形图可以看到,0.0-0.05秒是在/p/这个爆破音的时候,其频率基本上都在1000Hz以下;而到了0.05-0.15秒,元音/ɑ:/的频率就非常明显,并且颜色已经非常非常深,是可以计算出基频来的;再随着时间的推移到了0.2秒以后的/s/,所有的频率基本上都到了5000Hz以上了,这一段声音是无法再进行计算基频的,属于清音部分。语谱图的好处是可以直观地看出共振峰频率的变化。

对于清音和浊音这里也介绍一下,因为这对于后续在基频检测以及针对频域数据做处理的时候会有很大帮助。语音学中,将发音时声带振动的音称为浊音,声带不振动音称为清音。辅音有清有浊,也就是大家常说的清辅音、浊辅音,而多数语言中,元音皆为浊音,鼻音、半元音也是浊音。我们可以尝试这发出/a/这个音,同时用手触摸喉部,此时,手是可以感觉出喉咙的振动的,而在我们发b/p/、d/t/、g/k/等音的时候喉咙是不振动的,这一些音都是清辅音,还有一种是鼻音,比如/m/、/n/、/l/等都是浊辅音。清音是无法检测出基频也就无从知道它所代表的音高,浊音一般都是可以检测出基频来的,所以也可以计算出它表示的音高。

8.1.4深入理解时域与频域

根据之前的介绍,想必读者已经比较清楚声音在时域和频域上的表示了,但是有的读者可能还是不太清楚到底声音的波形是如何产生的,又是如何跟频域联系起来的。先来生成一段单一频率的声音,然后在进行逐步叠加不同频率的声音,以此作为我们的声源,从而逐步分析快速傅里叶变换(FFT)能为我们做一些什么。

首先写一个函数来生成频率为440Hz,单声道,采样频率为44100Hz(注意采样频率代表的波形的平滑程度),时长为5s的声音,代码如下:

double sample_rate = 44100.0;

double duration = 5.0;

int nb_samples = sample_rate * duration;

short* samples = new short[nb_samples];

double tincr = 2 * M_PI * 440.0 / sample_rate;

double angle = 0;

short* tempSamples = samples;

for (int i = 0; i < nb_samples; i++) {

float amplitude = sin(angle);

*tempSamples = (int)(amplitude * 32767);

tempSamples += 1;

angle += tincr;

}

//Write To PCM File

delete[] samples;

代码中我们生成的就是一个相位为零的正弦波,使用Audacity软件将生成的PCM文件以裸数据(raw data)的方式导入进来,放大横轴的刻度可以看到波形图,如图8-7所示。

图 8-7

从图8-7中可以看出,时间为0的地方正处于正弦波幅度为0的地方,所以相位为0,并且还可以看出,一个周期大约是2.27ms,其实恰好代表了生成的这段声音的频率是440Hz,有的读者可能会问,那采样率在波形图中又代表着什么呢?其实采样率在波形图中代表着整个波形的平滑程度,采样点越多波形就会越平滑。紧接着选中20ms的音频来做傅里叶变换看一下得到的频谱图,如图8-8所示。

图 8-8

在这个频谱图中可以看到,波峰就是440Hz,可见傅里叶变化之后我们得到了这个波形在频域上的表示,并且是正确的,但是我们所听到的声音永远不会只是一个单调的正弦波,而是有很多波叠加而成的,所以稍微改动生成波形的代码来生成一个更加复杂的声音,仅需要修改生成幅度的那一行代码,如下:

float amplitude = (sin(angle) + sin(angle * 2 + M_PI / 3) +

sin(angle * 3 + M_PI / 2) + sin(angle * 4 + M_PI / 4))

* 0.25;

代码中使用了四个正弦波叠加,并且每个正弦波都有自己的相位,相位是随机给的,至于后面乘以0.25,是因为我们后续要将这个值在转换为SInt16表示的值,所以将其转换为-1到+1的范围之内。使用Audacity软件打开生成的这个PCM文件,将横轴的刻度拉大,波形图如图8-9所示。

图 8-9

可以看到,这个波形图就没有一个单一的正弦波(图8-7)看起来那么规范了,显然这是有很多个波叠加而成的,并且不同的波还有自己的相位,因为在时间为0的时候能量不是从0开始的,但是观察这个波形图,还是可以看出它具有周期特性的,每一个周期大约是2.25ms左右,其实根据代码我们也可以看出,最主要的频率还是440Hz所产生的正弦波的频率,所以我们选中20ms的波形图来观察一下它的频谱图,如图8-10所示。

图 8-10

在图8-10中,第一个波峰在440Hz,第二个波峰在880Hz,第三个波峰在1320Hz处,第四个波峰在1760Hz处,其实这个频谱图就非常类似于我们人所发出的非清音的频谱图,这个频谱图的基频就是440Hz。

好了,看了这么多波形图和频谱图的对比,想必读者已经比较熟悉声音在时域上的表示,以及时域和频域的转换了,在接下来的小节中,笔者会带着大家将声音的时域信号转换为频域信号,然后提取特征甚至做一些操作,让我们马上开始吧!

8.2 数字音频处理

本节讨论音频的处理,根据8.1节的介绍可知,其实声音主要的表示形式就是时域和频域的表示,而音频处理就是针对于声音分别在时域和频域上的处理,本节会详细介绍如何从时域和频域方面对于音频做一些处理。对于时域方面的处理,比较简单,不需要额外进行转换的操作,因为一般情况下拿到的音频数据就是时域表示的音频数据。但是要对音频的频域方面做处理的话,那么就得将拿到的音频数据先转换为频域上的信号,然后再进行处理了。那么如何转换为频域上的信号呢?在8.1节中曾提到过,使用FFT,即使用傅里叶变换,所以下面首先来学习傅里叶变换。

8.2.1 快速傅里叶变换

离散傅里叶变换简称是DFT,由于计算速度太慢,所以就演变出了快速傅里叶变换即我们常说的FFT,在处理音频的过程中常使用MayerFFT这个实现。在本节中不会讨论傅里叶变换的原理以及公式推导,而是讲解FFT的物理意义以及如何使用FFT将时域信号变为频域信号,以及如何利用逆FFT将频域信号重新变换为时域信号,同时在iOS平台会使用vDSP来提升效率,在Android平台的armv7的CPU架构以上,会使用neon指令集加速来提升性能,这样的安排相信会使得读者更加深入地了解FFT,并且还可以迅速地将优化应用于自己的日常工作中。

1. FFT的物理意义

FFT是离散傅立叶变换的快速算法,可以将一个时域信号变换到频域。有些信号在时域上是很难看出什么特征的,但是如果变换到频域之后,就很容易看出特征了。这就是很多信号分析(声音只是众多信号的一种)采用FFT变换的原因。虽然很多人都知道FFT是什么,可以用来做什么,怎么去做,但是却不知道做FFT变换之后结果的意义,本节就来和大家一块分析一下FFT的物理意义。

声音的时域信号可以直接用于FFT变换,假如N个采样点经过FFT之后,就可以得到N个点的FFT结果,为了方便进行FFT运算,通常N取值为2的整数次幂,比如:512、1024、2048等。根据采样定理,采样频率要大于信号频率的两倍,所以假设采样频率为Fs,信号频率为F,采样点数为N,那么FFT之后的结果就是N个点的复数,每一个复数分为实部a和虚部b,表示为:

z = a + b * i

每个点对应着一个频率点,而这个点的复数的模值就是这个频率点的幅度值,可以计算为:

amplitude = sqrt(a * a + b * b);

而每个复数都会有一个相位,其实在物理意义上代表的就是这个周期的波形的起始相位是多少,相位的计算如下:

phase = atan2(b, a);

由于输入是声音信号,声音信号在时域上表示为一个一个独立的采样点,因此在要做FFT变换之前,需要先将其变换为一个复数,即将时域上的某一个点的值作为实部,虚部统一设置为0,由于所有输入的虚部都是0,从而导致FFT的结果就是对称的即前面半部分和后边半部分的结果是一致的。所以在对声音信号做FFT之后,只需要使用前半部分就可以了,后半部分的其实是对称的,不需要使用。那FFT得到的结果与真实的频率有什么关系呢?

还是用8.1.4节中的生成音频文件的代码来说明,利用以下公式来生成一段采样率为44100Hz的音频文件,如下:

float amplitude = (sin(angle) + sin(angle * 2 + M_PI / 3) +

sin(angle * 3 + M_PI / 2) + sin(angle * 4 + M_PI / 4))

* 0.25;

拿到这个音频文件后,先去做一个FFT,具体如何操作,这里先不讨论,先把FFT的计算当做一个黑盒子,给它输入音频的时域信号,得到的就是频域信号,我们来理解一下它的物理意义。由于这个音频文件的采样率为44100,做FFT的窗口大小是8192,那么生成的FFT的结果,第一个点的频率就是0Hz,而最后一个点的频率就是44100Hz,而一共是8192个点,所以相邻两点之间表示的频率差值就是:

44100 / 8192 = 5.3833Hz

这就是我们通常所说的,使用8192作为窗口大小来给采样频率44100Hz的声音样本做傅里叶变换,得到的结果分辨率是5.3833Hz。接下来,我们在FFT的结果数组中找出第一个峰值(即第一个最大的值),可以发现是Index位置为82的元素,我们可以计算出来它代表的频率是:

5.3833 * 82 = 441.43Hz

由于声音源是由4个波叠加而成的,因此找到的第一个峰值则是频率最低的峰值,其实也是440Hz所代表的峰值,那为什么我们得到的结果却是441.43Hz呢?这就是前面所说的分辨率问题了,如果我们要想准确地算出440Hz,那就需要增加窗口大小以提高频带分布的分辨率,才能使计算出来的频率更加准确。接着来看第二个峰值,它是在Index为163的位置,计算它代表的频率是:

5.3833 * 163 = 877.478Hz

得到了第二个波峰的频率信息,接着可以计算出第三个波的频率为5.3833 * 245 = 1319Hz,第四个波的频率为5.3833 * 327 = 1760.3Hz,这和在前面图8-10看到的波峰分布情况是一致的。每个点的峰值以及相位的计算也可用上述公式计算出来,这里不再赘述。其实FFT就是把多个波叠加后的时域信号,可以按照频率将各个波拆开,进行更加清晰的展示。下面的小节会更加详细地讲解如何做FFT,以及如何在移动平台上进行优化。

2. MayerFFT的使用

在C++语言中,进行FFT变换时,最常使用的就是MayerFFT的实现,本节就来看一下如何使用MayerFFT将音频文件做一个FFT转换。

首先,下载一个MayerFFT的实现,它的实现虽然比较复杂(我们不做讨论),但是已经比较好地封装在一个类中,这个类也可以在代码仓库中的本章代码部分找到,下面写一个类文件FFTRoutine将具体的实现封装起来,然后提供接口给外界调用。

首先来看一下构造函数和析构函数:

FFTRoutine(int nfft);

~FFTRoutine();

可以看到构造函数中有一个参数nfft,这个参数代表FFT运算钟一个窗口的大小,这也是做FFT最基本的设置,为避免频繁的内存开辟和释放操作,在构造函数的实现中,要开辟一个nfft大小的浮点类型的数组,以供做FFT运算的时候使用,在析构函数中要销毁这个浮点类型数组。接下来,看一下从时域信号到频域信号的正向FFT变换的接口,代码如下:

void fft_forward(float* input, float* output_re, float* output_im);

从接口中也可以看出,第一个参数是输入的时域信号(浮点类型表示),第二个参数和第三个参数分别代表了转换为频域信号之后实部和虚部的两个数组,这个函数的具体实现如下。

首先要将输入数据复制到在构造函数中开辟的数组中,然后调用MayerFFT进行FFT变换:

memcpy(m_fft_data, input, sizeof(float) * nfft);

MayerFFT::mayer_realfft(nfft, n_fft_data);

待MayerFFT做完时域到频域转换之后,实部和虚部的数据也已经存放到n_fft_data中去了,只不过是实部和虚部是对称存储的,我们需要按照顺序取出来,但是首先要将直流分量(第一个元素)的虚部置为0,代码如下:

output_im[0] = 0;

for(int i = 0; i < nfft / 2; i++) {

output_re[i] = n_fft_data[i];

output_im[i + 1] = n_fft_data[nfft-1-i];

}

这样就可以利用开源的MayerFFT做了声音信号的时域到频域的转换。接下来,再做一个逆FFT操作,即把频域数据变换为时域数据,接口如下:

void fft_inverse(float* input_re, float* input_im, float* output)

从接口中可以看出来,输入的是频域信号,分为实部和虚部,输出是时域信号即一个浮点型的数组。来看一下具体的实现,先将输入的实部和虚部按照MayerFFT中存储复数的存储格式还原回去,代码如下:

int hnfft = nfft/2;

for (int ti=0; ti<hnfft; ti++) {

m_fft_data[ti] = input_re[ti];

m_fft_data[nfft-1-ti] = input_im[ti+1];

}

m_fft_data[hnfft] = input_re[hnfft];

然后调用MayerFFT进行逆FFT运算,运算之后的结果还是存储到m_fft_data这个浮点数组中,而此时这个浮点数组中存储的就是时域信号了,最终,把时域信号拷贝到输出参数中,代码如下:

MayerFft::mayer_realifft(nfft, m_fft_data);

memcpy(output, nfft_data, sizeof(float) * nfft);

这样就可以将频域信号又转换回时域信号了,这种逆FFT运算会在一些音频处理中有特殊的作用,比如变调效果器(PitchShift)中就会用到这种操作。MayerFFT的运算都是在CPU上进行计算的,跨平台特性可以做的比较好(因为只有一个cpp的实现文件),但是性能是它的瓶颈,而在移动平台上最需要注意的就是性能问题,所以在下面会给出在各个平台上的优化处理。

3. iOS平台的vDSP加速

前面已经讲解如何使用工具类MayerFFT来将时域信号的音频转换为频域的表示。而在移动平台上我们最关心的就是效率,所以下面要针对于移动平台上给出相应的实现优化。在iOS平台上可使用iOS提供给开发者的vDSP来做FFT的优化操作。苹果为开发者提供的vDSP无论在iOS上还是在MacOS上都可以使用,当然,在使用之前,必须要将Accelerate这个framework引入到我们的项目中,具体做法就是在工程文件的Build Phases里面的Link Binary With Libraries中添加Accelerate.framework这个库,然后再要使用到vDSP的类中用以下代码引入头文件:

#include <Accelerate/Accelerate.h>

现在就可以使用vDSP来加速运算了,vDSP中提供了很多函数来完成DSP运算,本节只介绍FFT的运算以及与FFT运算相关的函数。使用FFT需要先构造出一个指针类型的OpaqueFFTSetup的结构体,需要调用函数如下:

OpaqueFFTSetup *fftsetup;

int m_LOG_N = log2(nfft);

fftsetup = vDSP_create_fftsetup(m_LOG_N,kFFTRadix2);

第一个参数是做FFT转换时,使用的窗口大小(为取log2之后的数值),在vDSP中,FFT的窗口一般是2的N次方,所以这里传入以2为底取对数的数值;第二个参数一般传递iOS提供的枚举类型kFFTRadix2,这样就构造出了fftSetup这个结构体类型。然后分配一个DSPSplitComplex的复数类型作为FFT的结果输出,代码如下:

size_t halfSize = nfft / 2;

splitComplex.realp = new float[halfSize + 1];

memset(splitComplex.realp, 0, sizeof(float) * (halfSize + 1));

splitComplex.imagp = new float[halfSize + 1];

memset(splitComplex.imagp, 0, sizeof(float) * (halfSize + 1));

如上述代码所示,由于声音做FFT的结果是对称的,因此只需要去取半部分的数据,那么,对于复数的实部和虚部分配空间时,也只需要分配一半的大小就可以了。结构体中的realp代表了实部的部分,imagp代表了虚部的部分,当我们分配好这个结构体之后,就可以进行FFT运算了。首先,把要做FFT的时域信号放入上面构造好的复数的结构体中:

vDSP_ctoz((DSPComplex*)input, 2, &splitComplex, 1, halfSize);

输入的时域信号是float类型的数值,首先强制类型转换为复数类型,然后利用vDSP_ctoz这个函数,将复数的实部和虚部分开存储,即原始的复数结构体是交错(interleaved)存放的,而转换之后就是平铺(Plannar)存放的。将转换之后的结构体作为FFT运算的输入:

vDSP_fft_zrip(fftsetup, &splitComplex, 1, m_LOG_N, kFFTDirection_Forward);

如上述代码,第一个参数是最开始构造的OpaqueFFTSetup指针类型的结构体,第二个参数是时域信号填充的复数结构体,后续的参数指定了行距和大小,最后一个参数代表做正向的FFT,FFT的结果还是会放入到这个复数结构体中,我们可以转换为自己的float指针类型的输出,代码如下。

for (int i = 0; i < halfSize; i++) {

outputRe[i] = splitComplex.realp[i];

outputIm[i] = splitComplex.imagp[i];

}

待使用完FFT之后,要将分配的OpaqueFFTSetup指针类型的结构体销毁掉,并且还要将分配的复数结构体内部的实部和虚部部分的数组销毁掉,代码如下:

if (fftsetup) {

vDSP_destroy_fftsetup(fftsetup);

fftsetup = NULL;

}

if (splitComplex.realp) {

delete[] splitComplex.realp;

}

if (splitComplex.imagp) {

delete[] splitComplex.imagp;

splitComplex.imagp = NULL;

}

这样就销毁完毕了,当然也可以利用vDSP来做逆FFT(IFFT)的运算,从名字上来看,就知道这是FFT运算的一个逆过程(从频域信号转换为时域信号),上面在讲解做FFT运算的时候,提到过最后一个参数代表了做正向的FFT,如果使用kFFTDirection_Inverse则代表要做逆向的FFT。一个完整的做逆FFT的代码如下:

void fft_inverse(float* input_re, float* input_im, float* output) {

DSPSplitComplex tsc;

tsc.realp = input_re;

tsc.imagp = input_im;

vDSP_fft_zrip(fftsetup, &tsc, 1, m_LOG_N, kFFTDirection_Inverse);

vDSP_ztoc(&tsc, 1, (DSPComplex*)output, 2, halfSize);

float scale = 1.0 / m_nfft;

vDSP_vsmul(output, 1, &scale, output, 1, m_nfft);

}

上述代码中首先将实部和虚部放到复数的结构体中,然后调用FFT运算函数,注意,此时最后一个参数传递代表要做逆FFT运算。接着调用vDSP_ztoc函数将平铺(Plannar)分布的复数转换为交错(interleaved)分布的output中,最终逆FFT的结果需要除以窗口大小才可以还原成原来的时域信号,其中在除以窗口大小时,使用了vDSP提供的vDSP_vsmul函数来实现性能的提升。

4. Android平台的Ne10加速

在Android平台上,我们能做的优化就是使用Neon指令集来加速运算,Neon指令集其实也是一种单指令多数据的计算模式,而开发者直接使用Neon指令集来实现运算的加速以及实现FFT的话成本太高了,一则是FFT的实现过于复杂,再一个来说测试成本也比较繁琐,所以下面使用开源的Ne10这个库来实现Android平台的性能提升。

Ne10这个库的介绍与安装可以参见本书的附录部分,而本节所介绍的仅仅是如何使用Ne10这个库来实现FFT与逆FFT的运算。首先引入Ne10的头文件:

#include “NE10.h”

然后构造一个FFT运算的配置结构体,注意,这个配置项结构体我们选用的是实数到复数(r2c)的配置项,因为这种FFT配置项在做音频的FFT运算的时候更加适合,代码如下:

ne10_fft_r2c_cfg_float32_t cfg;

cfg = ne10_fft_alloc_r2c_float32(nfft);

构造这个结构体需要传入的参数是使用FFT运算时的窗口大小,由于我们使用的是Ne10这个库,所以在进行FFT运算时需要将声音时域信号构造成Ne10需要的结构体来进行输入,由于这里输入的是float类型的数组,因此Ne10中定义的也是float类型的数据,代码如下:

ne10_float32_t* in;

in = (ne10_float32_t*) NE10_MALLOC (nfft * sizeof (ne10_float32_t));

为了获得FFT运算之后的结果,也需要构造出输出结构体来接受FFT的运算结果,因为FFT的输出是复数的结构,分为实部和虚部,所以在Ne10中也采用了复数的结构,不过它是有单独的结构体来表示的,代码如下:

ne10_fft_cpx_float32_t* out;

out = (ne10_fft_cpx_float32_t*) NE10_MALLOC (nfft * sizeof

(ne10_fft_cpx_float32_t));

准备好以上内容后,就可以做真正的FFT运算了,将输入的音频时域信号的float数组拷贝到上述定义的输入结构体in中,然后调用Ne10这个库提供的FFT运算函数进行FFT运算。注意,在这里我们使用的运算函数是实数到复数的FFT运算,这种FFT运算更适合在音频场景下做FFT运算,最终得到的结果会放到上述分配的结构体out中,代码如下:

memcpy(in, input, sizeof(float) * m_nfft);

ne10_fft_r2c_1d_float32_neon(out, in, cfg);

for (int i = 0; i < m_nfft / 2; i++) {

output_re[i] = out[i].r;

output_im[i] = out[i].i;

}

经过FFT运算之后的结果就存在于结构体out中,取出前半部分数据赋值给输出的实部的float数组和虚部的float数组中。最终在做完所有的FFT运算之后,需要释放掉分配的资源,包括FFT配置项、输入结构体、输出结构体,代码如下:

NE10_FREE(in);

NE10_FREE(out);

NE10_FREE(cfg);

其实类似于iOS平台的vDSP的优化方案,Ne10提供的FFT方案肯定也提供了逆FFT的运算操作,代码如下:

for (int i = 0; i < m_nfft / 2; i++) {

out[i].r = input_re[i];

out[i].i = input_im[i];

}

ne10_fft_c2r_1d_float32_neon(in, out, cfg);

memcpy(output, in, sizeof(float) * m_nfft);

可以看到,调用逆FFT运算时,调用的是c2r的FFT函数,即使用从复数到实数的转换函数来完成逆FFT的运算,最终再把in这个结构体中的实数拷贝到整个函数的output即时域信号的float数组中去。

8.3 基本乐理知识

本节来学习一下基本的乐理知识,为什么要学习乐理知识呢?因为在处理声音的时候,有一部分是与伴奏或者背景音乐有关的,或者说与唱歌或听歌处理相关的,这就需要我们要掌握一些基本的乐理知识来解决了。

8.3.1乐谱

为了能记录之前发生的事情,人们撰写出了历史,而不是口口相传,导致后世不知前世之事。而类似的问题发生在各个领域,比如医学、数学、化学、物理等各个领域。同样在音乐界也不例外,人们为了能使美好的乐曲保留下来,并且便于学习和交流,创造出了各种各样的记谱方法,而这一些记谱方法就是我们要说的乐谱。而记谱的方法也有很多种,像壁画一样,世界各地的人都会创造音乐,也都会有自己的记谱方法,比如在中国古代广为流传的《工尺谱》。但是现在仍然被我们广为应用并且熟悉的有两种记谱方法,其中一种就是用阿拉伯数字表示的《简谱》,还有就是国际上流行通用的《五线谱》。

下面来看一下《我是一个粉刷匠》这首歌曲的第一句,使用简谱记谱方法和五线谱的记谱方法有何区别,简谱记谱方法如图8-11所示。

图8-11

在图8-11中,左上角用于指明节拍信息和谱号,具体节拍和谱号的意义先简单了解一下:谱号G指明是高音谱号;2/4代表是拍号,每一拍(代表时间长度)是四分音符的时值,每一小节有两拍(代表节奏信息)。乐曲的第一行指明了具体的音符排练顺序以及节奏信息,5 3代表的是Sol Mi两个连起来算作一拍,而小节之间是使用 | 进行分割的,第二个小节的第二拍do自己占用了这一拍的时值,所以前两个小节连接起来就是sol mi sol mi sol mi do,大家自己可以体会一下。这是一首儿歌,音符都在一个音组之内(一个八度之内),而有一些歌曲可能要跨越多个音组,对于低音就在数字下面加点来表示,高音则在数字上面加点来表示,加的点越多则代表越低或越高。接下来,看一下五线谱的记谱方法,如图8-12所示。

图8-12

图8-12中其实同时具有简谱和五线谱的表示方法,这里看五线谱部分,第一个∮代表了是高音谱号,2/4代表拍号,表示每一拍都是四分音符,每一小节有两拍。剩下的就是谱表部分,在制作五线谱,首先得画出五根线,而五根线画出来之后中间就形成四个空行,这在五线谱中称之为间,所以五线谱由五条平行的“线”和四条平行的“间”组成。而线和间的命名也比较简单,从下向上依次是第一线、第一间、第二线、第二间、第三线、第三间、第四线、第四间、第五线。而我们的音符就画在这一些线和间上,具体画在哪一个线还是哪一个间上所表达的音高必定是不一样的,而这九个音高必定不能满足我们想表达的音符,这应该如何办呢?五线谱的上边和下边都可以再加线和间,向上即所谓的上加一间,上加一线,直至上加五线,向下即所谓的下加一间,下加一线,直至下加五线。但是即使是这样,也才能够表示29个音符(本来可以表示9个音符,上边可以加五间五线,下边可以加五间五线),能表示的音符还是不够多,所以就有了谱号这个东西,即在五线谱的最开始都要标记到底是高音谱号还是低音谱号或者中音谱号,用的最多的就是高音谱号和低音谱号,高音谱号如图8-13所示。

图8-13

高音谱号也称为G谱号,对于高音谱号,下加一线是do(是中央C的do),三间就是高八度的do,上加两线是再高一个八度的do,对于度数后面也会详细讲到,大家可以理解为更高的一组音。低音谱号如图8-14所示。

图8-14

低音谱号也称为F谱号,对于低音谱号上加一线是do(是中央C的do),二间是低八度的do,下加二线是再低八度的do。如图8-15所示,图中将简谱、五线谱、唱名与钢琴键盘画在了一个图中,大家可以对照着看一下,以便加深理解。

图8-15

可见不同谱号也就代表了在五线谱中每个线或者每个间所表达的音高是不一样的。五线谱之所以是世界范围内通用的记谱方法,就是因为它是最科学也是最容易理解的记谱方法,大家可以看从下到上就是音高在不断增长,一目了然。

五线谱由三部分组成,分别是谱号、谱表和音符,其中前两部分在不知不觉中已经介绍完了,剩下的就是音符了,音符其实比较复杂,因为它涉及比较多的概念,所以在下面的部分,分别从音符的音高和时值两个方面来进行介绍。

8.3.2音符的音高与十二平均律

如何描述一个乐谱呢?常用的有五线谱、简谱等。而无论是五线谱,还是简谱,都是在表示音符的音高和音符的时值。当然时值是由节拍信息定义的,而在本节讨论的是音符的音高部分。对于音符也有两种表述方式,第一种就是大家经常唱的do re mi fa sol la si,即唱名,也是大家最常使用的;除此之外还有另外一种表示方式,就是音名,即C D E F G A B,这种标记方式叫做音名。

由于基本乐理的概念比较多,所以我们逐一来理清楚,读者可以看到图8-16中所示的键盘图片,可以看到一个中央C的白键,音名记为c1,我们从c1向右数,一直数到c2,这一串连续的音称之为一个音阶,同时c2这个白键所发出声音的频率恰好是c1这个白键发出声音频率的2倍。一个音阶同时也称为一个音组,在键盘上中央C所在的这一个音组称为小字一组,向右数下去的每个组分别是c2所在的小字二组,c3所在音组是小字三组,c4所在的音组是小字四组。那么,向相反的方向数的话,即c所在的音组是小字组,C所在的音组是大字组,C1所在的音组是大字一组,可这么多组如何记忆呢?其实很简单,大家只要记住音名就可以了,首先找到比中央C低八度的音名为c的这一组,所有的音名都是小写字母表示,所以称之为小字组,从这一音组向右数每增加一个八度音名都会在小写字母后边加1,同时音组就成为小字几组(比如中央C所在的音组成为小字一组),然后再来看比中央C低2个八度的音名是C的这一组,所有的音名都是大写字母表示,所以称之为大字组,从这一组向左数,每低一个八度就音名就在大写字母后边加1,而所在的音组就是大字几组,这样应该就能比较简单地记住音名及所有的音组了。

在这里,不得不在引入一个音程的概念,所谓音程是指两个音符之间的音高关系,一般用度来表示,还是对照着图8-16中的钢琴键盘来看,从c1到c1称之为一度,从c1到c2称之为两度,那么以此类推,从c1到c2之间我们常说差了八度,所谓的八度实际上指的就是音程之间的关系。

图8-16

可以数一下,所有的键(包括黑键和白键)加起来恰好是十二个,而相邻键之间称之为一个半音,即e1和f1之间是一个半音,而c1和d1之间是两个半音即是一个全音,而从c1到c2有12个半音,这也就引出了即将和大家介绍的概念——十二平均律,十二平均律只是一个比较好入门的一个理论,以下是十二平均律的定义:

十二平均律,亦称“十二等程律”,世界上通用的把一组音分成十二个半音音程的律制,各相邻两律之间的振动数之比完全相等。

钢琴就是十二平均律制的乐器,国际标准音规定,钢琴的a1(小字组的A音,其实就是中央C这个音节的A音)的频率是440Hz,并且规定每相邻半音的频率比值为2^(1/12)≈1.059463。根据这两个规定就可以得出钢琴上每一个琴键音的频率,比如a1的左边的黑键升g1的频率就是:

440 / 1.059463 = 415.305Hz

a1右边的黑键升a1的频率就是:

440 * 1.059463 = 466.16372Hz

而依照这种运算,计算出来a的频率是220Hz,a2的频率是880Hz,恰好差了12个半音频率是一倍的关系。而这种定音方式就是“十二平均律”。为什么钢琴称为乐器之王,是因为钢琴的音域范围从A2(27.5Hz)至c5(4186Hz),几乎囊括了乐音体系中的全部乐音。

而有的读者文言功底比较深厚,可能知道中国传统五声音阶是:

宫gōng、商shāng、角jué、徵zhǐ、羽yǔ

这是我国五声音阶中五个不同音的名称,对应于唱名的话,宫等于Do、商等于Re、角等于Mi、徵等于Sol、羽等于La。那比现代乐谱中少了Fa和Xi这两个音,其实在我们的古音阶中有变宫与变徵分别对应于Fa和Xi。关于中国的五声音阶最早的记载出现在春秋时期,可见其实音乐是不分国界不分时间的,每个国家都有自己的乐律,而中国音乐史上著名的“三分损益法”就是古代发明制定音律时所用的生律法。对应于中国民族的传统乐器中的古筝,其实只有这五个音作为一个音阶,另外大家非常熟悉的沧海一声笑,其实也是只有这五个音以及相差八度的五个音共同组成的歌曲,并且大家听到的中国风的歌曲大都是采用了宫调式的主旋律,比如流行歌曲中的东风破、青花瓷、烟花易冷、红尘客栈、庐州月等。

8.3.3 音符的时值

所谓音符的时值就是表示这个音符所持续的时间,平时大家衡量时间是有单位的,而音符是如何体现自己的单位的呢?其实就是靠长得不一样。音符一般由三部分组成,分别是符头、符干、符尾,让我们拿一个简单的音符来看,如图8-17所示。

图8-17

大家应该很熟悉这个音符,因为在很多地方都以此音乐符号来代表音乐,那这个音符是什么音符呢?这个音符其实是一个八分音符,具体音符一共有多少种表示呢?我们来看图8-18所示。

图8-18

在图8-18中,一拍的单位一般是一个四分音符,用一个实心符头和一个符干表示,为什么符干有的向上画,有的向下画的?其实主要就是为了在五线谱中更加容易被识谱者观察。在五线谱中规定符头要左低右高呈椭圆形,在五线谱三线以上符干要向下画并且在符头的左边,在三线以下的符干要向上画并且在符头的右边,符杆的长度一般以一个八度为单位。

大家看了之后,可能觉得图8-12所展示的粉刷匠的五线谱中的音符并不存在于这个表中,不要着急,其实在五线谱中还有一种画法叫共用符尾,像粉刷匠中的第一个小节就是有4个八分音符共用一个符尾,这中共用符尾的记谱方法更便于识谱。

另外还有一些休止符、变化音等比较特殊的音符标记方法,在这里不在一一讨论,其实我们也只是想通过学习五线谱来学习基本的乐理知识,所以很细节的东西我们就不再讨论,毕竟我们也是希望掌握基本的原理以助于在日常工作中更好地实现App上的功能,如果读者有兴趣,可以买一本基本乐理的书去深入学习一下。

8.3.4 节拍

节拍是指强拍和弱拍的组合规律,有很多有强有弱的音,在长度时间内,按照一定的顺序反复出现,形成有规律的强弱变化,使得整个乐谱更有节奏感。根据强、弱的不同组合可以形成各种情绪,各种不同风格的乐曲来,因此节拍非常重要,它等于是音乐大厦的基石,且必须是有规律、有秩序的。

在前面小节讲解过乐谱上的音符除了记录音符的音高,还要记录音符的时值,而所谓时值的表示,就是通过节拍来表示的。在介绍节拍之前,先来介绍一下拍号。

拍号是乐谱小节的书写标准,在乐谱中是以一个分数的形式来表示的。比如4/4,分母的4表示的是以一个四分音符为一拍,分子的4表示的是每小节有四拍。其实拍号是一个相对的时间单位,它只能表示出每一个小节里面有几个拍子以及每个拍子的时值,但是具体占多长时间该怎么表示呢?这又要引入另外一个概念,即BPM。

BPM = Beat Per Minute,每分钟节拍数的单位。最浅显的理解就是在一分钟的时间之内,声音节拍的数量(相当于拿一个节拍器在一分钟之内发出节拍的数量),这个数量的单位便是BPM,也叫做拍子数。BPM就是每分钟的节拍数,是全曲速度标记,是独立在曲谱外的速度标准,一般以一个四分音符为一拍,60BPM为一分钟演奏均匀60个四分音符(或等效的音符组合)。由于60BPM对应的曲目速度为一分钟均匀演奏60个四分音符(或等效音符组合),所以一个四分音符(或等效音符组合)的时值应为1秒,而对应的提供给演奏者显示的演奏速度。一般情况下,歌曲分为慢速(节奏)歌曲、中速歌曲、快速(节奏)歌曲,对应于节拍的话,慢速每分钟40—69(60左右)拍;中速90拍左右;快速108—208(120左右)拍。

8.3.4 MIDI格式

MIDI(Musical Instrument Digital Interface)乐器数字接口,是20世纪80年代初为解决电声乐器之间的通信问题而提出的。MIDI是编曲界最广泛的音乐标准格式,可称为“计算机能理解的乐谱”。它用音符的数字控制信号来记录音乐,一首完整的MIDI音乐只有几十KB大,而且能包含数十条音乐轨道。MIDI里面存储的不是声音,而是音符、控制参数等指令,而能解析MIDI的设备会根据MIDI文件里面的指令来播放。具体音符在MIDI中是如何表示的呢?还是使用一张键盘图来看一下音符对应的MIDI的名字以及MIDI值,如图8-19所示。

对于键盘图大家应该会比较熟悉了,我们借助于键盘图来了解MIDI是再合适不过的了。可以看到中央C的频率是261.63Hz,而所对应的MIDI名称是C4,对应的MIDI值是60。对于MIDI值,以及MIDI的名称该如何记忆呢?其实也很简单,前面在讲音组时,已经知道键盘最低的音组是大字二组(当然大字二组只有A和B两个音),大字二组的第一个音A就是MIDI的A0,第二个音B就是MIDI的B0,在图8-19中,向下数就可以数出所有的MIDI名称来了。而对于MIDI值的话,我们只要记住中央C(c1)的MIDI值是60或者小字一组的A音(a1)是69,然后根据十二平均律就可以全部都计算出来了。

图8-19

具体的MIDI制作就不在这里详细展开了,可以使用某一些电子钢琴,甚至现在有一些App都可以将乐者弹奏的音符(包括音高和时值)记录下来。一般由作曲人或者音乐编辑将制作出一首伴奏的MIDI,然后会将这个MIDI文件加密制作成私有的格式,等客户端下载了这个MIDI文件之后,在进行解码,最终能解析出对应时间上的音符。那我们解析出了对应的时间上的音符能做什么呢?其实有很多种用处,如果我们知道一首歌曲对应的MIDI信息,在K歌应用中就可以给用户做打分,即评测用户唱的音高和MIDI中的音高是不是匹配,从而作为打分的依据;也可以做一些节奏修正和音高修正,因为MIDI中包含了时间信息和音高信息,我们可以对用户唱的歌曲进行对齐节奏和修正音高等操作。所以了解MIDI格式是非常重要的,尤其是对于音乐属性的App来讲,大家可以基于MIDI能做很多事情。

至此,基本乐理也接近尾声了,毕竟我们不可能通过一节的内容将别人一本书的内容全部都讲解出来,所以笔者就根据自己的理解与工作中需要用到的知识点和大家一块进行了讨论,如果读者对基础乐理比较感兴趣的话,可以自行深入学习。从下一节开始会进行混音效果器的介绍,让大家了解一下具体的混音过程,以帮助大家在工作中对声音做出更好的处理。

8.4 混音效果器

在音乐的App中,进行混音处理是必不可少的一项工作,而混音这门学科也是非常复杂的,一个优秀的混音师也是非常值钱的。作为一个优秀的混音师,不单单是要有编曲经验,还得会乐器懂乐理,并且耳朵要好用,可以分辨什么样的声音是好声音,同时还要会使用混音的工具。本节会介绍一些混音中的基础知识,也包括一些常用混音工具的使用。针对最常用的四种效果器会分别给出介绍,让大家明白这四种效果器分别能影响声音哪一方面的特性,应该如何合理地使用这些效果器的组合来美化我们的声音,从而产生一首优秀的作品。

8.4.1 均衡效果器

均衡效果器又称均衡器(Equalizer),最大的作用就是决定声音的远近层次,而我们时常听到别人说这首歌曲是重金属风格的歌曲,或者说这首歌曲是舞曲风格等,其实就与声音的远近层次有关。不同的歌曲风格区别在于声音在不同频段的提升或衰减不同,要想完成一首具体风格的作品就离不开均衡效果器。

均衡效果器具有美化声音的作用,即调整音色,每个人由于自己声道以及颅腔、口腔的形状不同,导致音色不同,有可能这个用户所发出的声音在低频部分比较薄弱,我们就可以在低频部分予以增强,使得整个声音听起来更加温暖;有可能这个用户所发出的声音在高频部分又过于强烈(薄弱),我们可以在高频部分予以减弱(增强),可以使声音听起来不那么刺耳(更加嘹亮),使得这个人的声音听起来更丰满,更悦耳。当然专家级别的混音师在为歌手处理后期混音阶段,会有更复杂的调节方法,比如这个歌手的声音低频部分有瑕疵,那么去提高一点中频部分来掩盖住有瑕疵的低频段的声音。

其实上面所描述的就是均衡效果器的使用场景了,明白了使用场景,下面就来具体的介绍一下均衡器。均衡器(Equalizer)最早是被发明用来补偿频率缺陷的,因为那时音频设备的信号品质很差,在传输过程中损失非常严重,到最后除非进行信号补偿,否则信号就会变得极差,工程师发明均衡器,就是为了补偿损失的频率,尽可能地还原声音。而现在均衡器更多的应用在掩盖歌手的某一个频段的声音缺陷,或者增强某一个频段的声音优势上。

那接下来看一下声音的频率分布。

1)超低频。1Hz-20Hz之间,大约是4个八度的范围。这个声音,人的耳朵是听不到的,如果音量很大,我们的耳朵能够感觉到一种压力感,比如地震就可以产生这种频率。一般来说这个频段和音乐没有关系。

2)非常低频。20Hz-40Hz之间,一个八度(频率差两倍)的范围。这个频率也是很低的,一般远距离的雷声以及风声在这个频段里。这个频段的音效,在音乐中还是会经常用到的。

3)低频。40Hz-160Hz之间,2个八度的范围。电贝斯的声音便属于这个频段,当然低音提琴、钢琴也都拥有这个音域。这就是音乐中常用的频段了。男低音也可以发出这个频段中的一部分声音。

4)低中频。160Hz-315Hz之间,1个八度。这个八度音,男中音可以发出。单簧管、巴松管、长笛也拥有这个频段的声音。

5)中频。315-2500Hz之间,3个八度。这是人耳最容易接受的声音频段。我们从电话听筒里听到的声音一般就是属于这个频段。如果没有低频和高频,单独听这个频段,是很干涩的。

6)中高频。2500Hz-5000Hz之间,1个八度。人耳对这段音程是最敏感的。声音的清晰度和透明度都是由这个频段来决定的。音乐的音量也主要由这个频段影响,人声的泛音也会在这个频段出现。我们知道,公共广播用的喇叭,就是专门设计成3000Hz左右的频段。

7)高频。5000Hz-10000Hz之间,1个八度。这个频段会使音乐更明亮。多种高音乐器都拥有这个频段的声音,人的唇齿音也在这个频段内。

8)超高频。10000Hz-20000Hz之间,1个八度。这是可听频率范围内最高的音程了,需要很高的泛音,才可以达到这个范围,在音乐中很少见,而且人耳对这个频段已经很难辨别。但是,这个频段丰富的泛音可以起作用于其他频段的声音,对音色有很大的影响。

了解了声音的分布之后,我们可以使用最简单的Audacity工具打开一段声音,在菜单中的特效选项下选择均衡(Equalizer),可以看到均衡器的调节菜单,如图8-20所示。

图 8-20

在图8-20中,中间部分有一个曲线,横轴是频率,纵轴是dB(0dB以上代表增强,0dB以下代表减弱),如果我们点击变平坦按钮会看到这个曲线会是在0dB上的一条水平的直线,而如果我们打开选择曲线的菜单会看到有一些默认的曲线,其中包括以下曲线。

  • Bass Boost: 低音增强;
  • Bass Cut: 低音截断(类似于高通滤波器);
  • Treble Boost: 高音增强;
  • Treble Cut: 高音截断(类似于低通滤波器);
  • Telephone: 代表电话音质(频率分布在400Hz-3000Hz);
  • AM Radio:代表收音机音质(频率分布在50Hz-400Hz);

选择其中一个预制的效果器可以看到曲线的变化,点击预览按钮可以针对加入这个效果器的音频效果进行预览。当然自己也可以进行拖曳曲线来对某一个频率进行增强或者减弱,然后进行预览,尝试着听一下效果。当然,也可以选择图形化的均衡单选框,如图8-21所示。

图 8-21

在图8-21中,出现了各个频率上的滑动块,可以通过滑动块来代表将这个频率的声音进行增强还是减弱,同时在曲线上也可以看到效果,调整完毕之后点击预览按钮可以试听效果,如果最终确定了所有参数,点击确定按钮,就可以将这一组均衡效果器作用到声音上了,可以听一下整个声音的效果。

上面描述了Audacity这个工具里面如何使用均衡器来修正声音,同时在一些更专业的工具比如LogicPro和Cubase里面,均衡效果器的使用都是非常类似的,在这里也就不再一一介绍,接下来的重点就是分析一下对于均衡器,我们需要设置哪些参数。

其实最直观的参数就是频率,即修正(增强或者减弱)哪一个频率附近的声音,所以第一个最主要的参数就是frequency(代表哪一个频率),如何修正呢?即增强多少,减弱多少,也就是使用参数gain(代表增益是多少),其实除该参数之外,还有一个参数是可以用的,但很多人想不到,就是bandWidth(代表频宽),均衡器修正的不是某一个单一频率上的声音而是一个频段的声音,所谓频段其实就是从一个频率作为中心点左右都扩充一定的频率,就形成了一个频段,而具体这个频段有多大,就是用这个bandWidth来表示的。bandWidth常用的表示单位有两个,一个是O,即Octave,代表一个音程即一个八度,基本乐理中我们描述过,一个八度体现在频率上就是2倍的频率,如果我们定义的中心频率为2KHz,频宽为1.0(单位为Octave),增益为3dB的话,那么对应的到图中的曲线就是从1KHz开始进行提升,到2KHz提升到峰值3dB,最后到3KHz以后不再进行提升,这就完全描述了对这个频段的增强;而另外一个是Q,即Quality Factor ( 质量系数 ),代表了一个音程调整的有效影响斜率,也就是大家常说的Q值,其实这和前面第一种表示方法达到的效果是一样的,实际上Q和O是有一定的换算关系的,因为我们在不同平台或者开源算法中使用EQ的时候,要填入的bandWidth单位不一定是什么,所以我们要知道两者是如何进行换算的。其中我们知道O值(有多少个八度)如何计算出Q值呢?如图8-22所示。

图 8-22

而如果我们知道Q值如何计算出O值(有多少个八度)呢?如图8-23所示。

图 8-23

知道了两个公式,其实只要给出任意一个值都可以计算出以另外一个单位描述的值了。

均衡效果器的作用以及应用场景大家也基本上清楚了,本节也仅仅是均衡器的一个入门,真正的混音师在使用均衡器的时候,很少会对某个频段上的声音进行能量增强,反而是经常会把其他频段上的声音能量进行衰减,所以在使用的时候,并不是一味地增加能量,而是要根据具体情况具体分析。后面会讨论具体如何在Andorid和iOS平台上如何实现均衡效果器。

8.4.2 压缩效果器

压缩效果器又称压缩器(Compressor),是在时域上对声音强度所进行的一个处理,压缩器可以简单地理解为,当音频的音量剧增的时候,自动将音量调整的小一点的功能。这也是大多数压缩器的工作方式,压缩器就是改变输入和输出信号电平大小比率的效果器,如图8-24所示。

图 8-24

在图8-24中,最重要的一个概念是门限值(Threshold),即到达了这个门限值才会进入到压缩器的工作范围,而整体增益(Unity Gain)就是输入信号和输出信号完全一样,也就是对输入信合不做任何改变,即压缩比为1:1。这就又提到了另外一个重要的概念,即压缩比,其实这个概念很简单,可以理解为图中直线的斜率。如果将压缩比调整为2:1,大于门限值的输入信号将以2:1的比例被压缩,比如2dB的输入信号在经过压缩器之后就被压缩掉了1dB,这也就是我们图中的2:1这个曲线;但是如果将压缩比改为20:1的曲线,即比率设置成为20:1,这时压缩器就变成了一个限制器,输入信号过了门限值之后每增加20dB的电平,输出只能增加1个dB,所谓限制器其实就是常说的峰值限制器(Peak Limiter)。在压缩器里面还有两个非常重要的参数,一个是作用时间(attack time),另外一个是释放时间(release time),下面分别介绍这两个参数的意义。

作用时间(attack time),又称为起始时间,决定了压缩器在超过门限值后多久会触发压缩器来工作,前面说过,超过了门限值之后就到了压缩器的工作范围,但是不带表压缩器就会立马工作,而这里的起始时间实际上就是来触发压缩器工作。为什么要使用这个参数来触发压缩器工作呢?假设输入电平有一个瞬时峰值我们的压缩器就进入工作,这就达不到压缩器的最初目的了,因为压缩器的存在就是为了使得整个音频作品平稳地在一定的能量范围内,所以设置一个起始时间,让压缩器躲过这些瞬时峰值而持续工作。

释放时间(release time)和起始时间正好相反,它决定了压缩器在低于门限信号多长时间之后停止工作,如果释放时间过短,那么在信号低于门限之后压缩器会立即停止工作,就会导致抽泵现象,声音会听起来非常不舒服。

最后一个参数就是输出增益(Output Gain),即增益补偿,比如我们压缩了3dB的增益,然后使用增益补偿提升了整个输出信号,那么就可以将压缩掉的动态空间补偿回来。

明白了以上参数之后,下面使用Audacity打开一个音频文件,然后在特效的菜单中选择压缩器,如图8-25所示。

图 8-25

在图8-25中,参数噪音底可以不用去管,其余的可以调整的参数包括阈值(实际上就是上面所讲的门限值)、比率(就是压缩比)、上升时间(就是AttackTime)、衰减时间(就是releaseTime),而压缩后增长到0dB就是上面所讲的输出增益了,我们可以自己调节这几个参数观察曲线的变化,点击预览可以试听效果,如果效果满意的话,点击确定可以将压缩器作用到音频文件中。

其实压缩器在日常工作中可以以多种其他效果器的形式出现,但是原理都是一样的,比如上面所提到的峰值限制器(Peak Limiter),将压缩比调整到足够大(一般我们认为压缩比率在20:1以上的压缩器就是限制器)的压缩效果器就是一个峰值限制器了;除此之外,唇齿音消除器(De-Esser)也是一种应用场景,因为唇齿音一般都是/ci/、/si/等高频的声音,频率一般是4KHz到8KHz,做一个可调频率的压缩效果器来压缩特定的频率,可以将这一些人声中的嘶嘶声给衰减掉,从而达到悦耳的效果;另外还有噪声门(Nosie Gate)也是压缩器的一种应用场景,即我们规定一个门限值,门限以上的声音可以通过,门限以下的声音被视为噪音被完全切掉,这也是压缩效果器的另外一个特殊的应用场景。

8.4.3 混响效果器

混响效果器又称混响器(Reverb),在介绍混响器之前先讨论一下什么是混响。其实混响在大部分场景下都会产生的,我们可以设想老师讲课的一个场景,老师的声音经过多次反射,假如有5条声音反射线(实际上有成千上万条)到达学生耳朵,老师每说一句话,学生实际上听到的就是6句话(一句话直接传到学生耳朵里,还有反射的5句话),但是由于这一些反射声到达时间间隔太近了,所以学生实际上市分辨不出6句话,而是1句带有混响感觉的话,混响效果器就是这样工作的,把很多路声音(由于经过不同的反射源反射,所以能量不同)进行很多很多次的叠加(因为反射的距离有长有短,所以到达听者耳朵的时间就不同,所以叠加的时间也不同)。而所谓的混响器就是接受一个输入的声音,然后进行[某种计算],就可以达到6个声音(实际上是成千上万个声音)叠加的效果。而这里面所谓的某种计算在数学中叫做“卷积”计算,英文是“convolution”。如果把教师的声音看做一个单一的脉冲,通过计算之后我们得到却是一个完整的声波,如图8-26所示。

图 8-26

在图8-26所示的这个脉冲图中,就是含有6个脉冲(实际上是成千上万个脉冲)的声波,也就是在这个房间里,从老师到学生座位的混响特征。在声学上,由于这个混响特征是由脉冲得到的,所以称之为脉冲反应,即impulse response,简称IR。很显然,在不同的空间里这个脉冲图并不相同,也就是说不同空间里的混响特征不同,进一步也就是说不同空间里的IR是不相同的。我们将一个输入声音作为源声音,这个声音通过与IR的卷积得到的结果就是这个声音源在这个混响空间内所产生的最终混响结果。其实在几乎在任何场景下都会产生混响,只不过有一些混响效果在人的耳朵里不太容易分辨,像比较专业的录音棚里,墙壁上做了很多突出的吸音棉,可以最大程度的减少混响的影响,而在混音阶段再给作品增加混响。

在浴室里面自己发出一个声音,最终我们自己听到的声音其实是代表了浴室这个空间的混响特征,在小礼堂里面,或者在一个非常开阔的大舞台上演唱歌曲,所听到的混响效果肯定是不同的,总之每个空间都有自己独特的混响特征也就是有自己独特的IR。为了模拟出各个空间的混响效果,也就是为了定制出不同的场景混响,我们就可以制定不同场景下的IR,然后将声音源与特定场景下代表的IR进行卷积就可以得到这个场景下的混响效果了。那我们如何确定特定场景下的IR呢?

第一种就是采样IR混响,Sony、Yamaha都出过采样混响,所谓采样混响全部是真实采样得来的wave文件,可以存放与任何存储器,采样混响的IR都是录音采样得来。在想要获得混响特征的地方,例如小礼堂、音乐厅舞台上安置音箱,座位席中安置立体声话筒,然后播放一系列测试信号,以脉冲信号为主,各种速度的全频段正弦波连续扫描为辅,录得声音,然后经过计算得到IR。用这种采样方法得到的IR,是最真实也是效果最好的一种,当然这种IR的制作也是极为昂贵的。

第二种就是算法混响,也是最常见的混响效果器,目前大多数的数字混响效果器以及软件混响都是这种类型的。这类混响器虽然不带有真实的IR,但是却提供了很多方法可以让你对它自带的原始脉冲序列进行修改,比如通过改变空间大小、早反射时间、衰减时间、阻尼等参数来修改IR,以达到控制混响效果的目的,为了性能的考虑,这种IR的脉冲个数其实是有限的,并不会像第一种采样混响中有无限的脉冲信号。

为了方便研究,声学上把混响分为几个部分,并规定了一些习惯用语。混响的第一个声音也就是直达声(Direct sound),也就是源声音,在效果器里叫做 dry out (干声输出),随后的几个明显相隔比较开的声音叫做“早反射声”(Early reflected sounds),它们都是只经过几次反射就到达了的声音,声音比较大,比较明显,它们特别能够反映空间中的源声音、耳朵及墙壁之间的距离关系。后面的一堆连绵不绝的声音叫做 reverberation。大多数的混响效果器会有一些参数选项给你调节,现在就来讲讲这些参数具体是什么意思。

(1)空间大小(Room Size)

空间可以体现出声场的宽度和纵深度,不同的效果器在这个参数上有不同的的算法体现,但是这个参数是非常重要的。

(2)余响大小(Reverbrance)

如果说早反射声可以决定空间的距离,而余响则代表了空间的构造,即空间里面的物体多少,以及墙壁的材质,墙壁及室内物体的表面材质越松软,则代表吸音的能力越强,余响则越小。

(3)阻尼控制(Damping)

这个代表了混响声音减弱的程度,对应到实际场景中就是场景里面的物体多少,物体越多,并且物体表面越不光滑,衰减的就越厉害,可以根据我们要想得到的实际场景去设置这个参数

(4)干湿比(Dry Wet Mix Ratio)

有的混响算法会有这个参数,干信号表示原始信号,湿信号表示混响信号,而干湿比就是代表了最终输出信号的干声和湿声的比例。设置为100%,则意味着只要湿声不要干声。

(5)立体声宽度(Stereo Width)

有的混响效果器有这样的参数,如果把这个值设大,那么效果器在产生IR的时候会使左右声道差异变大,最终就会产生立体声的感觉。

和前面的内容一样,我们也是打开Audacity,在特效菜单中选择Reverb,如图8-27所示。

图 8-27

在图8-27中,可以看到一些可调节的参数,这些参数前面已经都一一介绍过了,大家可以自己调节参数进行预览,并且可以点击Dry Preview来预览干声,最后点击确定,即可将这个混响效果器作用到音频文件上。

混音效果器的介绍先介绍到这里了,我们不可能使用一节的内容讲别人一本书的内容都给讲解清楚,并且针对于我们开发者来讲知道这一些基础知识,也可以满足我们的日常开发需求了,接下来会讲解如何在这两个平台上实现这一些效果器。

8.5 效果器实现

在8.4节中已经了解了各个混音效果器的作用,本节将会讲解如何在Android和iOS平台上实现各个效果器,并最终集成到我们的App中去,可以试听效果。

8.5.1 Android平台的实现

在Andorid平台上实现上面介绍过的效果器有很多种方法,如果我们从头开始一个一个效果器去书写,显然不是一个合理的方案。我们应该去寻找优秀的开源仓库来实现这三种类型的效果器,比如sox开源库,它在音频处理界是一个非常优秀的框架,号称音频处理界的瑞士军刀。所以我们就先来编译sox,然后看一下它能做什么吧。

1. sox编译与命令行使用

Sox是最为著名的声音处理开源库,已经被广泛移植到Windows、Linux、MacOSX等多个平台,Sox项目是由Lance Norskog创立的,后来被众多的开发者逐步完善,现在已经能够支持很多种声音文件格式和声音处理效果。它默认支持的输入输出是wav文件,如果想要支持mp3等格式,需要预先安装libmp3lame库来支持这种格式的的编码与解码。那我们就下载这个库的源码,编译出二进制的命令行工具,先试着使用一下里面的三种效果器。Sox的源代码放在SourceForge上,主页在如下的链接中:

https://sourceforge.net/projects/sox/

进入Sox的主页之后,找到Code目录,下载整个源码目录,我们使用git将整个目录clone下来。所以首先建立一个sox目录,然后进入到这个目录中,执行如下命令:

git clone https://git.code.sf.net/p/sox/code sox-code

当上面这一行命令执行结束之后,进入sox-code目录,可以看到仓库的源代码已经全部被下载下来了,接下来的工作就是将源码编译成为二进制命令行工具。先查看源码目录下面的INSTALL文件,这个文件中指明了如果要编译的源(即代码仓库)是使用git下载的源码,则要先执行如下命令:

autoreconf -i

这个命令执行完毕之后,会在源码目录下生成configure、install-sh等文件,由于我们要编译最基本的Sox的二进制命令行工具出来,所以应建立一个shell脚本config_pc.sh,键入以下代码:

#!/bin/bash

CWD=`pwd`

LOCAL=$CWD

./configure \

–prefix=”$LOCAL/pc_lib” \

–enable-static \

–disable-shared \

–disable-openmp \

–without-libltdl \

–without-coreaudio

然后给config_pc.sh以及configure增加执行权限,并在源码目录下面,新建pc_lib目录,最终执行这个shell脚本文件:

./config_pc.sh

当这个shell脚本执行结束之后,代表配置结束,接下来就可以执行安装命令了:

make && make install

执行成功之后,进入到pc_lib目录下,可以看到这个目录里被安装脚本生成了bin、lib、include等目录,各个目录的作用本书中已经讲过很多遍了。进入bin目录,可以看到play、rec、sox等二进制文件,其中,sox就是我们要运行的二进制命令行工具了,而play则可以在处理的同时直接播放一个音频文件,类似于FFmpeg中的ffplay工具,至于rec,则是录制声音的工具。由于我们在config_pc.sh中关闭了硬件设备的配置选项,所以play和record工具不能使用,我们只使用sox来处理音频文件,输入是wav格式的音频文件,输出也是wav格式的音频文件。那我们就使用sox这个二进制命令行工具,对一个输入文件分别完成前面提到的三种效果器。

首先是均衡效果器,在前面已讲过均衡器的设置,整个参数分为N组参数,每一组参数代表对具体频率的增强或者减弱,每一组参数中包括频率、频带宽度、增益,sox的均衡器参数设置中也是一样的,来看下面这条命令:

sox song.wav song_eq.wav equalizer 89.5 1.5q 5.8 equalizer 120 2.0q -5

上面这条命令前两个参数分别代表输入文件和输出文件,它们后面有两个均衡器,第一个均衡器在89.5Hz作为中心频率,频带宽度为1.5q(具体Q值代表的意义,在之前的章节中已经提到过),增加5.8dB的能量;第二个均衡器是在中心频率为120Hz,频带宽度为2.0q,减少5dB的能量。如果想再给声音多作用几个均衡器,在后面依次再写上几组就可以了。待执行完命令之后,可以听一下输出文件的效果,或者使用Audacity软件打开处理前和处理后的音频文件,使用频谱图来观察一下处理前后的频谱分布的变化。

其次是压缩效果器,在前面也讲过压缩器的设置,整个参数包括门限值、压缩比、Attack Time、Decay Time等,sox中的压缩效果器使用库中的compand来实现,先来看一下命令:

sox song.wav song_compressor.wav compand 0.3,1

-100,-140,-85,-100,-70,-60,-55,-50,-40,-40,-25,-25,0,-20 0 -100dB 0.1

前两个参数依次是输入文件和输出文件,后面的compand代表的是效果器的名称,在sox中使用compand效果器来实现压缩-扩展器(Compressor-Expander),后面的参数以空格分开,首先是0.3和1,分别代表了Attack Time和Decay Time,至于它们所代表的含义前面已经介绍过了,接下来的一组参数代表了压缩器的转换函数表,每个数值的单位都是dB,稍后会详细解释这一条曲线,继续来看下面三个参数分别是0,-100,0.1,第一个0代表的是增益,即压缩完毕之后可以给一个整体增益作用到输出上,这里就不给任何增益了,第二个-100代表的初始音量,可以设置成为-100dB,代表初始音量从一个几乎为静音的音量开始,最后的0.1是延迟量。在实际的音频处理场景中,压缩器对于声音的忽然升高有很好的抑制作用。

现在来看一下由压缩器转换函数表绘制出的一个压缩曲线,如图8-28所示。

图 8-28

在图8-18中,红色直线为一条斜率为1的直线,实际上就是不作任何处理时的曲线,而蓝色曲线就是我们的压缩曲线。整个蓝色曲线分为四部分,可以看到在能量比较低的部分(-100dB到-80dB)将输出能量降低,相当于底部噪声部分给压低了;在中间能量部分(-75dB到-45dB)有所提升;而接下来一部分(-40dB到-25dB)我们保持不动,可以看到蓝色曲线和红色曲线重合;在接下来比较高能量部分(-20dB到0dB)再进行压缩处理,就形成了整个曲线。当然,输入输出点数越多,曲线就会画得越平滑,处理得到的声音效果也会越好。大家可以听一下经过压缩器处理完毕的声音,是不是整个音量的动态变化范围被压缩了呢?而这也就是压缩效果器的作用。

最后是混响效果器,对于混响器的参数之前也详细的介绍过了,直接来看如何使用sox给一个声音增加混响:

sox song.wav song_reverb.wav reverb 50 50 90 50 30

第一个参数是reverbrance即余响的大小,先设置为50听听效果,第二个参数是HF-damping即高频阻尼,设置为50,第三个参数是room-scale即房间大小,这里设置为90,代表一个比较大的房间,第四个参数代表立体声深度,设置越大则代表立体声效果越明显,这里设置为50,最后一个参数是pre-delay即早反射声的时间,单位是毫秒,这里设置为30毫秒。执行完以上命令,读者听一下处理完的声音,会发现有一个比较明显的混响效果了。

这里介绍了如何编译Sox,以及使用Sox这个二进制命令行工具,下面会把它交叉编译到Android平台,并且介绍它的SDK的使用。

2. sox的交叉编译

这里会将Sox交叉编译到Android平台,并且介绍如何在Andorid平台使用Sox的SDK来使库中的效果器工作。首先,要将Sox这个开源库交差编译出一个静态库以及头文件,以方便我们在Android的NDK开发的编译阶段和链接阶段分别引用。新建立config_armv7a.sh,键入以下代码,来编译出静态库与头文件:

#!/bin/bash

NDK_BASE=/Users/apple/soft/android/android-ndk-r9b

NDK_SYSROOT=$NDK_BASE/platforms/android-8/arch-arm

NDK_TOOLCHAIN_BASE=$NDK_BASE/toolchains/arm-linux-androideabi-4.6/prebuilt/darw

in-x86_64

CC=”$NDK_TOOLCHAIN_BASE/bin/arm-linux-androideabi-gcc –sysroot=$NDK_SYSROOT”

LD=$NDK_TOOLCHAIN_BASE/bin/arm-linux-androideabi-ld

CWD=`pwd`

PROJECT_ROOT=$CWD

./configure \

–prefix=”$PROJECT_ROOT/lib/armv7″ \

CFLAGS=”-O2″ \

CC=”$CC” \

LD=”$LD” \

–target=armv7a \

–host=arm-linux-androideabi \

–with-sysroot=”$NDK_SYSROOT” \

–enable-static \

–disable-shared \

–disable-openmp \

–without-libltdl

然后执行config_armv7a.sh(如果没有执行权限,要加上执行权限),最终可以看到在当前目录里的lib目录下有一个armv7的目录,armv7目录中会有我们非常熟悉的include、lib目录,里面就是我们需要的头文件sox.h与静态库文件libsox.a,至此交叉编译工作就完成了,接下来就来看看如何使用Sox库中提供的API在代码层面使用各种效果器。

3. SDK介绍

要使用Sox库中提供的API,就要从它的官方实例中开始,在Sox的根目录下进入到src目录下,在src目录下有几个以example开头的C文件,这就是提供给开发者参考的使用Demo。打开example0.c这个文件,首先可以看到,在这个文件的开头引用了sox.h这个头文件,然后再来看一下main函数,因为主要使用API的流程都是在main函数中,所以下面逐步看一下。在使用sox这个库之前,必须初始化整个库的一些全局参数,需要调用如下代码:

sox_init();

上述函数返回一个整数,如果返回的是SOX_SUCCESS这个枚举值,则代表初始化成功了。在整个应用程序中,如果没有调用sox_quit方法,是不可以再一次调用sox_init,否则会造成Crash。接下来初始化输入文件,代码如下:

sox_format_t* in;

const char* input_path = “/Users/apple/input.wav”;

in = sox_open_read(input_path, NULL, NULL, NULL);

初始化好了输入文件之后,再来初始化输出文件,代码如下:

sox_format_t* out;

const char* output_path = “/Users/apple/output.wav”;

out = sox_open_write(output_path, &in->signal, NULL, NULL, NULL, NULL);

这样就初始化好了输出文件,可以看到输入和输出文件都是wav格式的,因为我们并没有集成其他编码格式的工具,所以就是直接用的wav格式。下面来使用效果器,Sox中提供的效果器种类比较多,为了方便开发者使用,Sox使用类似责任链设计模式的方式来设计整个系统,所以我们使用的时候需要先构造一个效果器链出来,然后将需要使用的效果器一个一个地加到这个链里面,最终传入输入文件中数据以及接受这个效果器链处理完的数据,就可以完成音效的处理工作了,所以首先我们先来构造这个效果器链:

sox_effects_chain_t* chain;

chain = sox_create_effects_chain(&in->encoding, &out->encoding);

上述代码就构造出了一个效果器链,重点来看一下里面的两个参数,这两个参数实际上就是告诉效果器链输入音频的数据格式和输出音频的数据格式,比如声道、采样率、表示格式等。而我们从最开始初始化的输入文件格式和输出文件格式中可以拿到数据格式,sox会存储到encoding这个属性中。接下来就需要向效果器链中增加效果器了,但是在增加实际的效果器之前,我们需要先考虑一个问题,就是如何将输入音频数据提供给效果器链,以及如何将效果器链处理完的音频数据写到文件中去。对于这个问题,其实Sox已经帮我们提供了对应的API,为了方便开发者,sox的作者把输入和输出分别构造成了一个特殊的效果器,待我们创建出提供输入数据的效果器之后,需要添加到效果器链的第一个位置;然后创建出输出数据的效果器,添加到效果器链的最后一个位置上。

下面首先来看一下为效果器链提供输入数据的特殊效果器的构造:

sox_effect_t* inputEffect;

inputEffect = sox_create_effect(sox_find_effect(“input”));

上述代码就构造出了一个用于给效果器链输入数据的特殊效果器,但是具体这个特殊效果器的数据从哪里来呢?答案就是我们上面初始化的输入文件,所以我们要将输入文件配置到这个效果器中,代码如下:

char* args[10];

args[0] = (char*) in;

sox_effect_options(inputEffect, 1, args);

可以看到上述代码将之前构造的输入文件格式的结构体强制转化为char指针类型的参数,并配置给了效果器,其实在sox中都是以char指针类型的参数,来配置效果器的。配置好了之后,要将这个效果器增加到效果器链中,并且将这个效果器释放掉,代码如下:

sox_add_effect(chain, inputEffect, &in->signal, &in->signal);

free(inputEffect);

至此我们给效果器链提供输入数据的特殊效果器就已经创建成功,并且进行了配置,最终成功的添加到了效果器链中,而这个过程也是任何一个效果器从创建到配置到添加到销毁的整个过程。

接下来就是我们想要使用的最核心的效果器部分了,这里以一个非常简单的增加音量的效果器作为讲解,在接下来的章节中会依次针对本章中重点介绍的三个效果器进行讲解,音量调整效果器添加代码如下:

sox_effect_t* volEffect;

volEffect = sox_create_effect(sox_find_effect(“vol”));

args[0] = “3dB”;

sox_effect_options(volEffect, 1, args);

sox_add_effect(chain, volEffect, &in->signal, &in->signal);

free(volEffect);

可以看到,使用这个音量效果器给整个音频文件增加3个dB的音量,整个过程也比较简单,但是有的读者会问到,若想使用一个效果器,从哪里可以找到这个效果器的名称呢?其实所有的效果器的名称都被定义在effects.h这个头文件中,读者可以自己去查阅。

接下来配置另外一个比较特殊的效果器,即接受效果器链处理完的数据,并将数据输出到文件中的效果器,代码如下:

sox_effect_t* outputEffect;

outputEffect = sox_create_effect(sox_find_effect(“output”));

args0 = (char*)out;

sox_effect_options(outputEffect, 1, ags);

sox_add_effect(chain, outputEffect, &in->signal, &in->signal);

free(outputEffect);

上述代码也比较简单,主要是把我们前面所构造的输出文件配置给output这个特殊效果器,最终再将效果器添加到整个效果器链中。至此我们这个效果器链就已经构造好了,整个结构如图8-29所示。

图 8-29

当构造好了这个效果器链,如何让整个效果器链运行起来呢?其实也很简单,只需要执行以下代码:

sox_flow_effects(chain, NULL, NULL);

这个方法执行结束,其实整个处理流程也就结束了,经过我们核心效果器——声音变化效果器处理之后的音频数据就被全部写入到output.wav这个文件中了,当然,完成之后还是要销毁掉这个效果器链:

sox_delete_effects_chain(chain);

然后需要关闭掉输入和输出文件:

sox_close(out);

sox_close(in);

最后需要释放掉sox这个库里面的全局参数,代码如下:

sox_quit();

至此我们就可以使用sox提供的SDK来处理音频文件了,大家可以熟悉一下,下面会继续讲解本章中的最重要的三个混音效果器在sox中的使用。

4. 均衡器的实现

下面来使用sox的均衡器,其实在前面已经介绍过命令行工具中如何使用均衡效果器,有了前面的基础,我们也基本上可以猜测出本节的代码如何来书写,代码如下:

sox_effect_t* e;

e = sox_create_effect((sox_find_effect(“equalizer”)));

首先根据均衡器的名字创建出效果器,然后使用中心频率、频带宽度,以及增益来配置这个效果器,代码如下:

char* frequency = “300”;

char* bandWidth = “1.25q”;

char* gain = “3dB”;

char* args[] = {frequency, bandWidth, gain};

int ret = sox_effect_options(e, 3, args);

由于均衡器的参数有3个,所以这里配置参数的第二个参数传递3,待执行完这个配置函数之后,返回的ret是SOX_SUCCESS则代表配置成功。最后将这个效果器添加到效果器链中,代码如下:

sox_add_effect(chain, e, &in->signal, &in->signal);

free(e);

至此,就将一个均衡器加入到我们的效果器链中了,但是一般情况下会有多个均衡器同时作用到音频上,如果有多个则创建多个均衡器,依次添加到效果器链中。

均衡效果器就讲解完毕了,但是一般情况下我们在处理音频的时候,还会加上高通和低通,类似于均衡器,都属于滤波器。高通就是高频率的声音可以通过这个滤波器,低频的声音就被过滤掉了,有另外一种叫法就是低切。低通就是高通的逆过程,也被称之为高切。这里只展示高通滤波器,代码如下:

sox_effect_t* e;

e = sox_create_effect(sox_find_effect(“highpass”));

char* frequency = “80”;

char* width = “0.5q”;

char* args[] = {frequency, width};

sox_effect_options(e, 2, args);

sox_add_effect(chain, e, &in->signal, &in->signal);

相对比与普通的均衡器,高通滤波器不需要增益这个参数,所以只需要这两个参数就够了,低通效果器的名字是【lowpass】。

在sox库中对于均衡器的具体实现是biquad(源码文件是biquads.c),而biquad也是大部分均衡器以及高通、低通等的实现方式。Biquad又称为双二阶滤波器,双二阶滤波器是双二阶(两个极点和两个零点)的IIR滤波器,它可以不用将声音转换到频域而给声音做频域上的某一些处。至此均衡器的所有内容就讲解完毕了,接下来会讲解压缩效果器。

5. 压缩器的实现

这里介绍如何使用sox中的压缩器,压缩效果器前面已经讲解得比较多了,所以我们就直接上代码,首先创建出压缩效果器:

sox_effect_t* e;

e = sox_create_effect(sox_find_effect(“compand”));

然后给这个压缩器配置参数,下面挨个介绍一下,首先是作用时间与释放时间,代码如下:

char* attackRelease = “0.3,1.0”;

然后是压缩比,在sox中用了更灵活的压缩曲线来控制压缩比,使用的是构造一个函数转换表的方式来实现,代码如下:

char* functionTransTable = “6:-90,-90,-70,-55,-31,-31,-21,-21,0,-20”;

最后是整体增益、初始化音量以及延迟时间,代码如下:

char* gain = “0”;

char* initialVolume = “-90”;

char* delay = “0.1”;

构造方这一些参数之后,利用这一些参数配置一下这个压缩效果器,代码如下:

char* args[] = {attackRelease, functionTransTable, gain, initialVolume, delay};

sox_effect_options(e, 5, args);

最终将这个效果器加入到效果器链中,并销毁这个效果器,代码如下:

sox_add_effect(chain, e, &in->signal, &in->signal);

free(e);

大家尝试着运行一下,最终拿出处理完毕的音频文件,进行播放,感受一下在代码层面使用SDK调用压缩效果器处理的音频是否和命令行工具处理出来的音频一致。

6. 混响器的实现

下面介绍如何使用sox的混响器,有了之前效果器的经验,我们首先创建出混响效果器,代码如下:

sox_effect_t* e;

e = sox_create_effect(sox_find_effect(“reverb”));

然后给这个混响效果器配置参数,混响效果器的参数比较多,下面来一一介绍,首先是是否纯湿声的参数:

char* wetOnly = “-w”;

然后是混响大小、高频阻尼以及房间大小:

char* reverbrance = “50”;

char* hfDamping = “50”;

char* roomScale = “85”;

最后是立体声深度、早反射声时间以及湿声增益:

char* stereoDepth = “100”;

char* preDelay = “30”;

char* wetGain = “0”;

将这一些参数一块配置到效果器中,代码如下:

char* args[] = {wetOnly, reverrance, hfDamping, roomScale, stereoDepth,preDelay,

wetGain};

sox_effect_options(e, 7, args);

最终将这个效果器加入到效果器链中,并运行程序。大家可以试听一下处理之后的音频效果,其实在使用混响效果器的时候,一般在混响效果器之前增加一个echo效果器往往可以获得比较好的效果,大家可以尝试着增加一下,由于篇幅的关系,这里就不再赘述了。

在sox库中Reverb使用经典的施罗德(Schroeder)混响模型来实现,施罗德(Schroeder)混响模型使用4个并联的梳状滤波器和2个串联的全通滤波器来建立混响模型。梳状滤波器提供了混响效果中延迟较长的回声,而延时较短的全通滤波器则起到了增加反射声波密度的作用,如图8-30所示:

图 8-30

那现在我们就来分析一下施罗德混响模型的优缺点,优点是,通过设置六个滤波器的参数,可以模仿出前期反射和后期混响效果,全通滤波器可以在一定程度上减轻梳状滤波器引入的渲染成分;缺点是产生的混响效果缺少早期反射声,这样会造成声音缺乏空间立体感而且不清晰。对于它的缺点我们可以去进行改造和优化,其中大家最为常用的一种手段就是使用干声的echo来填充早期的反射声,改造后结构如图8-31所示:

图 8-31

如图8-31所示,在混响中一定要将isWetOnly属性设置为True,即只要湿声,然后使用Echo来填充早起反射声部分,所以将湿声与Echo加起来之后,作为混响的湿声部分,然后在与干声以一定的干湿比混合起来作为最终的输出结果。

至此在Android平台上的效果器实现已经讲解完毕了,读者可以把sox里面的效果器都玩一遍试试效果,说不定哪一个效果器在你以后的工作场景下会用到。特别的说明,这一些效果器都是以C语言书写的,所以理论上是通过交叉编译就可以运行在iOS平台上,但是iOS平台的多媒体库非常强大,是否有更高效的处理方式呢?下一节就介绍iOS平台上如何使用更高效的方式来完成音频处理。

8.5.2 iOS平台的实现

相比较于Android的开发者来说,iOS平台上的开发者应该感到非常幸运,因为苹果已经为开发者提供了足够强大的多媒体方面的API,所以我们的原则是首先来看iOS平台本身的音频处理API都有什么,能否满足我们的效果器实现。查阅开发者文档中不难得出,使用AudioUnit是可以来处理音频的,而这个结论在第4章中就有提到过,此外,还提到过AudioUnit有多种类型,其中有一种类型就是EffectType的AudioUnit,所以我们就来看一下如何使用AudioUnit来实现本章中提到的三种效果器。

1. 均衡器的实现

下面使用AudioUnit来实现均衡器,在AudioUnit中对于均衡器有两种类型的实现,第一种实现是叫做ParametricEQ的实现,这种类型均衡器的设置非常类似于sox中的均衡效果器,如果想要给声音多个均衡器,则需要增加多个此类型的效果器。第二种实现是叫做NBandEQ,从名字上来看,这种均衡器可以同时对多个频带进行设置,不用为了对多频带进行调整而添加多个效果器。在实际的生产过程中,笔者常用的是NBandEQ,读者应该根据自己的场景来选择具体的均衡效果器,在这里分别进行介绍一下这两种类型的AudioUnit的使用,还是按照之前AUGraph的方式来使用。我们使用这两种实现方式实现同一个需求,完成给3个中心频率的增加或减少能量。

(1)ParametricEQ

先来看一下它的描述,类型是Effect的类型,子类型就是ParametricEQ,厂商肯定都是Apple,代码如下:

CAComponentDescription equalizer_desc(kAudioUnitType_Effect,

kAudioUnitSubType_ParametricEQ,

kAudioUnitManufacturer_Apple)

然后按照这个描述将均衡器AUNode加入到AUGraph中,并且根据这个AUNode取出这个效果器对应的AudioUnit,代码如下:

for(int i = 0; i < 3; i++) {

AUNode equalizerNode;

AUGraphAddNode(playGraph, &equalizer_desc, &equalizerNode);

equalizerNodes[i] = equalizerNode;

}

AUGraphOpen(playGraph)

for (int i = 0; i < EQUALIZER_COUNT; i++) {

AUGraphNodeInfo(playGraph, equalizerNodes[i], NULL,

&equalizerUnits[i]);

}

根据之前章节中讲述的AudioUnit配置过程,来给这个效果器配置参数,至于均衡器的参数有哪一些分别代表了什么意义,前面已经讲解得非常清楚了,所以这里就直接上代码了:

for (int i = 0; i < 3; i++) {

int frequency = [[frequencys objectAtIndex:i] integerValue];

float band = [[bands objectAtIndex:i] floatValue];

int gain = [[gains objectAtIndex:i] integerValue];

AudioUnitSetParameter(equalizerUnits[i],

kParametricEQParam_CenterFreq,

kAudioUnitScope_Global, 0, frequency, 0);

AudioUnitSetParameter(equalizerUnits[i],

kParametricEQParam_Q,

kAudioUnitScope_Global, 0, band, 0);

AudioUnitSetParameter(equalizerUnits[i],

kParametricEQParam_Gain,

kAudioUnitScope_Global, 0, gain, 0);

}

注意,配置频带的参数不是Q值也不是以O(Octave八度)为单位的,而是以Hz为单位,中心频率的设置以及增益的设置都是和之前一致的。配置好参数之后,就将这个效果器连接到数据源(RemoteIO或者Audio File Player),然后可以去试听一下效果或者将数据保存下来。

(2)NBandEQ

先来看一下它的描述,类型是Effect类型,子类型为NBandEQ,代码如下:

CAComponentDescription n_band_equalizer_desc(

kAudioUnitType_Effect, kAudioUnitSubType_NBandEQ,

kAudioUnitManufacturer_Apple);

从名字上也可以看出,所谓的NBandEQ,其实这一个效果器就可以满足为多个频带增强或者减弱能量的需求,在这里不再展示构造AUNode以及从具体的AUNode中获取出AudioUnit的代码,而是直接把设置参数的代码展示如下:

for (int i = 0; i < 3; i++) {

float frequency = [[frequencys objectAtIndex:i] floatValue];

float band = [[bands objectAtIndex:i] floatValue];

float gain = [[gains objectAtIndex:i] floatValue];

AudioUnitSetParameter(nBandEqUnit, kAUNBandEQParam_FilterType + i,

kAudioUnitScope_Global, 0, kAUNBandEQFilterType_Parametric,0);

AudioUnitSetParameter(nBandEqUnit, kAUNBandEQParam_BypassBand + i,

kAudioUnitScope_Global, 0, 1, 0);

AudioUnitSetParameter(nBandEqUnit, kAUNBandEQParam_Frequency + i,

kAudioUnitScope_Global, 0, frequency,0);

AudioUnitSetParameter(nBandEqUnit, kAUNBandEQParam_Gain + i,

kAudioUnitScope_Global, 0, gain, 0);

AudioUnitSetParameter(nBandEqUnit, kAUNBandEQParam_Bandwidth + i,

kAudioUnitScope_Global, 0, band, 0);

}

乍一看可能会觉得这里的参数怎么多。下面就逐一来解释一下各个参数的含义,首先是NBandEQ,要想给哪一个Band设置就直接在某一个参数后面加几即可,第一项设置是选择EQ类型,其中EQ类型包括高通、低通、带通等,这里选择的就是Parametric类型的普通EQ;而第二项参数就是Bypass的设置,也就是是否直接通过而不做任何处理,0代表这个不对这个频带不作处理,1代表对这个频带进行处理;剩余的三个参数是前面讲过的,但是需要注意的是,BandWidth设置的单位是O(Octave,八度),所以如果你的频宽单位是Q值的话,这里要进行一下转换。

接着可以将这个效果器以AUNode的形式连接到数据源(RemoteIO或者Audio File Player)后面,然后可以去试听效果以及生成处理后的音频文件。

2. 压缩器的实现

下面使用AudioUnit来实现压缩器,相比上一节的均衡器,压缩器的设置可以说是比较简单的,首先来看一下压缩器的描述,类型就是Effect类型,而子类型是DynamicProcessor,代码如下:

CAComponentDescription compressor_desc(kAudioUnitType_Effect,

kAudioUnitSubType_DynamicsProcessor,

kAudioUnitManufacturer_Apple);

接着利用这个描述(compressor_desc)构造AUNode,然后找出对应的AudioUnit来:

AUNode compressorNode;

AudioUnit compressorUnit;

AUGraphAddNode(mGraph, &compressor_desc, &compressorNode);

AUGraphNodeInfo(mGraph, compressorNode, NULL, &compressorUnit);

之后就是给这个AudioUnit来设置参数了,代码如下:

AudioUnitSetParameter(compressorUnit, kDynamicsProcessorParam_Threshold,

kAudioUnitScope_Global, 0, -20, 0);

AudioUnitSetParameter(compressorUnit, kDynamicsProcessorParam_HeadRoom,

kAudioUnitScope_Global, 0, 12.937, 0);

AudioUnitSetParameter(compressorUnit, kDynamicsProcessorParam_ExpansionRatio,

kAudioUnitScope_Global, 0, 1.3, 0);

AudioUnitSetParameter(compressorUnit,

kDynamicsProcessorParam_ExpansionThreshold,

kAudioUnitScope_Global, 0, -25, 0);

AudioUnitSetParameter(compressorUnit, kDynamicsProcessorParam_AttackTime,

kAudioUnitScope_Global, 0, 0.001, 0);

AudioUnitSetParameter(compressorUnit, kDynamicsProcessorParam_ReleaseTime,

kAudioUnitScope_Global, 0, 0.5, 0);

AudioUnitSetParameter(compressorUnit, kDynamicsProcessorParam_MasterGain,

kAudioUnitScope_Global, 0, 1.83, 0);

这里的参数比较简单,和之前介绍的压缩器参数是一致的,主要是有门限值、压缩比、作用时间和释放时间等。

最后将这个效果器连接到数据源AudioUnit的后面,然后可以试听效果,如果满意最终可以试着生成一个目标音频文件。

3. 混响器的实现

下面使用AudioUnit来实现混响器,代码如下:

CAComponentDescription reverb_desc(kAudioUnitType_Effect,

kAudioUnitSubType_Reverb2, kAudioUnitManufacturer_Apple);

这里利用这个描述构造出AUNode,并且取出对应的AudioUnit,代码如下:

AUNode reverbNode;

AudioUnit reverbUnit;

AUGraphAddNode(mGraph, &reverb_desc, &reverbNode);

AUGraphNodeInfo(mGraph, reverbNode, NULL, &reverbUnit);

然后就是设置参数了,代码如下:

AudioUnitSetParameter(reverbUnit, kReverb2Param_DryWetMix,

kAudioUnitScope_Global, 0, 15.65, 0);

AudioUnitSetParameter(reverbUnit, kReverb2Param_Gain,

kAudioUnitScope_Global, 0, 9.3, 0);

AudioUnitSetParameter(reverbUnit, kReverb2Param_MinDelayTime,

kAudioUnitScope_Global, 0, 0.02, 0);

AudioUnitSetParameter(reverbUnit, kReverb2Param_MaxDelayTime,

kAudioUnitScope_Global, 0, 0.25, 0);

AudioUnitSetParameter(reverbUnit, kReverb2Param_DecayTimeAt0Hz,

kAudioUnitScope_Global, 0, 1.945, 0);

AudioUnitSetParameter(reverbUnit, kReverb2Param_DecayTimeAtNyquist,

kAudioUnitScope_Global, 0, 10, 0);

AudioUnitSetParameter(reverbUnit, kReverb2Param_RandomizeReflections,

kAudioUnitScope_Global, 0, 1, 0);

这里的参数和前面大部分的混响设置也差不多,大家可以自己调试各项参数,分别来设置参数试听效果。设置好参数之后,可以将这个混响器连接到数据源之后,并试听效果,如果满意最终可以试着生成一个目标音频文件。

至此,本章的内容就全部结束了,本章内容比较多,从数字音频的表示形式,到基本乐理知识,以及到最后各种效果器的介绍以及实践,读者可以依据自己工作中的需求逐一去学习和应用。

8.6 本章小结

本章从声音的时域、频域表示开始进行讲解,并且讲解了FFT的物理意义,掌握这一些基本的表示对于数字音频的理解是很有帮助的;然后讲解到了一些基本的乐理知识,掌握这一些乐理知识之后相信读者对于声音的理解可以达到一个更高层次的理解;最后介绍了混音效果器,在8.4和8.5小结从各个效果器的原理以及实现进行了分析,并且在各自平台的优化策略也做出了总结。本章内容比较多,读者可以慢慢阅读,深入理解。