WebAudioとWebGL(Three.js)で、音声ファイルを3D表現

こんにちわ。 teratailチームDevRel担当の木下(@afroscript)です。

WebAudio Web MIDI API Advent Calendar 2016の5日目です。

2016年11月に開催されたWebAudio API 初心者向けハンズオンで教えてもらった内容をもとに、「音声ファイルを3Dで表現する」というのに挑戦してみました。

(僕は職業的にはエンジニアではない(仕事上はほぼコードを書かない)ので、コードの汚さや効率の悪さもろもろは大目に見ていただけるとうれしいです)

今回やること

ベースは先ほど話に出たハンズオンの際に用いた下記資料の7番目のレッスンです。 WebAudio APIのAnalyserというNodeを使って、読み込んだ音声ファイルデータを取得し、音声を可視化するというものです。

資料↓

webmusicdevelopers.appspot.com

※上記ページの下のほうに「start」ボタンがあるので、それを押して一旦音声の可視化(平面)を確認してみてください。

なお、Three.jsのrevisionはr81を使ってます。 (Three.jsはrevisionによって動いたり動かなかったりが激しいのでご注意を)

事前準備

WebAudio APIについて

WebAudioに関する詳しい説明はここでは省略しますが、先ほど挙げた下記資料(作成は@ryoyakawaiさん)を読み進めると、ハンズオン形式で進めながらWebAudioの使い方を学ぶことができるので順にやってみることをオススメします。

webmusicdevelopers.appspot.com

Three.jsについて

Three.jsに関する説明も詳しくは省略しますが、@yomotsuさんの下記記事が一番分かりやすいかと思います。

html5experts.jp

簡単に3Dの基本的な流れだけを述べておくと、下図のように、

  • var scene = new THREE.Scene()でシーンを用意して、その上に、
  • var camera = new THREE.PerspectiveCamera(fov, aspect, near, far)でカメラを用意し(これが画面から見たときの視点になる)、
  • var light = new THREE.DirectionalLight(0xffffff)でライトを追加してシーンを照らし、
  • そこにvar xxx = new THREE.xxx(geometory, material)で3Dの物体(3Dオブジェクト)を追加していき、
  • var renderer = new THREE.WebGLRenderer()でレンダリングしていく(その際に、カメラや3D物体の位置座標を動かすことでアニメーションになる)

っといった流れが基本です。

f:id:afroscript:20161205233307p:plain

↑画像引用先

完成品

まずは完成品です。下記URLから見ることができます。

※【注意】音声がなります!

左上にある「start」ボタンをクリックすると、サンプルの音が鳴り出すとともに、白い線がウネウネと音に合わせて動き出します。

http://usaqwako.sakura.ne.jp/3dAudio/3dLineAudio.html

一応gifも載せておきます。

f:id:afroscript:20161205235123g:plain

コードと解説

3D空間での線描写

解説はコードの中のコメントで、だいたい入れちゃってますが、今回3D空間で線描画をするのにTHREE.Lineを使用してます。THREE.Lineについては下記ブログが分かりやすいです。

gupuru.hatenablog.jp

OrbitControls.js

また、マウスでグリグリ動くように、OrbitControls.js使ってます。これ使うとちゃんと物体やもろもろが3Dで表示されているか確認することができます。

OrbitControls.jsの使い方に関しては、下記を参考にされるといいかと思います。

gupuru.hatenablog.jp

あとは事前準備に提示した2つのリンク先を見ると詳しい解説が載ってます。

3dLineAudio.html

HTMLファイルはこんな感じです。特になにもないです。

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <title>Three.js</title>
    <script src="three.min.js"></script> <!--r81-->
    <script src="OrbitControls.js"></script>
    <style>
        body {
            margin: 0;
            padding: 0;
        }
    </style>
</head>
<body>
<button id="playsound" disabled>Play</button><br/><br/> 
<script src="3dLineAudio.js"></script>
</body>
</html>

3dLineAudio.js

続いてJavaScriptのファイルの全体像はこんな感じです。

/*
 * 3Dのもろもろ下準備
 */

//sceneを用意
var scene = new THREE.Scene();

//cameraを用意
var fov = 75;
var threeWidth = window.innerWidth;
var threeHeight = window.innerHeight * 0.9;
var aspect = threeWidth / threeHeight;
var near = 0.1;
var far = 1000;
var camera = new THREE.PerspectiveCamera(fov, aspect, near, far);
var camera_position_x0 = 0;
var camera_position_y0 = 0;
var camera_position_z0 = -100;
camera.position.set(camera_position_x0,camera_position_y0,camera_position_z0);

//ライトを用意
var light = new THREE.DirectionalLight(0xffffff);
light.position.set(50,50,50);
scene.add(light);

//環境光を用意
var ambient = new THREE.AmbientLight(0x333333);
scene.add(ambient);

//rendererの設定
var renderer = new THREE.WebGLRenderer();
renderer.setSize(threeWidth, threeHeight);
renderer.setPixelRatio(window.devicePixelRatio);

document.body.appendChild(renderer.domElement);

//マウスでぐるぐる動かせるようにする
var controls = new THREE.OrbitControls(camera)

//補助軸の表示(赤:X軸、緑:y軸、青:z軸)
var axis = new THREE.AxisHelper(1000);
scene.add(axis);

/*
 *ここから波長/周波数の3D表現をしていく
 */

//音声を扱うオブジェクトの生成
var audioctx = new AudioContext();

//サンプルの音をXMLHttpRequestで読み込む。
var buffer = null;
var src = null;
const LoadSample = (ctx, url) => {
    var req = new XMLHttpRequest();
    req.open("GET", url, true);
    req.responseType = "arraybuffer";
    req.onload = () => {
        if(req.response) {
            ctx.decodeAudioData(req.response, (b) => {buffer=b;}, () => {});
            document.querySelector("button#playsound").removeAttribute("disabled");
        }
    }
    req.send();
}
LoadSample(audioctx, "./loop.wav");

//Analyser Nodeを用意
var timerId;
var analyser = audioctx.createAnalyser();
analyser.fftSize = 512; //フーリエ変換のデータサイズを指定
analyser.minDecibels = -100;
analyser.maxDecibels = -30;

//3D空間に線を描画
var line;

const DrawGraph = () => {

    var data = new Uint8Array(512);

    scene.remove(line);

    analyser.getByteFrequencyData(data); //Spectrum Data
    // analyser.getByteTimeDomainData(data); //Waveform Dataもとれるよ

    //前に表示していた線を消す
    scene.remove(line);

    //新たに線を描写する
    var geometry = new THREE.Geometry();

    for(var i = 0; i < 32; ++i) {
        //dataの0〜31番目を使って線の頂点を追加していく
        geometry.vertices.push(
            new THREE.Vector3( 5 * i, data[i] * 0.1, 0)
        ); 
    }

    //線オブジェクトの生成   
    line = new THREE.Line( geometry, new THREE.LineBasicMaterial( { color: 0xffffff} ) );

    //sceneにlineを追加
    scene.add( line );

    requestAnimationFrame(DrawGraph);
}
timerId=requestAnimationFrame(DrawGraph);


/*
 * startボタンを押したらよばれるやつ
 */
document.querySelector("button#playsound").addEventListener("click", (event) => {
    var label; 
    if(event.target.innerHTML=="Stop") {
        src.stop(0);
        cancelAnimationFrame(timerId);
        label="Start";
    } else {
        src = audioctx.createBufferSource();
        src.buffer = buffer;
        src.loop = true;
        src.connect(audioctx.destination);
        src.connect(analyser);
        src.start(0);
        label="Stop";
    }
    event.target.innerHTML=label;
});


/*
 * レンダリングするよ
 */
function rendererRender() {

    renderer.render(scene,camera);
    controls.update(); //OrbitControlsを更新する。
    requestAnimationFrame(rendererRender);
}

rendererRender();

応用編:3Dオブジェクトで表現してみた。

線だけでは寂しいので、音声データを3Dオブジェクトで表現してみました。

今回はTorusKnotというちょっと変わった形状の3Dオブジェクトを、音に合わせて変化させてみました。

下記からご覧になれます。

http://usaqwako.sakura.ne.jp/3dAudio/3dTorusKnotAudio.html

一応gifも。

f:id:afroscript:20161206000655g:plain

応用編のコード

htmlはほぼそのままなので、jsファイルのみ掲載します。

/*
 * 3Dのもろもろ下準備
 */

//scene
var scene = new THREE.Scene();

//camera
var fov = 75;
var threeWidth = window.innerWidth;
var threeHeight = window.innerHeight * 0.95;
var aspect = threeWidth / threeHeight;
var near = 0.1;
var far = 1000;
var camera = new THREE.PerspectiveCamera(fov, aspect, near, far);
var camera_position_x0 = 0;
var camera_position_y0 = 0;
var camera_position_z0 = -50;
camera.position.set(camera_position_x0,camera_position_y0,camera_position_z0);

//light
var light = new THREE.DirectionalLight(0xffffff);
light.position.set(50,50,50);
scene.add(light);

//環境光
var ambient = new THREE.AmbientLight(0x333333);
scene.add(ambient);

//renderer
var renderer = new THREE.WebGLRenderer();
renderer.setSize(threeWidth, threeHeight);
renderer.setPixelRatio(window.devicePixelRatio);

document.body.appendChild(renderer.domElement);

//マウスでぐるぐる動かせるようにする
var controls = new THREE.OrbitControls(camera)

//補助軸の表示(赤:X軸、緑:y軸、青:z軸)
var axis = new THREE.AxisHelper(1000);
scene.add(axis);


/*
 *ここから波長を3D表現をしていく
 */

//音声を扱うオブジェクトの生成
var audioctx = new AudioContext();

//サンプルの音をXMLHttpRequesで読み込む。
var buffer = null;
var src = null;
const LoadSample = (ctx, url) => {
    var req = new XMLHttpRequest();
    req.open("GET", url, true);
    req.responseType = "arraybuffer";
    req.onload = () => {
        if(req.response) {
            ctx.decodeAudioData(req.response, (b) => {buffer=b;}, () => {});
            document.querySelector("button#playsound").removeAttribute("disabled");
        }
    }
    req.send();
}
LoadSample(audioctx, "./loop.wav");

//Analyser Nodeを用意
var timerId;
var analyser = audioctx.createAnalyser();
analyser.fftSize = 128;
analyser.minDecibels = -100;
analyser.maxDecibels = -30;

//3D空間でトーラスノットを描写する
var obj;
const DrawGraph = () => {

    var data = new Uint8Array(512);

    scene.remove(obj);

    analyser.getByteTimeDomainData(data); //Waveform Data
    // analyser.getByteFrequencyData(data); //Spectrum Dataもとれるよ

    var geo = new THREE.TorusKnotGeometry( //少数をかけてるのは値を小さくして、3Dオブジェクトのサイズを小さくするため
        Math.round(data[0] * 0.12), //全体的な大きさ
        Math.round(data[1] * 0.03), //チューブの太さ
        Math.round(data[2] * 0.5), //クネクネの進む方向に対してなん分割するか
        Math.round(data[3] * 0.06), //チューブ方向に何分割するか
        Math.round(data[4] * 0.03), //なんかクネクネ具合が変わる数値その1
        Math.round(data[5] * 0.02) //なんかクネクネ具合が変わる数値その1
    );
    //// 表示がいい具合になるように、console.logで地道に数値を微調整した痕跡...orz
    // console.log(
    //     Math.round(data[0] * 0.12), //全体的な大きさ
    //     Math.round(data[1] * 0.03), //チューブの太さ
    //     Math.round(data[2] * 0.5), //クネクネの進む方向に対してなん分割するか
    //     Math.round(data[3] * 0.06), //チューブ方向に何分割するか
    //     Math.round(data[4] * 0.03), //なんかクネクネ具合が変わる数値その1
    //     Math.round(data[5] * 0.02) //なんかクネクネ具合が変わる数値その1
    //);
    var mat = new THREE.MeshBasicMaterial({color: 0xffffff, wireframe:true });
    obj = new THREE.Mesh( geo, mat);
    scene.add(obj);
    
    requestAnimationFrame(DrawGraph);
}
timerId=requestAnimationFrame(DrawGraph);


/*
 * startボタンを押したらよばれるやつ
 */
document.querySelector("button#playsound").addEventListener("click", (event) => {
    var label; 
    if(event.target.innerHTML=="Stop") {
        src.stop(0);
        cancelAnimationFrame(timerId);
        label="Start";
    } else {
        src = audioctx.createBufferSource();
        src.buffer = buffer;
        src.loop = true;
        src.connect(audioctx.destination);
        src.connect(analyser);
        src.start(0);
        label="Stop";
    }
    event.target.innerHTML=label;
});


/*
 * レンダリングするよ
 */
function rendererRender() {
    renderer.render(scene,camera);
    controls.update(); //OrbitControlsを更新する。
    requestAnimationFrame(rendererRender);
}

rendererRender();

おまけ

下記にいろんな3Dオブジェクトの生成方法をまとめてますので、TorusKnot以外でも遊んでみたい方はぜひ参考にしてみてください:)

qiita.com