前言
最近在做一個項目,需要對webRTC錄製的音頻進行處理,包括音頻的裁剪、多音頻合併,甚至要將某個音頻的某一部分替換成另一個音頻。
原本筆者打算將這件工作交給服務端去完成,但考慮,其實無論是前端還是後臺,所做的工作是差不多的,而且交給服務端還需要再額外走一個上傳、下載音頻的流程,這不僅增添了服務端的壓力,而且還有網絡流量的開銷,於是萌生出一個想法:爲什麼音頻處理這件事不能讓前端來做呢?
於是在筆者的半摸索半實踐下,產生出了這篇文章。廢話少說,先上倉庫地址,這是一個開箱即用的前端音頻剪輯sdk(點進去了不如就star一下吧)
ffmpeg
ffmpeg是實現前端音頻處理的非常核心的模塊,當然,不僅是前端,ffmpge作爲一個提供了錄製、轉換以及流化音視頻的業界成熟完整解決方案,它也應用在服務端、APP應用等多種場景下。關於ffmpeg的介紹,大家自行google即可,這裏不說太多。
由於ffmpeg在處理過程中需要大量的計算,直接放在前端頁面上去運行是不可能的,因爲我們需要單獨開個web worker,讓它自己在worker裏面運行,而不至於阻塞頁面交互。
可喜的是,萬能的github上已經有開發者提供了ffmpge.js,並且提供worker版本,可以拿來直接使用。
於是我們便有了大體的思路:當獲取到音頻文件後,將其解碼後傳送給worker,讓其進行計算處理,並將處理結果以事件的方式返回,這樣我們就可以對音頻爲所欲爲了:)
開啓美妙之旅前的必要工作
需要提前聲明的是,由於筆者的項目需求,是僅需對.mp3
格式進行處理的,因此下面的代碼示例以及倉庫地址裏面所涉及的代碼,也主要是針對mp3
,當然,其實不管是哪種格式,思路是類似的。
創建worker
創建worker的方式非常簡單,直接new之,注意的是,由於同源策略的限制,要使worker正常工作,則要與父頁面同源,由於這不是重點,所以略過
function createWorker(workerPath: string) {
const worker = new Worker(workerPath);
return worker;
}
postMessage轉promise
仔細看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的方式來處理,更易於空間上的拓展
audio、blob與arrayBuffer的互相轉換
ffmpeg-worker
所需要的數據格式是arrayBuffer
,而一般我們能直接使用的,要麼是音頻文件對象blob
,或者音頻元素對象audio
,甚至有可能僅是一條鏈接url,因此這幾種格式的轉換是非常有必要的:
audio轉arrayBuffer
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
.
blob轉arrayBuffer
這個也很簡單,只需要藉助FileReader
將blob內容提取出來即可
function blobToArrayBuffer(blob) {
return new Promise(resolve => {
const fileReader = new FileReader();
fileReader.onload = function() {
resolve(fileReader.result);
};
fileReader.readAsArrayBuffer(blob);
});
}
arrayBuffer轉blob
利用File
創建出一個blob
function audioBufferToBlob(arrayBuffer) {
const file = new File([arrayBuffer], 'test.mp3', {
type: 'audio/mp3',
});
return file;
}
blob轉audio
blob
轉audio是非常簡單的,js提供了一個原生API——URL.createObjectURL
,藉助它我們可以把blob轉成本地可訪問鏈接進行播放
function blobToAudio(blob) {
const url = URL.createObjectURL(blob);
return new Audio(url);
}
接下來我們進入正題。
音頻裁剪——clip
所謂裁剪,即是指將給定的音頻,按給定的起始、結束時間點,提取這部分的內容,形成新的音頻,先上代碼:
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"
}
]
};
}
多音頻合成——concat
多音頻合成很好理解,即將多個音頻按數組先後順序合併成一個音頻
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
一共需要合併哪些音頻以及它們的合併順序。
音頻裁剪替換——splice
它有點類似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,前端自己也能處理音頻,豈不美哉?
上述這些代碼只是爲了更好的說明講解,所以做了些簡化,有興趣的童鞋可直接源碼,歡迎交流、拍磚:)