前端录音方案优化实践
背景
在视听类业务或重交互的业务场景下,有时需要在前端采集用户语音。前端实现录音功能可以使用MediaRecorder,或getUserMedia结合AudioContext。其中,前一种方法的支持度惨不忍睹,使用getUserMedia的方式是较为常用的选择。
现有问题
在实现前端录音上,Recorder.js实现了一个基础可用版,不过它支持的可配置项很少,音频采样率、声道数、采样的比特位数都使用的采集配置的默认值。但在大多场景下,录音文件体积较大,4s的录音可以达到700 ~ 800KB,不利于网络传输,需要录音采集参数可配置,以优化文件体积。
另外,有些场景录制的语音需要交给算法组做语音识别,对语音有特定要求:
- 采样率16000Hz
- 单声道
- 采样位数16bit
这时就需要一个优化的前端录音方案,支持根据输入配置修改音频流数据。
优化
这里将原有录音方案的几个关键代码流程整理如下:
其中:
- 先调用
getUserMedia
获取音频流,并初始化一个MediaStreamAudioSourceNode
。使用connect
连接到ScriptProcessorNode
上,并连续触发audioprocess
事件。 - 在
onaudioprocess
事件处理函数中,拿到录音数据。根据当前recording
的值判断是否写入recBuffers
中。recording
状态可以通过record
和stop
方法控制。 exportWAV
方法会触发导出流程,导出步骤里mergeBuffers
将recBuffers
数组扁平化interleave
将各声道信息数组扁平化encodeWAV
为即将生成的音频文件写入音频头- 最后
floatTo16bitPCM
将音频设备采集的元素范围在[0,1]之间的Float32Array,转换成一个元素是16位有符号整数的Float32Array中
- 最后拿到的Blob类型数据可以本地播放或通过FormData上传服务端使用。
下面分几方面介绍录音方案优化的设计和实现。
音频头拓展
要支持可拓展的采样率、声道、采样比特数,wav音频头也要动态配置。
WAVE格式是Resource Interchange File Format(RIFF)的一种,其基本块名称是“WAVE”,其中包含两个子块“fmt”和“data”。结构上由WAVE_HEADER、WAVE_FMT、WAVE_DATA、采样数据4个部分组成。可以看到实际上就是在PCM数据前面加了一个文件头。WAVE类型文件整体结构图如下:
其中和采样率、声道、采样位数相关的字段有:
- NumChannels
- SampleRate
- ByteRate,等于SampleRate * BlockAlign
- BlockAlign,等于ChannelCount * BitsPerSample / 8
- BitsPerSample
这几个字段根据输入的配置项设置即可实现音频头拓展部分。
另外,需要注意的是其中字段有Big Endian和Little Endian的区分,对应在代码里,通过setUint16
和setUIint32
的最后一个入参决定。如下所示:
1 | function encodeWAV(samples) { |
采样率
通常前端录音的音频采样率是音频设备默认使用的44.1kHz(或48kHz)。开发者需要默认以外的采样率时(比如16kHz),可以在录音数据交给encodeWAV
封装前根据新的采样率做重采样。
1 | function compress(samples, ratio) { |
重采样的原理上,程序根据重采样和原始采用率的比值,间隔采样音频原数据,丢弃掉其他采样点数据,从而模拟采样率的等比例下降。
注:间隔丢弃原数据在重采样率是原采样率的整数倍分之一时(即1、1/2、1/3…)才不会损失用户音色。另外,重采样率比原采样率高时,需要在采样点中间额外插值,这里未实现;
声道数
在audioprocess
事件中,需要根据配置项中的声道数,从inputBuffer取对应声道数据,一般的处理下,会丢弃多余的声道数据。类似地,在存储声道数据时,也要灵活考虑配置项。
1 | this.node.onaudioprocess = (e) => { |
在最后导出时,根据声道数判断是否需要interleave的步骤。
1 | if (numChannels === 2) { |
采样位数
默认的采样位数是16位,在对音质或位数没有明确要求时,可以转成8位。
PCM16LE格式的采样数据的取值范围是-32768到32767,而PCM8格式的采样数据的取值范围是0到255。因此PCM16LE转换到PCM8需要将-32768到32767的16bit有符号数值转换为0到255的8bit无符号数值。实现上,见下面的对比:
1 | function floatTo16BitPCM(output, offset, input) { |
上方的floatTo16BitPCM
是转换音频采样数据到PCM数据的原始方法,下面的floatTo8BitPCM
方法中parseInt(val / 256 + 128, 10)
做了16位到8位的转换。最后在封装音频数据为Blob类型时,根据采样位数使用不同函数即可。
1 | function encodeWAV(samples) { |
其他
最后,由于前端录音场景下,音频流基本都来自getUserMedia
,为了减少模板代码,库里封装了一个static方法,快捷地直接由getUserMedia
构造一个recorder对象。
1 | static async createFromUserMedia(config) { |
使用
在之前提到了需要算法组音频识别的场景下,只需要在构造时指定配置项即可。
1 | import Recorder from './audio-recorder'; |
此时,一个500ms的录音大概15KB,换算下来4s大约120KB,比此前的体积小了很多。在不强调音质的场景下,表现要好许多。
小结
上面的录音方案优化实践主要包含下面几点:
- WAVE音频头修改
- 重采样音频数据
- 丢弃多余的声道数据
- 转换16位音频数据到8位
源码在这里,欢迎使用与拍砖。