前言
在前文 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 的输出流, 完成流通道的建立
7AuidoUnit 录制回调(手机 A)
手机 A 的 AudioUnit 回调, 会把人声数据缓存到 mOutputCircleBuffer 里, 等待发送 mOutputCircleBuffer 是一个环形缓冲区, 如果写入的时候已满, 会丢弃最早的部分, 以保证数据不堆积
8 发送人声数据(手机 A)
手机 A 在流通道空闲的时候会发送人声数据人声数据缓存在 mOutputCircleBuffer, 每次发送的字节数位 2048 因为 AudioUnit 在 44.1K 采样率时, 回调间隔为 12ms, 每次的大小为 1024 字节同时为保证缓存发送速度大于写入速度, 所以每次发送 size 为写入 size 的两倍
9AuidoUnit 录制回调(手机 B)
同步骤 7
10AuidoUnit 录制回调(手机 B)
同步骤 8
11/12AuidoUnit 播放回调
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 不能调用阻塞的方法;
3runtime 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 满了之后, 丢弃人声数据造成, 与方案 12 抉择无关
综合考虑, 选择方案 2 实现
6 同步启动
为了实现 AudioUnit 的同步启动, 当 server/client 在进行建立流通道握手时, 先满足启动的条件的一端要延迟启动 timeDelay, 尽量保证 AudioUnit 启动时间相差更小
timeDelay 为 Multipeer 的单向消息延迟时间
遇到的问题
1Stream 偶现发送失败
问题的出现在以下这行代码:
[self.mInputStream scheduleInRunLoop:[NSRunLoop currentRunLoop] forMode:NSRunLoopCommonModes];
最初的设计里, 输入流的执行线程可能有两个:
1 主线程: 创建 output 流之后, 满足就绪条件;
2MultiPeer 回调的线程: 收到 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 爆音
开发过程中, 偶现爆音的现象, 波形图如下:
收到的二进制流数据
经过 CircleBuffer 缓存后, 再读取的数据
这里有两个原因导致:
情况 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,23,45, 那么读取的数据就变成 6,78,910,11, 导致中间错乱的一段数据, 直到再出现一次丢弃奇数个字节的情况
解决方案:
情况 1 每次使用 tempBuffer 都初始化;
情况 2 每次读取丢弃都按偶数字节进行操作
PS: 对于采样深度为 16 位的音频, 其处理基本单元是 Short(2Bytes)
5 偶现间断的杂音
手机 A 收到人声数据
手机 B 发送的人声数据
手机 B 录入的人声数据
根据上述截图的分析, 确定问题出现在本地录制到 inputCircleBuffer 缓存再到发送的过程出现存储上的异常导致
该现象的表现形式是连续正常值中, 出现一个异常值, 仅有一个(排除连续的内存紊乱)
而且出现的情况非常频繁, 而且可能出现在任何波形中间(排除是某些特定的数字引起的异常)
从这个波形图, 可以很明显看出来, 是中间某个数字偏离了正常的轨迹(录制的没有问题)
分析到这里, 我们可以确定是环形缓冲区存在问题于是采用利用一种方式 (deque) 实现了环形缓冲区, 然后写测试样例进行测试
终于定位到问题: 环形缓冲区申请了大小为 m 的内存, 但是使用了 m+1, 多了 1byte!! 如果这个 byte 被系统其它类所使用, 将导致数值异常
两个环形缓冲区的代码在地址, 可以参考下
该问题出现的原因在于环形缓冲区是我临时实现, 没有经过单元测试就放到工程中使用
6Multipeer 导致的 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.
来源: http://www.jianshu.com/p/e5d1d2d9a63e