前言
哈嘍,大家好,我是海怪。
最近看了一下鐘文澤的 Macbook Pro 測評視頻(唉,最近又想買電子產(chǎn)品了),他在測評音響的時(shí)候,點(diǎn)播了一首蔡琴的《渡口》。

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

當(dāng)我在發(fā)呆、無聊的時(shí)候,音頻頻譜圖里的小浮塊總能讓我盯上一整天。而如今,在各大音樂軟件中已很少看到這樣的頻譜圖了。那今天就跟大家一起用原生的 Audio API 來實(shí)現(xiàn)這個(gè)頻譜圖吧。

由于原來的擬物風(fēng)格實(shí)現(xiàn)太難實(shí)現(xiàn)了,只能做個(gè)粗糙的版本 :)
解決思路
首先我們要理解頻譜圖里的這些“長條”是什么意思。實(shí)際上這是音頻里的 頻率 Frequency,我們常說的低音炮和美高音就是指在聲音在低頻區(qū)和高頻區(qū)的表現(xiàn)。

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

從音頻獲取音頻流 stream,通過中間的解析器分析出頻率值 freqency,將這些頻率值通過“長條”的方式繪制在 <canvas> 上,然后以此不斷循環(huán)就可以實(shí)現(xiàn)這樣的頻譜動(dòng)態(tài)圖了。
根據(jù)上面的思路,我們首先要準(zhǔn)備好這樣的頁面結(jié)構(gòu):
const Player: FC = () => {
const {visualize} = useAudioVisualization('#canvas', 50);
const audioRef = useRef<HTMLAudioElement>(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 的方式來封裝可視化邏輯:
const useAudioVisualization = (selector: string, length = 50) => {
// 開始可視化
const visualize = (stream: MediaStream) => {
}
return { visualize };
}
visualize
在拿到音頻的流之后,我們就可以調(diào)用 Audio API 來創(chuàng)建解析器并分析音頻了。

// 開始可視化
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);
// 開始遞歸畫圖
drawEachFrame(canvasEl, dataArray);
}
這里主要做了幾件事:
- 通過
AudioContext創(chuàng)建analyser - 將音頻輸入源連接
analyser,每次播放的時(shí)候,音頻都會經(jīng)過analyser進(jìn)行處理 - 設(shè)置
fft,從analyser獲取音頻頻率數(shù)據(jù)dataArray
經(jīng)過上面的操作我們已經(jīng)拿到了音頻的數(shù)據(jù),接下來就是渲染 <canvas> 的時(shí)候了,開始實(shí)現(xiàn) drawEachFrame。
drawEachFrame
我們?nèi)粘K吹降膭?dòng)畫本質(zhì)上都是一個(gè)畫面一個(gè)畫面連續(xù)播放的效果。

只要畫面足夠快就可以讓畫面動(dòng)起來,那究竟要多快呢?相信有的同學(xué)已經(jīng)開始拿起紙和筆來算了。
其實(shí)并不用這么復(fù)雜,這里給大家推薦一個(gè) API requestAnimationFrame。它會以瀏覽器的顯示頻率來作為其動(dòng)畫動(dòng)作的頻率,比如瀏覽器每 10ms 刷新一次,動(dòng)畫回調(diào)也每 10ms 調(diào)用一次,這樣就不會存在過度繪制的問題,動(dòng)畫不會掉幀,自然流暢。
只要我們在 requestAnimationFrame 的 callback 里不斷地繪制 <canvas> 就可以獲得一個(gè)流暢的頻譜圖了。
// 每個(gè)動(dòng)畫幀都畫圖
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);
// 更新長度
const bars = dataArray.slice(0, Math.min(length, dataArray.length));
// 畫圖
clearCanvas(canvasEl);
// 繪制小浮塊
drawFloats(canvasEl, bars);
// 繪制條狀圖
drawBars(canvasEl, bars);
}
}
上面的 drawEachFrame 里又調(diào)用了一次 requestAnimationFrame,以此來實(shí)現(xiàn)遞歸循環(huán)調(diào)用的效果。
這里我們還會把 requestAnimateFrameId 給記錄下來,以防之后銷毀時(shí)要調(diào)用 window.cancelAnimationFrame(id) 來清除。
clearCanvas
在繪制 <canvas> 前,我們先把它給清空一下:
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
接下來實(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');
// 畫 bar
canvasCtx.fillStyle = gradient;
canvasCtx.fillRect(x, canvasHeight - barHeight, barWidth, barHeight);
x += barWidth + BAR_GAP;
})
}
這里有幾個(gè)點(diǎn)要注意:
- 畫長方形的時(shí)候,原點(diǎn)是在左上角,所以
y的值為canvasHeight - barHeight,即總高度 - 條形高度 - 畫下一個(gè) bar 的時(shí)候,需要
+ BORDER_WIDTH來空出一個(gè)空隙,不然 bar 就都黏在一起了 - 在
<canvas>中畫漸變,需要用addColorStop來實(shí)現(xiàn)
最后效果:

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

圖示:

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)好了之后,來一首試音原聲大碟《渡口》,即可享受頻譜圖帶來的快樂:

stopVisualize
有開始就有結(jié)束,由于這里動(dòng)用了 <canvas>, requestAnimationFrame 這些資源,所以當(dāng)組件銷毀時(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 扔出來,可由開發(fā)者自己處理。
完整的使用方式是這樣的:
const Player = () => {
const {visualize, stopVisualize, resetCanvas} = useAudioVisualization('#canvas', 50);
const audioRef = useRef<HTMLAudioElement>(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ù) - 通過遞歸調(diào)用
requestAnimationFrame來實(shí)現(xiàn)動(dòng)畫效果 - 使用 Canvas API 來繪制條形圖以及小浮塊,將這繪制操作放在
requestAnimationFrame的回調(diào)中,從而展示動(dòng)態(tài)的頻譜圖
如果你看完還是做不出自己的千千靜聽,可以在 我的 Github 項(xiàng)目 里直接看源碼實(shí)現(xiàn)。
好了,這個(gè)千千靜聽的項(xiàng)目就給大家?guī)У竭@里。如果你喜歡我的文章,可以走一波關(guān)注,一鍵三連我也不介意,比心 ??