19
15

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

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

Last updated at Posted at 2021-05-27

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

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

表記について

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

画面を用意

ui component

<!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>
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自体の説明をすることが目的ではないので、その辺りの説明は割愛します。

サンプリング

sound wave diagram

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

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

sampling diagram

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

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

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

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

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

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

PEAKレベルの算出

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

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

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

decibel graph

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

表示域の調整

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

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

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

\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
}
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以上の値が入力されたらクリッピングと判定します。

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ファイルに追加します。

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を下記のように書き換えて、スタートボタンを押してください。
※音が出るので音量に注意してください。

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

0dB.gif

表示域未満(< -32dB)で変位

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

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

-32dB.gif

表示域以上(< 10dB)で変位

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

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

10dB.gif

最終成果物

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

<!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>
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);
}
19
15
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
19
15

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?