本文探讨两个方面的内容,一个是wav音频数据的解析,另一个是使用wasm对其实现加速
音频数据的存储
第一部分我们先讲音频数据是如何存储在计算机上的,对其结构了然,才能更深入一步。
PCM
要了解其存储结构,首先要知道PCM:
PCM(Pulse Code Modulation)脉冲编码调制是数字通信的编码方式之一。主要过程是将话音、图像等模拟信号每隔一定时间进行取样,使其离散化,同时将抽样值按分层单位四舍五入取整量化,同时将抽样值按一组二进制码来表示抽样脉冲的幅值。
在计算机应用中,能够达到最高保真水平的就是PCM编码,被广泛用于素材保存及音乐欣赏,CD、DVD以及我们常见的 WAV文件中均有应用。因此,PCM约定俗成了无损编码,因为PCM代表了数字音频中最佳的保真水准,并不意味着PCM就能够确保信号绝对保真,PCM也只能做到最大程度的无限接近。要算一个PCM音频流的码率是一件很轻松的事情,采样率值×采样大小值×声道数 bps。一个采样率为44.1KHz,采样大小为16bit,双声道的PCM编码的WAV文件,它的数据速率则为 44.1K×16×2 =1411.2 Kbps。我们常见的Audio CD就采用了PCM编码,一张光盘的容量只能容纳72分钟的音乐信息。
音频的PCM 采样数据的时候有三个指标尤为重要:
- 采样率: 1秒内运行几次采样, 例如44.1khz意味着1秒运行44.1k次采样
- 采样精度: 原始数据采样之后,保存的时候用几个字节表示,一般以8-16位居多,一个或者两个字节
- 声道数: 单或双声道
典型的小端序存储结构如下:

- 对于单声道来说,存储序列就是按照地址递增的方向依次排列的
- 对于双声道来说,是以
左右左右...
的序列交替排列的
WAV
PCM存储的仅仅是原始音频数据,对于正常的用户或者应用层应用程序来说,还不够,因此wav顺势而出:
WAV为微软公司(Microsoft)开发的一种声音文件格式,它符合RIFF(Resource Interchange File Format)文件规范,用于保存Windows平台的音频信息资源,被Windows平台及其应用程序所广泛支持。WAVE文件通常只是一个具有单个"WAVE"块的RIFF文件,该块由两个子块("fmt"子数据块和"data"子数据块)
wav也是一种无损格式,简单理解,给PCM数据加个头就是wav了,一个wav的格式如下图:

一般解析wav数据需要关注以下几个头:
- 前四个字节,如上图所示ChunkID有的也叫chunk descriptor,是固定字符串
RIFF
的ASCII编码; - 字节序列[4,7]四个字节描述了chunk的大小;
-
8,15\] 序列是`WAVEfmt `(注意空格)的ASCII编码;
-
22\] 位代表声道数
-
44, ∞) 之后就是PCM数据了
JS的实现
原生JS
JS 实现分以下几步走:
- 首先借助fileReader或其他方法拿到文件的ArrayBuffer,并将其转化成Unint8Array
- 然后通过解析其头部拿到格式头,采样率,声道数,data位置等关键数据
- 通过对比其与标准格式的差异决定其是否为合法格式,进行初步数据校验
javascript
function wav2pcmJS(wavView) {
//检测wav文件头
var eq = function (p, s) {
for (var i = 0; i < s.length; i++) {
if (wavView[p + i] != s.charCodeAt(i)) {
return false;
}
}
return true;
};
var pcm;
if (eq(0, 'RIFF') && eq(8, 'WAVEfmt ')) {
var numCh = wavView[22];
if (wavView[20] == 1 && (numCh == 1 || numCh == 2)) {
//raw pcm 单或双声道
var sampleRate =
wavView[24] + (wavView[25] << 8) + (wavView[26] << 16) + (wavView[27] << 24);
var bitRate = wavView[34] + (wavView[35] << 8);
//搜索data块的位置
var dataPos = 0; // 44 或有更多块
for (var i = 12, iL = wavView.length - 8; i < iL; ) {
if (
wavView[i] == 100 &&
wavView[i + 1] == 97 &&
wavView[i + 2] == 116 &&
wavView[i + 3] == 97
) {
//eq(i,"data")
dataPos = i + 8;
break;
}
i += 4;
i +=
4 +
wavView[i] +
(wavView[i + 1] << 8) +
(wavView[i + 2] << 16) +
(wavView[i + 3] << 24);
} // 这里我自己存疑,有没有大佬解答下?
// console.log('wav info', sampleRate, bitRate, numCh, dataPos);
if (dataPos) {
if (bitRate == 16) {
pcm = new Int16Array(wavView.buffer.slice(dataPos));
} else if (bitRate == 8) {
pcm = new Int16Array(wavView.length - dataPos);
//8位转成16位
for (var j = dataPos, d = 0; j < wavView.length; j++, d++) {
var b = wavView[j];
pcm[d] = (b - 128) << 8;
}
}
}
if (pcm && numCh == 2) {
//双声道简单转单声道
var pcm1 = new Int16Array(pcm.length / 2);
for (let i = 0; i < pcm1.length; i++) {
pcm1[i] = (pcm[i * 2] + pcm[i * 2 + 1]) / 2;
}
pcm = pcm1;
}
}
}
if (!pcm) {
False && False('非单或双声道wav raw pcm格式音频,无法转码');
return;
}
};
原始代码参见(Recorder代码运行和静态分发工具),这里也有不少存疑的地方比如第40行,以及可以使用es6在实际使用的时候进行重构等(注意性能)
WASM
对于大量计算,我们很容易想到用WASM去实现,从而提高性能,逻辑上我们不做任何改动,只需将以上代码重构为WASM版的即可,实现上我们使用AssembleScript实现:
typescript
function eq(p: i32,s: string, wavView: Uint8Array): bool{
for(let i: i32=0;i<s.length;i++){
if(wavView[p+i]!=s.charCodeAt(i)){
return false;
};
};
return true;
};
export function wav2pcmWasm (wavView: Uint8Array): Int16Array {
var pcm = new Int16Array(0);
if(eq(0,"RIFF", wavView)&&eq(8,"WAVEfmt ", wavView)){
var numCh=wavView[22];
if(wavView[20]==1 && (numCh==1||numCh==2)){//raw pcm 单或双声道
var sampleRate=wavView[24]+(wavView[25]<<8)+(wavView[26]<<16)+(wavView[27]<<24);
var bitRate=wavView[34]+(wavView[35]<<8);
//搜索data块的位置
var dataPos=0; // 44 或有更多块
for(var i=12,iL=wavView.length-8;i<iL;){
// console.log('dataPos search')
// console.log(i.toString())
if(wavView[i]==100&&wavView[i+1]==97&&wavView[i+2]==116&&wavView[i+3]==97){//eq(i,"data")
dataPos=i+8;break;
}
i+=4;
i+=4+wavView[i]+(wavView[i+1]<<8)+(wavView[i+2]<<16)+(wavView[i+3]<<24);
}
// console.log("wav info"+sampleRate.toString() + ','+bitRate.toString()+','+numCh.toString()+','+dataPos.toString());
if(dataPos){
if(bitRate==16){
const slicedBuffer = wavView.slice(dataPos)
const L = slicedBuffer.length
pcm = new Int16Array(L >> 1 + (L % 2));
const pcmLength = pcm.length
// console.log('这儿 bitRate')
var ii = 0, j = 0
// for(; j < pcmLength ; ii += 2, j += 1) {
// // 两个字节组合成一个16位
// var nextNumber = ii === L - 1 ? 0 :slicedBuffer[ii+1]
// pcm[j] = (slicedBuffer[ii] << 8) | nextNumber
// }
}else if(bitRate==8){
pcm=new Int16Array(wavView.length-dataPos);
//8位转成16位
var jj=dataPos,d=0
for(;jj<wavView.length;jj++,d++){
var b=wavView[jj];
// 0-255 -》 -128-127
pcm[d]=(b-128)<<8;
};
};
};
if(pcm && numCh==2){//双声道简单转单声道
// console.log('这儿numCh')
var pcm1=new Int16Array(pcm.length/2);
var k=0
for(;k<pcm1.length;k++){
pcm1[k]=(pcm[k*2]+pcm[k*2+1])/2;
}
pcm=pcm1;
};
};
} else {
console.error('RIFF头,WAVEfmt 头不存在')
};
return pcm
}
后记,wasm代码要在生产环境运行,请务必慎重,笔者实际测了一下二者的性能,实际数据相差无几,不知道是不是我WASM版的哪里写的不对,如果有大佬看出来了也请指正下哈。