Cable Messenger:语音波纹曲线生成策略

文章目录


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;
}

波纹曲线控件核心逻辑

波纹曲线控件展示效果如图:

Cable Messenger:语音波纹曲线生成策略
在取得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)
    }

绘制过程代码仅供参考,因为控件还牵涉到很多操作,如播放中的进度显示,手势左右拖动的进度响应,播放完后的颜色切换等,所以就不一一上完整的代码了。以上代码为绘制的核心逻辑。有时间的话,会整理出一个模块放到我自己的开源项目中去供大家参考。

世上无难事,只怕有心人。很多优秀的第三方库固然很值处我们使用,但更重要的是学会思考,深入探究别人的库是怎么实现的。这才是一个合格程序员的基本修养。

上一篇:「USACO 2020.12 Platinum」Cowmistry


下一篇:USACO 2020 January Contest, Platinum Problem 1. Cave Paintings