Web Audio API 第5章 音訊的分析與視覺化

池中物王二狗發表於2024-04-18

到目前為止,我們僅討論了音訊的合成與處理,但這僅是 Web Audio API 提供的一半功能。另一半功能則是音訊的分析,它播放起來應該是什麼樣子的。它最典型的例子就是音訊視覺化,但其實有更多的其它應用場景,包括聲調檢測,節減檢測,語音識別等,這些已大大超出本書範圍。

對於遊戲或互動式應用開發者來說,這是一個重要的主題,原因有幾點。首先,一個好的視覺化分析器可以用於類似調式工具(顯然這是除了你耳朵之外,良好的計量工具)用於調音。其次,對於某些關於音樂相關的遊戲或應用來說視覺化是重點比如遊戲“吉它英雄”或者應用軟體 GarageBand (蘋果電腦上吉它教學軟體)

頻率分析

在 Web Audio API 分析聲音是最主要的方式利用 AnalyserNodes。這些節點不會對聲音本身做任何改變,可以在音訊上下文任意處呼叫。一旦在音訊圖中建立了這樣的節點,它就會提供兩種主要方式用於檢視聲音波形:時域和頻域上

得到的結果是基於特定緩衝區大小的 FFT 分析。我們有一些定製化節點輸出的屬性可用:

  • fftSize
    定義緩衝區大小用於實現分析。大小一定是2的冪。較高的值將導致對訊號進行更細粒度的分析,但代價是一些效能損失。

  • frequencyBinCount
    這個屬性是隻讀的,自動為 fftSize / 2。

  • smoothingTimeConstant
    值範圍是 0 - 1. 值為1會導致較大的移動平均平滑結果。值為零意味著沒有移動平均線,結果波動很快。

最基本的設定就是把分析節點插到我們感興趣的音訊圖譜中:

// 假設節點A與B普普通通相連
var analyser = context.createAnalyser(); 
A.connect(analyser); 
analyser.connect(B);

然後我們就可以得到時域或頻域的陣列了:

var freqDomain = new Float32Array(analyser.frequencyBinCount); 
analyser.getFloatFrequencyData(freqDomain);

在上面的程式碼例子中,freqDomain 是一個頻域 32 位浮點陣列。這些陣列記憶體儲的值都被標準化為 0-1。輸出的指標可以在0和奈奎斯特頻率之間線性對映,奈奎斯特頻率被定義為取樣率的一半(在 Web Audio API 中透過 context.sampleRate 獲取)。下面的程式碼片段將 frequency 對映到頻率陣列中的正確位置:

奈奎斯特頻率是離散訊號系統取樣頻率的一半也就是 1/2 fs,fs為取樣頻率

function getFrequencyValue(frequency) {
  var nyquist = context.sampleRate/2;
  var index = Math.round(frequency/nyquist * freqDomain.length); 
  return freqDomain[index];
}

如果我們分析的是一個 1000 Hz 的正弦波,舉例,我們期望呼叫 getFrequencyValue(1000) 時返回影像內的峰值,如圖 5-1。

頻域透過呼叫 getByteFrequencyData 使用8位無符號儲存也可以。 這些值就是無符號整型,在分析節點( analyzer node)它會縮放以適配在最大分貝和最小分貝之間(即在 dBFS中,decibels full scale)。因此可以根據需要調整這些引數以縮放輸出。

image

圖 5-1,一個 1000Hz 的視覺化聲音(全頻域是從 0 至 22050Hz)

requestAnimationFrame 實現動畫

如果我們想要對我們的聲音進行視覺化,我們需要週期性的查詢分析節點(analyzer node), 處理返回的結果,並渲染出來。我們可以利用 JavaScript 的定時器實現,setInterval, setTimeout, 但現在有更好用的:requestAnimationFrame。該 API 允許瀏覽器將你的自定義繪製函式合併到瀏覽器本地渲染迴圈中,這對效能來講會有很大提升。不同於指定固定繪製間隔需要等待瀏覽器空閒時才來處理你的定時器不同,你只需要將它提交到佇列中,瀏覽器會以最快的速度執行它。

由於 requestAnimationFrame 還是處於實驗性質,你需要為其指定瀏覽器字首,且給它定一個相似功能的 setTimeout 來兜底。程式碼如下:

window.requestAnimationFrame = (function(){ return window.requestAnimationFrame || window.webkitRequestAnimationFrame || window.mozRequestAnimationFrame || window.oRequestAnimationFrame || window.msRequestAnimationFrame ||
function(callback){
      window.setTimeout(callback, 1000 / 60);
    };
})();

一但定義好了 requestAnimationFrame 函式,我們需要利用它來查詢分析節點得到音訊流的詳細資訊。

requestAnimationFrame 現在早就加入肯德基豪華午餐了直接用就可以了

聲音視覺化

把它們全組合在一起,設定一個渲染迴圈用於查詢和渲染之前用到的分析節點,將存進 freqDomain 陣列:

var freqDomain = new Uint8Array(analyser.frequencyBinCount); 
analyser.getByteFrequencyData(freqDomain);
for (var i = 0; i < analyser.frequencyBinCount; i++) {
  var value = freqDomain[i];
  var percent = value / 256;
  var height = HEIGHT * percent;
  var offset = HEIGHT - height - 1;
  var barWidth = WIDTH/analyser.frequencyBinCount;
  var hue = i/analyser.frequencyBinCount * 360; 
  drawContext.fillStyle = 'hsl(' + hue + ', 100%, 50%)'; 
  drawContext.fillRect(i * barWidth, offset, barWidth, height);
}

對時域也可以進行類似的操作

var timeDomain = new Uint8Array(analyser.frequencyBinCount); 
analyser.getByteTimeDomainData(timeDomain);
for (var i = 0; i < analyser.frequencyBinCount; i++) {
  var value = timeDomain[i];
  var percent = value / 256;
  var height = HEIGHT * percent;
  var offset = HEIGHT - height - 1;
  var barWidth = WIDTH/analyser.frequencyBinCount; 
  drawContext.fillStyle = 'black'; 
  drawContext.fillRect(i * barWidth, offset, 1, 1);
}

此程式碼將時域內的值利用 HTML5 canvas 繪製,建立一個簡單的視覺化圖形,在代表頻域資料的彩色色條狀圖的頂部繪製了一個波形線條。

結果在 canvas 上繪製出來應該如圖 5-2

image

圖 5-2 某一時刻的視覺化截圖

以上分析器節點的程式碼實現 demo 可參考 https://github.com/willian12345/WebAudioAPI/tree/master/examples/ch05/demo.html

我們處理視覺化方案遺漏了很多資料。但對音樂的視覺化來說足夠了。當然如果我們想綜合全面分析整個音訊緩衝區,我們需要看看其它的方法。

額外部分--顯示整個聲音檔案的音量高低圖

這一部分並非 Web Audio API 書說中述,是譯者本人所述

這是我在專案中遇到的一個問題

網上一堆例子都是顯示實時音訊訊號的,就像上一節中的那樣

可是如果我想要的是顯示整段 mp3 檔案的音量高低圖呢?

即如何分析整斷音訊的資料?

原理是載入 mp3 檔案後解碼分析音訊資料,獲取某一段音訊資料的取樣最高和最低點並繪製出來

首先就是從獲取音訊檔案開始 利用 html 的 <input type="file" /> 標籤獲取 file 後:

const reader = new FileReader();
reader.onload = function (e) {
  const audioContext = new (window.AudioContext ||
    window.webkitAudioContext)();
  audioContext.decodeAudioData(e.target.result, function (buffer) {
    // 獲取音訊緩衝區資料
    const channelData = buffer.getChannelData(0);
    // 繪製波形
    drawWaveform(channelData);
  });
};
reader.readAsArrayBuffer(file);

利用 FileReader 以 ArrayBuffer 形式讀取檔案內容

再使用 audioContext.decodeAudioData 解碼

解碼後獲取 channelData, 此時的 channelData 就包含該通道的音訊樣本資料,可以理解為標準化後的 PCM 資料

如果忘記了什麼是 PCM 可以回顧第一章的內容

如圖 5-5 只要解析這個 channelData 內的 PCM 資料並繪製出來就行了 drawWaveform(channelData)

function drawWaveform(data) {
  const canvas = document.getElementById("waveform"); // 獲取canvas元素
  const ctx = canvas.getContext("2d"); // 獲取2D繪圖上下文
  const width = canvas.width; // canvas的寬度
  const height = canvas.height; // canvas的高度
  const step = Math.ceil(data.length / width); // 計算每個畫布畫素對應的音訊樣本數
  const amp = height / 2; // 放大因子,用於控制波形在畫布上的高度

  ctx.fillStyle = "#fff"; // 設定填充顏色為白色
  ctx.fillRect(0, 0, width, height); // 填充整個畫布為白色

  ctx.beginPath(); // 開始繪製新的路徑
  ctx.moveTo(0, amp); // 將繪圖遊標移動到畫布中央的起始點

  // 繪製波形
  for (let i = 0; i < width; i += 4) {
    // 遍歷畫布的每一個畫素
    let min = 1.0; // 初始化最小值
    let max = -1.0; // 初始化最大值
    for (let j = 0; j < step; j++) {
      // 遍歷與當前畫素對應的音訊樣本

      const datum = data[i * step + j]; // 獲取單個音訊樣本
      if (datum < min) min = datum; // 更新最小值
      if (datum > max) max = datum; // 更新最大值
    }

    ctx.lineTo(i, (1 + min) * amp); // 繪製從當前位置到最小值的線
    
    ctx.lineTo(i, (1 + max) * amp); // 繪製從當前位置到最大值的線
    
  }
  ctx.stroke(); // 根據路徑繪製線條
  
}

image

圖 5-5 載入 test1.mp3 後顯示的圖

可參考 https://github.com/willian12345/WebAudioAPI/tree/master/examples/ch05/volume-visualization1.html

步驟:

  1. 根據 canvas 的 width 確定取樣資料範圍()寬度
    const step = Math.ceil(data.length / width);

  2. 在 step 取樣資料範圍迴圈找出最高與最低音量

for (let j = 0; j < step; j++) {
    // 遍歷與當前畫素對應的音訊樣本

    const datum = data[i * step + j]; // 獲取單個音訊樣本
    if (datum < min) min = datum; // 更新最小值
    if (datum > max) max = datum; // 更新最大值
  }
  1. 有了音量高低的值,直接繪製線條或柱型就可以了
ctx.lineTo(i, (1 + min) * amp); // 繪製從當前位置到最小值的線
ctx.lineTo(i, (1 + max) * amp); // 繪製從當前位置到最大值的線

把線條獨立開後加點色彩或許更好看

function drawWaveform(data) {
  const canvas = document.getElementById("waveform"); // 獲取canvas元素
  const ctx = canvas.getContext("2d"); // 獲取2D繪圖上下文
  const width = canvas.width; // canvas的寬度
  const height = canvas.height; // canvas的高度
  const step = Math.ceil(data.length / width); // 計算每個畫布畫素對應的音訊樣本數
  const amp = height / 2; // 放大因子,用於控制波形在畫布上的高度

  ctx.fillStyle = "#fff"; // 設定填充顏色為白色
  ctx.fillRect(0, 0, width, height); // 填充整個畫布為白色

  ctx.beginPath(); // 開始繪製新的路徑
  ctx.moveTo(0, amp); // 將繪圖遊標移動到畫布中央的起始點

  // 繪製波形
  for (let i = 0; i < width; i += 4) {
    // 根據 i 遍歷畫布的寬度
    let min = 1.0; // 初始化最小值
    let max = -1.0; // 初始化最大值
    ctx.moveTo(i, amp);
    for (let j = 0; j < step; j++) {
      // 遍歷與當前畫素對應的音訊樣本

      const datum = data[i * step + j]; // 獲取單個音訊樣本
      if (datum < min) min = datum; // 更新最小值
      if (datum > max) max = datum; // 更新最大值
    }
    var hue = (i / width) * 360;

    ctx.beginPath()
    ctx.strokeStyle = "hsl(" + hue + ", 100%, 50%)";
    ctx.moveTo(i, amp);
    ctx.lineTo(i, (1 + min) * amp); // 繪製從當前位置到最小值的線
    ctx.stroke(); // 根據路徑繪製線條

    ctx.beginPath()
    ctx.moveTo(i, amp);
    ctx.lineTo(i, (1 + max) * amp); // 繪製從當前位置到最大值的線
    ctx.stroke(); // 根據路徑繪製線條
  }
}

image

圖 5-3

可參考 https://github.com/willian12345/WebAudioAPI/tree/master/examples/ch05/volume-visualization2.html

還可以把 i 的間隔縮小,再把 step 的大小也縮小試試,我得到了下面 圖 5-4 效果

image

圖 5-4

在網上見過有人用扇形或者螺旋形來視覺化音訊。效果也是相當的酷

請自行搜尋,只要得到資料了實現起來還是比較簡單的


注:轉載請註明出處部落格園:王二狗Sheldon池中物 (willian12345@126.com)

相關文章