14

如何实现前端录音功能

 6 years ago
source link: https://www.yinchengli.com/2018/09/02/js-record-audio/?amp%3Butm_medium=referral
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

有两种方式,一种是使用 MediaRecorder ,另一种是使用WebRTC的getUserMedia结合AudioContext,MediaRecorder出现得比较早,但是Safari/Edge等浏览器一直没有实现API,所以兼容性不是很好,而WebRTC已经得到了所有主流浏览器的实现,Safari 11开始就支持了。所以我们用WebRTC的方式进行录制。

AudioContext播放声音相关使用我已经在《 Chrome 66禁止声音自动播放之后 》做过介绍,本篇我们会继续用到AudioContext的API.

为实现录音功能,我们先从播放本地文件音乐说起,因为有些API是通用的。

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

播放音频可以使用audio标签,也可以使用AudioContext,audio标签需要一个URL,它可以是一个远程的http协议的url,也可以是一个本地的blob协议的url,怎么创建一个本地的url呢?

使用以下html做为说明:

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

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

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即原始的二进制内容,把它打印如下所示:

yQfYBr6.png!web

可以用这个ArrayBuffer实例化一个Uint8Array就能读取它里面的内容,Uint8Array数组里面的每个元素都是一个无符号整型8位数字,即0 ~ 255,相当于每1个字节的二进制内容就保存为一个数。更多讨论可以见这篇《 前端本地文件操作与上传 》。

这个arrayBuffer可以转成一个blob,然后用这个blob生成一个url,如下代码所示:

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怎么播放声音呢,我们来分析一下它的模型,如下图所示:

IZFzYf3.png!web

我们拿到一个ArrayBuffer之后,使用AudioContext的decodeAudioData进行解码,生成一个AudioBuffer实例,把它做为一个AudioBufferSourceNode对象的buffer属性,它继承于AudioNode,它还有connect和start两个方法,start是开始播放,在开始播放之前,需要调一个connect把这个audioNode连结到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打印出来,如下图所示:

2qMB73j.png!web

他有几个对开发人员可见的属性,包括音频时长,声道数量和采样率。从打印的结果可以知道播放的音频是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到扬声器。如下图示意:

fI3yiuB.png!web

这些节点都是使用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);
    }).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);
});

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

r2Any2J.png!web

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

AVnMbuy.png!web

它是音频流的抽象,把这个流用来初始化一个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,另外两个输入和输出频道,这里设定为双声道。缓冲区大小是jsNode每次处理的数据单位,onaudioprocess函数是不断触发的,每次都是取4kB的数据。它里面有两个缓冲区,一个是输入inputBuffer,另一个是输出outputBuffer,它们是AudioBuffer实例。可以在onaudioprocess回调里面获取到inputBuffer的数据,处理之后,然后放到outputBuffer,如下图所示:

6j6Jve2.png!web

例如我们可以把第1步播放本音频用到的bufferSourceNode连接到jsNode,然后jsNode再连接到扬声器,就能在process回调里面分批处理声音的数据,如降噪。当扬声器把4kB的outputBuffer消费完之后,就会触发process回调。

而在录音的例子里,是要把mediaNode连接到这个jsNode,这样一旦inputBuffer满4KB就会触发process事件,进而拿到录音的数据,这些数据不断地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:

Fze2Y3z.png!web

也就是说每隔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位的单精度浮点数,如下图所示:

ryI7bam.png!web

这里有个问题,录音的数据到底表示的是什么呢,它是采样采来的表示声音的强弱,声波被麦克风转换为不同强度的电流信号,这些数字就代表了信号的强弱。它的取值范围是[-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);
    rightDataList.push(rightChannelData);
}

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

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

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

Enmmqur.png!web

是一个普通数组里面有很多个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 + 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);
}

接下来写入录音数据,我们准备写入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;
}

最后,再用最开始提到的生成一个本地播放的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. 小结

回顾一下,整体的流程是这样的:

fa2UNza.png!web

先调用webRTC的getUserMediaStream获取音频流,用这个流初始化一个mediaNode,把它connect连接到一个jsNode,在jsNode的process回调里面不断地获取到录音的数据,停止录音后,把这些数据合并换算成16位的整型数据,并写入wav头部信息生成一个wav音频文件的内存buffer,把这个buffer封装成Blob文件,就能够在本地播放,或者是借助FormData进行上传。这个过程理解了就不是很复杂了。

本篇涉及到了WebRTC和AudioContext的API,重点介绍了AudioContext整体的模型,并且知道了音频数据实际上就是声音强弱的记录,存储的时候通过乘以16位整数的最大值换算成16位位深的表示。还可以利用blob和URL.createObjectURL生成一个本地数据的blob链接。

那个录音库最后面还使用了webworker进行合并左右声道数据和生成wav文件,可进一步提高效率,避免录音文件太大最后处理的时候卡住了。

Post Views: 5


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK