爺青回!用原生 Audio API 實(shí)現(xiàn)一個(gè)千千靜聽

前言

哈嘍,大家好,我是海怪。

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

image

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

image

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

項(xiàng)目已經(jīng)放在 Github,也可以在 這里預(yù)覽

image

由于原來的擬物風(fēng)格實(shí)現(xiàn)太難實(shí)現(xiàn)了,只能做個(gè)粗糙的版本 :)

解決思路

首先我們要理解頻譜圖里的這些“長條”是什么意思。實(shí)際上這是音頻里的 頻率 Frequency,我們常說的低音炮和美高音就是指在聲音在低頻區(qū)和高頻區(qū)的表現(xiàn)。

image

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

image

從音頻獲取音頻流 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)建解析器并分析音頻了。

image
// 開始可視化
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ù)播放的效果。

image

只要畫面足夠快就可以讓畫面動(dòng)起來,那究竟要多快呢?相信有的同學(xué)已經(jīng)開始拿起紙和筆來算了。

其實(shí)并不用這么復(fù)雜,這里給大家推薦一個(gè) API requestAnimationFrame。它會以瀏覽器的顯示頻率來作為其動(dòng)畫動(dòng)作的頻率,比如瀏覽器每 10ms 刷新一次,動(dòng)畫回調(diào)也每 10ms 調(diào)用一次,這樣就不會存在過度繪制的問題,動(dòng)畫不會掉幀,自然流暢。

只要我們在 requestAnimationFramecallback 里不斷地繪制 <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è)純色的 “白板” 了:

image

drawBars

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

image

代碼實(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)

最后效果:

image

drawFloats

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

image

圖示:

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

image

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):

  1. 使用 Audio API 創(chuàng)建 analyser,將音頻流 stream 連接到 analyser
  2. 設(shè)置 analyserfft 參數(shù),以此獲取音頻數(shù)據(jù)
  3. 通過遞歸調(diào)用 requestAnimationFrame 來實(shí)現(xiàn)動(dòng)畫效果
  4. 使用 Canvas API 來繪制條形圖以及小浮塊,將這繪制操作放在 requestAnimationFrame 的回調(diào)中,從而展示動(dòng)態(tài)的頻譜圖

如果你看完還是做不出自己的千千靜聽,可以在 我的 Github 項(xiàng)目 里直接看源碼實(shí)現(xiàn)。

好了,這個(gè)千千靜聽的項(xiàng)目就給大家?guī)У竭@里。如果你喜歡我的文章,可以走一波關(guān)注,一鍵三連我也不介意,比心 ??

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容