前言
在前文iOS近距离实时通信解决方案的基础上对MultipeerConnectivity
深入研究,实现实时合唱的效果,重点介绍MultipeerConnectivity
框架相关的问题。
正文
合唱功能使用流程:
1、选择歌曲,选择合唱模式,下载伴奏;
2、选择合唱身份,发起者等待连接,加入者,选择附近的合唱加入;
3、连接建立,录歌同步启动,开始合唱。
表达为技术上的流程:
第一步,建立连接。由手机A发起广播,手机B搜索广播并选择对应的设备建立连接。
第二步,建立数据流通道。手机A创建数据流的输出通道,并接受手机B的数据流输入;同时手机B创建爱你数据流的输出通道,并接受手机A的数据流输入。
第三步,实时合唱。手机A和手机B同步启动录歌,在录歌的过程中不断发送/接受人声数据,实现合唱。
合唱的流程如下:
1、手机A发起广播
手机A作为server,需要先发起广播。
MCPeerID是连接中表示本设备的标识,长度不能超过63 bytes(UTF-8 编码)。
MCAdvertiserAssistant
是广播管理类,提供广播发起接口、广播代理回调。
发起广播需要先创建MCPeerID
和MCAdvertiserAssistant
。2、手机B搜索广播
手机B作为client,需要搜索并请求建立连接。建立连接前同样需要创建MCPeerID
和MCSession
。3、手机A接受连接
当手机B请求建立连接之后,手机A会弹出建立连接的请求,完成连接的建立过程。
连接成功建立之后,MCSession
会回调MCSessionStateConnected
。4、手机A创建输出流
手机A作为server,主动建立输出流。
注意,需要把mOutputStream
放入RunLoop,并调用open。5、手机B接受输入流并创建输出流
手机B作为client,接受server的输出流,并且创建client的输出流。6、手机A接受输入流
手机A作为server,接受client的输出流,完成流通道的建立。7、AuidoUnit录制回调(手机A)
手机A的AudioUnit回调,会把人声数据缓存到mOutputCircleBuffer
里,等待发送。mOutputCircleBuffer
是一个环形缓冲区,如果写入的时候已满,会丢弃最早的部分,以保证数据不堆积。8、发送人声数据(手机A)
手机A在流通道空闲的时候会发送人声数据。人声数据缓存在mOutputCircleBuffer
,每次发送的字节数位2048。因为AudioUnit在44.1K采样率时,回调间隔为12ms,每次的大小为1024字节。同时为保证缓存发送速度大于写入速度,所以每次发送size为写入size的两倍。9、AuidoUnit录制回调(手机B)
同步骤7。10、AuidoUnit录制回调(手机B)
同步骤8。11/12、AuidoUnit播放回调
AudioUnit播放回调会请求收到的人声数据,已缓存的数据在mInputputCircleBuffer
里。这里每次只能读取偶数字节,否则会产生严重的噪声。
整体的代码结构图如下:
这样便达到两个iPhone手机近距离的场景下,通过WiFi进行通讯,达到实时合唱。
实现心得
1、打印录入、发送、收到、合唱的人声数据
在开发过程中会遇到很多问题,类似噪音、声音卡顿、快慢放的现象,需要把人声数据导出查看。
这里使用的是NSOutputStream
,直接把每个流程中的人声数据(PCM)写到文件,再通过沙盒导出。
创建日志输出流:
NSDate *currentDate = [NSDate date];
//用于格式化NSDate对象
NSDateFormatter *dateFormatter = [[NSDateFormatter alloc] init];
//设置格式
[dateFormatter setDateFormat:@"yyyy_MM_dd_HH_mm_ss"];
//NSDate转NSString
NSString *currentDateString = [dateFormatter stringFromDate:currentDate];
self.mLogInputStream = [[NSOutputStream alloc] initToFileAtPath:[NSString stringWithFormat:@"%@/Documents/KSongKit/%@mLogInputStream.pcm", NSHomeDirectory(), currentDateString] append:NO];
[self.mLogInputStream open];
self.mLogOutputStream = [[NSOutputStream alloc] initToFileAtPath:[NSString stringWithFormat:@"%@/Documents/KSongKit/%@mLogOutputStream.pcm", NSHomeDirectory(), currentDateString] append:NO];
[self.mLogOutputStream open];
打印日志:
[self.mLogOutputStream write:(unsigned const char *)mMultipeerTempBuffer maxLength:length];
[self.mLogInputStream write:(unsigned const char *)mMultipeerTempBuffer maxLength:length];
2、人声数据分析
拿到PCM人声数据之后,我们需要对数据进行分析,这是需要用工具Adobe Audition
。
用Adobe Audition
打开PCM选择对应的采样率和声道,便可以查看PCM的波形和频谱。
如下,从波形图可以看出在1分20秒处有明显的噪声,并且前面间断出现波形异常,比如4秒、21秒、 34秒。
如下,声音出现两段明显能量集中的区间。表现在声音就是刺耳的声音,也就是爆音:
用Adobe Audition
把波形拉到最长,我们可以看到波形其实就是一个个采样点形成。
3、卡顿定位
合唱有主线程、Multipeer相关线程和AudioUnit线程,其中AudioUnit线程是一个实时的线程,需要注意:
1、不能分配大量内存;
2、不能调用阻塞的方法;
3、runtime unsafe;
为监控AudioUnit的卡顿,可添加每次AudioUnit线程回调的耗时统计。方法就是分别在AudioUnit的Playback和Recordback两大回调函数起点位置打点,在函数结束的时候打点,统计期间的时间差。
2018-01-31 11:27:31.653184+0800 ###Multipeer### test cost, :14.28ms
2018-01-31 11:27:31.780877+0800 ###Multipeer### test cost, :118.19ms
2018-01-31 11:27:31.782139+0800 ###Multipeer### test cost, :1.15ms
2018-01-31 11:27:31.782328+0800 ###Multipeer### test cost, :0.14ms
4、数据收发
下面这段代码是发送人声数据。
uint32_t length = (uint32_t)[self.mOutputStream write:(const unsigned char *)mMultipeerTempBuffer maxLength:maxSize];
Xcode的API文档里,并没有阻塞相关的描述。但实际运行中,却有一定的概率会阻塞。
通过查找苹果开发者官网更详细的资料,知道当NSOutputStream
是针对网络的时候,本地会有一个发送数据的缓存。当这个缓存满了之后,再调用发送的接口便会阻塞,以防止数据丢失,建议发送的时机放在NSStreamEventHasSpaceAvailable
之后。
case NSStreamEventHasSpaceAvailable:{//输出流通道有空间可用
if (aStream == self.mOutputStream) {
[self requestMultipeerSendData];
}
break;
}
于是把发送数据放在NSStreamEventHasSpaceAvailable
之后,把接受数据放在NSStreamEventHasBytesAvailable
之后。
但因此又产生一个隐藏的Bug,考虑以下两种情况:
1、当输出流回调NSStreamEventHasSpaceAvailable
事件时,但是本地没有数据;
2、当输入流回调NSStreamEventHasBytesAvailable
事件时,但是本地缓存已满。
这两种情况发生后,就不再发送/接收数据。
比较好的解决方案是在NSStreamEventHasSpaceAvailable
的时候,设置为YES;然后每次AudioUnit回调都调用requestMultipeerSendData
,里面再判断mCanSendAble
是否为YES。
5、环形缓冲
在整个合唱过程中,AudioUnit不断录制人声用于Multipeer发送,同时不断播放消费Multipeer收到的人声。数据会缓存在Multipeer收发队列里面,等待AudioUnit的调用。这样当网络抖动时,会造成Multipeer的数据不断堆积。
为了让收发更加迅速,引入本地的环形缓冲区。把所有收到的人声数据全部读取到本地的缓存(InputCircleBuffer),把所有要发送的人声数据先写入本地的缓存(OutputCircleBuffer)。
在把收到的人声数据写入InputCircleBuffer的时候,如果遇到InputCircleBuffer剩余空间不足,有两种解决方案:
1、假设收到的长度为l,剩余空间为x,那么写入x的数据,丢弃掉收到的l-x人声数据;
2、假设收到的长度为l,剩余空间为x,那么先丢弃circleBuffer最早的l-x的数据,写入x的数据;
方案1的优点是简单,缺点是体验上为延迟效果加剧,声音断断续续;
方案2的优点是延迟效果可控,缺点是实现复杂,声音断断续续;
声音断断续续本质是由于buffer满了之后,丢弃人声数据造成,与方案1、2抉择无关。
综合考虑,选择方案2实现。
6、同步启动
为了实现AudioUnit的同步启动,当server/client在进行建立流通道握手时,先满足启动的条件的一端要延迟启动timeDelay,尽量保证AudioUnit启动时间相差更小。
timeDelay为Multipeer的单向消息延迟时间。
遇到的问题
1、Stream偶现发送失败
问题的出现在以下这行代码:
[self.mInputStream scheduleInRunLoop:[NSRunLoop currentRunLoop] forMode:NSRunLoopCommonModes];
最初的设计里,输入流的执行线程可能有两个:
1、主线程:创建output流之后,满足就绪条件;
2、MultiPeer回调的线程:收到input流之后,满足就绪条件;
当出现情况2的时候,因为是加入到currentRunLoop
,就会导致回调失败。(子线程的runloop默认不启用)
一个很小的错误,导致了很长的定位。问题最初出现是因为想把数据发送和接收统一到一个线程,避免阻塞主线程。
后面解决收发数据阻塞的问题之后,就统一放到主线程。
2、连接异常断开
开发过程中,突然中断连接的情况。
实际开发过程中,如果进行断点调试,恢复运行之后连接也会断开。以此作为参考,怀疑是阻塞(类似断点调试)导致。
查看线程情况,并没有发现阻塞、卡顿的现象,也没有出现数据堆积。
[2018-01-30 11:04:10.205][:0][[M:UI][KS:INFO]###Multipeer### requestMultipeerSendData readWithBuffer, 0
[2018-01-30 11:04:10.205][:0][[M:UI][KS:INFO]###Multipeer### requestMultipeerSendData mOutputStream write, 0
查看最后的Log,发现最后的read/write操作出现为0的情况。
Socket网络编程里,对read返回0有特殊的意义(断开连接),难道是这里导致?
通过Google查找和开发者官网确认,当read接口返回0的时候,连接会主动断开。
修复方案:当发送的环形缓冲区没有数据时,不进行数据发送。
3、采样率问题
实时合唱过程中频繁出现滋滋声的情况,这个现象在录制前几秒钟是正常的,后续频繁出现噪声。手机(7p)和模拟器进行合唱没问题,但真机合唱(7p和6p)出现问题。
查看Log,发现真机合唱的情况下,6p的手机出现了数据堆积的现象。数据堆积是因为6p收到人声数据比处理的人声数据更多,而环形缓冲区满了之后,新收到的数据会顶替前面的数据,造成爆音、滋滋声、卡顿、快放/慢放等现象。
因为手机和模拟器是正常的,故而猜测手机的性能差异,导致6p的处理速率跟不上。对比真机两端的生产/消费速率,发现两个数字:6p的生产/消费速率大致为44k,而7p是48k。突然意识到,可能是采样率设置不同导致!
通过检查代码,发现工程中确实存在针对不同设备,分别采用44.1k和48k采样率的设置。因为6s以上的机型,硬件采集的就是48k的音频,如果使用44.1k,需要audioUnit做重采样,降低音质以及增加性能消耗。
这里的解决方案,就是在合唱的时候,统一设置为44.1k。
PS:这里设置7p的采样率为44k,修改的是每次回调的size,而不是回调次数。即是每次回调不在是1024bytes,而是940bytes。
从这里有一丝猜想:
7p的采样率默认为48000,并且是以满足自己的要求为主(frame的size为2^n);
如果业务侧提供的采样率是44100,那么需要做一次转换:512*(44100/48000)=470 frame切合猜想。
同时,如果把buffer放大,回调大小会变成1880,1880=940frame,验证猜想。
从iPhone 6s机型开始,RemoteIO Audio Unit默认的采样率就是48K。
引用1
引用2
4、爆音
开发过程中,偶现爆音的现象,波形图如下:
这里有两个原因导致:
- 情况1、当从inputCircleBuffer(收到人声的环形缓冲区)读取数据的时候,如果读到的数据为空,返回读取的size为0。
tempBuffer(读取用buffer)没有初始化,而tempBuffer还用于混响等音效器。
那为什么返回的size是0,还会读取超过size的值?
这是因为本地人声和收到人声的混合是以本地人声的长度为准,即使读取到的size为0,但还是会以AudioUnit回调本地的人声size为混合长度; - 情况2、当收到个数为953(奇数)字节时,根据原来规则,会腾出953个字节的空间(方案2)。这样导致下一次读取的时候,字节错乱出现杂音!!
比如说10个字节,我们用1,2,3,4,5,6,7,8,9,10来表示,本来是【1,2】,【3,4】,【5,6】,【7,8】,【9,10】表示5个short数字;
丢弃掉5个字节【1,2】【3,4】【5】,那么读取的数据就变成【6,7】【8,9】【10,11】,导致中间错乱的一段数据,直到再出现一次丢弃奇数个字节的情况。
解决方案:
情况1、每次使用tempBuffer都初始化;
情况2、每次读取、丢弃都按偶数字节进行操作。
PS:对于采样深度为16位的音频,其处理基本单元是Short(2Bytes)。
5、偶现间断的杂音
根据上述截图的分析,确定问题出现在本地录制到inputCircleBuffer缓存再到发送的过程出现存储上的异常导致。
该现象的表现形式是连续正常值中,出现一个异常值,仅有一个。(排除连续的内存紊乱)
而且出现的情况非常频繁,而且可能出现在任何波形中间。(排除是某些特定的数字引起的异常)
从这个波形图,可以很明显看出来,是中间某个数字偏离了正常的轨迹。(录制的没有问题)
分析到这里,我们可以确定是环形缓冲区存在问题。于是采用利用一种方式(deque)实现了环形缓冲区,然后写测试样例进行测试。
终于定位到问题: 环形缓冲区申请了大小为m的内存,但是使用了m+1,多了1byte!!如果这个byte被系统其它类所使用,将导致数值异常。
两个环形缓冲区的代码在地址,可以参考下。
该问题出现的原因在于环形缓冲区是我临时实现,没有经过单元测试就放到工程中使用。
6、Multipeer导致的Crash
以下三个线程是iOS系统用于建立连接和收发数据使用。
当Multipeer出于异常情况或者主动断开连接后,如果再进行通信会导致Crash。
复现方法:手机A/B先建立连接,当手机A在正常通信的时候,Xcode用断点调试的模式暂停手机A执行,此时手机B的Multipeer连接会断开,此时如果手机B再进行数据收发会导致Crash。
解决方案:当系统回调通知连接断开之后,要保证不再进行数据收发。
总结
读完本文,MultipeerConnectivity的坑也踩了大部分。
为了实现这个效果,耗时将近一个月,收获满满。每天不断产生新的沙盒文件,因为格式是pcm的缘故,长度几分钟的歌曲,录入、缓存、网络收发等人声都很大,每次沙盒文件都有上百M。
幸不辱命,也是填完绝大多数坑,完成这个功能。
思考记录总结不易,多谢大家支持。
附录
WritingOutputStreams
Before you open the stream to begin the streaming of data, send a scheduleInRunLoop:forMode: message to the stream object to schedule it to receive stream events on a run loop. By doing this, you are helping the delegate to avoid blocking when the stream is unable to accept more bytes.
NSOutputStream
You can write to a stream at any time, but for network streams, -write:maxLength: returns only until at least one byte has been written to the socket write buffer. Therefore, if the socket write buffer is full (e.g. because the other end of the connection does not read the data fast enough),
this will block the current thread.