LoginSignup
41
30

More than 3 years have passed since last update.

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

Posted at

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

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

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

visualization.PNG


Web Audio APIでマイクから音声を取得するにはMediaDevices.getUserMediaMediaStreamAudioSourceNodeを使用し、音声の解析を行うためにはAnalyserNodeを使用します。input.connect(analyzer)AnalyserNodeにマイクからの音声を入力して解析できるようにしています。Web Auido APIでマイクから音声を取得するためにはユーザー操作が必要になるので、今回はclickイベントを使っています。

// マイクから音声を取得するためにはユーザー操作が必要
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コンテキストを作成します。

  ...
  // 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になり、周波数データのサイズはAnalyserNode.frequencyBinCountになるのでその大きさで配列を作成しています。VBOはレンダリングループ内でAnalyserNodeから取得できる現在の音声データをもとに更新されるのでgl.DYNAMIC_DRAWで作成しておきます。

  // 波形データを保存する配列
  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で渡した値が線の色になるようにしています。

頂点シェーダー
#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
  );
}
フラグメントシェーダー
#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で取得でき、周波数データはAnalyserNode.getFloatFrequencyData()で取得できます。getFloatFrequencyDataで取得できる値の最小値はAnalyserNode.minDecibelsになり、最大値はAnalyserNode.maxDecibelsになります。VBOはWebGLRenderingContext.bufferSubData()を使用することで更新できます。

  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);

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

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);
});
41
30
1

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
41
30