html5錄音功能實戰

  • 蘇格團隊
  • 做者:Dee

緣起

因爲項目須要,咱們要在web端實現錄音功能。一開始,找到的方案有兩個,一個是經過iframe,一個是html5的getUserMedia api。因爲咱們的錄音功能不須要兼容IE瀏覽器,因此堅決果斷的選擇了html5提供的getUserMedia去實現。基本思路是參考了官方的api文檔以及網上查找的一些方案作結合作出了適合項目須要的方案。但因爲咱們必須保證這個錄音功能可以同時在pad端、pc端均可以打開,因此其中也踩了一些坑。如下爲過程還原。html

步驟1

因爲新的api是經過navigator.mediaDevices.getUserMedia,且返回一個promise。html5

而舊的api是navigator.getUserMedia,因而作了一個兼容性。代碼以下:git

// 老的瀏覽器可能根本沒有實現 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對象。能夠經過該對象的方法實現開始錄音、暫停、中止、播放等功能。github

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有幾個問題必須解決。web

  • 錄音格式必須是mp3才能播放
  • window.URL.createObjectURL傳入blob數據在pad端報錯,轉不了

如下爲解決這兩個問題的方案。api

步驟3

如下爲我實現 錄音格式爲mp3window.URL.createObjectURL傳入blob數據在pad端報錯 的方案。promise

一、修改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' });
    }
};

複製代碼

二、修改HZRecord的音頻採集的調用方法。緩存

// 音頻採集
recorder.onaudioprocess = function(e) {
    audioData.encode(e.inputBuffer.getChannelData(0));
};
複製代碼

三、HZRecord的getBlob方法。app

this.getBlob = function() {
    this.stop();
    return audioData.finish();
};
複製代碼

四、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可參考這個github github.com/akrennmair/…

相關文章
相關標籤/搜索