文章目录
Cable Messenger 聊天中进行短语音发送时,可以通过对语音文本数据进行实时分析,生成相关的语音波纹起伏曲线。
此篇文章主要为了倡导大家,在项目开发中,要多思考多实践,不要动手就离不开第三方库,没有第三方库就迈不开腿,甚至去找产品经理去改需求。很多东西其实可以自己写自己实现,而且要多了解和学习技术,多了解事物的本身,做项目不是简单的堆第三方库。
波纹数据的生成与分析
PCM(Pulse Code Modulation,脉冲编码调制)音频数据是未经压缩的音频采样数据裸流,它是由模拟信号经过采样、量化、编码转换成的标准数字音频数据。
如果是单声道的音频文件,采样数据按时间的先后顺序依次存,如果是双声道的话就按照LRLRLR的方式存储,存储的时候与字节序有关。以量化位数为16bit为例,对于双声道的音频文件而言,在每个采样时间间隔内,会同时生成 16 * 2 bit的数字音频数据,以顺序的形式进行存储。
PCM数据,作为设备生成的最原始数据,在进行各种压缩算法和封装格式进行封装后,生成了我们大家所熟知的MP3, AMR 等格式。而当我们接收到各种各样的音频格式,要进行播放前,要反向地对各种格式进行解封装,对相应该压缩后的数据进行算法还原,还原成原始的PCM数据后才能进行播放。因为本编不是对音频格式理论的详细描述,所以只一笔带过。
Cable Messenger 对于短语音文件格式上,采用了AMR 格式进行传输。AMR具有文件小比较适合短语音发送的场景。在具体的选型上,AMR-NB文件更小,AMR-WEB清晰度更高等特点。
在安卓平台上,原生的播放控件已经完美支持AMR下两种格式文件的播放。而在IOS平台上,声称在过去的版本曾经支持过AMR文件格式的播放。但就现时,原生的播放器还是缺少了对于AMR文本进行直接拆封解压生成PCM数据进行播放的能力。于是IOS端在接收到AMR格式文件时,在播放前就对AMR数据进行了自动的转换,生成PCM格式数据,以WAV封装规范对PCM数据进行封装。在播放的时候交由原生音频播放器进行播放。
PCM作为最原始的音频数据,是生成波纹数据的基础。所以第一步我们要做的就是如何分离PCM数据。
在介绍前,先要了解什么是RIFF资源互换文件格式。RIFF文件由一个或多个“块”组成。每个“块”由“块标识”(4Byte)“长度”(4Byte)“数据”(由前面的长度决定)。
就WAV文件而言,它由一个“块标识”值RIFF
的“块”进行封装。而在这个“块”内部的“数据”中,由一个标准的块组成,“块标识”值为WAVE
。
在WAVE
子块中,又可能存在以fmt
(格式信息) data
(PCM数据)fact
(附加数据)为“块标识”值的三种子块。其中波纹数据就放在 data
“块标识”值的的“数据”中。
好了,说到我自己都绕进去了。因为不是一篇理论型的文章,就不长篇大论的说理论了,有兴趣的话,这些资料到处都可以查来。
以下以Objective C 代码为例给出PCM数据的取值方法,因为只关注于PCM数据的取值,其它的块信息就不给出分析代码了
#pragma mark - 分析wav的声纹曲线, 返回声纹数据
+ (nullable NSData*) decodePCM:(nonnull NSString*)path{
NSData* wavData = [NSData dataWithContentsOfFile:path];
if wavData == nil{
return null;
}
int index = 0;
int dataSize = 0;
BOOL enable = NO;
NSData *RIIFData = nil;
//1. 先判断文件是否是标准RIFF格式
NSData *dType = [wavData subdataWithRange:NSMakeRange(0, 4)];
NSString *sType = [[NSString alloc] initWithData:dType encoding:NSUTF8StringEncoding];
if([@"RIFF" isEqualToString:fileType]){
enable = YES;
//2.取得RIFF数据长度
int RIIFsize;
[[wavData subdataWithRange:NSMakeRange(4, 4)] getBytes:&RIIFsize length:sizeof(RIIFsize)];
//判断 是否是 WAVE 格, WAVE格式后面会有 format 和 data chunk
NSData *dWave = [wavData subdataWithRange:NSMakeRange(8, 4)];
NSString *sWave = [[NSString alloc] initWithData:dWave encoding:NSUTF8StringEncoding];
if ([@"WAVE" isEqualToString:sWave] == NO){
enable = NO;
}
//3.截取 WAVE chunk 数据
if (RIIFsize > 0){
RIIFsize = RIIFsize - 4;
RIIFData = [wavData subdataWithRange:NSMakeRange(12, RIIFsize)];
}
}
//3.取得 wav 中的数据内容
while (enable && RIIFData != nil && RIIFData.length > 0) {
NSData *dData = [RIIFData subdataWithRange:NSMakeRange(index, 4)];
NSString *chunkType = [[NSString alloc] initWithData:dData encoding:NSUTF8StringEncoding];
int chunkSize; //chunk 的数据长度
[[RIIFData subdataWithRange:NSMakeRange(index + 4, 4)] getBytes:&chunkSize length:sizeof(chunkSize)];
//找到 data 类型
if([chunkType isEqualToString:@"data"] == YES){
dataSize = chunkSize;
break;
}
index = index + 8 + chunkSize;
}
//4.截取PCM数据返回
if(dataSize != 0){
NSData *dPCM = [RIIFData subdataWithRange:NSMakeRange(index + 8, dataSize * sizeof(char))];
return dPCM;
}
return nil;
}
波纹曲线控件核心逻辑
波纹曲线控件展示效果如图:
在取得PCM数据后,为了把数据呈现到有限长度的控件上,要对PCM数据值进行一定比较的二次采样。
量化位数为16bit为例,简单的采样代码如下:
#pragma mark - 对生成的声纹数据进行二次采样
+ (nullable NSMutableArray*)encodeLineValue:(nonnull NSData*)data offset:(int)offset{
int size = data.length * 0.5;
NSMutableArray *yPoins = [[NSMutableArray alloc] init];
for (int i = 0; i < size; i++) {
if ((i % offset) == 0){
int16_t value;
[[data subdataWithRange:NSMakeRange(i * 2 , 2)] getBytes:&value length:sizeof(value)];
[yPoins addObject:[NSNumber numberWithInt:value]];
}
}
return yPoins;
}
在控件的实现中,控件的长度与音频的长度成一定的比例关系。波纹曲线的宽度为固定值,曲线在控件中的数量可以通过先计算出长度,再整除运算后得出曲线的个数。
曲线高度的计算会稍微复杂。要先对二次有采样的数据进行扫描,取出绝对值最大的数据作为参考。曲线高度最大值为固定值,先计算出两个值间的比例。然后遍历二次有采样的数据,通过生成的比例值,计算出各个曲线的真实的高度。相关代码如下:
///
/// 生成声纹坐标
///
public func createPCMLineData(path:String, width:CGFloat) -> Data?{
///线条高度最大值
let maxHeight:CGFloat = self.Max
let height:CGFloat = self.frame.size.height - self.paddingBotton;
var lineCount:Int = Int(width / (self.lineWidth + self.linePadding))
lineCount = lineCount > 0 ? lineCount : 1
guard let dPCM:Data = self.decodePCM(path) else{
return nil
}
self.points.removeAll()
///以16位为例,两个字节为一个单完
let unitCount:Int = Int(dPCM.count / 2)
///采样间隔
let offset:Int32 = Int32(unitCount / lineCount)
// 1. ========== 生成二次采样数据 ============
guard let values:[Int16] = self.encodeLineValue(dPCM, offset:offset) as? [Int16]{
return nil
}
// 2. ============== 取出最大值 =============
var maxValue:Int16 = 0
for item in 0..<values.count{
if abs(values[item]) > maxValue{
maxValue = Int16(abs(values[item]))
}
}
// 3. ============ 计算缩放比例 ==============
let scrol:CGFloat = maxHeight / CGFloat(maxValue)
// 4. ============ 生成线条坐标 ==============
let totalWidth:CGFloat = self.lineWidth + self.linePadding
for i in 0..<values.count{
///水平方向坐标
let x:CGFloat = totalWidth * CGFloat(i + 1) - (self.lineWidth + self.linePadding) * 0.5
var value:CGFloat = CGFloat(fabsf(Float(values[i]))) * scrol
if value < self.Min{ // 最少值为 1
value = self.Min
}
if value >= height * 0.5 - 1{
value = height * 0.5 - 2
}
///竖直方向坐标
let y:CGFloat = CGFloat(fabsf(Float(height * 0.5) - Float(value)))
self.points.append(CGPoint(x: x, y: y))
}
}
///生成数据后,主动刷新,触发绘制
self.setNeedsDisplay()
}
///返回声纹数据
return NSKeyedArchiver.archivedData(withRootObject:self.points)
}
波纹曲线从标生成后,就可以进行控件描制逻辑的编写,代码如下:
override public func draw(_ rect: CGRect) {
objc_sync_enter(self)
let width:CGFloat = rect.size.width;
let height:CGFloat = rect.size.height;
let progressBackCGColor:CGColor = self.progressBC.cgColor
let progressFormCGColor:CGColor = self.progressFC.cgColor
let defaultCGolor:CGColor = self.defaultColor.cgColor
if let context:CGContext = UIGraphicsGetCurrentContext(){
if self.progress == 0 || self.isAnimationRunning == false{
context.setStrokeColor(defaultCGolor)
}else{
context.setStrokeColor(progressBackCGColor)
}
context.setLineWidth(lineWidth)
for i in 0..<points.count {
//点设置
let point:CGPoint = points[i]
context.move(to: CGPoint(x: point.x, y: point.y))
context.addLine(to: CGPoint(x: point.x, y: height - point.y))
context.strokePath()
}
context.saveGState()
// 2. 设置裁剪区域
context.beginPath()
context.addRect(CGRect(x: 0, y: 0, width: width * self.progress, height: height))
context.closePath()
context.clip()
// 3. 画【上层进度】浅色层
context.setStrokeColor(progressFormCGColor)
context.setLineWidth(lineWidth)
for i in 0..<points.count {
//点设置
let point:CGPoint = points[i]
context.move(to: CGPoint(x: point.x, y: point.y))
context.addLine(to: CGPoint(x: point.x, y: height - point.y))
context.strokePath()
}
context.saveGState()
}
objc_sync_exit(self)
}
绘制过程代码仅供参考,因为控件还牵涉到很多操作,如播放中的进度显示,手势左右拖动的进度响应,播放完后的颜色切换等,所以就不一一上完整的代码了。以上代码为绘制的核心逻辑。有时间的话,会整理出一个模块放到我自己的开源项目中去供大家参考。
世上无难事,只怕有心人。很多优秀的第三方库固然很值处我们使用,但更重要的是学会思考,深入探究别人的库是怎么实现的。这才是一个合格程序员的基本修养。