转载至: https://juejin.im/post/5b8bf7e3e51d4538c210c6b0
前端实现录音有两种方式,一种是使用MediaRecorder,另一种是使用 WebRTC 的 getUserMedia 结合 AudioContext,MediaRecorder 出现得比较早,只不过 Safari/Edge 等浏览器一直没有实现,所以兼容性不是很好,而 WebRTC 已经得到了所有主流浏览器的支持,如 Safari 11 起就支持了。所以我们用 WebRTC 的方式进行录制。
利用 AudioContext 播放声音的使用,我已经在《Chrome 66 禁止声音自动播放之后》做过介绍,本篇我们会继续用到 AudioContext 的 API.
为实现录音功能,我们先从播放本地文件音乐说起,因为有些 API 是通用的。
1. 播放本地音频文件实现
播放音频可以使用 audio 标签,也可以使用 AudioContext,audio 标签需要一个 url,它可以是一个远程的 http 协议的 url,也可以是一个本地的 blob 协议的 url,怎么创建一个本地的 url 呢?
使用以下 html 做为说明:
1 | <input type="file" onchange="playMusic.call(this)" class="select-file"> |
提供一个 file input 上传控件让用户选择本地的文件和一个 audio 标签准备来播放。当用户选择文件后会触发 onchange 事件,在 onchange 回调里面就可以拿到文件的内容,如下代码所示:
1 | function playMusic () { |
这里使用一个 FileReader 读取文件,读取为 ArrayBuffer 即原始的二进制内容,把它打印如下所示:
可以用这个 ArrayBuffer 实例化一个 Uint8Array 就能读取它里面的内容,Uint8Array 数组里面的每个元素都是一个无符号整型 8 位数字,即 0 ~ 255,相当于每 1 个字节的 0101 内容就读取为一个整数。更多讨论可以见这篇《前端本地文件操作与上传》。
这个 arrayBuffer 可以转成一个 blob,然后用这个 blob 生成一个 url,如下代码所示:
1 | fileReader.onload = function () { |
主要利用 URL.createObjectURL 这个 API 生成一个 blob 的 url,这个 url 打印出来是这样的:
blob:null/c2df9f4d-a19d-4016-9fb6-b4899bac630d
然后丢给 audio 标签就能播放了,作用相当于一个远程的 http 的 url.
在使用 ArrayBuffer 生成 blob 对象的时候可以指定文件类型或者叫 mime 类型,如下代码所示:
1 | let blob = new Blob([new Int8Array(this.result)], { |
这个 mime 可以通过 file input 的 files[0].type 得到,而 files[0]是一个 File 实例,File 有 mime 类型,而 Blob 也有,因为 File 是继承于 Blob 的,两者是同根的。所以在上面实现代码里面其实不需要读取为 ArrayBuffer 然后再封装成一个 Blob,直接使用 File 就行了,如下代码所示:
1 | function playMusic () { |
而使用 AudioContext 需要拿到文件的内容,然后手动进行音频解码才能播放。
2. AudioContext 的模型
使用 AudioContext 怎么播放声音呢,我们来分析一下它的模型,如下图所示:
我们拿到一个 ArrayBuffer 之后,使用 AudioContext 的 decodeAudioData 进行解码,生成一个 AudioBuffer 实例,把它做为 AudioBufferSourceNode 对象的 buffer 属性,这个 Node 继承于 AudioNode,它还有 connect 和 start 两个方法,start 是开始播放,而在开始播放之前,需要调一下 connect,把这个 Node 连结到 audioContext.destination 即扬声器设备。代码如下所示:
1 | function play (arrayBuffer) { |
把解码后的 audioBuffer 打印出来,如下图所示:
他有几个对开发人员可见的属性,包括音频时长,声道数量和采样率。从打印的结果可以知道播放的音频是 2 声道,采样率为 44.1k Hz,时长为 196.8s。关于声音这些属性的意义可见《从 Chrome 源码看 audio/video 流媒体实现一》.
从上面的代码可以看到,利用 AudioContext 处理声音有一个很重要的枢纽元素 AudioNode,上面使用的是 AudioBufferSourceNode,它的数据来源于一个解码好的完整的 buffer。其它继承于 AudioNode 的还有 GainNode:用于设置音量、BiquadFilterNode:用于滤波、ScriptProcessorNode:提供了一个 onaudioprocess 的回调让你分析处理音频数据、MediaStreamAudioSourceNode:用于连接麦克风设备,等等。这些结点可以用装饰者模式,一层层 connect,如上面代码使用到的 bufferSourceNode 可以先 connect 到 gainNode,再由 gainNode connect 到扬声器,就能调整音量了。
如下图示意:
这些节点都是使用 audioContext 的工厂函数创建的,如调 createGainNode 就可以创建一个 gainNode.
说了这么多就是为了录音做准备,录音需要用到 ScriptProcessorNode.
3. 录音的实现
上面播放音乐的来源是本地音频文件,而录音的来源是麦克风,为了能够获取调起麦克风并获取数据,需要使用 WebRTC 的 getUserMedia,如下代码所示;
1 | <button onclick="record()">开始录音</button> |
在调用 getUserMedia 的时候指定需要录制音频,如果同时需要录制视频那么再加一个 video: true 就可以了,也可以指定录制的格式:
1 | window.navigator.mediaDevices.getUserMedia({ |
调用的时候,浏览器会弹一个框,询问用户是否允许使用用麦克风:
如果用户点了拒绝,那么会抛异常,在 catch 里面可以捕获到,而如果一切顺序的话,将会返回一个 MediaStream 对象:
它是音频流的抽象,把这个流用来初始化一个 MediaStreamAudioSourceNode 对象,然后把这个节点 connect 连接到一个 JavascriptProcessorNode,在它的 onaudioprocess 里面获取到音频数据,然后保存起来,就得到录音的数据。
如果想直接把录的音直接播放出来的话,那么只要把它 connect 到扬声器就行了,如下代码所示:
1 | function beginRecord (mediaStream) { |
但一边录一边播的话,如果没用耳机的话容易产生回音,这里不要播放了。
为了获取录到的音的数据,我们把它 connect 到一个 javascriptProcessorNode,为此先创建一个实例:
1 | function createJSNode (audioContext) { |
这里是使用 createScriptProcessor 创建的对象,需要传三个参数:一个是缓冲区大小,通常设定为 4kB,另外两个是输入和输出频道数量,这里设定为双声道。它里面有两个缓冲区,一个是输入 inputBuffer,另一个是输出 outputBuffer,它们是 AudioBuffer 实例。可以在 onaudioprocess 回调里面获取到 inputBuffer 的数据,处理之后,然后放到 outputBuffer,如下图所示:
例如我们可以把第 1 步播放本音频用到的 bufferSourceNode 连接到 jsNode,然后 jsNode 再连接到扬声器,就能在 process 回调里面分批处理声音的数据,如降噪。当扬声器把 4kB 的 outputBuffer 消费完之后,就会触发 process 回调。所以 process 回调是不断触发的。
在录音的例子里,是要把 mediaNode 连接到这个 jsNode,进而拿到录音的数据,把这些数据不断地 push 到一个数组,直到录音终止了。如下代码所示:
1 | function onAudioProcess (event) { |
我们把 inputBuffer 打印出来,可以看到每一段大概是 0.09s:
也就是说每隔 0.09 秒就会触发一次。接下来的工作就是在 process 回调里面把录音的数据持续地保存起来,如下代码所示,分别获取到左声道和右声道的数据:
1 | function onAudioProcess (event) { |
打印出来可以看到它是一个 Float32Array,即数组里的每个数字都是 32 位的单精度浮点数,如下图所示:
这里有个问题,录音的数据到底表示的是什么呢,它是采样采来的表示声音的强弱,声波被麦克风转换为不同强度的电流信号,这些数字就代表了信号的强弱。它的取值范围是[-1, 1],表示一个相对比例。
然后不断地 push 到一个 array 里面:
1 | let leftDataList = [], |
最后加一个停止录音的按钮,并响应操作:
1 | function stopRecord () { |
把保存的数据打印出来是这样的:
是一个普通数组里面有很多个 Float32Array,接下来它们合成一个单个 Float32Array:
1 | function mergeArray (list) { |
那为什么一开始不直接就弄成一个单个的,因为这种 Array 不太方便扩容。一开始不知道数组总的长度,因为不确定要录多长,所以等结束录音的时候再合并一下比较方便。
然后把左右声道的数据合并一下,wav 格式存储的时候并不是先放左声道再放右声道的,而是一个左声道数据,一个右声道数据交叉放的,如下代码所示:
1 | // 交叉合并左右声道的数据 |
最后创建一个 wav 文件,首先写入 wav 的头部信息,包括设置声道、采样率、位声等,如下代码所示:
1 | function createWavFile (audioData) { |
接下来写入录音数据,我们准备写入 16 位位深即用 16 位二进制表示声音的强弱,16 位表示的范围是 [-32768, +32767],最大值是 32767 即 0x7FFF,录音数据的取值范围是[-1, 1],表示相对比例,用这个比例乘以最大值就是实际要存储的值。如下代码所示:
1 | function createWavFile (audioData) { |
最后,再用第 1 点提到的生成一个本地播放的 blob url 就能够播放刚刚录的音了,如下代码所示:
1 | function playRecord (arrayBuffer) { |
或者是把 blob 使用 FormData 进行上传。
整一个录音的实现基本就结束了,代码参考了一个录音库RecordRTC。
4. 小结
回顾一下,整体的流程是这样的:
先调用 webRTC 的 getUserMediaStream 获取音频流,用这个流初始化一个 mediaNode,把它 connect 连接到一个 jsNode,在 jsNode 的 process 回调里面不断地获取到录音的数据,停止录音后,把这些数据合并换算成 16 位的整型数据,并写入 wav 头部信息生成一个 wav 音频文件的内存 buffer,把这个 buffer 封装成 Blob 文件,生成一个 url,就能够在本地播放,或者是借助 FormData 进行上传。这个过程理解了就不是很复杂了。
本篇涉及到了 WebRTC 和 AudioContext 的 API,重点介绍了 AudioContext 整体的模型,并且知道了音频数据实际上就是声音强弱的记录,存储的时候通过乘以 16 位整数的最大值换算成 16 位位深的表示。同时可利用 blob 和 URL.createObjectURL 生成一个本地数据的 blob 链接。
RecordRTC 录音库最后面还使用了 webworker 进行合并左右声道数据和生成 wav 文件,可进一步提高效率,避免录音文件太大后面处理的时候卡住了。
转载至: https://juejin.im/post/5b8bf7e3e51d4538c210c6b0