html5录音功能实战示例

2022-10-20,,,

缘起

由于项目需要,我们要在web端实现录音功能。一开始,找到的方案有两个,一个是通过iframe,一个是html5的getusermedia api。由于我们的录音功能不需要兼容ie浏览器,所以毫不犹豫的选择了html5提供的getusermedia去实现。基本思路是参考了官方的api文档以及网上查找的一些方案做结合做出了适合项目需要的方案。但由于我们必须保证这个录音功能能够同时在pad端、pc端都可以打开,所以其中也踩了一些坑。以下为过程还原。

步骤1

由于新的api是通过navigator.mediadevices.getusermedia,且返回一个promise。

而旧的api是navigator.getusermedia,于是做了一个兼容性。代码如下:

// 老的浏览器可能根本没有实现 mediadevices,所以我们可以先设置一个空的对象
if (navigator.mediadevices === undefined) {
    navigator.mediadevices = {};
}

// 一些浏览器部分支持 mediadevices。我们不能直接给对象设置 getusermedia
// 因为这样可能会覆盖已有的属性。这里我们只会在没有getusermedia属性的时候添加它。
if (navigator.mediadevices.getusermedia === undefined) {
    let getusermedia =
        navigator.getusermedia ||
        navigator.webkitgetusermedia ||
        navigator.mozgetusermedia ||
        navigator.msgetusermedia;
    navigator.mediadevices.getusermedia = function(constraints) {
        // 首先,如果有getusermedia的话,就获得它

        // 一些浏览器根本没实现它 - 那么就返回一个error到promise的reject来保持一个统一的接口
        if (!getusermedia) {
            return promise.reject(new error('getusermedia is not implemented in this browser'));
        }

        // 否则,为老的navigator.getusermedia方法包裹一个promise
        return new promise(function(resolve, reject) {
            getusermedia.call(navigator, constraints, resolve, reject);
        });
    };

步骤2

这是网上存在的一个方法,封装了一个hzrecorder。基本上引用了这个方法。调用hzrecorder.get就可以调起录音接口,这个方法传入一个callback函数,new hzrecorder后执行callback函数且传入一个实体化后的hzrecorder对象。可以通过该对象的方法实现开始录音、暂停、停止、播放等功能。

var hzrecorder = function (stream, config) {  
    config = config || {};  
    config.samplebits = config.samplebits || 8;      //采样数位 8, 16  
    config.samplerate = config.samplerate || (44100 / 6);   //采样率(1/6 44100)  

      
    //创建一个音频环境对象  
    audiocontext = window.audiocontext || window.webkitaudiocontext;  
    var context = new audiocontext();  

    //将声音输入这个对像  
    var audioinput = context.createmediastreamsource(stream);  
      
    //设置音量节点  
    var volume = context.creategain();  
    audioinput.connect(volume);  

    //创建缓存,用来缓存声音  
    var buffersize = 4096;  

    // 创建声音的缓存节点,createscriptprocessor方法的  
    // 第二个和第三个参数指的是输入和输出都是双声道。  
    var recorder = context.createscriptprocessor(buffersize, 2, 2);  

    var audiodata = {  
        size: 0          //录音文件长度  
        , buffer: []     //录音缓存  
        , inputsamplerate: context.samplerate    //输入采样率  
        , inputsamplebits: 16       //输入采样数位 8, 16  
        , outputsamplerate: config.samplerate    //输出采样率  
        , oututsamplebits: config.samplebits       //输出采样数位 8, 16  
        , input: function (data) {  
            this.buffer.push(new float32array(data));  
            this.size += data.length;  
        }  
        , compress: function () { //合并压缩  
            //合并  
            var data = new float32array(this.size);  
            var offset = 0;  
            for (var i = 0; i < this.buffer.length; i++) {  
                data.set(this.buffer[i], offset);  
                offset += this.buffer[i].length;  
            }  
            //压缩  
            var compression = parseint(this.inputsamplerate / this.outputsamplerate);  
            var length = data.length / compression;  
            var result = new float32array(length);  
            var index = 0, j = 0;  
            while (index < length) {  
                result[index] = data[j];  
                j += compression;  
                index++;  
            }  
            return result;  
        }  
        , encodewav: function () {  
            var samplerate = math.min(this.inputsamplerate, this.outputsamplerate);  
            var samplebits = math.min(this.inputsamplebits, this.oututsamplebits);  
            var bytes = this.compress();  
            var datalength = bytes.length * (samplebits / 8);  
            var buffer = new arraybuffer(44 + datalength);  
            var data = new dataview(buffer);  

            var channelcount = 1;//单声道  
            var offset = 0;  

            var writestring = function (str) {  
                for (var i = 0; i < str.length; i++) {  
                    data.setuint8(offset + i, str.charcodeat(i));  
                }  
            };  
              
            // 资源交换文件标识符   
            writestring('riff'); offset += 4;  
            // 下个地址开始到文件尾总字节数,即文件大小-8   
            data.setuint32(offset, 36 + datalength, true); offset += 4;  
            // wav文件标志  
            writestring('wave'); offset += 4;  
            // 波形格式标志   
            writestring('fmt '); offset += 4;  
            // 过滤字节,一般为 0x10 = 16   
            data.setuint32(offset, 16, true); offset += 4;  
            // 格式类别 (pcm形式采样数据)   
            data.setuint16(offset, 1, true); offset += 2;  
            // 通道数   
            data.setuint16(offset, channelcount, true); offset += 2;  
            // 采样率,每秒样本数,表示每个通道的播放速度   
            data.setuint32(offset, samplerate, true); offset += 4;  
            // 波形数据传输率 (每秒平均字节数) 单声道×每秒数据位数×每样本数据位/8   
            data.setuint32(offset, channelcount * samplerate * (samplebits / 8), true); offset += 4;  
            // 快数据调整数 采样一次占用字节数 单声道×每样本的数据位数/8   
            data.setuint16(offset, channelcount * (samplebits / 8), true); offset += 2;  
            // 每样本数据位数   
            data.setuint16(offset, samplebits, true); offset += 2;  
            // 数据标识符   
            writestring('data'); offset += 4;  
            // 采样数据总数,即数据总大小-44   
            data.setuint32(offset, datalength, true); offset += 4;  
            // 写入采样数据   
            if (samplebits === 8) {  
                for (var i = 0; i < bytes.length; i++, offset++) {  
                    var s = math.max(-1, math.min(1, bytes[i]));  
                    var val = s < 0 ? s * 0x8000 : s * 0x7fff;  
                    val = parseint(255 / (65535 / (val + 32768)));  
                    data.setint8(offset, val, true);  
                }  
            } else {  
                for (var i = 0; i < bytes.length; i++, offset += 2) {  
                    var s = math.max(-1, math.min(1, bytes[i]));  
                    data.setint16(offset, s < 0 ? s * 0x8000 : s * 0x7fff, true);  
                }  
            }  

            return new blob([data], { type: 'audio/wav' });  
        }  
    };  

    //开始录音  
    this.start = function () {  
        audioinput.connect(recorder);  
        recorder.connect(context.destination);  
    };  

    //停止  
    this.stop = function () {  
        recorder.disconnect();  
    };  
    
    // 结束
    this.end = function() {
        context.close();
    };
    
    // 继续
    this.again = function() {
        recorder.connect(context.destination);
    };

    //获取音频文件  
    this.getblob = function () {  
        this.stop();  
        return audiodata.encodewav();  
    };  

    //回放  
    this.play = function (audio) {  
        audio.src = window.url.createobjecturl(this.getblob());  
    };  

    //上传  
    this.upload = function (url, callback) {  
        var fd = new formdata();  
        fd.append('audiodata', this.getblob());  
        var xhr = new xmlhttprequest();  
        if (callback) {  
            xhr.upload.addeventlistener('progress', function (e) {  
                callback('uploading', e);  
            }, false);  
            xhr.addeventlistener('load', function (e) {  
                callback('ok', e);  
            }, false);  
            xhr.addeventlistener('error', function (e) {  
                callback('error', e);  
            }, false);  
            xhr.addeventlistener('abort', function (e) {  
                callback('cancel', e);  
            }, false);  
        }  
        xhr.open('post', url);  
        xhr.send(fd);  
    };  

    //音频采集  
    recorder.onaudioprocess = function (e) {  
        audiodata.input(e.inputbuffer.getchanneldata(0));  
        //record(e.inputbuffer.getchanneldata(0));  
    };  

};  

//抛出异常  
hzrecorder.throwerror = function (message) {  
    throw new function () { this.tostring = function () { return message; };};  
};  
//是否支持录音  
hzrecorder.canrecording = (navigator.getusermedia != null);  
//获取录音机  
hzrecorder.get = function (callback, config) {  
   if (callback) {
        navigator.mediadevices
            .getusermedia({ audio: true })
            .then(function(stream) {
                let rec = new hzrecorder(stream, config);
                callback(rec);
            })
            .catch(function(error) {
                hzrecorder.throwerror('无法录音,请检查设备状态');
            });
    }
};  
window.hzrecorder = hzrecorder;

以上,已经可以满足大部分的需求。但是我们要兼容pad端。我们的pad有几个问题必须解决。

  • 录音格式必须是mp3才能播放
  • window.url.createobjecturl传入blob数据在pad端报错,转不了

以下为解决这两个问题的方案。

步骤3

以下为我实现 录音格式为mp3 和 window.url.createobjecturl传入blob数据在pad端报错 的方案。

1、修改hzrecorder里的audiodata对象代码。并引入网上一位大神的一个js文件lamejs.js

const lame = new lamejs();
let audiodata = {
    samplesmono: null,
    maxsamples: 1152,
    mp3encoder: new lame.mp3encoder(1, context.samplerate || 44100, config.bitrate || 128),
    databuffer: [],
    size: 0, // 录音文件长度
    buffer: [], // 录音缓存
    inputsamplerate: context.samplerate, // 输入采样率
    inputsamplebits: 16, // 输入采样数位 8, 16
    outputsamplerate: config.samplerate, // 输出采样率
    oututsamplebits: config.samplebits, // 输出采样数位 8, 16
    convertbuffer: function(arraybuffer) {
        let data = new float32array(arraybuffer);
        let out = new int16array(arraybuffer.length);
        this.floatto16bitpcm(data, out);
        return out;
    },
    floatto16bitpcm: function(input, output) {
        for (let i = 0; i < input.length; i++) {
            let s = math.max(-1, math.min(1, input[i]));
            output[i] = s < 0 ? s * 0x8000 : s * 0x7fff;
        }
    },
    appendtobuffer: function(mp3buf) {
        this.databuffer.push(new int8array(mp3buf));
    },
    encode: function(arraybuffer) {
        this.samplesmono = this.convertbuffer(arraybuffer);
        let remaining = this.samplesmono.length;
        for (let i = 0; remaining >= 0; i += this.maxsamples) {
            let left = this.samplesmono.subarray(i, i + this.maxsamples);
            let mp3buf = this.mp3encoder.encodebuffer(left);
            this.appendtobuffer(mp3buf);
            remaining -= this.maxsamples;
        }
    },
    finish: function() {
        this.appendtobuffer(this.mp3encoder.flush());
        return new blob(this.databuffer, { type: 'audio/mp3' });
    },
    input: function(data) {
        this.buffer.push(new float32array(data));
        this.size += data.length;
    },
    compress: function() {
        // 合并压缩
        // 合并
        let data = new float32array(this.size);
        let offset = 0;
        for (let i = 0; i < this.buffer.length; i++) {
            data.set(this.buffer[i], offset);
            offset += this.buffer[i].length;
        }
        // 压缩
        let compression = parseint(this.inputsamplerate / this.outputsamplerate, 10);
        let length = data.length / compression;
        let result = new float32array(length);
        let index = 0;
        let j = 0;
        while (index < length) {
            result[index] = data[j];
            j += compression;
            index++;
        }
        return result;
    },
    encodewav: function() {
        let samplerate = math.min(this.inputsamplerate, this.outputsamplerate);
        let samplebits = math.min(this.inputsamplebits, this.oututsamplebits);
        let bytes = this.compress();
        let datalength = bytes.length * (samplebits / 8);
        let buffer = new arraybuffer(44 + datalength);
        let data = new dataview(buffer);

        let channelcount = 1; // 单声道
        let offset = 0;

        let writestring = function(str) {
            for (let i = 0; i < str.length; i++) {
                data.setuint8(offset + i, str.charcodeat(i));
            }
        };

        // 资源交换文件标识符
        writestring('riff');
        offset += 4;
        // 下个地址开始到文件尾总字节数,即文件大小-8
        data.setuint32(offset, 36 + datalength, true);
        offset += 4;
        // wav文件标志
        writestring('wave');
        offset += 4;
        // 波形格式标志
        writestring('fmt ');
        offset += 4;
        // 过滤字节,一般为 0x10 = 16
        data.setuint32(offset, 16, true);
        offset += 4;
        // 格式类别 (pcm形式采样数据)
        data.setuint16(offset, 1, true);
        offset += 2;
        // 通道数
        data.setuint16(offset, channelcount, true);
        offset += 2;
        // 采样率,每秒样本数,表示每个通道的播放速度
        data.setuint32(offset, samplerate, true);
        offset += 4;
        // 波形数据传输率 (每秒平均字节数) 单声道×每秒数据位数×每样本数据位/8
        data.setuint32(offset, channelcount * samplerate * (samplebits / 8), true);
        offset += 4;
        // 快数据调整数 采样一次占用字节数 单声道×每样本的数据位数/8
        data.setuint16(offset, channelcount * (samplebits / 8), true);
        offset += 2;
        // 每样本数据位数
        data.setuint16(offset, samplebits, true);
        offset += 2;
        // 数据标识符
        writestring('data');
        offset += 4;
        // 采样数据总数,即数据总大小-44
        data.setuint32(offset, datalength, true);
        offset += 4;
        // 写入采样数据
        if (samplebits === 8) {
            for (let i = 0; i < bytes.length; i++, offset++) {
                const s = math.max(-1, math.min(1, bytes[i]));
                let val = s < 0 ? s * 0x8000 : s * 0x7fff;
                val = parseint(255 / (65535 / (val + 32768)), 10);
                data.setint8(offset, val, true);
            }
        } else {
            for (let i = 0; i < bytes.length; i++, offset += 2) {
                const s = math.max(-1, math.min(1, bytes[i]));
                data.setint16(offset, s < 0 ? s * 0x8000 : s * 0x7fff, true);
            }
        }

        return new blob([data], { type: 'audio/wav' });
    }
};

2、修改hzrecord的音频采集的调用方法。

// 音频采集
recorder.onaudioprocess = function(e) {
    audiodata.encode(e.inputbuffer.getchanneldata(0));
};

3、hzrecord的getblob方法。

this.getblob = function() {
    this.stop();
    return audiodata.finish();
};

4、hzrecord的play方法。把blob转base64url。

this.play = function(func) {
    readblobasdataurl(this.getblob(), func);
};

function readblobasdataurl(data, callback) {
    let filereader = new filereader();
    filereader.onload = function(e) {
        callback(e.target.result);
    };
    filereader.readasdataurl(data);
}

至此,已经解决以上两个问题。

步骤4

这里主要介绍怎么做录音时的动效。我们的一个动效需求为:

根据传入的音量大小,做一个圆弧动态扩展。

// 创建analyser节点,获取音频时间和频率数据
const analyser = context.createanalyser();
audioinput.connect(analyser);
const inputanalyser = new uint8array(1);
const wrapele = $this.refs['wrap'];
let ctx = wrapele.getcontext('2d');
const width = wrapele.width;
const height = wrapele.height;
const center = {
    x: width / 2,
    y: height / 2
};

function drawarc(ctx, color, x, y, radius, beginangle, endangle) {
    ctx.beginpath();
    ctx.linewidth = 1;
    ctx.strokestyle = color;
    ctx.arc(x, y, radius, (math.pi * beginangle) / 180, (math.pi * endangle) / 180);
    ctx.stroke();
}

(function drawspectrum() {
    analyser.getbytefrequencydata(inputanalyser); // 获取频域数据
    ctx.clearrect(0, 0, width, height);
    // 画线条
    for (let i = 0; i < 1; i++) {
        let value = inputanalyser[i] / 3; // <===获取数据
        let colors = [];
        if (value <= 16) {
            colors = ['#f5a631', '#f5a631', '#e4e4e4', '#e4e4e4', '#e4e4e4', '#e4e4e4'];
        } else if (value <= 32) {
            colors = ['#f5a631', '#f5a631', '#f5a631', '#f5a631', '#e4e4e4', '#e4e4e4'];
        } else {
            colors = ['#f5a631', '#f5a631', '#f5a631', '#f5a631', '#f5a631', '#f5a631'];
        }
        drawarc(ctx, colors[0], center.x, center.y, 52 + 16, -30, 30);
        drawarc(ctx, colors[1], center.x, center.y, 52 + 16, 150, 210);
        drawarc(ctx, colors[2], center.x, center.y, 52 + 32, -22.5, 22.5);
        drawarc(ctx, colors[3], center.x, center.y, 52 + 32, 157.5, 202.5);
        drawarc(ctx, colors[4], center.x, center.y, 52 + 48, -13, 13);
        drawarc(ctx, colors[5], center.x, center.y, 52 + 48, 167, 193);
    }

    // 请求下一帧
    requestanimationframe(drawspectrum);
})();

缘尽

至此,一个完整的html5录音功能方案已经完成。有什么需要补充,不合理的地方的欢迎留言。

ps:lamejs可参考这个

以上就是本文的全部内容,希望对大家的学习有所帮助,也希望大家多多支持。

《html5录音功能实战示例.doc》

下载本文的Word格式文档,以方便收藏与打印。