# wavesurfer 處理大音頻文件波形渲染
原文出自:[https://juejin.im/post/5d453bc3e51d4561e36ad9f1](https://juejin.im/post/5d453bc3e51d4561e36ad9f1)
wavesurfer 是一個利用Web Audio API和Canvas進行交互式導航音頻可視化三方庫。[github 地址](https://github.com/katspaugh/wavesurfer.js)
#### 背景:
通常用 wavesurfer 來做音軌波形的渲染。筆者目前所做的項目中用到 wavesurfer 來實現了對音頻截取段落的分析標注,實現過程中發現大音頻(**100M以上wav文件**)的加載和渲染會占用過多的瀏覽器內存,導致瀏覽器崩潰。針對此現象,調研過目前能尋找到的解決方案,例如:
* [基于wavesurfer.js的超大音頻的漸進式請求實現](https://www.cnblogs.com/webhmy/p/10175785.html)
由于服務端小伙伴業務繁忙不能支持原因沒能夠嘗試此方案,所以沒有進行驗證是否可行。最終閱讀了部分的 wavesurfer 源碼之后,采用了一種類似的分段加載方式解決了目前的問題。
* * *
#### 采用音頻分段加載方式的主要流程:
1. 主動發起請求wav資源的前100字節
2. 根據前100字節內容拼接出當前wav資源文件的頭信息
3. 按照一定的字節范圍順序請求當前的wav資源,每請求一段就拼接上之前獲取的頭信息,處理成 wavesurfer 可以操作的 buffer
4. wavesurfer 處理之前獲取到的每一小段的 buffer 產生每一小段的波形信息
5. 當wav資源的所有字節都被請求到,并且 buffer 也都被 wavesurfer 處理完畢成波形信息,拼接所有請求段的波形信息,交給 wavesurfer 進行渲染,在渲染的同時,生成波形信息文件上傳到服務端保存,下次再獲取相同的wav資源就直接獲取波形信息文件,避免重復的 decode
* * *
### 以下對之前的流程步驟做出解釋:
在此之前需要了解wav資源是什么(基礎知識)
* [參考1](https://blog.csdn.net/mlkiller/article/details/12567139)
* [參考2](https://baike.baidu.com/item/WAV#1)
#### 為什么第一次請求前100字節
注意以上參考文中提到一個wav資源文件的數據信息`從 50H 位開始就是真正的數據部分(16進制)`,所以獲取資源的100字節就可以截取到一個完整的頭部信息
由于第3步的時候需要調用 wavesurfer 方法進行 decode,在這個過程中必須要是一個完整的音頻文件,否則會 decode 失敗。
那么分段加載的音頻,除了第一段請求結果會帶有wav資源的頭信息之外,之后其他的所有請求段落都不會自動帶有頭信息,即非完整音頻文件。所以就需要拿到當前wav資源的頭信息拼接到之后所有的請求段前形成一個"完整"的音頻文件,讓 wavesurfer 正常進行 decode 產生當前請求段的波形信息。
#### 如何從前100字節獲取到頭信息
[參考2](https://baike.baidu.com/item/WAV#1)中文章介紹到`48H ~ 4BH 64 61 74 71 對應的 ACSCII 碼是 data`以及`從 50H 開始就是真正的數據部分`,并且`data`對應的數據信息永遠都是`64 61 74 71 (對應的10進制是 100 97 116 97)`,所以就可以以此為目標點獲取真正數據前面的數據信息就是完整的頭部信息。
~~~
import axios from 'axios';
function getWavHeaderInfo (buffer) {
const firstBuffer = buffer;
// 創建數據視圖來操作數據
const dv = new DataView(firstBuffer);
// 從數據視圖拿出數據
const dvList = [];
for (let i = 0, len = dv.byteLength; i < len; i += 1) {
dvList.push(dv.getInt8(i));
}
// 找到頭部信息中的data字段位置
const findDataIndex = dvList.join().indexOf(',100,97,116,97');
if (findDataIndex <= -1) {
throw new Error('解析失敗');
}
// data 字段之前所有的數據信息字符串
const dataAheadString = dvList.join().slice(0, findDataIndex);
// data 字段之后 8位 是 頭部信息的結尾
const headerEndIndex = dataAheadString.split(',').length + 7;
// 截取全部的頭部信息
const headerBuffer = firstBuffer.slice(0, headerEndIndex + 1);
return headerBuffer;
}
const requestOptions = {
url: '音頻url地址',
method: 'get',
responseType: 'arraybuffer', // 請求wav信息格式
headers: {
Range: `bytes=0-99`, // 前100字節
},
};
axios(requestOptions).then(response => {
const headerBuffer = getWavHeaderInfo(response.data);
})
復制代碼
~~~
#### 拼接每一段的請求結果和已經得到的頭部信息
之前已經通過第一段請求獲取到了當前資源的頭部信息,接下來就是按照順序一段一段請求剩余的資源去拼接頭部信息,然后交給 wavesurfer 去進行 decode
(PS: 為了代碼簡潔,以下代碼示例采用 Class 寫法)
~~~
import axios from 'axios';
import _ from 'lodash';
class requestWav {
constructor() {
}
defaultOptions = {
responseType: 'arraybuffer',
rangeFirstSize: 100,
rangeSize: 1024000 * 2,
requestCount: 1, // 請求個數
loadRangeSucess: null, // 每一段請求之后完成的回調
loadAllSucess: null, // 全部加載完成之后的回調
}
// 合并配置
mergeOptions(options) {
this.options = Object.assign({}, this.defaultOptions, options);
}
loadBlocks(url, options) {
if (!url || !_.isString(url)) {
throw new Error('Argument [url] should be supplied a string');
}
this.mergeOptions(options);
this.url = url;
this.rangeList = [];
this.rangeLoadIndex = 0; // 當前請求的索引
this.rangeLoadedCount = 0; // 當前已經請求個數
// 先取 100 個字節長度的資源以便獲取資源頭信息
this.rangeList.push({ start: 0, end: this.options.rangeFirstSize - 1 });
this.loadFirstRange();
}
// 真正發起請求
requestRange(rangeArgument) {
const range = rangeArgument;
const { CancelToken } = axios;
const requestOptions = {
url: this.url,
method: 'get',
responseType: this.options.responseType,
headers: {
Range: `bytes=${range.start}-${range.end}`,
},
// 配置一個可取消請求的擴展
cancelToken: new CancelToken((c) => {
range.cancel = c;
}),
};
return axios.request(requestOptions);
}
fileBlock(fileSize) {
// 根據文件頭所帶的文件大小 計算需要請求的每一段大小 范圍隊列
let rangeStart = this.options.rangeFirstSize;
for (let i = 0; rangeStart < fileSize; i += 1) {
const rangeItem = {
start: rangeStart,
end: rangeStart + this.options.rangeSize - 1,
};
this.rangeList.push(rangeItem);
rangeStart += this.options.rangeSize;
}
}
// 請求第一段
loadFirstRange() {
this.requestRange(this.rangeList[this.rangeLoadIndex]).then((response) => {
const fileRange = response.headers['content-range'].match(/\d+/g);
// 獲取文件大小
const fileSize = parseInt(fileRange[2], 10);
// 計算請求塊
this.fileBlock(fileSize);
// 放置請求結果到請求隊列中的data字段
this.rangeList[0].data = response.data;
// 獲取資源頭部信息 (方法同第二步)
this.headerBuffer = this.getWavHeaderInfo(response.data);
// 每一段加載完成之后處理回調
this.afterRangeLoaded();
// 加載剩下的
this.loadOtherRanges();
}).catch((error) => {
throw new Error(error);
});
this.rangeLoadIndex += 1;
}
afterRangeLoaded() {
this.rangeLoadedCount += 1;
// 每一次請求道數據之后 判斷當前請求索引和應當請求的所有數量
// 觸發 loadRangeSucess 和 loadAllSucess 回調
if (this.rangeLoadedCount > 1
&& this.options.loadRangeSucess
&& typeof this.options.loadRangeSucess === 'function'
) {
this.contactRangeData(this.options.loadRangeSucess);
}
if (this.rangeLoadedCount >= this.rangeList.length
&& this.options.loadAllSucess
&& typeof this.options.loadAllSucess === 'function'
) {
this.options.loadAllSucess();
this.rangeList = [];
}
}
loadOtherRanges() {
// 循環請求范圍隊列
if (this.rangeLoadIndex < this.rangeList.length) {
this.loadRange(this.rangeList[this.rangeLoadIndex]);
}
}
loadRange(rangeArgument) {
const range = rangeArgument;
this.requestRange(range).then((response) => {
// 放置請求結果到請求隊列中的data字段
range.data = response.data;
this.afterRangeLoaded();
this.loadOtherRanges();
}).catch((error) => {
throw new Error(error);
});
this.rangeLoadIndex += 1;
}
contactRangeData(callback) {
const blobIndex = this.rangeLoadIndex - 1;
if (!this.headerBuffer) {
return;
}
// 從請求隊列中的每一個data獲取數據,拼接上已經有的header頭信息,保存為 audio/wav blob文件
const blob = new Blob(
[this.headerBuffer,
this.rangeList[blobIndex].data],
{ type: 'audio/wav' }
);
const reader = new FileReader();
// 將blob讀取為 buffer 交給 loadRangeSucess 回調
reader.readAsArrayBuffer(blob);
reader.onload = () => {
callback(reader.result, blobIndex);
};
reader.onerror = () => {
throw new Error(reader.error);
};
}
destroyRequest() {
// 銷毀請求,使用場景是:
// 如果當前的資源沒有加載完成,此時更換了資源的URL地址,應該取消之前設定的請求,避免浪費請求資源
if (!this.rangeList) {
return;
}
this.rangeList.forEach((rang) => {
if (rang.cancel) {
rang.cancel('取消音頻下載');
}
});
}
}
export default new requestWav();
復制代碼
~~~
至此前三步已經完成,拿到了所有的音頻段落,并且拼裝成了"完整"的音頻,讀取到了相對應的buffer。之后下一篇將繼續完善第四步和第五步分段將獲取到的每一段的 buffer 去產生波形以及最終拼裝所有波形文件。
[wavesurfer 處理大音頻文件波形渲染(二)](https://juejin.im/post/5d481cf25188250586752b22)
文章作為一個項目爬坑記錄分享出來,有不對的地方希望能得到小伙伴們的指正。