基于浏览器录音的实现与探索

1,375 阅读8分钟

引言:

最近接了一个需求:需要借助浏览器的录音能力,借助科大讯飞语音评测API,实现pc端的语音评测

所以我把需求的工作内容拆成了两个方向分别是

  1. 音频流传给科大讯飞的语音识别平台,获取分数
  2. 音频流转成音频文件上传到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

当两个份数据都拉到之后,向外部抛出结果

流程图

image.png

具体实现

1、录音部分

image.png

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数据获取与处理

image.png

    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返回的数据

image.png

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、对结果的处理

image.png

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偏低

参考文档

语音评测(流式版)API文档

浅谈H5音频处理

H5录音实践总结(Preact)

arrayBuffer、typedarray、dataview