浅谈H5音频处理(更多谈谈录音方向的内容)

11,872 阅读18分钟

最近需要做一个实时录音然后根据音频流实时反馈出调用静音分析(VAD)以及语音识别(ASR)接口的功能。于是研究起H5有关这方面的支持。

H5的Web Audio API

首先需要弄清一点,Web Audio API和H5的<audio>完全不是一个体量级的东西,<audio>可以很方便地让你将音频文件丢进去就自带各种花式功能。但是如果直接用Web Auido API进行操作,你甚至可以无中生有地创造声音。 这里粗糙地罗列一下它能做什么事情(尽我所能查到的资料):

  1. 对简单或复杂的声音进行混合;
  2. 精确地控制声音的密度和节奏;
  3. 内置淡入、淡出,颗粒噪点、音调控制等效果;
  4. 灵活的处理在音频流的声道,使它们成为拆分和合并;
  5. 处理从<audio>音频或<video>视频的媒体元素和音频源。
  6. 使用MediaStream的getUserMedia()方法实时处理现场输入的音频,例如变声;
  7. 立体音效,支持多种3D游戏和沉浸式环境。
  8. 利用卷积引擎,创造各种线性音效,例如小/大房间、大教堂、音乐厅、洞穴、隧道、走廊、森林等。尤其适用于创建高质量的房间效果。(就是音乐播放器的那种特效~)
  9. 高效实时的时域和频的分析,配合CSS3或Canvas或WebGL可以做到音乐的可视化支持。
  10. 音频波形算法控制,也就是只要你研究地足够透彻,你把AU搬到web上实现也就阔以的。

然而,这么多高深地功能,很多前端开发者其实都没有这样地需求去接触。罗列在这里,是为了让自己接到类似需求的时候能准确判断对此类需求能做到什么程度。

简单生成点声音

网页一般是无声的,但是当你尝试去给你的点击产生一个声音,对于特殊场景下会让客户耳目一新。这里例子主要参考自张鑫旭--利用HTML5 Web Audio API给网页交互增加声音给到的例子。我们逐行来分析一下代码来看看如何实现这种效果。

1.  window.AudioContext = window.AudioContext || window.webkitAudioContext;
// 生成一个AudioContext对象
2.  var audioCtx = new AudioContext();
// 创建一个OscillatorNode, 它表示一个周期性波形(振荡),基本上来说创造了一个音调
3.  var oscillator = audioCtx.createOscillator();
 // 创建一个GainNode,它可以控制音频的总音量
4.  var gainNode = audioCtx.createGain();
// 把音量,音调和终节点进行关联
5.  oscillator.connect(gainNode);
// audioCtx.destination返回AudioDestinationNode对象,表示当前audio context中所有节点的最终节点,一般表示音频渲染设备
6.  gainNode.connect(audioCtx.destination);
// 指定音调的类型,其他还有square|triangle|sawtooth
7.  oscillator.type = 'sine';
// 设置当前播放声音的频率,也就是最终播放声音的调调
8.  oscillator.frequency.value = 196.00;
// 当前时间设置音量为0
9.  gainNode.gain.setValueAtTime(0, audioCtx.currentTime);
 // 0.01秒后音量为1
10. gainNode.gain.linearRampToValueAtTime(1, audioCtx.currentTime + 0.01);
// 音调从当前时间开始播放
11. oscillator.start(audioCtx.currentTime);
// 1秒内声音慢慢降低,是个不错的停止声音的方法
12. gainNode.gain.exponentialRampToValueAtTime(0.001, audioCtx.currentTime + 1);
// 1秒后完全停止声音
13. oscillator.stop(audioCtx.currentTime + 1);

oscillator

需要了解上面的代码我们需要对音频有一些基础的认识。首先,声音的本质其实就是震动,而震动又务必牵扯到波形,不同的波形会发出不同的声音。然后相同的波形下还会有不一样的震动频率,最终会表现为音调的高低。因此当我们需要生成一个声音的时候,就需要为它设置波形以及对应的音调,所以你可以这么理解oscillator就是一个创造音调的玩意。

那么给一个音调的创造过程就如下:设置波形-->设置频率

波形

波形主要内置了4种波形,对应发出不同的声音。主要有sine(正弦波)、square(方波)、triangle(三角波)以及sawtooth锯齿波。 当然如果有需要还可以使用setPeriodicWave自定义波形。

频率

频率这玩意很好理解。就是我们生活中接触地“do、re、mi、fa、sol、la、si”.数值越小,越低沉;数值越大,越清脆。

connect

其实是h5audio一个十分重要的概念。具体我把它简单理解为中间件的一个概念。例如这个例子,音调产生后经过音量处理的中间件然后再将这些声音结点输出到扬声器上。(在其他文章有很介绍得十分详尽的,在这里就不太想展开,最下面的外链可以找到)

剩下的就是淡入淡出的设置以及播放声音的内容。这些部分在张大大的博客中有详细地阐述,该块内容也是参考着它的博文进行二次翻译记录自己一些理解(很粗浅)然后总结罢了。

玩点录音

获取到原始的pcm数据

自己创造声音有点过于高大上,只播声音又显得有点无趣。那么干脆来玩一下录音好了。开启录音很简单,只需要这么简单的一行代码(当然不考虑兼容性咯)

navigator.mediaDevices.getUserMedia({audio: true, video: true})

可以试一下在浏览器控制台输入这段代码, 你就会看到网站想要调用你的摄像头以及麦克风的请求。点击允许后,你的摄像头就会亮灯,开启录音和录屏的状态。

navigator.mediaDevices.getUserMedia(videoObj, (stream) => {}, errBack)

你应该马上就会问,那么录完的音跑到哪里去了。我们从这个调用这个方法后,看到中间其实是有一个回调函数,让我们拿到麦克风和摄像头产生的数据流的。这时候我们可以调用AudioContext的接口使得音频PCM数据在到达目的地前通过不同的处理节点(增益、压缩等),所以我们需要从这里来入手。

navigator.mediaDevices.getUserMedia({audio: true}, initRecorder)

function initRecorder(stream) {
    const AudioContext = window.AudioContext
    const audioContext = new AudioContext()
    // 创建MediaStreamAudioSourceNode对象,给定媒体流(例如,来自navigator.getUserMedia实例),然后可以播放和操作音频。
    const audioInput = audioContext.createMediaStreamSource(stream)
    // 缓冲区大小为4096,控制着多长时间需触发一次audioprocess事件
    const bufferSize = 4096
    // 创建一个javascriptNode,用于使用js直接操作音频数据
    // 第一个参数表示每一帧缓存的数据大小,可以是256, 512, 1024, 2048, 4096, 8192, 16384,值越小一帧的数据就越小,声音就越短,onaudioprocess 触发就越频繁。4096的数据大小大概是0.085s,就是说每过0.085s就触发一次onaudioprocess,第二,三个参数表示输入帧,和输出帧的通道数。这里表示2通道的输入和输出,当然我也可以采集1,4,5等通道
    const recorder = audioContext.createScriptProcessor(bufferSize, 1, 1)
    // 每个满足一个分片的buffer大小就会触发这个回调函数
    recorder.onaudioprocess = recorderProcess
    // const monitorGainNode = audioContext.createGain()
    // 延迟0.01秒输出到扬声器
    // monitorGainNode.gain.setTargetAtTime(音量, audioContext.currentTime, 0.01)
    // monitorGainNode.connect(audioContext.destination)
    // audioInput.connect(monitorGainNode)
    // const recordingGainNode = audioContext.createGain()
    // recordingGainNode.gain.setTargetAtTime(音量, audioContext.currentTime, 0.01)
    // recordingGainNode.connect(audioContext.scriptProcessorNode)
    // 将音频的数据流输出到这个jsNode对象中
    audioInput.connect(recorder)
    // 最后先音频流输出到扬声器。(将录音流原本的输出位置再定回原来的目标地)
    recorder.connect(audioContext.destination)
}

做到这一步我们已经能拿到录音数据了,而且还是按照我们预想的样子去得到已经分好片的buffer数据。那么我们终于可以开始愉快地处理我们的录音流了。

补充一下上面代码中注释的两段用来控制录音音量大小以及将录音声音实时反馈到扬声器的两段方法函数。原理相一致,也就是设置一个处理数据的中间件,在录音设备最终走到扬声器(目标地)前进行二进制的控制

function recorderProcess(e) {
  // 左声道
  const left = e.inputBuffer.getChannelData(0);
}

注意在recorderProcess里面调用了一个getChannelData的方法,可以传入整型,取到对应声道的数据,进行分别处理。由于我们是单声道录制,所以只需要拿到左声道的数据流即可。

如果这时候你不需要对音频的输出再进行控制,已经可以将这段二进制数据直接用websoket传输到后台去了。

数据处理与转换

假如真不凑巧,后台大佬要的数据不是你这玩意的样子,大佬们对音频质量有要求:只接受一个采样率是8khz、位深16的wav文件。很好,那么提取关键字,我们需要先确认我们这段pcm数据是否8khz以及位深16.最后再把这些二进制组合起来转成wav格式。看起来很复杂,没关系,我们一步一步来。

采样率(sampleRate)和位深度(bitDepth)

首先,采样率(sampleRate)是什么呢,百度一下。

音频采样率是指录音设备在一秒钟内对声音信号的采样次数,采样频率越高声音的还原就越真实越自然。在当今的主流采集卡上,采样频率一般共分为22.05KHz、44.1KHz、48KHz三个等级,22.05KHz只能达到FM广播的声音品质,44.1KHz则是理论上的CD音质界限,48KHz则更加精确一些。

那么接下来这段代码就可以让你获取到你麦克风采样率是多少。

const AudioContext = window.AudioContext
const audioContext = new AudioContext()
// 可读属性
console.log(audioContext.sampleRate)
// 44100

很好,这段输出代表你的录音设备采样率高到44100HZ,那么根据需求,你就需要将自己的音频采样率降低下来了。然而不幸的是,浏览器并不允许去修改录音时的采样率,而且不同电脑设备的表现还不一样。这意味着,你需要在中间node拿到的二进制再进行一次处理。那么怎么去降低自己的采样率呢。根据上面百度的资料,你很容易就能发现,采样率的高低其实只是在一秒内的音频它的数据点有多少个的问题,我们需要把原来一秒内有44100个点的数据流减少成8000个点的数据流。很简单嘛,不是么,不过这个有个小点是需要注意的。

Downsample PCM audio from 44100 to 8000参考这里的一个回复: Lets take a simple case of downsampling by a factor of 2. (e.g. 44100->22050). A naive approach would be to just throw away every other sample. But imagine for a second that in the original 44.1kHz file there was a single sine wave present at 20khz. It is well within nyquist (fs/2=22050) for that sample rate. After you throw every other sample away it is still going to be there at 10kHz but now it will be above nyquist (fs/2=11025) and it will alias into your output signal. The final result is that you will have a big fat sine wave sitting at 8975 Hz! In order to avoid this aliasing during downsampling you need to first design a lowpass filter with a cutoff selected according to your decimation ratio. For the example above you would cutoff everything above 11025 first and then decimate.

这里的大概意思就是单纯地抽点是不行的。当然里面内在逻辑还涉及到频率波长什么的,我自然就不太清楚了。有兴趣的朋友可以详细了解一下原因。所幸这里还提供了代码的实现方式。

   function interleave(e){
      var t = e.length;
      sampleRate += 0.0;
      outputSampleRate += 0.0;
      var s = 0,
      o = sampleRate / outputSampleRate,
      u = Math.ceil(t * outputSampleRate / sampleRate),
      a = new Float32Array(u);
      for (i = 0; i < u; i++) {
        a[i] = e[Math.floor(s)];
        s += o;
      }
      return a;
    }

那么采样率的问题就解决了。剩下还有两个问题。将音频流转为16位深,这里就比较简单了。只要确保你生成的位数足够就行。比如,8位深的音频只需要生成一个Uint8Array,16位深就要生成2个的长度。new Unit8Array(bitDepth / 8)

pcm数据转wav

好不容易终于走到最后一步了。怎样把pcm数据转为wav数据。本来以为将是一个极其棘手的问题,但是所幸。pcm->wav的方法非常简单,将wav文件以二进制的方式打开后,去除前44位的字节的头文件信息就是一段pcm数据了。那么我们只需要把录音过程中的所有内容都收集起来,然后插入头文件信息即可。这一步相对于前面来说简直简单太多了。

但是我们还有一个问题要解决。“javascript如何操作二进制数据”。所以接下来就要介绍这几个玩意了。

Bolb、ArrayBuffer、Unit8Array、DataView

经过漫长地查询,看源码看API查资料,我终于集齐了有关这次操作的所有对象,可以召唤神龙了!

这个几个对象真的是老衲学了这么久都没接触过几个,有些甚至听都没听过。所以一个一个来,而且我也只能提供一些我初略的见解。(欢迎有大佬给我订正,想要具体了解还是更适合单独去搜索)

ArrayBuffer是个啥玩意呢。ArrayBuffer又称类型化数组。类型化数组,我记得在一篇详解数组的文章有看到,但是由于用处不多后面就给忘了。大致的意思就是js的数组对象,其实并不是像其他面向对象语言的实现方式一样,内存不连续而且类型不可控严重影响性能。(但是各大浏览器引擎都有做优化,所以实际编码不需要考虑)。而ArrayBuffer则是专门放0和1组成的二进制数据,所以当你在捕获到pcm数据的时候,将它打印到控制台上会看到这是个ArrayBuffer类型的数组,就是因为这段是二进制数据。

ArrayBuffer对象并没有提供任何读写内存的方法,而是允许在其上方建立“视图”,从而插入与读取内存中的数据。那么视图又是啥呢??

视图类型 数据类型 占用位数 占用字节 有无符号
Int8Array 整数 8 1
Uint8Array 整数 8 1
Uint8ClampedArray 整数 8 1
Int16Array 整数 16 2
Uint16Array 整数 16 2
Int32Array 整数 32 4
Uint32Array 整数 32 4
Float32Array 整数 32 4 \
Float64Array 浮点数 64 8 \

这对于一个计算机基础极差的大兄弟来说简直是噩梦。这么多玩意,我得怎么搞。又怎么选择,啊咧要崩溃了。但是要操作二进制呀,求助源码库是一个最简单的做法。在源码里面就发现这个玩意了。

DataView视图。为了解决各种硬件设备、数据传输等对默认字节序的设定不一而导致解码时候会发生的混乱问题,javascript提供了DataView类型的视图来让开发者在对内存进行读写时手动设定字节序的类型。于是.wav的文件头就要这么写。具体想知道怎么写还是得百度出来.wav的文件头信息字节具体如何分配

    var view = new DataView(wav.buffer)

    view.setUint32(0, 1380533830, false) // RIFF identifier 'RIFF'
    view.setUint32(4, 36 + dataLength, true) // file length minus RIFF identifier length and file description length
    view.setUint32(8, 1463899717, false) // RIFF type 'WAVE'
    view.setUint32(12, 1718449184, false) // format chunk identifier 'fmt '
    view.setUint32(16, 16, true) // format chunk length
    view.setUint16(20, 1, true) // sample format (raw)
    view.setUint16(22, this.numberOfChannels, true) // channel count
    view.setUint32(24, this.sampleRate, true) // sample rate
    view.setUint32(28, this.sampleRate * this.bytesPerSample * this.numberOfChannels, true) // byte rate (sample rate * block align)
    view.setUint16(32, this.bytesPerSample * this.numberOfChannels, true) // block align (channel count * bytes per sample)
    view.setUint16(34, this.bitDepth, true) // bits per sample
    view.setUint32(36, 1684108385, false) // data chunk identifier 'data'
    view.setUint32(40, dataLength, true) // data chunk length

上面的内容就是头信息的写法了。最后将数据以unit8Array的格式写入一个wav二进制数据就有了。我们还需要的就是将它转成文件对象。

Blob你可能没听过,但是File你肯定听过,因为经常需要form表单传文件嘛。那么你这么理解,Blob是一种JavaScript的对象类型。HTML5的文件操作对象,file对象就是Blob的一个分支或说一个子集。

也就是为啥File对象能进行文件分割上传,就是利用了Blob操作二进制数据的方法。

new Bolb(wavData, { type: 'audio/wav' })

验证效果

上面的代码都是二进制操作,是不是特别没信息。那就验证一下吧!把生成的blob对象,这样操作:const url = URL.createObjectURL(blob)然后把它丢到<a>标签里面来下载这个文件。命令行工具:

$ file test.wav
test.wav: RIFF (little-endian) data, WAVE audio, Microsoft PCM, 16 bit, mono 8000 Hz

完全符合效果。nice,剩下的问题就是回到实时处理pcm数据的问题了。

传输arrayBuffer

文件我们会传,但是二进制buffer数组要怎么传递呢?只有当你使用blob生成后的对象才能作为文件对象使用。但是当我们还是pcm数据段的时候怎么来作为一个文件传递过去呢。

这里只稍微列举一下自己的尝试,不一定是个正确的使用方法。这里使用的是websocket

// client
const formData = new FormData()
formData.append(blob, new Blob(arrayBuffer))

// server
console.log(ctx.args[0]) // ArrayBuffer<xx,xxx>

做点小优化

Web Worker了解一下,由于涉及到文件的转码操作。所以很耗费性能,这时候是时候掏出web Worker来深度优化这玩意。

有关这个的内容,其实看起来很高深但是很简单,worker就相当于一个处理源数据的方法,然后交互方式是用事件监听。

这里仅仅记录几个坑,就不详细介绍:

  1. web worker必须起服务来实现。单纯html文件时不能打开worker的。worker必须要传一个.js文件路径,且保证同源策略。
  2. 不要向worker去传递函数,否则会错误。而且浏览器还不暴露这个错误。排查了好久的说
  3. worker必须传入一个js的路径。在vue下,只能把它丢在static文件去引用了喵。

这里还有些小问题没解决

  1. 如何实时地绘制出录音的音量。首先音量可以取得到的buffer里面的大小作为分贝参考。但是具体实现方式还不太懂,或者说没有简便的实现方式吧。
  2. 前端收到一个buffer的时候想要实时播放如何做到,现在做的方式其实是最后才嵌回去。做了一个小实验,没成功。(直接把录音转化好的内容转回ArrayBuffer但是失败了)。感觉如果有后端来传递数据应该可以实现。
audioContext.decodeAudioData(play_queue[index_play], function(buffer) {//解码成pcm流
            var audioBufferSouceNode = audioContext.createBufferSource();
            audioBufferSouceNode.buffer = buffer;
            audioBufferSouceNode.connect(audioContext.destination);
            audioBufferSouceNode.start(0);
        }, function(e) {
            console.log("failed to decode the file");
        });
  1. 如何实时地计算到现在音频的录制实现。因为是数据流没办法转成音频对象导致没能拿到这些方法来获取。现在比较粗暴的方法就是通过字节长度 采样率 位数来计算 但是感觉计算量比较大 不属于一个好的实现方式?
  2. 改变音调和声音位置的api没能成功调用起来。
const filterNode = audioContext.createBiquadFilter();
// ...
source.connect(filterNode);
filterNode.connect(source.destination);

const updateFrequency = frequency => filterNode.frequency.value = frequency;

const rangeX = document.querySelector('input[name="rangeX"]');
const source = audioContext.createBufferSource();
const pannerNode = audioContext.createPanner();

source.connect(pannerNode);
pannerNode.connect(source.destination);

rangeX.addEventListener('input', () => pannerNode.setPosition(rangeX.value, 0, 0));

node端切割音频文件

这里补充一点小小知识点。node端并没有特别好的处理音频的库。查询了很久之后发现一个调用机器环境ffmpeg来辅助处理的库。用起来起码是没问题的,而且非常全面。(毕竟接触音频多的人应该都懂这个玩意)这里粗糙mark一下自己之前用到的api。

      var command = ffmpeg(filePath)
        // .seekInput(60.0) // 开始切割的时间
        .seekInput(7.875) // 开始切割的时间,延迟7秒?为啥
        .duration(4.125) // 需要切割的音频时长
        .save(path.join(__dirname, '../../../app/public/test-1.wav'))
        .on('end', () => {
          console.log('finish')
        })
        .run()

最后,感谢付总、涂老师耐心地和我讲解有关音频的种种问题。感谢前端组各位大佬在我不懂的内容即使自己也没有过多接触但是还是耐心和我探讨问题。


参考文章:这里列出的文章都是我查找资料时看到的不错的文章。和上面的内容不一定强烈相关,建议对音频感兴趣的朋友完全可以自己看看

一个使用 HTML5 录音的例子

Getting Started with Web Audio API

Using Recorder.js to capture WAV audio in HTML5 and upload it to your server or download locally

HTML5录音控件

chris-rudmin/opus-recorder

Tutorial: HTML Audio Capture streaming to Node.js (no browser extensions)

[前端教程]HTML5制作好玩的麦克风音量检测器(Web Audio API)

Downsample PCM audio from 44100 to 8000 DataView Typed Arrays: Binary Data in the Browser Tech Tip: Sample Rate and Bit Depth—An Introduction to Sampling How to convert ArrayBuffer to and from String 用html5-audio-api开发游戏的3d音效和混音 理解DOMString、Document、FormData、Blob、File、ArrayBuffer数据类型 深入浅出 Web Audio Api