爺青回!用原生 Audio API 實(shí)現(xiàn)一個(gè)千千靜聽(tīng)
前言
最近看了一下鐘文澤的 Macbook Pro 測(cè)評(píng)視頻(唉,最近又想買(mǎi)電子產(chǎn)品了),他在測(cè)評(píng)音響的時(shí)候,點(diǎn)播了一首蔡琴的《渡口》。

當(dāng)聽(tīng)到這首歌的時(shí)候,我真的是情不自禁地感嘆:“爺青回??!”,想當(dāng)年,第一次聽(tīng)這首歌的時(shí)候還是在 Windows XP 系統(tǒng)上的 千千靜聽(tīng) 這個(gè)播放器里聽(tīng)到的,那時(shí)印象最深刻的就是里面的 音頻可視化(頻譜圖) 了。

當(dāng)我在發(fā)呆、無(wú)聊的時(shí)候,音頻頻譜圖里的小浮塊總能讓我盯上一整天。而如今,在各大音樂(lè)軟件中已很少看到這樣的頻譜圖了。那今天就跟大家一起用原生的 Audio API 來(lái)實(shí)現(xiàn)這個(gè)頻譜圖吧。
項(xiàng)目已經(jīng)放在 Github[1],也可以在 這里預(yù)覽[2]
由于原來(lái)的擬物風(fēng)格實(shí)現(xiàn)太難實(shí)現(xiàn)了,只能做個(gè)粗糙的版本 :)
解決思路
首先我們要理解頻譜圖里的這些“長(zhǎng)條”是什么意思。實(shí)際上這是音頻里的 頻率 Frequency,我們常說(shuō)的低音炮和美高音就是指在聲音在低頻區(qū)和高頻區(qū)的表現(xiàn)。

了解了音頻頻率后,我們可以先理清一下這個(gè)小玩具的實(shí)現(xiàn)思路:

從音頻獲取音頻流 stream,通過(guò)中間的解析器分析出頻率值 freqency,將這些頻率值通過(guò)“長(zhǎng)條”的方式繪制在 上,然后以此不斷循環(huán)就可以實(shí)現(xiàn)這樣的頻譜動(dòng)態(tài)圖了。
根據(jù)上面的思路,我們首先要準(zhǔn)備好這樣的頁(yè)面結(jié)構(gòu):
const?Player:?FC?=?()?=>?{
??const?{visualize}?=?useAudioVisualization('#canvas',?50);
??const?audioRef?=?useRef(null);
??const?onPlay?=?async?()?=>?{
????if?(audioRef.current)?{
??????await?audioRef.current.play();
??????const?stream?=?(audioRef.current?as?any).captureStream();
??????visualize(stream)
????}
??}
??return?(
????<div?className={styles.player}>
??????<div?className={styles.canvas}>
????????<canvas?id="canvas"?width={500}?height={300}/>
??????div>
??????<div?className={styles.controls}>
????????<audio?ref={audioRef}?src={audioUrl}?onPlay={onPlay}?controls?/>
??????div>
????div>
??)
}
useAudioVisualization
這里使用 React Hook 的方式來(lái)封裝可視化邏輯:
const?useAudioVisualization?=?(selector:?string,?length?=?50)?=>?{
??//?開(kāi)始可視化
??const?visualize?=?(stream:?MediaStream)?=>?{
??
??}
??return?{?visualize?};
}
visualize
在拿到音頻的流之后,我們就可以調(diào)用 Audio API 來(lái)創(chuàng)建解析器并分析音頻了。

//?開(kāi)始可視化
const?visualize?=?(stream:?MediaStream)?=>?{
??const?canvasEl:?HTMLCanvasElement?|?null?=?document.querySelector(selector);
??if?(!canvasEl)?{
????throw?new?Error('找不到?canvas');
??}
??//?創(chuàng)建解析器
??audioCtxRef.current?=?new?AudioContext()
??analyserRef.current?=?audioCtxRef.current.createAnalyser();
??//?獲取音頻源
??const?source?=?audioCtxRef.current.createMediaStreamSource(stream);
??//?將音頻源連接解析器
??source.connect(analyserRef.current);
??//?準(zhǔn)備數(shù)據(jù)數(shù)組
??analyserRef.current.fftSize?=?256;
??const?bufferLength?=?analyserRef.current.frequencyBinCount;
??const?dataArray?=?new?Uint8Array(bufferLength);
??//?開(kāi)始遞歸畫(huà)圖
??drawEachFrame(canvasEl,?dataArray);
}
這里主要做了幾件事:
通過(guò) AudioContext創(chuàng)建analyser將音頻輸入源連接 analyser,每次播放的時(shí)候,音頻都會(huì)經(jīng)過(guò)analyser進(jìn)行處理設(shè)置 fft,從analyser獲取音頻頻率數(shù)據(jù)dataArray
經(jīng)過(guò)上面的操作我們已經(jīng)拿到了音頻的數(shù)據(jù),接下來(lái)就是渲染 的時(shí)候了,開(kāi)始實(shí)現(xiàn) drawEachFrame。
drawEachFrame
我們?nèi)粘K吹降膭?dòng)畫(huà)本質(zhì)上都是一個(gè)畫(huà)面一個(gè)畫(huà)面連續(xù)播放的效果。

只要畫(huà)面足夠快就可以讓畫(huà)面動(dòng)起來(lái),那究竟要多快呢?相信有的同學(xué)已經(jīng)開(kāi)始拿起紙和筆來(lái)算了。
其實(shí)并不用這么復(fù)雜,這里給大家推薦一個(gè) API requestAnimationFrame。它會(huì)以瀏覽器的顯示頻率來(lái)作為其動(dòng)畫(huà)動(dòng)作的頻率,比如瀏覽器每 10ms 刷新一次,動(dòng)畫(huà)回調(diào)也每 10ms 調(diào)用一次,這樣就不會(huì)存在過(guò)度繪制的問(wèn)題,動(dòng)畫(huà)不會(huì)掉幀,自然流暢。
只要我們?cè)?requestAnimationFrame 的 callback 里不斷地繪制 就可以獲得一個(gè)流暢的頻譜圖了。
//?每個(gè)動(dòng)畫(huà)幀都畫(huà)圖
const?drawEachFrame?=?(canvasEl:?HTMLCanvasElement,?dataArray:?Uint8Array)?=>?{
??//?遞歸調(diào)用
??requestAnimateFrameIdRef.current?=?requestAnimationFrame(()?=>?drawEachFrame(canvasEl,?dataArray));
??if?(analyserRef.current)?{
????//?讀取當(dāng)前幀新的數(shù)據(jù)
????analyserRef.current.getByteFrequencyData(dataArray);
????//?更新長(zhǎng)度
????const?bars?=?dataArray.slice(0,?Math.min(length,?dataArray.length));
????//?畫(huà)圖
????clearCanvas(canvasEl);
????//?繪制小浮塊
????drawFloats(canvasEl,?bars);
????//?繪制條狀圖
????drawBars(canvasEl,?bars);
??}
}
上面的 drawEachFrame 里又調(diào)用了一次 requestAnimationFrame,以此來(lái)實(shí)現(xiàn)遞歸循環(huán)調(diào)用的效果。
這里我們還會(huì)把 requestAnimateFrameId 給記錄下來(lái),以防之后銷(xiāo)毀時(shí)要調(diào)用 window.cancelAnimationFrame(id) 來(lái)清除。
clearCanvas
在繪制 前,我們先把它給清空一下:
export?const?clearCanvas?=?(canvasEl:?HTMLCanvasElement)?=>?{
??const?canvasWidth?=?canvasEl.width;
??const?canvasHeight?=?canvasEl.height;
??const?canvasCtx?=?canvasEl.getContext("2d");
??if?(!canvasCtx)?{
????return;
??}
??//?繪制圖形
??canvasCtx.fillStyle?=?'rgb(29,19,62)';
??canvasCtx.fillRect(0,?0,?canvasWidth,?canvasHeight);
}
這樣就能得到一個(gè)純色的 “白板” 了:

drawBars
接下來(lái)實(shí)現(xiàn)條狀圖,圖示:

代碼實(shí)現(xiàn):
//?浮動(dòng)的小塊
let?floats:?any?=?[];
//?高度
const?FLOAT_HEIGHT?=?4;
//?下落高度
const?DROP_DISTANCE?=?1;
//?Bar?的?border?寬度
const?BAR_GAP?=?2;
export?const?drawBars?=?(canvasEl:?HTMLCanvasElement,?dataArray:?Uint8Array)?=>?{
??const?canvasWidth?=?canvasEl.width;
??const?canvasHeight?=?canvasEl.height;
??const?canvasCtx?=?canvasEl.getContext("2d");
??if?(!canvasCtx)?{
????return;
??}
??const?barWidth?=?canvasWidth?/?dataArray.length
??let?x?=?0;
??dataArray.forEach((dataItem)?=>?{
????const?barHeight?=?dataItem;
????//?添加漸變色
????const?gradient?=?canvasCtx.createLinearGradient(canvasWidth?/?2,?canvasHeight?/?2,?canvasWidth?/?2,?canvasHeight);
????gradient.addColorStop(0,?'#68b3ec');
????gradient.addColorStop(0.5,?'#4b5fc9');
????gradient.addColorStop(1,?'#68b3ec');
????//?畫(huà)?bar
????canvasCtx.fillStyle?=?gradient;
????canvasCtx.fillRect(x,?canvasHeight?-?barHeight,?barWidth,?barHeight);
????x?+=?barWidth?+?BAR_GAP;
??})
}
這里有幾個(gè)點(diǎn)要注意:
畫(huà)長(zhǎng)方形的時(shí)候,原點(diǎn)是在左上角,所以 y的值為canvasHeight - barHeight,即總高度 - 條形高度畫(huà)下一個(gè) bar 的時(shí)候,需要 + BORDER_WIDTH來(lái)空出一個(gè)空隙,不然 bar 就都黏在一起了在 中畫(huà)漸變,需要用addColorStop來(lái)實(shí)現(xiàn)
最后效果:

drawFloats
有了上面畫(huà)條狀 bar 的經(jīng)驗(yàn)后,我們很容易就能想到怎么畫(huà)這些小塊了:

圖示:

export?const?drawFloats?=?(canvasEl:?HTMLCanvasElement,?dataArray:?Uint8Array)?=>?{
??const?canvasWidth?=?canvasEl.width;
??const?canvasHeight?=?canvasEl.height;
??const?canvasCtx?=?canvasEl.getContext("2d");
??if?(!canvasCtx)?{
????return;
??}
??//?找到最大值,以及初始化高度
??dataArray.forEach((item,?index)?=>?{
????//?默認(rèn)值
????floats[index]?=?floats[index]?||?FLOAT_HEIGHT;
????//?處理當(dāng)前值
????const?pushHeight?=?item?+?FLOAT_HEIGHT;
????const?dropHeight?=?floats[index]?-?DROP_DISTANCE;
????//?取最大值
????floats[index]?=?Math.max(dropHeight,?pushHeight);
??})
??const?barWidth?=?canvasWidth?/?dataArray.length;
??let?x?=?0;
??floats.forEach((floatItem:?number)?=>?{
????const?floatHeight?=?floatItem;
????canvasCtx.fillStyle?=?'#3e47a0';
????canvasCtx.fillRect(x,?canvasHeight?-?floatHeight,?barWidth,?FLOAT_HEIGHT);
????x?+=?barWidth?+?BAR_GAP;
??})
}
這里最關(guān)鍵就是這個(gè)小浮塊的高度,我們直接取浮想塊下降了的高度 dropHeight 以及被 bar 推高的高度 pushHeight 他們兩的最大值就可以了 floats[index] = Math.max(dropHeight, pushHeight)。
在實(shí)現(xiàn)好了之后,來(lái)一首試音原聲大碟《渡口》,即可享受頻譜圖帶來(lái)的快樂(lè):

stopVisualize
有開(kāi)始就有結(jié)束,由于這里動(dòng)用了 , requestAnimationFrame 這些資源,所以當(dāng)組件銷(xiāo)毀時(shí)應(yīng)該清空他們:
const?useAudioVisualization?=?(selector:?string,?length?=?50)?=>?{
??...
??//?重置?canvas
??const?resetCanvas?=?()?=>?{
????const?canvasEl:?HTMLCanvasElement?|?null?=?document.querySelector(selector);
????if?(canvasEl)?{
??????const?emptyDataArray?=?(new?Uint8Array(length)).map(()?=>?0);
??????clearFloats();
??????clearCanvas(canvasEl);
??????drawFloats(canvasEl,?emptyDataArray);
????}
??}
??//?停止
??const?stopVisualize?=?()?=>?{
????if?(requestAnimateFrameIdRef.current)?{
??????window.cancelAnimationFrame(requestAnimateFrameIdRef.current);
??????resetCanvas();
????}
??};
??return?{
????visualize,
????stopVisualize,
????resetCanvas,
????requestAnimateFrameId:?requestAnimateFrameIdRef.current
??};
}
這里我們也把 requestAnimateFrameId 扔出來(lái),可由開(kāi)發(fā)者自己處理。
完整的使用方式是這樣的:
const?Player?=?()?=>?{
??const?{visualize,?stopVisualize,?resetCanvas}?=?useAudioVisualization('#canvas',?50);
??
??const?audioRef?=?useRef(null);
??const?onPlay?=?async?()?=>?{
????if?(audioRef.current)?{
??????stopVisualize();
??????await?audioRef.current.play();
??????const?stream?=?(audioRef.current?as?any).captureStream();
??????visualize(stream)
????}
??}
??const?onPause?=?async?()?=>?{
????resetCanvas();
??}
??useEffect(()?=>?{
????resetCanvas();
????return?()?=>?{
??????stopVisualize()
????}
??},?[]);
??return?(
????<div?className={styles.player}>
??????<div?className={styles.canvas}>
????????<canvas?id="canvas"?width={500}?height={300}/>
??????div>
??????<div?className={styles.controls}>
????????<audio?ref={audioRef}?src={audioUrl}?onPlay={onPlay}?onPause={onPause}?controls?/>
??????div>
????div>
??)
}
更好看的樣式就交給同學(xué)們自己實(shí)現(xiàn)了 :) 當(dāng)然你也可以在 我的 Github 項(xiàng)目 里直接 Copy 我的丑陋樣式。
總結(jié)
最后總結(jié)一下這個(gè)頻譜圖的實(shí)現(xiàn):
使用 Audio API 創(chuàng)建 analyser,將音頻流stream連接到analyser設(shè)置 analyser的fft參數(shù),以此獲取音頻數(shù)據(jù)通過(guò)遞歸調(diào)用 requestAnimationFrame來(lái)實(shí)現(xiàn)動(dòng)畫(huà)效果使用 Canvas API 來(lái)繪制條形圖以及小浮塊,將這繪制操作放在 requestAnimationFrame的回調(diào)中,從而展示動(dòng)態(tài)的頻譜圖
如果你看完還是做不出自己的千千靜聽(tīng),可以在 我的 Github 項(xiàng)目 里直接看源碼實(shí)現(xiàn)。
好了,這個(gè)千千靜聽(tīng)的項(xiàng)目就給大家?guī)У竭@里。如果你喜歡我的文章,可以走一波關(guān)注,一鍵三連我也不介意,比心 ??
參考資料
Github 地址: https://github.com/haixiangyan/ttplayer
[2]預(yù)覽地址: https://github.yanhaixiang.com/ttplayer/
