浏览器录音实践与踩坑

之前做某个语音留言互动的需求时用到了浏览器录音相关功能,当时查了相关的资料,后续实际开发中也遇到了一些坑。

本次就web端实现语音录制的原理及一些踩坑记录做一个分享。

MediaRecorder

MediaRecorder在原生app开发中,是一个应用广泛的api,用于在app内录制音频和视频。

随着web侧的应用越逐渐富媒体化,w3c也制定了相应的web标准,称为MediaRecorder API,它给我们的web页面赋予了录制音视频的能力,使得web可以脱离服务器、客户端的辅助,独立进行媒体流的录制。

该API由官方推出,对前端开发者友好,并且支持标准编码,直接返回媒体流数据,可以注入video/audio标签或者直接打包成文件。

基本API如下:

  • MediaRecorder.start() 开始录制媒体
  • MediaRecorder.pause() 暂停媒体录制
  • MediaRecorder.resume() 继续录制之前被暂停的录制动作
  • MediaRecorder.stop() 停止录制. 同时触发dataavailable事件,返回一个存储Blob内容的录制数据.之后不再记录

以及对应的事件处理方法:

  • MediaRecorder.onstart
  • MediaRecorder.onpause
  • MediaRecorder.onresume
  • MediaRecorder.onstop
  • MediaRecorder.ondataavailable:调用它用来处理 dataavailable 事件, 该事件可用于获取录制的媒体资源 (在事件的 data 属性中会提供一个可用的 Blob 对象)

音频格式

  • Chrome默认支持编码为webm
  • Firefox默认支持编码为ogg
  • 其他类型的格式需要对应的转化库重新进行音频格式转化

可以看到MediaRecorder API可读性好、使用简单,一切都显得很美好。

可惜MediaRecorder也有比较致命的问题:兼容性较差

具体兼容情况如下表所示,可以看到pc端基本只有Chrome和Firefox可用的状态,移动端苹果系统下的各大浏览器也是完全不支持(IOS用户在哭泣)。

桌面浏览器兼容性

Feature Chrome Firefox (Gecko) Internet Explorer Opera Safari (WebKit)
Basic support 47.0 25.0 (25.0) 未实现 未实现 未实现

移动端浏览器兼容性

Feature Android Webview Chrome for Android Firefox Mobile (Gecko) Firefox OS IE Phone Opera Mobile Safari Mobile
Basic support 47.0 47.0 25.0 (25.0) 1.3(只支持音频) 未实现 未实现 未实现

这样的兼容程度显然是不满足主流用户的使用的,因此我们一般选择WebRTC提供的另一个API:AudioContext

AudioContext

随着WebRTC的日益流行,目前所有主流浏览器都已添加了对WebRTC的支持。

我们可以选择使用WebRTC中的getUserMedia结合AudioContext来实现录音的功能。

getUserMedia

MediaDevices.getUserMedia()
会提示用户给予使用媒体输入的许可,媒体输入会产生一个MediaStream,里面包含了请求的媒体类型的轨道。此流可以包含一个视频轨道(来自硬件或者虚拟视频源,比如相机、视频采集设备和屏幕共享服务等等)、一个音频轨道(同样来自硬件或虚拟音频源,比如麦克风、A/D转换器等等),也可能是其它轨道类型。

它返回一个 Promise 对象,成功后会resolve回调一个 MediaStream 对象。若用户拒绝了使用权限,或者需要的媒体源不可用,promise会reject回调一个 PermissionDeniedError 或者 NotFoundError 。

AudioContext

AudioContext接口表示由链接在一起的音频模块构建的音频处理图,每个模块由一个AudioNode表示。音频上下文控制它包含的节点的创建和音频处理或解码的执行。在做任何其他操作之前,您需要创建一个AudioContext对象,因为所有事情都是在上下文中发生的。建议创建一个AudioContext对象并复用它,而不是每次初始化一个新的AudioContext对象,并且可以对多个不同的音频源和管道同时使用一个AudioContext对象。

基本流程如下

流程

Demo(代码省略兼容性处理)

开始录音

let context, source, processor, mainStream;
const start = () => {
    navigator.getUserMedia({ audio: true, video: false }, async function (stream) {
        mainStream = stream;
        context = new AudioContext();
        source = context.createMediaStreamSource(stream); // 与音频流连接
        processor = (context.createScriptProcessor || context.createJavaScriptNode).call(context, params.bufferSize, params.numChannels, params.numChannels);
        
        // 接受录音的数据流
        processor.onaudioprocess = function (e) {
            if (self._paused) return;
    
            var data = [], i = 0;
            for (; i < numChannels; i++) {
                data.push(e.inputBuffer.getChannelData(i));
            }
    
            worker.postMessage({ cmd: "encode", data: data });
        };
        source.connect(processor);
        processor.connect(context.destination);
    }
}

暂停录音

const pause = () => context.suspend();

恢复录音

const resume = () => context.resume();

停止录音

const stop = async () => {
    if (source) source.disconnect();
    if (processor) await processor.disconnect();
    if ( mainStream ){
      if ( mainStream.getTracks ) {
        mainStream.getTracks().forEach(track => track.stop());
      } else {
        mainStream.stop();
      }
    }
    if (context) {
      await context.close();
      context = null;
    }
}

音频文件的编码

常见的音频文件编码格式有FLAC、WAV、OGG、MP3等。

各种音频格式的特点如下:

名称 描述 优点 缺点
wav 无损音频 几乎无损 体积最大
flac 无损压缩 音质很好,体积小很多 音质比wav略差,兼容性一般
ogg 一种压缩格式 音质比mp3好,体积和mp3差不多 兼容性低
mp3 一种压缩格式 最流行,兼容性最好 音质最差

本次主要介绍如何用MP3格式进行编码存储,如果有其他格式的需求,只需要更换下方案例中的编码库即可。

mp3编码采用的是一款目前社区较为流行的MP3开源编码库lamejs

需要注意的是,音频的编码过程会占用cpu与内存,如果放在js主线程中执行计算编码会导致用户界面卡顿,因此我们选择开一个WebWorker线程去执行编码操作,示例代码如下:

main.js

const worker = new Worker(encodeWorkerJsPath);
// 监听编码端的回调
worker.onmessage = (e) => {
    var obj = e.data, data = obj.data;

    switch (obj.cmd) {
        case "complete":
            if (onComplete) onComplete(new Blob(data, { type: "audio/mp3" }), ".mp3");
            break;

        case "error":
            if (onError) onError(data);
            break;
    }
};
worker.postMessage({
    cmd: "init",
    data: { 
        // 编码相关参数
        numChannels,
        sampleBits,
        inputSampleRate,
        outputSampleRate,
        bitRate
    }
});
// 音频流发送到编码端进行编码
processor.onaudioprocess = function (e) {
    if (paused) return;

    let data = [], i = 0;
    for (; i < numChannels; i++) {
        data.push(e.inputBuffer.getChannelData(i));
    }

    worker.postMessage({ cmd: "encode", data: data });
};
// stop方法
const stop = () => {
    // ...其他stop相关逻辑
    worker.postMessage({ cmd: "stop" });
}

worker.js

(function (undefined) {
    //发送 worker 数据
    function postMessage(cmd, data) {
        self.postMessage({
            cmd: cmd,
            data: data
        });
    }


    //导入lame.js以实现mp3编码
    //Worker 内可以使用 importScripts 导入js
    importScripts('./lame.all.js');

    var dataBuffer = [],     //数据缓冲区
        mp3Encoder,          //mp3编码器
        numChannels,         //通道数
        sampleBits,          //采样位数
        inputSampleRate,     //输入采样率
        outputSampleRate;    //输出采样率

    //添加缓冲数据
    function appendBuffer(buffer) {
        dataBuffer.push(new Int8Array(buffer));
    }

    //清除缓冲数据
    function clearBuffer() {
        dataBuffer = [];
    }

    //初始化
    function init(data) {
        numChannels = data.numChannels || 1;
        inputSampleRate = data.inputSampleRate;
        outputSampleRate = Math.min(data.outputSampleRate || inputSampleRate, inputSampleRate);

        clearBuffer();

        mp3Encoder = new lamejs.Mp3Encoder(numChannels, outputSampleRate, data.bitRate || 128);
    }

    //数据压缩与转换
    function convertBuffer(buffer) {
        var input;

        //修改采样率
        if (inputSampleRate != outputSampleRate) {
            var compression = inputSampleRate / outputSampleRate,
                length = Math.ceil(buffer.length / compression),
                input = new Float32Array(length),
                index = 0,
                i = 0;

            for (; index < length; i += compression) {
                input[index++] = buffer[~~i];
            }

        } else {
            input = new Float32Array(buffer);
        }

        //floatTo16BitPCM
        var length = input.length,
            output = new Int16Array(length),

            i = 0;

        for (; i < length; i++) {
            var s = Math.max(-1, Math.min(1, input[i]));

            output[i] = s < 0 ? s * 0x8000 : s * 0x7FFF;
        }

        return output;
    }

    //编码音频数据
    function encode(data) {
        var samplesLeft = convertBuffer(data[0]),
            samplesRight = numChannels > 1 ? convertBuffer(data[1]) : undefined,

            maxSamples = 1152,
            length = samplesLeft.length,
            remaining = length,
            i = 0;

        for (; remaining >= maxSamples; i += maxSamples) {
            var left = samplesLeft.subarray(i, i + maxSamples),
                right = samplesRight ? samplesRight.subarray(i, i + maxSamples) : undefined,

                mp3buffer = mp3Encoder.encodeBuffer(left, right);

            appendBuffer(mp3buffer);
            remaining -= maxSamples;
        }
    }

    function stop() {
        appendBuffer(mp3Encoder.flush());

        postMessage("complete", dataBuffer);

        clearBuffer();
    }

    //---------------- worker ----------------

    self.onmessage = function (e) {
        var obj = e.data, data = obj.data;

        switch (obj.cmd) {
            case "init":
                init(data);
                break;

            case "encode":
                encode(data);
                break;

            case "stop":
                stop();
                break;
        }
    };

})();

实际使用中的踩坑记录

录音文件过大

前期不了解音频格式的情况下,选用了wav格式做音频编码。

在开发阶段测试音频较短,对于几秒的音频大小区分不明显;而联调时录制了一批较长的音频,其中一段5分钟的音频,达到了惊人的100M

这对于用户的访问加载流量以及服务器存储流量显然是造成了很大的浪费。

因此后续经过调研,权衡文件大小、清晰度与兼容性之后,选用了mp3格式做编码,并对编码参数做如下限制:

sampleRate: 8000,  // 采样率,一般由设备提供,比如 48000
bitRate: 128,   // 比特率, 一般不要低于64,否则可能录制丢失人声
numChannels: 2, // 声道数,默认为1

后续实测mp3音频的保存大小基本都在1M以内。

WebWorker问题

上述提到音频编码时需要建立WebWorker线程,而在现有的静态js打包引用架构下,会存在以下问题:

1. worker js文件单独打包

此处的重点是workerjs的独立打包,可以基于不同的打包脚手架配置,进行多入口打包即可。

在业务js中引用时可以使用如下方式,基于当前业务js路径计算出worker路径进行引用:

const workerScript = Array.prototype.find.call(document.scripts, item => item.src.indexOf('mp3RecorderWorker') > -1);
const workerPath = workerScript ? workerScript.src : '';

2. 页面域名与静态服务器域名不同导致worker跨域**

如题所示,我们页面访问根域名为www.kujiale.com

而js文件所在的静态服务根域名为qhstaticssl.kujiale.com

这时再进行webworker调用就会因Chrome安全机制而出现蛋疼的跨域问题。

解决方案如下。


方案一(纯前端方案):

基本思路是通过xhr异步请求拿到worker的文件内容,转化blob之后再利用window.URL.createObjectURL进行webWorker引用,具体实现如下

const XHRWorker = (url) => {
    return new Promise((resolve, reject) => {
        try {
            // 避免重复请求
            if (window.__RECORDER_WEBWORK_BLOB) {
                let worker = new Worker(window.__RECORDER_WEBWORK_BLOB);
                resolve(worker);
                return;
            }
            let oReq = new XMLHttpRequest();
            oReq.addEventListener('load', function() {
                const workBlob = window.URL.createObjectURL(new Blob([this.responseText]));
                if (!window.__RECORDER_WEBWORK_BLOB) window.__RECORDER_WEBWORK_BLOB = workBlob;
                let worker = new Worker(workBlob);
                resolve(worker);
            }, oReq);
            oReq.open("get", url, true);
            oReq.send();
        } catch (e) {
            reject(e);
        }
    })
};

方案二(结合服务器转发):

另一个解决思路是采用一个中间层的代理服务,将文件路径转发到主域名之下。

当然这里会有一些服务端的技术成本(除非有现成的文件转发服务,比如我司目前的Serverless服务即可快速构建文件转发业务)。

参考资料

mp3-recorder
Recordmp3js
Recorderjs
lamejs

知识共享许可协议
本作品采用 知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议 进行许可。
comments powered by Disqus