Web Audio API 解説
17.パンナーの使い方
パンナーとは
パンナーは音源が聞こえる位置を決めるものです。
音楽制作系のアプリでは「パン」機能に求めるものと言えば(マルチチャンネルは置いておくと)、2ch ステレオで左右のどこに定位させるかという事になりますが、3D ゲーム等のアプリでは音源とリスナーの空間上の座標や向きなどを設定する形になります。
これを実現するために Web Audio API では左右の位置を決めるだけの StereoPanner と 三次元空間内の位置関係を決める Panner という2種類のノードが準備されています。
単に Panner が StereoPanner の上位互換というわけではなく、3D 処理が必要無い所に Panner を使用すると不要なフィルタリング処理が発生するなどの不都合がありますので目的に応じて使い分けてください。
StereoPanner の場合は、単純に左右の位置を決定する pan というパラメータが一つあるだけです。
パラメータ | 型 | 単位 | デフォルト値 | 値の範囲 | 内容 |
---|---|---|---|---|---|
pan | AudioParam | なし | 0 | -1 ~ +1 | 定位。-1 で左、 +1で右 |
もう一方の Panner は、各種ゲーム機等でもサポートされている OpenALと呼ばれるサウンド処理ライブラリに近い形で準備されていて、音源の座標や向き、指向性など非常にパラメータが多くあります。
パンナーの座標系は OpenAL / OpenGL と同じ右手系と呼ばれるもので、右が +X、上が +Y、手前が +Z となります。音がどのように聴こえるかは音源と AudioContext のメンバーである listener の関係によって決まりますが、デフォルト状態でリスナーは (0,0,0) の場所で正立して画面奥を向いています。
なお、この Panner には以前はドップラー効果を発生させるための音源の速度のパラメータがあったのですが、仕様上の問題から現在は削除されています。
パラメータ | 型 | 単位 | デフォルト値 | 値の範囲 | 内容 |
---|---|---|---|---|---|
panningModel | 文字列 | - | "equalpower" | "equalpower" "HRTF" | パンニングモデル "equalpower":パワー均等モデル "HRTF":高品質(コンボリューション)モデル |
positionX | AudioParam | - | 0 | -∞ ~ +∞ | 音源の位置X座標 |
positionY | AudioParam | - | 0 | -∞ ~ +∞ | 音源の位置Y座標 |
positionZ | AudioParam | - | 0 | -∞ ~ +∞ | 音源の位置Z座標 |
orientationX | AudioParam | - | 0 | -∞ ~ +∞ | 音源の方向X座標 |
orientationY | AudioParam | - | 0 | -∞ ~ +∞ | 音源の方向Y座標 |
orientationZ | AudioParam | - | 0 | -∞ ~ +∞ | 音源の方向Z座標 |
distanceModel | 文字列 | - | "inverse" | "linear" "inverse" "exponential" | 距離モデル。リスナーまでの距離に対する音量の減衰方法。 "linear":リニア減衰 "inverse":逆数減衰 "exponential":指数減衰 |
refDistance | 数値 | なし | 1 | - | 距離モデルで使用する基準距離 |
maxDistance | 数値 | なし | 10000 | - | 距離モデルで使用する最大距離 |
rolloffFactor | 数値 | なし | 1 | - | 距離モデルで使用する減衰の速さ |
coneInnerAngle | 数値 | 度 | 360 | - | 音源の指向性。指向性コーンの内側の角度 |
coneOuterAngle | 数値 | 度 | 360 | - | 音源の指向性。指向性コーンの外側の角度 |
coneOuterGain | 数値 | なし | 0 | - | 音源の指向性。指向性コーン外側での減衰率。倍率を指定する |
setPosition(x, y, z) | 関数 | - | (0,0,0) | - | 音源の位置の設定 |
setOrientation(x, y, z) | 関数 | - | (1,0,0) | - | 音源の方向の設定 |
パンナーの使いかた
StereoPanner を作成するには new StereoPanner(audiocontext) または AudioContext.createStereoPanner()、Pannerを作成するには new Panner(audiocontext) または AudioContext.createPanner()を使用します。そしてパンナーを通した音源の位置を、StereoPanner の場合は pan、Panner の場合は positionX / Y / Z 等のパラメータを設定するとその方向から聞こえるようになるというわけです。
Panner の場合は AudioParam 型の各パラメータと X / Y / Z をまとめて設定する setPotision() / setOrientation() 関数が準備されていてどちらを使っても構いません。なお、音源はデフォルトでは無指向性なので coneInnerAngle、coneOuterAngle で指向性を設定しないと setOrientation(x,y,z) で設定する音源の向きは意味を持ちません。
パラメータの panningModel は、デフォルト値が "equalpower" となっていますがこれは単に位置関係から左右の音量レベルを決定する、というだけのアルゴリズムなので前後/上下などの表現は基本的にできません。もう一つの "HRTF" は人間の頭部の形状のデータを基に音の伝わり方をシミュレートするもので、前後/上下などの表現も可能なアルゴリズムです。基本的にヘッドホンを使用して聴く事を想定しており、いわゆる「バイノーラル録音」的なものと思えば良いかと思いますが、それなりに CPU の負荷はかかり、また実際にどう聞こえるかには結構個人差があります。
なおリスナー側も位置、向きなどを指定可能で、これは AudioContext のメンバーである listener に対して設定を行います。3D 空間を移動しながら音を鳴らすようなアプリだとこちらの設定も必要ですね。
パンナーの使用例
この例は Panner を通した音をマウス操作で動かせるようにしています。左に表示される図が上から見た xz 平面の音源の位置、隣の縦棒は音源の高さ (y 座標)になります。マウスドラッグで音源位置の指定が可能です。
いわゆる「バイノーラル」的な効果は panningModel を"HRTTF" に切り替えないとかかりません。定位の状態はヘッドフォンで聴く方がわかりやすいと思います。個人的にはこういう立体音響は基本技術としてまだ万人が納得できるような完成度まで至っていない気がします。前後や上下の定位は感じ方の個人差が大きいので思ったように定位しなくてもそういうものだと思ってください。雰囲気はつかめると思います。
テストページ:パンナー
<!DOCTYPE html>
<html>
<body>
<h1>Panner Test</h1>
<button id="play">Play</button> <button id="stop">Stop</button><br/>
<table>
<tr><th>panningModel</th>
<td><select id="panmodel">
<option>equalpower</option><option selected>HRTF</option>
</select></td></tr>
<tr><th>PositionX</th>
<td><input type="range" id="posx" min="-10" max="10" step="0.1" value="0"/></td>
<td span id="posxval">0</td></tr>
<tr><th>PositionY</th>
<td><input type="range" id="posy" min="-10" max="10" step="0.1" value="0"/></td>
<td id="posyval">0</td></tr>
<tr><th>PositionZ</th>
<td><input type="range" id="posz" min="-10" max="10" step="0.1" value="-5"/></td>
<tdn id="poszval">0</td></tr>
</table>
<br/>
<canvas id="cv" width="250" height="200"></canvas><br/>
Drag to set position.
<script>
window.addEventListener("load", async ()=>{
let px, py, pz;
px = py = pz = 0;
const audioctx = new AudioContext();
const buffer = await LoadSample(audioctx,"./loopmono.wav");
const source = new AudioBufferSourceNode(audioctx, {buffer:buffer, loop:true});
const panner = new PannerNode(audioctx, {panningModel:"HRTF"});
source.connect(panner).connect(audioctx.destination);
audioctx.suspend();
source.start();
const cv = document.getElementById("cv");
const canvasctx = cv.getContext("2d");
cv.addEventListener("mousemove", Mouse);
cv.addEventListener("mousedown", Mouse);
SetupModel();
SetupPos();
function Draw() {
canvasctx.fillStyle = "#444";
canvasctx.fillRect(0, 0, 200, 200);
canvasctx.fillRect(210, 0, 20, 200);
canvasctx.fillStyle = "#080";
canvasctx.fillRect(219, 0, 2, 200);
canvasctx.fillStyle = "#f00";
canvasctx.fillRect(0, 99, 200, 3);
canvasctx.fillStyle = "#08f";
canvasctx.fillRect(99, 0, 3, 200);
canvasctx.fillStyle = "#fff";
canvasctx.strokeStyle = "#fff";
canvasctx.beginPath();
canvasctx.arc(100 + px * 10, 100 + pz * 10, 5, 0, 360, false);
canvasctx.arc(220, 100 - py * 10, 5, 0, 360, false);
canvasctx.fill();
}
function Mouse(e) {
let b;
if(!e) e=window.event;
if(typeof(e.buttons) === "undefined")
b = e.which;
else
b = e.buttons;
if(b) {
const rc = e.target.getBoundingClientRect();
const x = (e.clientX - rc.left)|0;
const y = (e.clientY - rc.top)|0;
if(x < 200) {
document.getElementById("posx").value = (x-100) * 0.1;
document.getElementById("posz").value = (y-100) * 0.1;
SetupPos();
}
if(x >= 210) {
document.getElementById("posy").value = (100-y) * 0.1;
SetupPos();
}
}
}
document.getElementById("play").addEventListener("click", ()=>{
audioctx.resume();
});
document.getElementById("stop").addEventListener("click", ()=>{
audioctx.suspend();
});
document.getElementById("panmodel").addEventListener("change", SetupModel);
document.getElementById("posx").addEventListener("input", SetupPos);
document.getElementById("posy").addEventListener("input", SetupPos);
document.getElementById("posz").addEventListener("input", SetupPos);
function SetupModel() {
panner.panningModel
= ["equalpower","HRTF"][document.getElementById("panmodel").selectedIndex];
}
function SetupPos() {
px = panner.positionX.value = parseFloat(document.getElementById("posxval").innerHTML
= document.getElementById("posx").value);
py = panner.positionY.value = parseFloat(document.getElementById("posyval").innerHTML
= document.getElementById("posy").value);
pz = panner.positionZ.value = parseFloat(document.getElementById("poszval").innerHTML
= document.getElementById("posz").value);
Draw();
}
function LoadSample(actx, url) {
return new Promise((resolv)=>{
fetch(url).then((response)=>{
return response.arrayBuffer();
}).then((arraybuf)=>{
return actx.decodeAudioData(arraybuf);
}).then((buf)=>{
resolv(buf);
})
});
}
});
</script>
</body>
</html>