如何实现前端录音功能

前端实现录音有两种方式,一种是使用MediaRecorder,另外一种是使用WebRTC的getUserMedia结合AudioContext,MediaRecorder出现得比较早,只不过Safari/Edge等浏览器一直没有实现,因此兼容性不是很好,而WebRTC已经获得了全部主流浏览器的支持,如Safari 11起就支持了。因此咱们用WebRTC的方式进行录制。javascript

利用AudioContext播放声音的使用,我已经在《Chrome 66禁止声音自动播放以后》作过介绍,本篇咱们会继续用到AudioContext的API.html

为实现录音功能,咱们先从播放本地文件音乐提及,由于有些API是通用的。前端

1. 播放本地音频文件实现

播放音频可使用audio标签,也可使用AudioContext,audio标签须要一个url,它能够是一个远程的http协议的url,也能够是一个本地的blob协议的url,怎么建立一个本地的url呢?java

使用如下html作为说明:node

<input type="file" onchange="playMusic.call(this)" class="select-file"> <audio class="audio-node" autoplay></audio>复制代码

提供一个file input上传控件让用户选择本地的文件和一个audio标签准备来播放。当用户选择文件后会触发onchange事件,在onchange回调里面就能够拿到文件的内容,以下代码所示:git

function playMusic () {
    if (!this.value) {
        return;
    }
    let fileReader = new FileReader();
    let file = this.files[0];
    fileReader.onload = function () {
        let arrayBuffer = this.result;
        console.log(arrayBuffer);
    }
    fileReader.readAsArrayBuffer(this.files[0]);
}复制代码

这里使用一个FileReader读取文件,读取为ArrayBuffer即原始的二进制内容,把它打印以下所示:github

能够用这个ArrayBuffer实例化一个Uint8Array就能读取它里面的内容,Uint8Array数组里面的每一个元素都是一个无符号整型8位数字,即0 ~ 255,至关于每1个字节的0101内容就读取为一个整数。更多讨论能够见这篇《前端本地文件操做与上传》。web

这个arrayBuffer能够转成一个blob,而后用这个blob生成一个url,以下代码所示:chrome

fileReader.onload = function () {
    let arrayBuffer = this.result;
    // 转成一个blob
    let blob = new Blob([new Int8Array(this.result)]);
    // 生成一个本地的blob url
    let blobUrl = URL.createObjectURL(blob);
    console.log(blobUrl);
    // 给audio标签的src属性
    document.querySelector('.audio-node').src = blobUrl;
}复制代码

主要利用URL.createObjectURL这个API生成一个blob的url,这个url打印出来是这样的:数组

blob:null/c2df9f4d-a19d-4016-9fb6-b4899bac630d

而后丢给audio标签就能播放了,做用至关于一个远程的http的url.

在使用ArrayBuffer生成blob对象的时候能够指定文件类型或者叫mime类型,以下代码所示:

let blob = new Blob([new Int8Array(this.result)], {
    type: 'audio/mp3' // files[0].type
});复制代码

这个mime能够经过file input的files[0].type获得,而files[0]是一个File实例,File有mime类型,而Blob也有,由于File是继承于Blob的,二者是同根的。因此在上面实现代码里面其实不须要读取为ArrayBuffer而后再封装成一个Blob,直接使用File就好了,以下代码所示:

function playMusic () {
    if (!this.value) {
        return;
    }
    // 直接使用File对象生成blob url
    let blobUrl = URL.createObjectURL(this.files[0]);
    document.querySelector('.audio-node').src = blobUrl;
}复制代码

而使用AudioContext须要拿到文件的内容,而后手动进行音频解码才能播放。

2. AudioContext的模型

使用AudioContext怎么播放声音呢,咱们来分析一下它的模型,以下图所示:

咱们拿到一个ArrayBuffer以后,使用AudioContext的decodeAudioData进行解码,生成一个AudioBuffer实例,把它作为AudioBufferSourceNode对象的buffer属性,这个Node继承于AudioNode,它还有connect和start两个方法,start是开始播放,而在开始播放以前,须要调一下connect,把这个Node连结到audioContext.destination即扬声器设备。代码以下所示:

function play (arrayBuffer) {
    // Safari须要使用webkit前缀
    let AudioContext = window.AudioContext || window.webkitAudioContext,
        audioContext = new AudioContext();
    // 建立一个AudioBufferSourceNode对象,使用AudioContext的工厂函数建立
    let audioNode = audioContext.createBufferSource();
    // 解码音频,可使用Promise,可是较老的Safari须要使用回调
    audioContext.decodeAudioData(arrayBuffer, function (audioBuffer) {
        console.log(audioBuffer);
        audioNode.buffer = audioBuffer;
        audioNode.connect(audioContext.destination); 
        // 从0s开始播放
        audioNode.start(0);
    });
}
fileReader.onload = function () {
    let arrayBuffer = this.result;
    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,以下代码所示;

<button onclick="record()">开始录音</button> <script> function record () { window.navigator.mediaDevices.getUserMedia({ audio: true }).then(mediaStream => { console.log(mediaStream); beginRecord(mediaStream); }).catch(err => { // 若是用户电脑没有麦克风设备或者用户拒绝了,或者链接出问题了等 // 这里都会抛异常,而且经过err.name能够知道是哪一种类型的错误  console.error(err); }) ; } </script>复制代码

在调用getUserMedia的时候指定须要录制音频,若是同时须要录制视频那么再加一个video: true就能够了,也能够指定录制的格式:

window.navigator.mediaDevices.getUserMedia({
    audio: {
        sampleRate: 44100, // 采样率
        channelCount: 2,   // 声道
        volume: 1.0        // 音量
    }
}).then(mediaStream => {
    console.log(mediaStream);
});复制代码

调用的时候,浏览器会弹一个框,询问用户是否容许使用用麦克风:

若是用户点了拒绝,那么会抛异常,在catch里面能够捕获到,而若是一切顺序的话,将会返回一个MediaStream对象:

它是音频流的抽象,把这个流用来初始化一个MediaStreamAudioSourceNode对象,而后把这个节点connect链接到一个JavascriptProcessorNode,在它的onaudioprocess里面获取到音频数据,而后保存起来,就获得录音的数据。

若是想直接把录的音直接播放出来的话,那么只要把它connect到扬声器就好了,以下代码所示:

function beginRecord (mediaStream) {
    let audioContext = new (window.AudioContext || window.webkitAudioContext);
    let mediaNode = audioContext.createMediaStreamSource(mediaStream);
    // 这里connect以后就会自动播放了
    mediaNode.connect(audioContext.destination);
}复制代码

但一边录一边播的话,若是没用耳机的话容易产生回音,这里不要播放了。

为了获取录到的音的数据,咱们把它connect到一个javascriptProcessorNode,为此先建立一个实例:

function createJSNode (audioContext) {
    const BUFFER_SIZE = 4096;
    const INPUT_CHANNEL_COUNT = 2;
    const OUTPUT_CHANNEL_COUNT = 2;
    // createJavaScriptNode已被废弃
    let creator = audioContext.createScriptProcessor || audioContext.createJavaScriptNode;
    creator = creator.bind(audioContext);
    return creator(BUFFER_SIZE,
                    INPUT_CHANNEL_COUNT, OUTPUT_CHANNEL_COUNT);
}复制代码

这里是使用createScriptProcessor建立的对象,须要传三个参数:一个是缓冲区大小,一般设定为4kB,另外两个是输入和输出频道数量,这里设定为双声道。它里面有两个缓冲区,一个是输入inputBuffer,另外一个是输出outputBuffer,它们是AudioBuffer实例。能够在onaudioprocess回调里面获取到inputBuffer的数据,处理以后,而后放到outputBuffer,以下图所示:

例如咱们能够把第1步播放本音频用到的bufferSourceNode链接到jsNode,而后jsNode再链接到扬声器,就能在process回调里面分批处理声音的数据,如降噪。当扬声器把4kB的outputBuffer消费完以后,就会触发process回调。因此process回调是不断触发的。

在录音的例子里,是要把mediaNode链接到这个jsNode,进而拿到录音的数据,把这些数据不断地push到一个数组,直到录音终止了。以下代码所示:

function onAudioProcess (event) {
    console.log(event.inputBuffer);
}
function beginRecord (mediaStream) {
    let audioContext = new (window.AudioContext || window.webkitAudioContext);
    let mediaNode = audioContext.createMediaStreamSource(mediaStream);
    // 建立一个jsNode
    let jsNode = createJSNode(audioContext);
    // 须要连到扬声器消费掉outputBuffer,process回调才能触发
    // 而且因为不给outputBuffer设置内容,因此扬声器不会播放出声音
    jsNode.connect(audioContext.destination);
    jsNode.onaudioprocess = onAudioProcess;
    // 把mediaNode链接到jsNode
    mediaNode.connect(jsNode);
}复制代码

咱们把inputBuffer打印出来,能够看到每一段大概是0.09s:

也就是说每隔0.09秒就会触发一次。接下来的工做就是在process回调里面把录音的数据持续地保存起来,以下代码所示,分别获取到左声道和右声道的数据:

function onAudioProcess (event) {
    let audioBuffer = event.inputBuffer;
    let leftChannelData = audioBuffer.getChannelData(0),
        rightChannelData = audioBuffer.getChannelData(1);
    console.log(leftChannelData, rightChannelData);
}复制代码

打印出来能够看到它是一个Float32Array,即数组里的每一个数字都是32位的单精度浮点数,以下图所示:

这里有个问题,录音的数据到底表示的是什么呢,它是采样采来的表示声音的强弱,声波被麦克风转换为不一样强度的电流信号,这些数字就表明了信号的强弱。它的取值范围是[-1, 1],表示一个相对比例。

而后不断地push到一个array里面:

let leftDataList = [],
    rightDataList = [];
function onAudioProcess (event) {
    let audioBuffer = event.inputBuffer;
    let leftChannelData = audioBuffer.getChannelData(0),
        rightChannelData = audioBuffer.getChannelData(1);
    // 须要克隆一下
    leftDataList.push(leftChannelData.slice(0));
    rightDataList.push(rightChannelData.slice(0));
}复制代码

最后加一个中止录音的按钮,并响应操做:

function stopRecord () {
    // 中止录音
    mediaStream.getAudioTracks()[0].stop();
    mediaNode.disconnect();
    jsNode.disconnect();
    console.log(leftDataList, rightDataList);
}复制代码

把保存的数据打印出来是这样的:

是一个普通数组里面有不少个Float32Array,接下来它们合成一个单个Float32Array:

function mergeArray (list) {
    let length = list.length * list[0].length;
    let data = new Float32Array(length),
        offset = 0;
    for (let i = 0; i < list.length; i++) {
        data.set(list[i], offset);
        offset += list[i].length;
    }
    return data;
}
function stopRecord () {
    // 中止录音
    let leftData = mergeArray(leftDataList),
        rightData = mergeArray(rightDataList);
}复制代码

那为何一开始不直接就弄成一个单个的,由于这种Array不太方便扩容。一开始不知道数组总的长度,由于不肯定要录多长,因此等结束录音的时候再合并一下比较方便。

而后把左右声道的数据合并一下,wav格式存储的时候并非先放左声道再放右声道的,而是一个左声道数据,一个右声道数据交叉放的,以下代码所示:

// 交叉合并左右声道的数据
function interleaveLeftAndRight (left, right) {
    let totalLength = left.length + right.length;
    let data = new Float32Array(totalLength);
    for (let i = 0; i < left.length; i++) {
        let k = i * 2;
        data[k] = left[i];
        data[k + 1] = right[i];
    }
    return data;
}复制代码

最后建立一个wav文件,首先写入wav的头部信息,包括设置声道、采样率、位声等,以下代码所示:

function createWavFile (audioData) {
    const WAV_HEAD_SIZE = 44;
    let buffer = new ArrayBuffer(audioData.length * 2 + WAV_HEAD_SIZE),
        // 须要用一个view来操控buffer
        view = new DataView(buffer);
    // 写入wav头部信息
    // RIFF chunk descriptor/identifier
    writeUTFBytes(view, 0, 'RIFF');
    // RIFF chunk length
    view.setUint32(4, 44 + audioData.length * 2, true);
    // RIFF type
    writeUTFBytes(view, 8, 'WAVE');
    // format chunk identifier
    // FMT sub-chunk
    writeUTFBytes(view, 12, 'fmt ');
    // format chunk length
    view.setUint32(16, 16, true);
    // sample format (raw)
    view.setUint16(20, 1, true);
    // stereo (2 channels)
    view.setUint16(22, 2, true);
    // sample rate
    view.setUint32(24, 44100, true);
    // byte rate (sample rate * block align)
    view.setUint32(28, 44100 * 2, true);
    // block align (channel count * bytes per sample)
    view.setUint16(32, 2 * 2, true);
    // bits per sample
    view.setUint16(34, 16, true);
    // data sub-chunk
    // data chunk identifier
    writeUTFBytes(view, 36, 'data');
    // data chunk length
    view.setUint32(40, audioData.length * 2, true);
}
function writeUTFBytes (view, offset, string) {
    var lng = string.length;
    for (var i = 0; i < lng; i++) { 
        view.setUint8(offset + i, string.charCodeAt(i));
    }
}
复制代码

接下来写入录音数据,咱们准备写入16位位深即用16位二进制表示声音的强弱,16位表示的范围是 [-32768, +32767],最大值是32767即0x7FFF,录音数据的取值范围是[-1, 1],表示相对比例,用这个比例乘以最大值就是实际要存储的值。以下代码所示:

function createWavFile (audioData) {
    // 写入wav头部,代码同上
    // 写入PCM数据
    let length = audioData.length;
    let index = 44;
    let volume = 1;
    for (let i = 0; i < length; i++) {
        view.setInt16(index, audioData[i] * (0x7FFF * volume), true);
        index += 2;
    }
    return buffer;
}复制代码

最后,再用第1点提到的生成一个本地播放的blob url就可以播放刚刚录的音了,以下代码所示:

function playRecord (arrayBuffer) {
    let blob = new Blob([new Uint8Array(arrayBuffer)]);
    let blobUrl = URL.createObjectURL(blob);
    document.querySelector('.audio-node').src = blobUrl;
}
function stopRecord () {
    // 中止录音
    let leftData = mergeArray(leftDataList),
        rightData = mergeArray(rightDataList);
    let allData = interleaveLeftAndRight(leftData, rightData);
    let wavBuffer = createWavFile(allData);
    playRecord(wavBuffer);
}复制代码

或者是把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文件,可进一步提升效率,避免录音文件太大后面处理的时候卡住了。

相关文章
相关标签/搜索