AudioWorklet volume meter

July 07, 2022

  • javascript
  • frontend

若有任何問題或有錯誤都可以至我的 Blog Github 發 issue 給我。

Preface

坦白說這部分的資料在網路上資源比較匱乏一點,不然就是沒有提供比較詳細的解釋,單純只有程式碼而已,所以需要自己去理解為何要這樣做,程式碼的部分我是參考 Google Chrome Lab github 的 連結

補充: 上方參考連結的程式碼會使用 JavaScript class private syntax, 像是: #lastUpdate #volume…等,請不要覺得難,加上 # 的值,只是不可被外部讀取而已,順帶一提,TypeScript private syntax 仍可以被外部讀取。

class Hello {
  #name

  constructor() {
    this.#name = "World"
  }
}

console.log(new Hello().#name)
// error: Property '#name' is not accessible outside class 'Hello' because it has a private identifier.

Introduce AudioWorklet initialize

還記得上次的語音辨識專案嗎?這是 AudioWorklet 初始化範例,如下:

class VolumeMeter extends AudioWorkletProcessor {
  static get parameterDescriptors() {
    return []
  }

  constructor() {
    super()
  }

  process(inputs, outputs, parameters) {
    return true
  }
}

Jump into Volume meter

process 被觸發時,我們將 inputs 的值帶入 calculateVolume 的函數內,接著我們要統計左聲道跟右聲道的值並加總後取平均值,這邊我們使用 平方平均数(rms) 的方式來進行加總(這方式在聲音取值方面是滿常做使用的),將左右聲道各 128 個 float32array 數組相乘加總並除以 128 再開更號,求得該時間點平均的音量強度。

class VolumeMeter extends AudioWorkletProcessor {
  static get parameterDescriptors() {
    return []
  }

  constructor() {
    super()
  }

  calculateVolume(inputs) {    const inputChannelData = inputs[0][0]    let sum = 0    // Calculate the squared-sum.    for (let i = 0; i < inputChannelData.length; ++i) {      sum += inputChannelData[i] * inputChannelData[i]    }    // Calculate the RMS level and update the volume.    const rms = Math.sqrt(sum / inputChannelData.length)  }
  process(inputs, outputs, parameters) {
    this.calculateVolume(inputs)
    return true
  }
}

Next Step

接著我們添加 SMOOTHING_FACTOR 這項參數,這項參數是一個滑動平均的參數,我們將該值設為 0.8,這樣我們就可以把音量的強度慢慢更新,而不是直接更新。

有些人對這步驟會產生困惑,我這邊解釋一下,假設上次 rms 我們取得的值是 10 好了,下一次取得的值是 0 ,透過 Math.max(rms, this.volume * SMOOTHING_FACTOR),這樣下一次的值就會變成 8 而不是直接變成 0 ,那因為 process 的觸發是毫秒等級的,透過這種方式可以有漸進式的降低音量的感覺。

const SMOOTHING_FACTOR = 0.8
class VolumeMeter extends AudioWorkletProcessor {
  static get parameterDescriptors() {
    return []
  }

  constructor() {
    super()
    this.volume = 0  }

  calculateVolume(inputs) {
    const inputChannelData = inputs[0][0]
    let sum = 0

    // Calculate the squared-sum.
    for (let i = 0; i < inputChannelData.length; ++i) {
      sum += inputChannelData[i] * inputChannelData[i]
    }

    // Calculate the RMS level and update the volume.
    const rms = Math.sqrt(sum / inputChannelData.length)

    this.volume = Math.max(rms, this.volume * SMOOTHING_FACTOR)  }

  process(inputs, outputs, parameters) {
    this.calculateVolume(inputs)

    return true
  }
}

Final Step

最後我們這邊是想要每 16ms 才派送一次值給前端進行渲染,這邊有一個 currentTime 參數可以做使用,該值不用額外做宣告,作法簡單講就是每次觸發都要扣掉上一次的值,如果區間大於 16ms 便會進行觸發。

至於為何 this.volume 最後要乘以 100 在於 rms 的值大部分在 0.1 以下,而我前端要渲染的格子只有 10 格,所以我將 rms 乘以 100 確保值的落點。

const SMOOTHING_FACTOR = 0.8

class VolumeMeter extends AudioWorkletProcessor {
  static get parameterDescriptors() {
    return []
  }

  constructor() {
    super()
    this.volume = 0
    this.lastUpdate = currentTime  }

  calculateVolume(inputs) {
    const inputChannelData = inputs[0][0]
    let sum = 0

    // Calculate the squared-sum.
    for (let i = 0; i < inputChannelData.length; ++i) {
      sum += inputChannelData[i] * inputChannelData[i]
    }

    // Calculate the RMS level and update the volume.
    const rms = Math.sqrt(sum / inputChannelData.length)

    this.volume = Math.max(rms, this.volume * SMOOTHING_FACTOR)

    // Post a message to the node every 16ms.    if (currentTime - this.lastUpdate > 0.016) {      this.port.postMessage({ eventType: "volume", volume: this.volume * 100 })      // Store previous time      this.lastUpdate = currentTime    }  }

  process(inputs, outputs, parameters) {
    this.calculateVolume(inputs)

    return true
  }
}

Frontend Example

最後再補上一點點前端的範例 code,這部分我就不額外敘述,畢竟每個人使用方式不同。

<div class="volume-group">
  <div class="volume-cell"></div>
  <div class="volume-cell"></div>
  <div class="volume-cell"></div>
  <div class="volume-cell"></div>
  <div class="volume-cell"></div>
  <div class="volume-cell"></div>
  <div class="volume-cell"></div>
  <div class="volume-cell"></div>
  <div class="volume-cell"></div>
  <div class="volume-cell"></div>
</div>

<script>
  const volumeCells = document.querySelectorAll(".volume-cell")

  /**
   * 該函數用來處理 volume cell 顏色變化事件
   */
  function handleVolumeCellColor() {
    const allVolumeCells = [...volumeCells]
    const numberOfCells = Math.round(volume)
    const cellsToColored = allVolumeCells.slice(0, numberOfCells)

    for (const cell of allVolumeCells) {
      cell.style.backgroundColor = "#e3e3e5"
    }

    for (const cell of cellsToColored) {
      cell.style.backgroundColor = "#79c545"
    }
  }
</script>
最後附上成果展示的圖。

volume meter

Conclusion

該功能需要去了解數學平方平均数,這部分因為我之前在公司其他專案已經有使用過了,那時專案印象中是後台 API 需使用 Java 轉換左右聲道音波,為此還去研究了一下 Java 要怎寫 😅 ,所以比較不會困惑,有興趣可以自行上網搜尋平方平均數通常使用在什麼時機上。

最後,由於網路上對音效處理的資源較為匱乏,希望大家能透過我的解釋,了解為何要這樣去做實作,並能多學習到一點東西,那我們下篇文章再見啦,如果我有想法的話 😅 。


Mayvis Chen
你好,我是 Mayvis Chen
住在台北,會點前端,會點後端,熱愛分享新知及愛喝奶茶的全端打雜工程師。