前端录音方案优化实践

背景

在视听类业务或重交互的业务场景下,有时需要在前端采集用户语音。前端实现录音功能可以使用MediaRecorder,或getUserMedia结合AudioContext。其中,前一种方法的支持度惨不忍睹,使用getUserMedia的方式是较为常用的选择。

现有问题

在实现前端录音上,Recorder.js实现了一个基础可用版,不过它支持的可配置项很少,音频采样率、声道数、采样的比特位数都使用的采集配置的默认值。但在大多场景下,录音文件体积较大,4s的录音可以达到700 ~ 800KB,不利于网络传输,需要录音采集参数可配置,以优化文件体积。

另外,有些场景录制的语音需要交给算法组做语音识别,对语音有特定要求:

  • 采样率16000Hz
  • 单声道
  • 采样位数16bit

这时就需要一个优化的前端录音方案,支持根据输入配置修改音频流数据。

优化

这里将原有录音方案的几个关键代码流程整理如下:

其中:

  • 先调用getUserMedia获取音频流,并初始化一个MediaStreamAudioSourceNode。使用connect连接到ScriptProcessorNode上,并连续触发audioprocess事件。
  • onaudioprocess事件处理函数中,拿到录音数据。根据当前recording的值判断是否写入recBuffers中。recording状态可以通过recordstop方法控制。
  • exportWAV方法会触发导出流程,导出步骤里
    • mergeBuffersrecBuffers数组扁平化
    • 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的区分,对应在代码里,通过setUint16setUIint32的最后一个入参决定。如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
function encodeWAV(samples) {
const buffer = new ArrayBuffer(44 + samples.length * 2);
const view = new DataView(buffer);
/* RIFF identifier */
writeString(view, 0, 'RIFF');
/* RIFF chunk length */
view.setUint32(4, 36 + samples.length * 2, true);
/* RIFF type */
writeString(view, 8, 'WAVE');
/* format chunk identifier */
writeString(view, 12, 'fmt ');
/* format chunk length, PCM use 16 */
view.setUint32(16, 16, true);
/* sample format (raw), PCM use 1 */
view.setUint16(20, 1, true);
/* channel count */
view.setUint16(22, numChannels, true);
/* sample rate */
view.setUint32(24, sampleRate, true);
/* byte rate (sample rate * block align) */
view.setUint32(28, sampleRate * numChannels * sampleBit / 8, true);
/* block align (channel count * bytes per sample) */
view.setUint16(32, numChannels * sampleBit / 8, true);
/* bits per sample */
view.setUint16(34, sampleBit, true);
/* data chunk identifier */
writeString(view, 36, 'data');
/* data chunk length */
view.setUint32(40, samples.length * 2, true);
// ...
return view;
}

采样率

通常前端录音的音频采样率是音频设备默认使用的44.1kHz(或48kHz)。开发者需要默认以外的采样率时(比如16kHz),可以在录音数据交给encodeWAV封装前根据新的采样率做重采样。

1
2
3
4
5
6
7
8
9
10
function compress(samples, ratio) {
const length = samples.length / ratio;
const result = new Float32Array(length);
for (let index = 0; index < length; index++) {
result[index] = samples[index * ratio];
}
return result;
}

重采样的原理上,程序根据重采样和原始采用率的比值,间隔采样音频原数据,丢弃掉其他采样点数据,从而模拟采样率的等比例下降。

注:间隔丢弃原数据在重采样率是原采样率的整数倍分之一时(即1、1/2、1/3…)才不会损失用户音色。另外,重采样率比原采样率高时,需要在采样点中间额外插值,这里未实现;

声道数

audioprocess事件中,需要根据配置项中的声道数,从inputBuffer取对应声道数据,一般的处理下,会丢弃多余的声道数据。类似地,在存储声道数据时,也要灵活考虑配置项。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
this.node.onaudioprocess = (e) => {
if (!this.recording) return;
const buffer = [];
for (let channel = 0; channel < this.config.numChannels; channel++) {
buffer.push(e.inputBuffer.getChannelData(channel));
}
// ...
};
// ...
function record(inputBuffer) {
for (let channel = 0; channel < numChannels; channel++) {
recBuffers[channel].push(inputBuffer[channel]);
}
recLength += inputBuffer[0].length;
}

在最后导出时,根据声道数判断是否需要interleave的步骤。

1
2
3
4
5
if (numChannels === 2) {
interleaved = interleave(buffers[0], buffers[1]);
} else {
[interleaved] = buffers;
}

采样位数

默认的采样位数是16位,在对音质或位数没有明确要求时,可以转成8位。

PCM16LE格式的采样数据的取值范围是-32768到32767,而PCM8格式的采样数据的取值范围是0到255。因此PCM16LE转换到PCM8需要将-32768到32767的16bit有符号数值转换为0到255的8bit无符号数值。实现上,见下面的对比:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function floatTo16BitPCM(output, offset, input) {
let initOffset = offset;
for (let i = 0; i < input.length; i++, initOffset += 2) {
const s = Math.max(-1, Math.min(1, input[i]));
output.setInt16(initOffset, s < 0 ? s * 0x8000 : s * 0x7FFF, true);
}
}
function floatTo8bitPCM(output, offset, input) {
let initOffset = offset;
for (let i = 0; i < input.length; i++, initOffset++) {
const s = Math.max(-1, Math.min(1, input[i]));
const val = s < 0 ? s * 0x8000 : s * 0x7FFF;
output.setInt8(initOffset, parseInt(val / 256 + 128, 10), true);
}
}

上方的floatTo16BitPCM是转换音频采样数据到PCM数据的原始方法,下面的floatTo8BitPCM方法中parseInt(val / 256 + 128, 10)做了16位到8位的转换。最后在封装音频数据为Blob类型时,根据采样位数使用不同函数即可。

1
2
3
4
5
6
7
8
9
function encodeWAV(samples) {
// ...
sampleBit === 8
? floatTo8bitPCM(view, 44, samples)
: floatTo16BitPCM(view, 44, samples);
return view;
}

其他

最后,由于前端录音场景下,音频流基本都来自getUserMedia,为了减少模板代码,库里封装了一个static方法,快捷地直接由getUserMedia构造一个recorder对象。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
static async createFromUserMedia(config) {
const stream = await navigator.mediaDevices.getUserMedia({
audio: {
mandatory: {
googEchoCancellation: 'false',
googAutoGainControl: 'false',
googNoiseSuppression: 'false',
googHighpassFilter: 'false'
},
optional: []
},
video: false
});
const context = new AudioContext();
return new Recorder(context.createMediaStreamSource(stream, config));
}

使用

在之前提到了需要算法组音频识别的场景下,只需要在构造时指定配置项即可。

1
2
3
4
5
6
7
import Recorder from './audio-recorder';
this.recorder = Recorder.createFromUserMedia({
sampleBit: 16, // 可省略
numChannels: 1,
sampleRate: 16000
});

此时,一个500ms的录音大概15KB,换算下来4s大约120KB,比此前的体积小了很多。在不强调音质的场景下,表现要好许多。

小结

上面的录音方案优化实践主要包含下面几点:

  • WAVE音频头修改
  • 重采样音频数据
  • 丢弃多余的声道数据
  • 转换16位音频数据到8位

源码在这里,欢迎使用与拍砖。

参考