最近在作一個項目,須要對webRTC錄製的音頻進行處理,包括音頻的裁剪、多音頻合併,甚至要將某個音頻的某一部分替換成另外一個音頻。前端
本來筆者打算將這件工做交給服務端去完成,但考慮,其實不管是前端仍是後臺,所作的工做是差很少的,並且交給服務端還須要再額外走一個上傳、下載音頻的流程,這不只增添了服務端的壓力,並且還有網絡流量的開銷,因而萌生出一個想法:爲何音頻處理這件事不能讓前端來作呢?ios
因而在筆者的半摸索半實踐下,產生出了這篇文章。廢話少說,先上倉庫地址,這是一個開箱即用的前端音頻剪輯sdk(點進去了不如就star一下吧)git
ffmpeg是實現前端音頻處理的很是核心的模塊,固然,不只是前端,ffmpge做爲一個提供了錄製、轉換以及流化音視頻的業界成熟完整解決方案,它也應用在服務端、APP應用等多種場景下。關於ffmpeg的介紹,你們自行google便可,這裏不說太多。github
因爲ffmpeg在處理過程當中須要大量的計算,直接放在前端頁面上去運行是不可能的,由於咱們須要單獨開個web worker,讓它本身在worker裏面運行,而不至於阻塞頁面交互。web
可喜的是,萬能的github上已經有開發者提供了ffmpge.js,而且提供worker版本,能夠拿來直接使用。
ajax
因而咱們便有了大致的思路:當獲取到音頻文件後,將其解碼後傳送給worker,讓其進行計算處理,並將處理結果以事件的方式返回,這樣咱們就能夠對音頻隨心所欲了:)axios
須要提早聲明的是,因爲筆者的項目需求,是僅需對.mp3
格式進行處理的,所以下面的代碼示例以及倉庫地址裏面所涉及的代碼,也主要是針對mp3
,固然,其實不論是哪一種格式,思路是相似的。數組
建立worker的方式很是簡單,直接new之,注意的是,因爲同源策略的限制,要使worker正常工做,則要與父頁面同源,因爲這不是重點,因此略過promise
function createWorker(workerPath: string) { const worker = new Worker(workerPath); return worker; }
仔細看ffmpeg.js
文檔的童鞋都會發現,它在處理音頻的不一樣階段都會發射事件給父頁面,好比stdout
,start
和done
等等,若是直接爲這些事件添加回調函數,在回調函數裏去區分、處理一個又一個音頻的結果,是不大好維護的。我的更傾向於將其轉成promise:網絡
function pmToPromise(worker, postInfo) { return new Promise((resolve, reject) => { // 成功回調 const successHandler = function(event) { switch (event.data.type) { case "stdout": console.log("worker stdout: ", event.data.data); break; case "start": console.log("worker receive your command and start to work:)"); break; case "done": worker.removeEventListener("message", successHandler); resolve(event); break; default: break; } }; // 異常捕獲 const failHandler = function(error) { worker.removeEventListener("error", failHandler); reject(error); }; worker.addEventListener("message", successHandler); worker.addEventListener("error", failHandler); postInfo && worker.postMessage(postInfo); }); }
經過這層轉換,咱們就能夠將一次postMessage請求,轉換成了promise的方式來處理,更易於空間上的拓展
ffmpeg-worker
所須要的數據格式是arrayBuffer
,而通常咱們能直接使用的,要麼是音頻文件對象blob
,或者音頻元素對象audio
,甚至有可能僅是一條連接url,所以這幾種格式的轉換是很是有必要的:
function audioToBlob(audio) { const url = audio.src; if (url) { return axios({ url, method: 'get', responseType: 'arraybuffer', }).then(res => res.data); } else { return Promise.resolve(null); } }
筆者暫時想到的audio轉blob的方式,就是發起一段ajax請求,將請求類型設置爲arraybuffer
,便可拿到arrayBuffer
.
這個也很簡單,只須要藉助FileReader
將blob內容提取出來便可
function blobToArrayBuffer(blob) { return new Promise(resolve => { const fileReader = new FileReader(); fileReader.onload = function() { resolve(fileReader.result); }; fileReader.readAsArrayBuffer(blob); }); }
利用File
建立出一個blob
function audioBufferToBlob(arrayBuffer) { const file = new File([arrayBuffer], 'test.mp3', { type: 'audio/mp3', }); return file; }
blob
轉audio是很是簡單的,js提供了一個原生API——URL.createObjectURL
,藉助它咱們能夠把blob轉成本地可訪問連接進行播放
function blobToAudio(blob) { const url = URL.createObjectURL(blob); return new Audio(url); }
接下來咱們進入正題。
所謂裁剪,便是指將給定的音頻,按給定的起始、結束時間點,提取這部分的內容,造成新的音頻,先上代碼:
class Sdk { end = "end"; // other code... /** * 將傳入的一段音頻blob,按照指定的時間位置進行裁剪 * @param originBlob 待處理的音頻 * @param startSecond 開始裁剪時間點(秒) * @param endSecond 結束裁剪時間點(秒) */ clip = async (originBlob, startSecond, endSecond) => { const ss = startSecond; // 獲取須要裁剪的時長,若不傳endSecond,則默認裁剪到末尾 const d = isNumber(endSecond) ? endSecond - startSecond : this.end; // 將blob轉換成可處理的arrayBuffer const originAb = await blobToArrayBuffer(originBlob); let resultArrBuf; // 獲取發送給ffmpge-worker的指令,併發送給worker,等待其裁剪完成 if (d === this.end) { resultArrBuf = (await pmToPromise( this.worker, getClipCommand(originAb, ss) )).data.data.MEMFS[0].data; } else { resultArrBuf = (await pmToPromise( this.worker, getClipCommand(originAb, ss, d) )).data.data.MEMFS[0].data; } // 將worker處理事後的arrayBuffer包裝成blob,並返回 return audioBufferToBlob(resultArrBuf); }; }
咱們定義了該接口的三個參數:須要被剪裁的音頻blob,以及裁剪的開始、結束時間點,值得注意的是這裏的getClipCommand
函數,它負責將傳入的arrayBuffer
、時間包裝成ffmpeg-worker
約定的數據格式
/** * 按ffmpeg文檔要求,將帶裁剪數據轉換成指定格式 * @param arrayBuffer 待處理的音頻buffer * @param st 開始裁剪時間點(秒) * @param duration 裁剪時長 */ function getClipCommand(arrayBuffer, st, duration) { return { type: "run", arguments: `-ss ${st} -i input.mp3 ${ duration ? `-t ${duration} ` : "" }-acodec copy output.mp3`.split(" "), MEMFS: [ { data: new Uint8Array(arrayBuffer), name: "input.mp3" } ] }; }
多音頻合成很好理解,即將多個音頻按數組前後順序合併成一個音頻
class Sdk { // other code... /** * 將傳入的一段音頻blob,按照指定的時間位置進行裁剪 * @param blobs 待處理的音頻blob數組 */ concat = async blobs => { const arrBufs = []; for (let i = 0; i < blobs.length; i++) { arrBufs.push(await blobToArrayBuffer(blobs[i])); } const result = await pmToPromise( this.worker, await getCombineCommand(arrBufs), ); return audioBufferToBlob(result.data.data.MEMFS[0].data); }; }
上述代碼中,咱們是經過for
循環來將數組裏的blob一個個解碼成arrayBuffer
,可能有童鞋會好奇:爲何不直接使用數組自帶的forEach
方法去遍歷呢?寫for
循環未免麻煩了點。實際上是有緣由的:咱們在循環體裏使用了await
,是指望這些blob一個個解碼完成後,才執行後面的代碼,for
循環是同步執行的,但forEach
的每一個循環體是分別異步執行的,咱們沒法經過await
的方式等待它們所有執行完成,所以使用forEach
並不符合咱們的預期。
一樣,getCombineCommand
函數的職責與上述getClipCommand
相似:
async function getCombineCommand(arrayBuffers) { // 將arrayBuffers分別轉成ffmpeg-worker指定的數據格式 const files = arrayBuffers.map((arrayBuffer, index) => ({ data: new Uint8Array(arrayBuffer), name: `input${index}.mp3`, })); // 建立一個txt文本,用於告訴ffmpeg咱們所需進行合併的音頻文件有哪些(相似這些文件的一個映射表) const txtContent = [files.map(f => `file '${f.name}'`).join('\n')]; const txtBlob = new Blob(txtContent, { type: 'text/txt' }); const fileArrayBuffer = await blobToArrayBuffer(txtBlob); // 將txt文件也一併推入到即將發送給ffmpeg-worker的文件列表中 files.push({ data: new Uint8Array(fileArrayBuffer), name: 'filelist.txt', }); return { type: 'run', arguments: `-f concat -i filelist.txt -c copy output.mp3`.split(' '), MEMFS: files, }; }
在上面代碼中,與裁剪操做不一樣的是,被操做的音頻對象不止一個,而是多個,所以須要建立一個「映射表」去告訴ffmpeg-worker
一共須要合併哪些音頻以及它們的合併順序。
它有點相似clip
的升級版,咱們從指定的位置刪除音頻A,並在此處插入音頻B:
class Sdk { end = "end"; // other code... /** * 將一段音頻blob,按指定的位置替換成另外一端音頻 * @param originBlob 待處理的音頻blob * @param startSecond 起始時間點(秒) * @param endSecond 結束時間點(秒) * @param insertBlob 被替換的音頻blob */ splice = async (originBlob, startSecond, endSecond, insertBlob) => { const ss = startSecond; const es = isNumber(endSecond) ? endSecond : this.end; // 若insertBlob不存在,則僅刪除音頻的指定內容 insertBlob = insertBlob ? insertBlob : endSecond && !isNumber(endSecond) ? endSecond : null; const originAb = await blobToArrayBuffer(originBlob); let leftSideArrBuf, rightSideArrBuf; // 將音頻先按指定位置裁剪分割 if (ss === 0 && es === this.end) { // 裁剪所有 return null; } else if (ss === 0) { // 從頭開始裁剪 rightSideArrBuf = (await pmToPromise( this.worker, getClipCommand(originAb, es) )).data.data.MEMFS[0].data; } else if (ss !== 0 && es === this.end) { // 裁剪至尾部 leftSideArrBuf = (await pmToPromise( this.worker, getClipCommand(originAb, 0, ss) )).data.data.MEMFS[0].data; } else { // 局部裁剪 leftSideArrBuf = (await pmToPromise( this.worker, getClipCommand(originAb, 0, ss) )).data.data.MEMFS[0].data; rightSideArrBuf = (await pmToPromise( this.worker, getClipCommand(originAb, es) )).data.data.MEMFS[0].data; } // 將多個音頻從新合併 const arrBufs = []; leftSideArrBuf && arrBufs.push(leftSideArrBuf); insertBlob && arrBufs.push(await blobToArrayBuffer(insertBlob)); rightSideArrBuf && arrBufs.push(rightSideArrBuf); const combindResult = await pmToPromise( this.worker, await getCombineCommand(arrBufs) ); return audioBufferToBlob(combindResult.data.data.MEMFS[0].data); }; }
上述代碼有點相似clip
和concat
的複合使用。
到這裏,就基本實現了咱們的需求,僅需藉助worker,前端本身也能處理音頻,豈不美哉?
上述這些代碼只是爲了更好的說明講解,因此作了些簡化,有興趣的童鞋可直接源碼,歡迎交流、拍磚:)