这篇博客是我在开发一个“音频文件拼接录音及裁剪”功能过程中的笔记,因为学习到了很多没接触过的内容,所以在这里做个记录,这不是个教程贴,文中不涉及业务代码,只有一小部分核心代码用来解释我描述的内容而已,基本上是给我自己看的哈哈。

本文是讲我在做个人项目中开发中涉及到的一个小需求,主要实现下面4个功能:

  1. 实现录制音频的基本功能,录制一段开始到结束的音频,并能导出文件形式;
  2. 可以对录制好的音频进行播放,可以拖动进度条改变进度,并能在进度条的位置继续录制音频,将新录制的音频覆盖进度条之后的音频,并与进度条之前的音频合并;
  3. 将音频文件导入并进行录音,录音完成后会将录制的音频与导入的音频合并;
  4. 同样是将音频文件导入后与第2条的功能相同;

    上面提到的功能我在开源社区没有找到同时满足的插件,基本只有第一个功能,所以我找了一个插件进行改造,我用的是 https://github.com/2fps/recorder 这个插件,后面的功能都是基于这个插件进行改造扩展的,非常感谢这个插件作者让我学习到了实现录制音频的原理,也是因为这个插件让我有信心完成了后面3个功能。

实现这几个小功能花了3天时间,主要是学习吧,我就按第x天来讲吧。

第一天

研究encodePCM,尝试逆向decode,但对于二进制概念基础还不够牢固,所以先研究二进制的各种对象 ArrayBuffer、TypedArray和DataView之间的关系与数据规律,总结出了以下特性:

Arraybuffer对象用来表示通用的、固定长度的原始二进制数据缓冲区,它不能直接被用来读写,需要转成DataView才能读写其中的数据,一般用来数据传输。

DataView视图是一个可以从 ArrayBuffer 对象中读写多种数值类型的底层接口,它的构造函数传参必须是Arraybuffer数组,主要就是用来按数据类型和位数读写数据,用到的方法有setInt16、getInt16这两个,还有很多set和get的方法可以使用,本项目还用到它来生成Blob对象进行文件处理。

TypedArray 可以认为是用来描述ArrayBuffer的类型化数组,与DataView没有直接关系,但作用和DataView类似都是对ArrayBuffer的数据进行读写,只不过是以特定的数据类型以数组来处理。

当你通过DataView对一个ArrayBuffer对象进行setInt8(0, 1)的操作,代表ArrayBuffer按Int8读取时的第一个值为1,用TypedArray的方式读取就可以用new Int8Array(buffer)来按Int8的数据类型来读取ArrayBuffer。这样就能得到第一个值为1的类数组对象,然后就可以按数组的方式对其进行处理。如果用Float32Array来读取这段ArrayBuffer的话,得到的第一个值就会自动转为Float32类型的值。

理清这三个家伙的关系回来看就知道该怎么做了,于是开始摸索怎样把encode出来并生成Blob的音频文件解码回能进行剪切的格式。

从录制音频的源头开始看起,AudioContext录制时的事件会返回buffer数据,是Float32Array类型的,值为[-1,1]区间的32位浮点型。由于音频采样率非常的高(最高达到每秒48000
次),所以每4096次采集一次存放在this.buffer数组当中,所以this.buffer是一个包含了Float32Array的二维数组。

所以我们如果要对音频进行截取拼接等操作都是对这个this.buffer进行操作的,实质上就是对采集得到的Float32Array数据进行处理。

知道了应该操作哪个数据就可以知道实现整个音频裁剪拼接功能的完整流程了。

大概是如下流程:

  1. 将音频文件进行decode解码,得到我们可以处理的this.buffer数据;
  2. 对this.buffer数据做相应的裁剪拼接处理;
  3. 将处理完的this.buffer再encode回去,编码成可以播放的音频文件;

第1步是最难的,后面主要讲第一步。
第2步其实就是像数组一样操作裁剪想要的长度,拼接也是跟数组一样的
第3步其实本身就提供了encodePCM、encodeWAV等方法,不需要做

第一天大部分是对概念的和流程的理清,从音频流接收的二进制到编码再到音频文件,明白了这其中是怎么进行转换的,就可以对需要处理的数据this.buffer进行想要的操作(包括但不限于音频裁剪和拼接),最后能编码成可播放的音频文件。

第二天

接下来开始写decodePCM了,先来分析encodePCM做了什么,它的主要作用就是把元数据的[-1,1]的Float32Array转换成Int16或者Int8的的DataView数据。那我们要做的decode当然就是把Int16或者Int8的的DataView数据转换成[-1,1]的Float32Array啦。

贴上encode的核心代码:

1
2
3
4
5
6
7
data = new Float32Array(dataview.byteLength)
for (var i = 0; i < bytes.length; i++ , offset += 2) {
        var s = Math.max(-1, Math.min(1, bytes[i]));
        // 16位的划分的是2^16=65536份,范围是-32768到32767
        // 因为我们收集的数据范围在[-1,1],那么你想转换成16位的话,只需要对负数*32768,对正数*32767,即可得到范围在[-32768,32767]的数据。
        data.setInt16(offset, s < 0 ? s * 0x8000 : s * 0x7FFF, true);
      }

再贴上decode的解码代码:

1
2
3
4
5
6
7
8
9
// byteLength是8位一个字节的长度,所以16位就要除以2
data = new Float32Array(dataview.byteLength / 2)
      for (var i = 0; i < data.length; i++, offset += 2) {
        // 在encodePCM的时候是setInt16,所以用getInt16的时候取到的是整数没有小数点,这里会有些误差
        var s = Math.max(-32768, Math.min(32767, bytes.getInt16(offset, true)));
        // 16位的划分的是2^16=65536份,范围是-32768到32767
        // 转换为[-1, 1]的数据
        data[i] = s < 0 ? s / 0x8000 : s / 0x7FFF
      }

字节序

实际上我在开发过程遇到了两个问题:

一个是字节序的问题,在解码的时候bytes.getInt16(offset)发现get出来的数据跟编码时set的值不一致!好像还比set的值要大,调试了半天最后发现set的时候带了第三个参数填了true,查文档是这么描述的:

应该是规定setInt16时用什么字节序,与之对应的getInt16也有这个参数,那么字节序是啥,我一开始也不懂,后来看到阮一峰大佬的这篇文章http://www.ruanyifeng.com/blog/2016/11/byte-order.html 就很好理解了。

字节序分大端和小端,参数为true时是用小端字节序,所以在get的时候也要对应同样的字节序传true,上面的decode代码就是正确的,这样子set和get就一致了。

第二个问题是get后的数值与set时的数据只是比较接近而已,并不是完全相等,这是因为我们用了setInt16就会以16位整数存在Buffer中,这样子就会把set的值小数点后的数都截掉,所以get出来的值就是个整数,所以这里会存在一点小误差,大概是0.003051850948%的误差,这样的误差几乎可以忽略不计,如果想更精确的话可以改为setFloat32和getFloat32,但是文件体积应该会加大一倍。

解决了之后,decodePCM算是完成了,接下来再写一个接收文件blob对象,转换为ArrayBuffer对象再交给decodePCM函数解码的一个函数。

Blob对象怎么转ArrayBuffer?有几种办法,一种是请求的方式,fetch、Response、ajax都可以,而我用的是另一种方式,用的是FileReader,直接贴代码:

1
2
3
4
5
6
7
function Blob2Arraybuffer(blob, cb) {
  var reader = new FileReader();
  reader.readAsArrayBuffer(blob);
  reader.onload = function () {
    cb(reader.result);
  }
}

原生就有提供readAsArrayBuffer的接口,还是很容易转的。接下来就是验证从纯文件解码出来的buffer到底能不能被处理过之后还能再编码出来播放了。

文件解码

我们可以开出一个load方法,让外部传文件进来做解码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
  Recorder.prototype.load = function(blob) {
    var _this = this;
    Recorder.decodePCM(blob, this.oututSampleBits, function(data) {
      // 将一维data提升二维,this.buffer的格式
      var buffer = [];
      var index = 0;
      while (index < data.length - 1){
        buffer.push(data.slice(index, index+=4096));
      }
      var size = data.length
      var duration = data.length / _this.inputSampleRate;
      _this.buffer = buffer;
      _this.size = size;
      _this.duration = duration; // 再编码成音频文件
const audio = new Audio(URL.createObjectURL(_this.getWAVBlob()));
      _this.audio = audio;
    })
  };

上面有句代码是 const audio = new Audio(URL.createObjectURL(_this.getWAVBlob()));就是看解码完成之后再编码成音频文件,然后我们再调用_this.audio.play()播放,是可以正常播放的,说明我解码出来的this.buffer是可以被正常编码回文件播放的,声音听起来是没有差别的。

然后在业务页面加载外部文件,得到blob调用load方法:

1
2
3
4
5
axios.get('/data.wav’, {
responseType: 'blob',
}).then((res) => {
this.recorder.load(res.data)
})

其实请求头的responseType可以是arraybuffer的,只是我觉得Blob作为数据交换会更通用一些。

验证完这步可行之后要把它改回直接用传入的文件Blob来播放会好一些,

const audio = new Audio(URL.createObjectURL(blob));

第三天

第三天就把需求的功能实现(音频裁剪、拼接),基本上只要对this.buffer做处理就好了,这里就不放具体代码了,说说思路就好了。

裁剪

传入一个进度相关的参数,我传的是[0,1]的百分比,然后把解码后的Float32Array大数组按百分比裁剪,size和duration直接拿裁剪后的数组长度填上即可,不过裁剪后的Float32Array大数组要再次合并成this.buffer的二维数组。

拼接

拼接就更简单了,先把裁剪后的数据存放在一个临时的对象里,然后清空this.buffer等数据重新录制,录制完后再把临时对象里的裁剪音频插入到this.buffer的前面,size和duration相加即可,

最后就是做一些代码结构的优化和小修改的优化的工作了。算是基本完成了想要的这两个功能了。

接下来还有一些计划要做的东西:

  • 优化性能,毕竟是在内存中处理二进制数据,内存回收释放还需要注意的,也可以考虑新开一个worker处理二进制;
  • 导出mp3格式的文件,能支持更多客户端环境播放。
  • 考虑改变人声,这个可能接触到更多我不知道的内容,比如波形、音调、之类的,这需要进行技术攻坚,我的目标是能把人声变成小黄人的声音。

总结

从用户角度仅仅是简单的录音功能,在程序中则要以极其微观的机器角度来思考怎样以数据的形式处理。通过这次开发,我也算是打开了音频处理程序的第一扇门吧,也是第一次感受到了ArrayBuffer、DataView和TypedArray 这三个js原生api真正的魅力。

我学到了很多音频相关的概念,这些概念不仅仅是用来开发出我需求的那几个小功能,我可以通过这些概念结合自己已有的知识碰撞出更酷的想法,创造出更酷的程序,现在的我说出这种话就像屁话,但可以作为目标,做更好的自己。