音頻處理之音頻文件拼接錄音及裁剪

這篇博客是我在開發一個「音頻文件拼接錄音及裁剪」功能過程當中的筆記,由於學習到了不少沒接觸過的內容,因此在這裏作個記錄,這不是個教程貼,文中不涉及業務代碼,只有一小部分核心代碼用來解釋我描述的內容而已,基本上是給我本身看的哈哈。html

本文是講我在作我的項目中開發中涉及到的一個小需求,主要實現下面4個功能:ios

  1. 實現錄製音頻的基本功能,錄製一段開始到結束的音頻,並能導出文件形式;
  2. 能夠對錄製好的音頻進行播放,能夠拖動進度條改變進度,並能在進度條的位置繼續錄製音頻,將新錄製的音頻覆蓋進度條以後的音頻,並與進度條以前的音頻合併;
  3. 將音頻文件導入並進行錄音,錄音完成後會將錄製的音頻與導入的音頻合併;
  4. 一樣是將音頻文件導入後與第2條的功能相同;

上面提到的功能我在開源社區沒有找到同時知足的插件,基本只有第一個功能,因此我找了一個插件進行改造,我用的是 github.com/2fps/record… 這個插件,後面的功能都是基於這個插件進行改造擴展的,很是感謝這個插件做者讓我學習到了實現錄製音頻的原理,也是由於這個插件讓我有信心完成了後面3個功能。git

實現這幾個小功能花了3天時間,主要是學習吧,我就按第x天來說吧。github

第一天

研究encodePCM,嘗試逆向decode,但對於二進制概念基礎還不夠牢固,因此先研究二進制的各類對象 ArrayBuffer、TypedArray和DataView之間的關係與數據規律,總結出瞭如下特性:ajax

Arraybuffer對象用來表示通用的、固定長度的原始二進制數據緩衝區,它不能直接被用來讀寫,須要轉成DataView才能讀寫其中的數據,通常用來數據傳輸。axios

DataView視圖是一個能夠從 ArrayBuffer 對象中讀寫多種數值類型的底層接口,它的構造函數傳參必須是Arraybuffer數組,主要就是用來按數據類型和位數讀寫數據,用到的方法有setInt1六、getInt16這兩個,還有不少set和get的方法可使用,本項目還用到它來生成Blob對象進行文件處理。api

TypedArray 能夠認爲是用來描述ArrayBuffer的類型化數組,與DataView沒有直接關係,但做用和DataView相似都是對ArrayBuffer的數據進行讀寫,只不過是以特定的數據類型以數組來處理。數組

當你經過DataView對一個ArrayBuffer對象進行setInt8(0, 1)的操做,表明ArrayBuffer按Int8讀取時的第一個值爲1,用TypedArray的方式讀取就能夠用new Int8Array(buffer)來按Int8的數據類型來讀取ArrayBuffer。這樣就能獲得第一個值爲1的類數組對象,而後就能夠按數組的方式對其進行處理。若是用Float32Array來讀取這段ArrayBuffer的話,獲得的第一個值就會自動轉爲Float32類型的值。 函數

理清這三個傢伙的關係回來看就知道該怎麼作了,因而開始摸索怎樣把encode出來並生成Blob的音頻文件解碼回能進行剪切的格式。性能

從錄製音頻的源頭開始看起,AudioContext錄製時的事件會返回buffer數據,是Float32Array類型的,值爲[-1,1]區間的32位浮點型。因爲音頻採樣率很是的高(最高達到每秒48000 次),因此每4096次採集一次存放在this.buffer數組當中,因此this.buffer是一個包含了Float32Array的二維數組。

因此咱們若是要對音頻進行截取拼接等操做都是對這個this.buffer進行操做的,實質上就是對採集獲得的Float32Array數據進行處理。

知道了應該操做哪一個數據就能夠知道實現整個音頻裁剪拼接功能的完整流程了。

大概是以下流程:

  1. 將音頻文件進行decode解碼,獲得咱們能夠處理的this.buffer數據;
  2. 對this.buffer數據作相應的裁剪拼接處理;
  3. 將處理完的this.buffer再encode回去,編碼成能夠播放的音頻文件;

第1步是最難的,後面主要講第一步。 第2步其實就是像數組同樣操做裁剪想要的長度,拼接也是跟數組同樣的 第3步其實自己就提供了encodePCM、encodeWAV等方法,不須要作

第一天大部分是對概念的和流程的理清,從音頻流接收的二進制到編碼再到音頻文件,明白了這其中是怎麼進行轉換的,就能夠對須要處理的數據this.buffer進行想要的操做(包括但不限於音頻裁剪和拼接),最後能編碼成可播放的音頻文件。

次日

接下來開始寫decodePCM了,先來分析encodePCM作了什麼,它的主要做用就是把元數據的[-1,1]的Float32Array轉換成Int16或者Int8的的DataView數據。那咱們要作的decode固然就是把Int16或者Int8的的DataView數據轉換成[-1,1]的Float32Array啦。

貼上encode的核心代碼:

data = new Float32Array(dataview.byteLength)
      for (var i = 0; i < bytes.length; i++ , offset += 2) {
        var s = Math.max(-1, Math.min(1, bytes[i]));
        // 16位的劃分的是2^16=65536份,範圍是-32768到32767
        // 由於咱們收集的數據範圍在[-1,1],那麼你想轉換成16位的話,只須要對負數*32768,對正數*32767,便可獲得範圍在[-32768,32767]的數據。
        data.setInt16(offset, s < 0 ? s * 0x8000 : s * 0x7FFF, true);
      }
複製代碼

再貼上decode的解碼代碼:

// byteLength是8位一個字節的長度,因此16位就要除以2
      data = new Float32Array(dataview.byteLength / 2)
      for (var i = 0; i < data.length; i++, offset += 2) {
        // 在encodePCM的時候是setInt16,因此用getInt16的時候取到的是整數沒有小數點,這裏會有些偏差
        var s = Math.max(-32768, Math.min(32767, bytes.getInt16(offset, true)));
        // 16位的劃分的是2^16=65536份,範圍是-32768到32767
        // 轉換爲[-1, 1]的數據
        data[i] = s < 0 ? s / 0x8000 : s / 0x7FFF
      }
複製代碼

字節序

實際上我在開發過程遇到了兩個問題:

一個是字節序的問題,在解碼的時候bytes.getInt16(offset)發現get出來的數據跟編碼時set的值不一致!好像還比set的值要大,調試了半天最後發現set的時候帶了第三個參數填了true,查文檔是這麼描述的:  應該是規定setInt16時用什麼字節序,與之對應的getInt16也有這個參數,那麼字節序是啥,我一開始也不懂,後來看到阮一峯大佬的這篇文章http://www.ruanyifeng.com/blog/2016/11/byte-order.html 就很好理解了。

字節序分大端和小端,參數爲true時是用小端字節序,因此在get的時候也要對應一樣的字節序傳true,上面的decode代碼就是正確的,這樣子set和get就一致了。

第二個問題是get後的數值與set時的數據只是比較接近而已,並非徹底相等,這是由於咱們用了setInt16就會以16位整數存在Buffer中,這樣子就會把set的值小數點後的數都截掉,因此get出來的值就是個整數,因此這裏會存在一點小偏差,大概是0.003051850948%的偏差,這樣的偏差幾乎能夠忽略不計,若是想更精確的話能夠改成setFloat32和getFloat32,可是文件體積應該會加大一倍。

解決了以後,decodePCM算是完成了,接下來再寫一個接收文件blob對象,轉換爲ArrayBuffer對象再交給decodePCM函數解碼的一個函數。

Blob對象怎麼轉ArrayBuffer?有幾種辦法,一種是請求的方式,fetch、Response、ajax均可以,而我用的是另外一種方式,用的是FileReader,直接貼代碼:

function Blob2Arraybuffer(blob, cb) {
  var reader = new FileReader();
  reader.readAsArrayBuffer(blob);
  reader.onload = function () {
    cb(reader.result);
  }
}
複製代碼

原生就有提供readAsArrayBuffer的接口,仍是很容易轉的。接下來就是驗證從純文件解碼出來的buffer到底能不能被處理過以後還能再編碼出來播放了。

文件解碼

咱們能夠開出一個load方法,讓外部傳文件進來作解碼。

  Recorder.prototype.load = function(blob) {
    var _this = this;
    Recorder.decodePCM(blob, this.oututSampleBits, function(data) {
      // 將一維data提高二維,this.buffer的格式
      var buffer = [];
      var index = 0;
      while (index < data.length - 1){
        buffer.push(data.slice(index, index+=4096));
      }
      var size = data.length
      var duration = data.length / _this.inputSampleRate;
      _this.buffer = buffer;
      _this.size = size;
      _this.duration = duration;	// 再編碼成音頻文件
	const audio = new Audio(URL.createObjectURL(_this.getWAVBlob()));
      _this.audio = audio;
    })
  };
複製代碼

上面有句代碼是 const audio = new Audio(URL.createObjectURL(_this.getWAVBlob()));就是看解碼完成以後再編碼成音頻文件,而後咱們再調用_this.audio.play()播放,是能夠正常播放的,說明我解碼出來的this.buffer是能夠被正常編碼迴文件播放的,聲音聽起來是沒有差異的。

而後在業務頁面加載外部文件,獲得blob調用load方法:

axios.get('/data.wav’, {
  responseType: 'blob',
}).then((res) => {
  this.recorder.load(res.data)
})
複製代碼

其實請求頭的responseType能夠是arraybuffer的,只是我以爲Blob做爲數據交換會更通用一些。

驗證完這步可行以後要把它改回直接用傳入的文件Blob來播放會好一些,

const audio = new Audio(URL.createObjectURL(blob));

第三天

第三天就把需求的功能實現(音頻裁剪、拼接),基本上只要對this.buffer作處理就行了,這裏就不放具體代碼了,說說思路就行了。

裁剪

傳入一個進度相關的參數,我傳的是[0,1]的百分比,而後把解碼後的Float32Array大數組按百分比裁剪,size和duration直接拿裁剪後的數組長度填上便可,不過裁剪後的Float32Array大數組要再次合併成this.buffer的二維數組。

拼接

拼接就更簡單了,先把裁剪後的數據存放在一個臨時的對象裏,而後清空this.buffer等數據從新錄製,錄製完後再把臨時對象裏的裁剪音頻插入到this.buffer的前面,size和duration相加便可,

最後就是作一些代碼結構的優化和小修改的優化的工做了。算是基本完成了想要的這兩個功能了。

接下來還有一些計劃要作的東西:

  • 優化性能,畢竟是在內存中處理二進制數據,內存回收釋放還須要注意的,也能夠考慮新開一個worker處理二進制;
  • 導出mp3格式的文件,能支持更多客戶端環境播放。
  • 考慮改變人聲,這個可能接觸到更多我不知道的內容,好比波形、音調、之類的,這須要進行技術攻堅,個人目標是能把人聲變成小黃人的聲音。

總結

從用戶角度僅僅是簡單的錄音功能,在程序中則要以極其微觀的機器角度來思考怎樣以數據的形式處理。經過此次開發,我也算是打開了音頻處理程序的第一扇門吧,也是第一次感覺到了ArrayBuffer、DataView和TypedArray 這三個js原生api真正的魅力。

我學到了不少音頻相關的概念,這些概念不只僅是用來開發出我需求的那幾個小功能,我能夠經過這些概念結合本身已有的知識碰撞出更酷的想法,創造出更酷的程序,如今的我說出這種話就像屁話,但能夠做爲目標,作更好的本身。

相關文章
相關標籤/搜索