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
という文字列が現れ、画面をクリックすると以下のように波形データを赤色で周波数データを青色で表示します。
Web Audio APIでマイクから音声を取得するにはMediaDevices.getUserMediaとMediaStreamAudioSourceNodeを使用し、音声の解析を行うためには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);
以下、ソースコード全文です。
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);
});