• 1
    Like
  • 0
    Comment

「定位、いいよね」
「いい...」

年の瀬もいよいよ押し詰まり、皆様におかれましてはもっぱら上記のような会話をされていることと存じます。そこで、この記事では音の定位(位置)を扱うPanner系オーディオノードのうち、特にStereoPannerNodeについて使い方や作例を紹介します。導入が雑で申し訳ない。

Panner系オーディオノードの歴史

ちょくちょく仕様変更があるので、大雑把にまとめました。

仕様 オーディオノード
2011.12.15 AudioPannerNode
2012.12.13 AudioPannerNode →(rename)→ PannerNode
2015.12.08 PannerNode、SpatialPannerNode、StereoPannerNode
editor's draft SpatialPannerNode →(merge)→ PannerNode、StereoPannerNode

立体音響(3D音響)用途のPannerNodeは最初期の仕様で定義されました。初期の仕様では音源を空間に設置するようなイメージです。音源のスムーズな移動(Web Audio APIのスケジューリング機能を使った移動)はあまり考えられていませんでした。その後、音源移動のためのオーディオパラメータを持ったSpatialPannerNodeが定義されます。そして、現在策定中の仕様ではSpatialPannerNodeがPannerNodeに置き換わり(統合され)ます。現行のブラウザではChrome54とFirefox50でオーディオパラメータを持った新しいPannerNodeが使用できます。

一方のStereoPannerNodeは後から追加されました。StereoPannerNodeは音を左右にだけ振り分けます。PannerNodeでも同じことができますが、StereoPannerNodeのほうがより簡単かつ少ない計算コストで使うことができます。ただし、Safari10は対応していない ので、使うには注意が必要です。未対応ブラウザ向けのポリフィルライブラリはこの記事の最後で紹介します。

StereoPannerNode

StereoPannerNodeは音を左右(ステレオ)に振り分けるためのオーディオノードです。panで音の定位(位置)を指定できます。StereoPannerNodeはAudioContextのcreateStereoPanner()メソッドで生成します。

var panner = audioContext.createStereoPanner();

pan

音の定位を指定するオーディオパラメータです。パラメータ値の範囲は-1.0~+1.0で、-1.0だと左、+1.0だと右に寄ります。範囲外の値は切り詰められます。

内部的なアルゴリズムは単純で、panの値に応じた左右のゲインを入力に適用します。モノラル入力の場合の簡単なアルゴリズムは次のとおりです。

const clamped = Math.max(-1, Math.min(pan.evaluatedValue, +1));
const gainL = Math.cos((clamped + 1) * Math.PI / 4);
const gainR = Math.sin((clamped + 1) * Math.PI / 4);
const outputL = input * gainL;
const outputR = input * gainR;

StereoPannerNodeの作例

まずはStereoPannerNodeを使わずに適当に和音を演奏するデモです。

https://jsfiddle.net/mohayonao/75vs3Lcr/

loop関数が演奏ループです。ここで和音の長さと構成音をランダムに設定しています。そして、chord関数で和音を演奏します。いくつかのユーティリティ関数はソースコードか雰囲気で読み取ってください。

何となく音が鳴っているという程度であまり面白みがないですね。
これをベースにchord関数を書き換えて、いくつかのパターンのStereoPannerNodeを追加してみましょう。

function loop() {
  var destination = analyser;
  var playbackTime = audioContext.currentTime;
  var duration = tdur(20, sample([
    1, 1, 2, 2, 2, 4, 4, 8,
  ])) * dotn(sample([ 0, 0, 1, 2 ]));

  var notes = Array.from({ length: 4 }, function() {
    return {
      frequency: mtof(sample([
        48,
        60, 64, 65, 69,
        72, 74, 77, 79,
      ])),
      rate: rand(6),
      offset: rand2(0.9),
    };
  });
  notes.duration = duration;

  chord(destination, playbackTime, notes);

  timerId = setTimeout(loop, duration * 1000);
}

function chord(destination, playbackTime, notes) {
  var duration = notes.duration;
  var t0 = playbackTime;
  var t1 = t0 + duration;
  var t2 = t1 + duration * 0.5;
  var audioContext = destination.context;
  var gain = audioContext.createGain();

  notes.forEach(function(note) {
    var oscillator = audioContext.createOscillator();

    oscillator.frequency.value = note.frequency;
    oscillator.start(t0);
    oscillator.stop(t2);
    oscillator.connect(gain);
  });

  gain.gain.setValueAtTime(0.15, t0);
  gain.gain.linearRampToValueAtTime(0.10, t1);
  gain.gain.linearRampToValueAtTime(0.00, t2);
  gain.connect(destination);
}

1. 発音時に音の定位を設定する

和音の構成音ごとに定位を設定しました。

https://jsfiddle.net/mohayonao/75vs3Lcr/1/

どうでしょうか?最初の作例よりは音の広がりを感じられないでしょうか。

この作例のようにパラメータ値が初期値のまま固定で変化がない場合は単純にvalueプロパティに値を設定するのが良いでしょう。応用として、たとえば定位が徐々に右に寄っていくなどの変化がほしい場合はvalueプロパティは使わずに、linearRampToValueAtTime()といったのオートメーションAPIを使用します。

function chord(destination, playbackTime, notes) {
  var duration = notes.duration;
  var t0 = playbackTime;
  var t1 = t0 + duration;
  var t2 = t1 + duration * 0.5;
  var audioContext = destination.context;
  var gain = audioContext.createGain();

  notes.forEach(function(note) {
    var oscillator = audioContext.createOscillator();
    var panner = audioContext.createStereoPanner();

    oscillator.frequency.value = note.frequency;
    oscillator.start(t0);
    oscillator.stop(t2);
    oscillator.connect(panner);

    panner.pan.value = note.offset;
    panner.connect(gain);
  });

  gain.gain.setValueAtTime(0.15, t0);
  gain.gain.linearRampToValueAtTime(0.10, t1);
  gain.gain.linearRampToValueAtTime(0.00, t2);
  gain.connect(destination);
}

2. オートパンニング

音の定位を別のOscillatorNodeで揺らしています。

https://jsfiddle.net/mohayonao/75vs3Lcr/2/

最初の作例よりさらに音の広がりが感じられるかと思います。

オーディオパラメータにはオーディオノードを接続できます。その場合、オーディオノードの出力を自身のパラメータ値に加算します。これによって、周期的な変化などオートメーション用APIだけでは難しいパラメータの動きを表現できます。

この作例では低速なOscillatorNodeの出力を使って定位を左右に揺らしています。このような低速なオシレーターはLFO(Low Frequency Oscillator)と呼ばれ、音量制御(パンニングも音量制御の一種ですが)や音程制御(ビブラート)などでよく使われます。

OscillatorNodeをLFOとして使う場合は、GainNodeを接続して効き具合を調節すると良いでしょう。応用として、たとえば定位の中心をずらしたい場合は、前項のように panner.pan.value で中央の値を設定します。

function chord(destination, playbackTime, notes) {
  var duration = notes.duration;
  var t0 = playbackTime;
  var t1 = t0 + duration;
  var t2 = t1 + duration * 0.5;
  var audioContext = destination.context;
  var gain = audioContext.createGain();

  notes.forEach(function(note) {
    var oscillator = audioContext.createOscillator();
    var panner = audioContext.createStereoPanner();
    var lfo = audioContext.createOscillator();
    var lfoGain = audioContext.createGain();

    oscillator.frequency.value = note.frequency;
    oscillator.start(t0);
    oscillator.stop(t2);
    oscillator.connect(panner);

    panner.connect(gain);

    lfo.frequency.value = note.rate;
    lfo.start(t0);
    lfo.stop(t2);
    lfo.connect(lfoGain);

    lfoGain.gain.setValueAtTime(1, t0);
    lfoGain.gain.linearRampToValueAtTime(0.6, t1);
    lfoGain.connect(panner.pan);
  });

  gain.gain.setValueAtTime(0.15, t0);
  gain.gain.linearRampToValueAtTime(0.10, t1);
  gain.gain.linearRampToValueAtTime(0.00, t2);
  gain.connect(destination);
}

3. ランダムに動かす

LFOを使用して周期的に変化させるのではなく、ランダムに定位を変化させています。

https://jsfiddle.net/mohayonao/75vs3Lcr/3/

単純な音の広がりだけでなく、雰囲気自体が変わったように感じないでしょうか。

この作例では次の箇所でランダムにタイミングと定位を設定しています。

for (var tx = t0; tx < t2; tx += 0.01) {
  var panValue = sample([ -1, +1 ]) * opts.offset + rand2(0.1);
  panner.pan.setTargetAtTime(panValue, tx, 0.0005);
  tx += rand(0.2);
}

setTargetAtTime(value, startTime, timeConstant)は今現在のパラメータ値から指定した値になめらかに遷移させるメソッドです。第3引数のtimeConstantで遷移のカーブを指定します。この値を小さくすることで、遷移のなめらかさが小さくなり定位が飛んでいるように聞こえます。応用としては、乱数だけに頼るのではなく周期的な揺らぎを加えるとさらに良くなったりします。

function chord(destination, playbackTime, notes) {
  var duration = notes.duration;
  var t0 = playbackTime;
  var t1 = t0 + duration;
  var t2 = t1 + duration * 0.5;
  var audioContext = destination.context;
  var gain = audioContext.createGain();

  notes.forEach(function(note) {
    var oscillator = audioContext.createOscillator();
    var panner = audioContext.createStereoPanner();

    oscillator.frequency.value = note.frequency;
    oscillator.start(t0);
    oscillator.stop(t2);
    oscillator.connect(panner);

    for (var tx = t0; tx < t2; tx += 0.01) {
      var panValue = sample([ -1, +1 ]) * note.offset + rand2(0.1);
      panner.pan.setTargetAtTime(panValue, tx, 0.0005);
      tx += rand(0.2);
    }
    panner.connect(gain);
  });

  gain.gain.setValueAtTime(0.15, t0);
  gain.gain.linearRampToValueAtTime(0.10, t1);
  gain.gain.linearRampToValueAtTime(0.00, t2);
  gain.connect(destination);
}

StereoPannerNodeのポリフィル

前述のようにSafari10はStereoPannerNodeに対応していません。しかし、ほぼ同じインターフェースを提供するポリフィルライブラリを導入すれば一応は使えるようになります。

https://github.com/mohayonao/stereo-panner-node

<script src="/path/to/stereo-panner-node.js"></script>
<script>
// audioContext.createStereoPanner() がない場合に使えるようにする
StereoPannerNode.polyfill();
</script>

おわり

というわけでコードばかりになってしまいましたが、StereoPannerNodeの使い方を紹介しました。StereoPannerNodeには音のイメージを変えることなく表現力をグッと引き上げる、ちょっとの工夫でこのうまさ的な使い勝手のよさがあると思います。
次の記事ではポリフィルの中身をみながら自作オーディオノードの作り方を解説したいと思います。

それでは良いウェブオーディオライフを!:santa::santa:

参考

おかしな部分や不明な点があれば気軽にコメントしてください。