ボリュームメーターの作り方

本稿ではWeb Audioを使ったボリュームメーターの作り方を紹介します。
ボリュームメーターを作ることを通して、サウンドプログラミングの基礎的な内容にも触れています。
JavaScriptで説明しますが、他言語にも通じる汎用的な内容になっています。

今回作るボリュームメーターにはマイクの音量をリアルタイムで表示させます。
音量の表し方にはいくつか種類があり、PEAKレベル・RMSレベル・LOUDNESSレベルなどがあります。
音声入力の際に重要なことはクリッピングさせないことなので、今回はPEAKメーターを作成します。

## 表記について

閉区間を表わす記号として [ , ] を使います。

## 画面を用意

![ui component](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/72814/8085a2d0-8b9d-d383-9d26-b178f9e3e897.gif)

```html
<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8" />
    <title>ボリュームメーター</title>
  </head>
  <body>
    <div style="border: 1px solid black; width: 500px;">
      <div id="volume" style="height: 10px; background: black; transition: width .1s; width: 0%"></div>
    </div>
    <button onClick="start()" type="button">スタート</button>
    <script src="main.js"></script>
  </body>
</html>
```

```javascript:main.js
const AudioContext = window.AudioContext || window.webkitAudioContext;
const meter = document.getElementById('volume');

function render(percent) {
  console.log('Percent:', percent);
  meter.style.width = Math.min(Math.max(0, percent), 100) + '%';
}

function onProcess(event) {
  const data = event.inputBuffer.getChannelData(0);
  const peak = data.reduce((max, sample) => {
    const cur = Math.abs(sample);
    return max > cur ? max : cur;
  });
  render(100 * peak);
}

async function start() {
  const media = await navigator.mediaDevices
    .getUserMedia({ audio: true })
    .catch(console.error);
  const ctx = new AudioContext();
  console.log('Sampling Rate:', ctx.sampleRate);

  const processor = ctx.createScriptProcessor(1024, 1, 1);
  processor.onaudioprocess = onProcess;
  processor.connect(ctx.destination);

  const source = ctx.createMediaStreamSource(media);
  source.connect(processor);
}
```

Web Audioの仕様上、録音を開始するにはユーザーのイベントを発生させる必要があるため、スタートボタンを設置しました。
動きが速すぎて目がチカチカするので、CSSで慣性を付けています。
Web Audio自体の説明をすることが目的ではないので、その辺りの説明は割愛します。

:::note alert
`createScriptProcessor` は非推奨APIです。
https://developer.mozilla.org/ja/docs/Web/API/BaseAudioContext/createScriptProcessor
:::

## サンプリング

![sound wave diagram](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/72814/9f1ac5f1-ff58-1af0-bc4b-6766e474aa5c.png)

音は連続的な波です。
しかし、`getChannelData` で取得できる値は離散的な値の集合、つまり配列になっています。
これはA-D（Analog-to-Digital）変換が行われたためです。

コンピューターはアナログ信号をそのままの状態で扱うことができないので、デジタル信号に変換します。
このように連続的な値を離散的な値に変換することを**サンプリング**とも呼びます。

![sampling diagram](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/72814/bbef603a-1b1d-6494-8c98-99e96af78291.png)

サンプリングの時間に対する細かさを**サンプリングレート（標本化精度）**といい、振幅に対する細かさを**量子化精度（量子化ビット数）**といいます。
上図の網目が細かいほど精度が高くなるというわけです。

サンプリングレートは周波数で表され、値が高いほど高域まで記録できます。
`AudioContext` インスタンスの `sampleRate` プロパティで現在のサンプリングレートを取得することができます。
デフォルトでは44100Hzになっていると思います。
[標本化定理](https://ja.wikipedia.org/wiki/標本化定理)より、44100Hzの場合はその半分の22050Hzの周波数成分まで記録できます。

量子化精度はビット数で表され、ビット数が高いほど波形の詳細な変化を記録できます。
ただし、量子化精度はIntとFloatで特性が異なり、そう単純ではありません。
`getChannelData` で取得したデータは32bitFloatになっています。

https://achapi2718.blogspot.com/2011/12/32-bit-float-ieee-754.html

さて、サンプリングレートが44100Hzの場合、1秒間に44100サンプルが形成されます。
ただし、1秒間に44100回のイベントが発生するのではないという点に注意してください。
サンプルの集合が**バッファ**という単位にまとめられ、バッファごとにイベントが発生します。

バッファの大きさをバッファサイズといい、`createScriptProcessor` の第一引数で指定しているのがこの値です。
この例では1バッファに1024サンプル含まれているということになります。
`getChannelData` で取得した配列の大きさが1024になっているのはそのためです。

バッファが生成されてイベントが発生すると、 `onaudioprocess` が呼び出されます。
この例では1秒間に約43（=44100 / 1024）回のイベントが発生する計算になります。

## PEAKレベルの算出

`onProcess` 関数内でバッファごとの変位の最大値を求めています。

```javascript
function onProcess(event) {
  const data = event.inputBuffer.getChannelData(0);
  const peak = data.reduce((max, sample) => {
    const cur = Math.abs(sample);
    return max > cur ? max : cur;
  });
  render(100 * peak);
}
```

`getChannelData` はFloat32Arrayを返し、配列の要素は[-1, 1]の小数です。
波はプラス・マイナスに振れるので、大きさは絶対値で求めます。
メーターの入力値は[0, 100]なので、最大変位 `peak` を100倍したものを `render` に渡しています。

## ヴェーバー・フェヒナーの法則

では、スタートボタンを押してマイクアクセスを許可し、音を出してみてください。
それっぽくは出来たけど、少し違和感を感じませんか？
反応が鈍いように思えます。

実は人間が五感で知覚する感覚量は、刺激量の大きさに比例するのではなく、その対数に比例することが分かっています。
つまり、我々は静音の変化量には敏感で、騒音の変化量には鈍感なのです。
そのため、変位をそのまま表示しても、納得感が得られないのです。

変位の対数をとった値をB（ベル）という単位で表し、それを10倍にしたものを**dB（デシベル）**という単位で表します。
音量を扱う際はデシベルがよく用いられます。

## デシベル換算

波の変位 _x_ をデシベルに変換する関数 _f_ は

```math
f(x)=10\ \text{log}_{10}∣x∣
```

![decibel graph](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/72814/e470f538-fa82-01fd-ba9e-89698c56f04f.png)

_x_ が1、または-1のとき0dBで、0のとき-&infin;dBです。
0dBが最大値で、それを超えるとクリッピング（音割れ）します。
基本的にデジタルオーディオの世界ではデシベルの値はマイナスになります。

## 表示域の調整

次に、表示域の最小値、つまりマイナス何dBまで表示するかを決める必要があります。
最小値が人間の最小可聴値を下回ってしまうと、音が鳴っていないのにメーターが振れているように見えてしまいます。

今回は-32dBまでとしました。
何デシベルに設定するかは用途によるので、バランスを見て決めてください。

最小値が-32dBならデシベルの値域は[-32, 0]となるので、表示域に収めるために[0, 100]の区間に変換する必要があります。
この関数 *g* を先ほどの関数 *f* を使って次のように求めます。

```math
\displaylines{
g(x)=a\ f(x) + b \\
f(x) = 0のとき、g(x) = 100なので、
b = 100\\
f(x) = -32のとき、g(x) = 0なので、\\
\begin{eqnarray}
0 &=& -32a + 100\\
a &=& \frac{100}{32}
\end{eqnarray}\\
よって\\
g(x) = \frac{100}{32} f(x) + 100
}
```

```diff_javascript:main.js
function onProcess(event) {
  const data = event.inputBuffer.getChannelData(0);
  const peak = data.reduce((max, sample) => {
    const cur = Math.abs(sample);
    return max > cur ? max : cur;
  });
- render(100 * peak);
+ render(100 / 32 * 10 * Math.log10(peak) + 100);
}
```

## クリッピングの判定

次に、クリッピングしたらメーターを赤色に変えてみましょう。
`render` に100以上の値が入力されたらクリッピングと判定します。

```diff_javascript:main.js
function render(percent) {
  console.log('Percent:', percent);
+ meter.style.background = percent < 100 ? 'black' : 'red';
  meter.style.width = Math.min(Math.max(0, percent), 100) + '%';
}
```

## 動作検証

ホワイトノイズを使ってボリュームメーターの動作を検証してみましょう。
検証用に下記のコードをJSファイルに追加します。

```javascript
function createWhiteNoize(ctx, maxDecibel) {
  const sec = 3;
  const frameCount = ctx.sampleRate * sec;
  const buffer = ctx.createBuffer(1, frameCount, ctx.sampleRate);
  const data = buffer.getChannelData(0);
  const max = 10 ** (maxDecibel / 10);
  for (let i = 0; i < frameCount; i++) {
    data[i] = (Math.random() * max * 2 - max) / frameCount * i;
  }
  return buffer;
}

function test(maxDecibel) {
  const ctx = new AudioContext();

  const processor = ctx.createScriptProcessor(1024, 1, 1);
  processor.onaudioprocess = onProcess;
  processor.connect(ctx.destination);

  const source = ctx.createBufferSource();
  source.buffer = createWhiteNoize(ctx, maxDecibel);
  source.connect(processor);
  source.connect(ctx.destination);
  source.start();
  source.onended = function() {
    ctx.close();
  };
}
```

### 表示域内（< 0dB）で変位

まずは表示域内で乱数を作りましょう。
ホワイトノイズがフェードインしていきます。
メーターが振れ、クリッピングが発生していなければOKです。

HTMLを下記のように書き換えて、スタートボタンを押してください。
※音が出るので音量に注意してください。

```diff_html
-   <button onClick="start()" type="button">スタート</button>
+   <button onClick="test(0)" type="button">スタート</button>
```

![0dB.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/72814/8327fbb1-2a4e-dde7-4cd1-341bd0985a29.gif)

### 表示域未満（< -32dB）で変位

次に表示域未満で乱数を作りましょう。
メーターが反応しなければOKです。

```diff_html
-   <button onClick="test(0)" type="button">スタート</button>
+   <button onClick="test(-32)" type="button">スタート</button>
```

![-32dB.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/72814/59fbddb1-c4df-6f02-dda1-b491dff9ad89.gif)

### 表示域以上（< 10dB）で変位

最後に表示域以上で乱数を作りましょう。
メーターがクリッピングすればOKです。

```diff_html
-   <button onClick="test(-32)" type="button">スタート</button>
+   <button onClick="test(10)" type="button">スタート</button>
```

![10dB.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/72814/62b017bb-ddeb-bfad-2eae-f9b1b9fde8e6.gif)

## 最終成果物

最終的に出来上がったソースが以下になります。
検証用のコードは除いています。

```html
<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8" />
    <title>ボリュームメーター</title>
  </head>
  <body>
    <div style="border: 1px solid black; width: 500px;">
      <div id="volume" style="height: 10px; background: black; transition: width .1s; width: 0%"></div>
    </div>
    <button onClick="start()" type="button">スタート</button>
    <script src="main.js"></script>
  </body>
</html>
```

```javascript:main.js
const AudioContext = window.AudioContext || window.webkitAudioContext;
const meter = document.getElementById('volume');

function render(percent) {
  console.log('Percent:', percent);
  meter.style.background = percent < 100 ? 'black' : 'red';
  meter.style.width = Math.min(Math.max(0, percent), 100) + '%';
}

function onProcess(event) {
  const data = event.inputBuffer.getChannelData(0);
  const peak = data.reduce((max, sample) => {
    const cur = Math.abs(sample);
    return max > cur ? max : cur;
  });
  render(100 / 32 * 10 * Math.log10(peak) + 100);
}

async function start() {
  const media = await navigator.mediaDevices
    .getUserMedia({ audio: true })
    .catch(console.error);
  const ctx = new AudioContext();
  console.log('Sampling Rate:', ctx.sampleRate);

  const processor = ctx.createScriptProcessor(1024, 1, 1);
  processor.onaudioprocess = onProcess;
  processor.connect(ctx.destination);

  const source = ctx.createMediaStreamSource(media);
  source.connect(processor);
}
```