引言:
最近接了一个需求:需要借助浏览器的录音能力,借助科大讯飞语音评测API,实现pc端的语音评测
所以我把需求的工作内容拆成了两个方向分别是
- 音频流传给科大讯飞的语音识别平台,获取分数
- 音频流转成音频文件上传到oss服务上,链接会作为答案提交后端服务
名词解释
缓冲区:在内存空间中预留了一定的存储空间,这些存储空间用来缓冲输入或输出的数据,这部分预留的空间就叫做缓冲区。
PCM:脉冲编码调制技术(Pulse Code Modulation)的英文缩写,麦克风会把声波转换成连续的模拟信号,模拟信号在转换成数字音频,简言之PCM就是原始的音频数据。
采样率:单位时间内对模拟信号的采样次数,它用赫兹(Hz)来表示,谷歌浏览器默认48000
采样位数:表示一个样本的二进制位数,单位bit,数值越大,声音还原度越高
ArrayBuffer:是一个构造函数,可以分配一段可以存放数据的连续内存区域,只能通过TypedArray视图和DataView视图来读写
TypedArray:用来读写简单的二进制数据,包含以下9种类型(有符号整数:二进制数据中,第一位表示符号,后面的表示真值,相当于正负数;无符号整数:二进制数据中所有位置都用来表示值,相当于绝对值)
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:读写复杂类型的二进制数据。包含8个get和8个set
getInt8:读取 1 个字节,返回一个 8 位整数。
getUint8:读取 1 个字节,返回一个无符号的 8 位整数。
getInt16:读取 2 个字节,返回一个 16 位整数。
getUint16:读取 2 个字节,返回一个无符号的 16 位整数。
getInt32:读取 4 个字节,返回一个 32 位整数。
getUint32:读取 4 个字节,返回一个无符号的 32 位整数。
getFloat32:读取 4 个字节,返回一个 32 位浮点数。
getFloat64:读取 8 个字节,返回一个 64 位浮点数。
setInt8:写入 1 个字节的 8 位整数。
setUint8:写入 1 个字节的 8 位无符号整数。
setInt16:写入 2 个字节的 16 位整数。
setUint16:写入 2 个字节的 16 位无符号整数。
setInt32:写入 4 个字节的 32 位整数。
setUint32:写入 4 个字节的 32 位无符号整数。
setFloat32:写入 4 个字节的 32 位浮点数。
setFloat64:写入 8 个字节的 64 位浮点数。
技术调研
1、如何使用浏览器录音?
getUserMedia + MediaRecorder API:MediaRecorder的构造函数需要传入音频格式(默认{type:'video/webm'}),通过MediaRecorder.ondataavailable事件捕获数据,数据类型是Blob
getUserMedia + AudioContext API:通过AudioContext.createScriptProcessor.onaudioprocess事件捕获PCM数据。
2、阅读科大讯飞API,了解传递参数
声道数:单声道
音频格式:支持PCM、wav、mp3、讯飞定制speex
采样率:16000
位深:16bit
3、PCM如何转音频文件?
转wav:wav就是再PCM数据前面加一个44位的文件头,转wav是最简单的、
转mp3: 可通过第三方库lamejs将PCM转换为mp3
4、webm转音频文件?
一般是上传至后台,由其他后端语音进行转换,在返回其他格式的文件流。前端转换的话,最佳实践较少。。
技术选型
选择getUserMedia + AudioContext API实现
理由:
i.基于需求,需要获取最原始PCM数据,PCM可以直接传给科大讯飞服务
ii.PCM可方便的转成wav或mp3文件,上传至后端保存起来
iii.AudioContext可以获取单声道的PCM
设计思路:
1、获取浏览器麦克风权限
getUserMedia获取,做好成功和失败的处理
2、处理麦克风媒体流
createMediaStreamSource创建麦克风媒体流
createScriptProcessor创建一个js可以编辑流的none(api提示被移除标准但现在的浏览器好像也还在支持)
createScriptProcessor.onaudioprocess 在缓冲区满了以后自动执行一次,回调中可以拿到麦克风的PCM数据
3、PCM数据上传至科大讯飞服务器
深拷贝PCM数据
PCM数据采样率转换至16000、bit转换为16
创建ws链接
处理参数,想ws服务器发送数据
4、把PCM转音频文件(mp3比wav体积更小)
深拷贝PCM数据
转换mp3
通过blob和formData上传oss
5、整合数据
获取ws的数据,拿到语音识别结果
拿到oss返回的音频url
当两个份数据都拉到之后,向外部抛出结果
流程图
具体实现
1、录音部分
try {
const controls = { audio: true, video: false };
const getUserMedia = navigator.mediaDevices.getUserMedia(controls) || navigator.getUserMedia(controls);
getUserMedia
.then(stream => getMediaSuccess(stream))
.catch(e => getMediaFail(e));
} catch (error) {
Toast.fail('无法获取浏览器录音功能,请升级浏览器或使用最新版chrome');
this.audioContext && this.audioContext.close();
}
拿到权限之后我们会创建麦克风媒体流和jsnode方便处理音频流。connect方法的作用相当于连接器,比如webpack的loader
const getMediaSuccess = stream => {
this.scriptProcessor = createJSNode(this.audioContext);
this.scriptProcessor.onaudioprocess = onAudioprocess; // 创建一个新的MediaStreamAudioSourceNode 对象,使来自MediaStream的音频可以被播放和操作
this.mediaSource = this.audioContext.createMediaStreamSource(stream); // 连接播放器
this.mediaSource.connect(this.scriptProcessor);
this.scriptProcessor.connect(this.audioContext.destination); // 链接websocket
this.connectWebSocket(); this.running = true; this.autoClose(); }; // 创建jsnode
function createJSNode(audioContext) { // 缓冲区 const BUFFER_SIZE = 0; // 声道 const
INPUT_CHANNEL_COUNT = 1; const OUTPUT_CHANNEL_COUNT = 1; // createJavaScriptNode已被废弃
let creator = audioContext.createScriptProcessor || audioContext.createJavaScriptNode;
creator = creator.bind(audioContext); return creator(BUFFER_SIZE, INPUT_CHANNEL_COUNT, OUTPUT_CHANNEL_COUNT);
}
没拿到权限会做简单的异常处理
const getMediaFail = e => {
Toast.fail('请求麦克风失败');
this.running = false;
this.audioContext && this.audioContext.close();
this.audioContext = null;
// 关闭websocket
if (this.webSocket && this.webSocket.readyState === 1) {
this.webSocket.close();
}
};
2、PCM数据获取与处理
if (this.status === 'ing') {
// 左声道
const leftData = e.inputBuffer.getChannelData(0) || [];
if (leftData.length === 0) {
Toast.fail('没有收到声音~请检查麦克风');
return;
}
MP3Worker.postMessage({
cmd: 'encode',
buf: e.inputBuffer.getChannelData(0).slice()
});
transWorker.postMessage({
audioData: e.inputBuffer.getChannelData(0).slice(),
sampleRate: this.sampleRate, bits: 16
});
}
};
处理PCM的采样率为16000和位深为16bit
// transCode.worker.js
(function () {
self.onmessage = function (e) {
transAudioData.transcode(e.data);
};
let transAudioData = {
transcode(audioData) {
let output = transAudioData.to16kHz(audioData);
output = transAudioData.to16BitPCM(output);
output = Array.from(new Uint8Array(output.buffer));
self.postMessage(output);
// return output
},
to16kHz(audioData) {
const data = new Float32Array(audioData);
const fitCount = Math.round(data.length * (16000 / 44100));
const newData = new Float32Array(fitCount);
const springFactor = (data.length - 1) / (fitCount - 1);
newData[0] = data[0];
for (let i = 1; i < fitCount - 1; i++) {
const tmp = i * springFactor;
const before = Math.floor(tmp).toFixed();
const after = Math.ceil(tmp).toFixed();
const atPoint = tmp - before;
newData[i] = data[before] + (data[after] - data[before]) * atPoint;
}
newData[fitCount - 1] = data[data.length - 1];
return newData;
},
to16BitPCM(input) {
const dataLength = input.length * (16 / 8);
const dataBuffer = new ArrayBuffer(dataLength);
const dataView = new DataView(dataBuffer);
let offset = 0;
for (let i = 0; i < input.length; i++, offset += 2) {
const s = Math.max(-1, Math.min(1, input[i]));
dataView.setInt16(offset, s < 0 ? s * 0x8000 : s * 0x7fff, true);
}
return dataView;
},
};
})();
把PCM转换为mp3,这里用到了lame.js,感兴趣伙伴可自行百度
// mp3.worker.js
(function () {
'use strict';
importScripts('lame.js');
var mp3Encoder, maxSamples = 1152, samplesMono, lame, config, dataBuffer;
var clearBuffer = function () {
dataBuffer = [];
};
var appendToBuffer = function (mp3Buf) {
dataBuffer.push(new Int8Array(mp3Buf));
};
var init = function (prefConfig) {
config = prefConfig || {};
lame = new lamejs();
mp3Encoder = new lame.Mp3Encoder(1, config.sampleRate || 44100, config.bitRate || 128);
clearBuffer();
self.postMessage({ cmd: 'init' });
};
var floatTo16BitPCM = function floatTo16BitPCM(input, output) {
for (var i = 0; i < input.length; i++) {
var s = Math.max(-1, Math.min(1, input[i]));
output[i] = (s < 0 ? s * 0x8000 : s * 0x7FFF);
}
};
var convertBuffer = function (arrayBuffer) {
var data = new Float32Array(arrayBuffer);
var out = new Int16Array(arrayBuffer.length);
floatTo16BitPCM(data, out) return out;
};
var encode = function (arrayBuffer) {
samplesMono = convertBuffer(arrayBuffer);
var remaining = samplesMono.length;
for (var i = 0; remaining >= 0; i += maxSamples) {
var left = samplesMono.subarray(i, i + maxSamples);
var mp3buf = mp3Encoder.encodeBuffer(left);
appendToBuffer(mp3buf);
remaining -= maxSamples;
}
};
var finish = function () {
appendToBuffer(mp3Encoder.flush());
self.postMessage({ cmd: 'end', buf: dataBuffer });
clearBuffer();
};
self.onmessage = function (e) {
switch (e.data.cmd) {
case 'init':
init(e.data.config);
break;
case 'encode':
encode(e.data.buf);
break;
case 'finish':
finish();
break;
}
};
})();
3、建立科大讯飞ws链接,并传输数据(这块按照官网demo写的)可自行下载demo查看
connectWebSocket() {
// websocket踢出去
return getWebSocketUrl().then(url => {
const WebSocket = window.WebSocket || window.MozWebSocket || null;
if (!WebSocket) {
Toast.fail('浏览器不支持WebSocket');
return;
}
const iatWS = new WebSocket(url);
this.webSocket = iatWS;
this.setStatus(statusMap.init);
bus.$emit('getStatus', statusMap.init);
iatWS.onopen = () => {
// 重新开始录音
this.setStatus(statusMap.ing);
bus.$emit('getStatus', statusMap.ing);
if (this.loading) {
this.loading = null;
Toast.clear();
}
// 使用setTimeout的意义
setTimeout(() => {
this.webSocketSend();
}, 500);
};
iatWS.onmessage = e => {
this.result(e.data);
};
iatWS.onerror = () => {
this.recorderStop();
};
iatWS.onclose = () => {
this.recorderStop();
};
});
}
webSocketSend() {
if (this.webSocket.readyState !== 1) {
return;
}
let audioData = this.audioData.splice(0, 1280);
const params = {
common: {
app_id: this.appId,
},
business: {
group: 'pupil',
sub: 'ise',
cmd: 'ssb',
ent: 'en_vip',
aus: 1,
text: `\uFEFF${this.question}`,
...paramsMap.readSentence,
...paramsMap.entirety,
...paramsMap.default
},
data: {
status: 0,
encoding: 'raw',
data_type: 1,
data: this.toBase64(audioData),
},
};
this.webSocket.send(JSON.stringify(params));
this.handlerInterval = setInterval(() => {
// websocket未连接
if (this.webSocket.readyState !== 1) {
this.audioData = [];
clearInterval(this.handlerInterval);
return;
}
// 最后一帧是必须传的
if (this.audioData.length === 0) {
if (this.status === 'stop') {
this.webSocket.send(
JSON.stringify({
business: {
cmd: 'auw',
aus: 4,
aue: 'raw'
},
data: {
status: 2,
encoding: 'raw',
data_type: 1,
data: '',
},
})
);
this.audioData = [];
clearInterval(this.handlerInterval);
}
return false;
}
audioData = this.audioData.splice(0, 1280);
// 每40毫秒传递一次中间帧
this.webSocket.send(
JSON.stringify({
business: {
cmd: 'auw',
aus: 2,
aue: 'raw'
},
data: {
status: 1,
encoding: 'raw',
data_type: 1,
data: this.toBase64(audioData),
},
})
);
}, 40);
}
4、上传mp3,拿到oss的url,这里就很简单了。
async createUrl(arrayBuffer) {
const blob = new Blob(arrayBuffer, {type: 'audio/mp3'});
const formData = new FormData();
formData.append('file', blob, 'audio.mp3');
const res = await fetch(formData);
this.reaultDataWithProxy.url = res || URL.createObjectURL(blob);
}
5、拿到科大讯飞ws返回的数据
result(resData = '{}') {
// 识别结束
const jsonData = JSON.parse(resData);
if (jsonData.code === 0 && jsonData?.data && jsonData?.data?.data) {
const data = Base64.decode(jsonData.data.data);
const grade = parser.parse(data, { attributeNamePrefix: '', ignoreAttributes: false });
this.reaultDataWithProxy.grade = grade;
}
if (jsonData.code === 0 && jsonData.data.status === 2) {
this.webSocket.close();
}
// 消除loading
if (this.loading) {
this.loading = null; Toast.clear();
}
if (jsonData.code !== 0) {
this.webSocket.close();
Toast.fail(`${errorMap[jsonData.code] || `语音评测失败,错误码${jsonData.code}`}`);
}
}
6、对结果的处理
this.proxyHandles = {
set(target, key, value, receiver) {
target[key] = value;
const {grade, url} = target;
console.log(target, key, value, receiver);
if (grade && url) {
bus.$emit('result', { grade, url });
self.setStatus(statusMap.result);
bus.$emit('getStatus', statusMap.result);
if (self.loading) {
self.loading = null; Toast.clear();
}
}
return true;
}
};
this.reaultDataWithProxy = new Proxy(resultData, this.proxyHandles);
踩过的坑
1、vue项目使用worker
需要安装worker-loader
修改vue.config.js
module.exports = {
...
chainWebpack(config) {
config.output.globalObject('this');
},
parallel: false,
configureWebpack(config) {
config.module.rules.push({
test: /.worker.js$/,
use: [
{
loader: 'worker-loader',
options: {inline: 'no-fallback', filename: 'workerName.[hash].js'},
}, {
loader: 'babel-loader'
}
],
});
},
};
// 项目中使用
import Worker from './Mp3.worker';
const worker = new Worker();
2、worker中通过importScripts路径错误问题
这里可以使用 Blob+URL.createObjectURL生成一个‘永远不会错’的路径
3、录音再播放时语速变快
这个问题是将PCM转wav时碰到的,具体的原因并没有找到
我怀疑是转换函数有问题,但网上博客的转换函数基本都是一样的
我尝试过改createScriptProcessor的缓冲区大小,缓冲区越大,正常录音的时长越长,但是缓冲区最大也只能设为16384,正常的录音也就20s,可业务需要支持90s以上
也有可能是没有使用worker,js主线程处理转换音频,内存不够导致(至今还有一个点击录音后内存飙升页面卡死的bug,在使用worker后bug消失了)
最后是通过worker转mp3解决了这个问题。
4、声道处理遇到的问题
createScriptProcessor创建的jsnode的声道参数和jsnode.onaudioprocess函数中的inputBuffer去的声道数据要保持一致,比如jsnode设置为单声道,onaudioprocess函数中取了inputBuffer中的双声道数据,这个时候录音会失真
科大讯飞需要单声道,我最开始尝试录制双声道的音频,直接导致科大讯飞返回的结果中有很多'fil'和'silv',查阅语音评测文档时发现,fil表示噪音,silv表示空白。
5、PCM采样率和采样位数修改时坑
谷歌浏览器的默认采样率是48000,而且无法更改,网上很多博客表示都能改,因为这差点怀疑人生
而科大讯飞官网demo中的转换函数是把采样率从44100转成16000,我把48000采样率PCM传给转换函数,直接导致语音识别分数对比app偏低