Web Audio APIでマイクから音声を取得してWebGLでビジュアライゼーションする

Web Audio APIを使ってマイクから音声を取得して、WebGLで波形データとFFTで解析した周波数データをビジュアライゼーションします。

動くものは[こちら](https://aadebdeb.github.io/web-audio-gl-sample/index.html)からアクセスでき、ソースコードは以下のGitHubに置いてあります。
[aadebdeb/web\-audio\-gl\-sample: Visualization of Sound from Microphone Using Web Audio API and WebGL](https://github.com/aadebdeb/web-audio-gl-sample)

実行すると、`Click to Start`という文字列が現れ、画面をクリックすると以下のように波形データを赤色で周波数データを青色で表示します。

![visualization.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/61569/ef5d64be-7cc6-9cd1-e5a2-013ae66e6e45.png)


---

Web Audio APIでマイクから音声を取得するには[MediaDevices\.getUserMedia](https://developer.mozilla.org/ja/docs/Web/API/MediaDevices/getUserMedia)と[MediaStreamAudioSourceNode](https://developer.mozilla.org/ja/docs/Web/API/MediaStreamAudioSourceNode)を使用し、音声の解析を行うためには[AnalyserNode](https://developer.mozilla.org/ja/docs/Web/API/AnalyserNode)を使用します。`input.connect(analyzer)`で`AnalyserNode`にマイクからの音声を入力して解析できるようにしています。Web Auido APIでマイクから音声を取得するためにはユーザー操作が必要になるので、今回は`click`イベントを使っています。

```js
// マイクから音声を取得するためにはユーザー操作が必要
addEventListener('click', async () => {
  ...
  // Audioコンテキストを作成する
  const audioCtx = new AudioContext();
  // マイクから音声を取得する
  const stream = await navigator.mediaDevices.getUserMedia({audio: true});
  const input  = audioCtx.createMediaStreamSource(stream);
  // 音声の解析を行うAnalyserNodeを作成する
  const analyzer = audioCtx.createAnalyser();
  // マイクからの入力をAnalyserNodeに繋げる
  input.connect(analyzer);
  ...
});
```

WebGLの方も準備しておきます。まずは`canvas`要素を作成してWebGLコンテキストを作成します。

```js
  ...
  // canvasを作成する
  const canvas = document.createElement('canvas');
  canvas.width = innerWidth;
  canvas.height = innerHeight;
  document.body.appendChild(canvas);

  // WebGL2RenderingContextを作成する
  const gl = canvas.getContext('webgl2');
  gl.clearColor(0.0, 0.0, 0.0, 1.0);

  ...
```

波形データを保存するための`timeDomainArray`と周波数データを保存するための`frequencyArray`とそれぞれに対応したVBOを作成しておきます。`AnalyserNode`から取得できる波形データのサイズは[AnalyserNode\.fftSize](https://developer.mozilla.org/ja/docs/Web/API/AnalyserNode/fftSize)になり、周波数データのサイズは[AnalyserNode\.frequencyBinCount](https://developer.mozilla.org/ja/docs/Web/API/AnalyserNode/frequencyBinCount)になるのでその大きさで配列を作成しています。VBOはレンダリングループ内で`AnalyserNode`から取得できる現在の音声データをもとに更新されるので`gl.DYNAMIC_DRAW`で作成しておきます。

```js
  // 波形データを保存する配列
  const timeDomainArray = new Float32Array(analyzer.fftSize);
  // 周波数データを保存する配列
  const frequencyArray = new Float32Array(analyzer.frequencyBinCount)
  // 波形データのVBO
  const timeDomainVbo = createVbo(gl, timeDomainArray, gl.DYNAMIC_DRAW);
  // 周波数データのVBO
  const frequencyVbo = createVbo(gl, frequencyArray, gl.DYNAMIC_DRAW);
```

線を描画するためのシェーダーは以下のようになります。頂点シェーダーでは頂点の番号と音声データ(波形データまたは周波数データ)の値をもとにx方向に線を描くように頂点位置を決定しています。y方向は最小値`u_minValue`の値が-1, 最大値`u_maxValue`の値が1になるように値を変換しています。フラグメントシェーダーでは単純に`uniform`で渡した値が線の色になるようにしています。

```glsl:頂点シェーダー
#version 300 es

precision highp float;

layout (location = 0) in float i_value;

uniform float u_length; // データ数
uniform float u_minValue; // 値の最小値
uniform float u_maxValue; // 値の最大値

#define linearstep(edge0, edge1, x) max(min((x - edge0) / (edge1 - edge0), 1.0), 0.0)

void main(void) {
  gl_Position = vec4(
    // x座標は頂点番号をもとに[-1, 1]の範囲になるようにする
    (float(gl_VertexID) / u_length) * 2.0 - 1.0,
    // y座標は音声のデータをもとに[-1, 1]の範囲になるようにする
    linearstep(u_minValue, u_maxValue, i_value) * 2.0 - 1.0,
    0.0,
    1.0
  );
}
```

```glsl:フラグメントシェーダー
#version 300 es

precision highp float;

out vec4 o_color;

uniform vec3 u_color;

void main(void) {
  o_color = vec4(u_color, 1.0);
}
```

準備ができたので、レンダリングループを開始します。レンダリングループ内では波形データの更新と描画および周波数データの更新と描画を行います。波形データは[AnalyserNode\.getFloatTimeDomainData](https://developer.mozilla.org/ja/docs/Web/API/AnalyserNode/getFloatTimeDomainData)で取得でき、周波数データは[AnalyserNode\.getFloatFrequencyData\(\)](https://developer.mozilla.org/ja/docs/Web/API/AnalyserNode/getFloatFrequencyData)で取得できます。`getFloatFrequencyData`で取得できる値の最小値は[AnalyserNode\.minDecibels](https://developer.mozilla.org/ja/docs/Web/API/AnalyserNode/minDecibels)になり、最大値は[AnalyserNode\.maxDecibels](https://developer.mozilla.org/ja/docs/Web/API/AnalyserNode/maxDecibels)になります。VBOは[WebGLRenderingContext\.bufferSubData\(\)](https://developer.mozilla.org/en-US/docs/Web/API/WebGLRenderingContext/bufferSubData)を使用することで更新できます。

```js
  const render = () => {
    gl.clear(gl.COLOR_BUFFER_BIT);

    // timeDomainArrayに波形データをコピーする
    analyzer.getFloatTimeDomainData(timeDomainArray);
    // timeDomainArrayの値でtimeDomainVboを更新する
    gl.bindBuffer(gl.ARRAY_BUFFER, timeDomainVbo);
    gl.bufferSubData(gl.ARRAY_BUFFER, 0, timeDomainArray);

    // 波形データを描画する
    gl.useProgram(program);
    gl.uniform1f(uniformLocs.get('u_length'), timeDomainArray.length);
    gl.uniform1f(uniformLocs.get('u_minValue'), -1.0);
    gl.uniform1f(uniformLocs.get('u_maxValue'), 1.0);
    gl.uniform3f(uniformLocs.get('u_color'), 1.0, 0.0, 0.0);
    gl.bindBuffer(gl.ARRAY_BUFFER, timeDomainVbo);
    gl.enableVertexAttribArray(0);
    gl.vertexAttribPointer(0, 1, gl.FLOAT, false, 0, 0);
    // 波形データは1次元配列として`timeDomainVbo`に入っているのでgl.LINE_STRIPを使用して線として描画する
    gl.drawArrays(gl.LINE_STRIP, 0, timeDomainArray.length);

    // frequencyArrayに周波数データをコピーする
    analyzer.getFloatFrequencyData(frequencyArray);
    // frequencyArrayの値でfrequencyVboを更新する
    gl.bindBuffer(gl.ARRAY_BUFFER, frequencyVbo);
    gl.bufferSubData(gl.ARRAY_BUFFER, 0, frequencyArray);

    gl.uniform1f(uniformLocs.get('u_length'), frequencyArray.length);
    // 周波数データの最小値はminDecibels, 最大値はmaxDecibelsになる
    gl.uniform1f(uniformLocs.get('u_minValue'), analyzer.minDecibels);
    gl.uniform1f(uniformLocs.get('u_maxValue'), analyzer.maxDecibels);
    gl.uniform3f(uniformLocs.get('u_color'), 0.0, 0.0, 1.0);
    gl.bindBuffer(gl.ARRAY_BUFFER, frequencyVbo);
    gl.enableVertexAttribArray(0);
    gl.vertexAttribPointer(0, 1, gl.FLOAT, false, 0, 0);
    // 周波数データは1次元配列として`timeDomainVbo`に入っているのでgl.LINE_STRIPを使用して線として描画する
    gl.drawArrays(gl.LINE_STRIP, 0, frequencyArray.length);

    requestId = requestAnimationFrame(render);
  };
  requestId = requestAnimationFrame(render);
```

---

以下、ソースコード全文です。

```js:index.js
const renderLineVertex = `#version 300 es

precision highp float;

layout (location = 0) in float i_value;

uniform float u_length;
uniform float u_minValue;
uniform float u_maxValue;

#define linearstep(edge0, edge1, x) max(min((x - edge0) / (edge1 - edge0), 1.0), 0.0)

void main(void) {
  gl_Position = vec4(
    (float(gl_VertexID) / u_length) * 2.0 - 1.0,
    linearstep(u_minValue, u_maxValue, i_value) * 2.0 - 1.0,
    0.0,
    1.0
  );
}
`;

const renderLineFragment = `#version 300 es

precision highp float;

out vec4 o_color;

uniform vec3 u_color;

void main(void) {
  o_color = vec4(u_color, 1.0);
}
`;

function createShader(gl, source, type) {
  const shader = gl.createShader(type);
  gl.shaderSource(shader, source);
  gl.compileShader(shader);
  if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
    throw new Error(gl.getShaderInfoLog(shader) + source);
  }
  return shader;
}

function createProgram(gl, vertShader, fragShader) {
  const program = gl.createProgram();
  gl.attachShader(program, vertShader);
  gl.attachShader(program, fragShader);
  gl.linkProgram(program);
  if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
    throw new Error(gl.getProgramInfoLog(program));
  }
  return program;
}

function getUniformLocs(gl, program, names) {
  const map = new Map();
  names.forEach((name) => map.set(name, gl.getUniformLocation(program, name)));
  return map;
}

function createVbo(gl, array, usage) {
  const vbo = gl.createBuffer();
  gl.bindBuffer(gl.ARRAY_BUFFER, vbo);
  gl.bufferData(gl.ARRAY_BUFFER, array, usage);
  gl.bindBuffer(gl.ARRAY_BUFFER, null);
  return vbo;
}

const clickElem = document.createElement('div');
clickElem.textContent = 'Click to Start';
document.body.appendChild(clickElem);

let clicked = false;
addEventListener('click', async () => {
  if (clicked) return;
  clicked = true;
  clickElem.remove();

  const audioCtx = new AudioContext();
  const stream = await navigator.mediaDevices.getUserMedia({audio: true});
  const input  = audioCtx.createMediaStreamSource(stream);
  const analyzer = audioCtx.createAnalyser();
  input.connect(analyzer);

  const canvas = document.createElement('canvas');
  canvas.width = innerWidth;
  canvas.height = innerHeight;
  document.body.appendChild(canvas);

  const gl = canvas.getContext('webgl2');
  gl.clearColor(0.0, 0.0, 0.0, 1.0);

  const program = createProgram(gl,
    createShader(gl, renderLineVertex, gl.VERTEX_SHADER),
    createShader(gl, renderLineFragment, gl.FRAGMENT_SHADER)
  );
  const uniformLocs = getUniformLocs(gl, program, ['u_length', 'u_minValue', 'u_maxValue', 'u_color']);

  const timeDomainArray = new Float32Array(analyzer.fftSize);
  const frequencyArray = new Float32Array(analyzer.frequencyBinCount);
  const timeDomainVbo = createVbo(gl, timeDomainArray, gl.DYNAMIC_DRAW);
  const frequencyVbo = createVbo(gl, frequencyArray, gl.DYNAMIC_DRAW);

  let requestId =  null;
  const render = () => {
    gl.clear(gl.COLOR_BUFFER_BIT);

    analyzer.getFloatTimeDomainData(timeDomainArray);
    gl.bindBuffer(gl.ARRAY_BUFFER, timeDomainVbo);
    gl.bufferSubData(gl.ARRAY_BUFFER, 0, timeDomainArray);

    gl.useProgram(program);
    gl.uniform1f(uniformLocs.get('u_length'), timeDomainArray.length);
    gl.uniform1f(uniformLocs.get('u_minValue'), -1.0);
    gl.uniform1f(uniformLocs.get('u_maxValue'), 1.0);
    gl.uniform3f(uniformLocs.get('u_color'), 1.0, 0.0, 0.0);
    gl.bindBuffer(gl.ARRAY_BUFFER, timeDomainVbo);
    gl.enableVertexAttribArray(0);
    gl.vertexAttribPointer(0, 1, gl.FLOAT, false, 0, 0);
    gl.drawArrays(gl.LINE_STRIP, 0, timeDomainArray.length);

    analyzer.getFloatFrequencyData(frequencyArray);
    gl.bindBuffer(gl.ARRAY_BUFFER, frequencyVbo);
    gl.bufferSubData(gl.ARRAY_BUFFER, 0, frequencyArray);

    gl.uniform1f(uniformLocs.get('u_length'), frequencyArray.length);
    gl.uniform1f(uniformLocs.get('u_minValue'), analyzer.minDecibels);
    gl.uniform1f(uniformLocs.get('u_maxValue'), analyzer.maxDecibels);
    gl.uniform3f(uniformLocs.get('u_color'), 0.0, 0.0, 1.0);
    gl.bindBuffer(gl.ARRAY_BUFFER, frequencyVbo);
    gl.enableVertexAttribArray(0);
    gl.vertexAttribPointer(0, 1, gl.FLOAT, false, 0, 0);
    gl.drawArrays(gl.LINE_STRIP, 0, frequencyArray.length);

    requestId = requestAnimationFrame(render);
  };

  addEventListener('resize', () => {
    if (requestId != null) {
      cancelAnimationFrame(requestId);
    }
    canvas.width = innerWidth;
    canvas.height = innerHeight;
    gl.viewport(0.0, 0.0, canvas.width, canvas.height);
    requestId = requestAnimationFrame(render);
  });

  requestId = requestAnimationFrame(render);
});
```