JavaScriptでリアルタイムに音を出すときに簡単便利なやつつくった


先日作成した「関西電気保安協会リズムマシーン」と「ONE-LINER-ORCHESTRA」が

で使えるようになりました。


どちらも pico-player.js っていう拙作ライブラリを使っているんだけど、普通に便利なので紹介します。
https://gist.github.com/1342081
CoffeeScriptで書いてコンパイルしています。もうJavaScript書けなくなってきました。

2011/11/08追記
別タブ選択時に音が途切れる減少を解消しました。
関連記事: http://d.hatena.ne.jp/mohayonao/20111108/1320756534

2011/11/07 追記
CoffeeScriptで書いた分は無駄が多いので、JavaScriptで書き直したバージョン更新しました。


具体的にどんなことをしているのかは以前に書いた記事が参考になるかも知れません。
JavaScriptでリアルタイムに音を鳴らす方法を3つほど - つまみ食う

使い方

これだけ、非常に簡単。

// ブラウザに応じたプレイヤーオブジェクトを生成
var player = pico.getplayer(options);

// 引数はオブジェクトで渡す。以下はデフォルト値。
options = {
  samplerate: 44100, // サンプリングレート
  channel: 1,        // チャンネル数
  duration: 40,      // ストリームの分割時間(ミリ秒)
  slice: 8           // ストリームセルの分割数
};
// ジェネレータ(後述)を引数に play
player.play(generator);
// 再生中?
player.isPlaying();
// 音を止める
player.stop();
// プレイヤーの種類 ["WebKitPlayer", "MozPlayer", "HTML5AudioPlayer"]
console.log( player.gettype() );


playerは以下の不変プロパティを持つ

player.SAMPLERATE         // サンプリングレート
player.CHANNEL            // チャンネル数
player.STREAM_FULL_SIZE   // ストリームのサイズ
player.STREAM_CELL_SIZE   // ストリームセルのサイズ
player.STREAM_CELL_COUNT  // ストリームセルの分割数
player.PLAY_INTERVAL      // ストリームの分割時間(ミリ秒)

この中で気にしないといけないのは、サンプリングレートとストリームのサイズくらいであとはおまけ。
あと、オブジェクト生成時の引数と実際に生成されたオブジェクトのプロパティは異なるので注意が必要。たとえば、Chrome(Web Audio API)はサンプリングレート固定なので、起動時の引数は無視されるし、Opera(HTMLAudioElementを大量生成)は他の2つに比べて無理やり処理している関係でストリームの分割時間が長め(400ミリ秒以上)に設定される。このへんは十分に検証できないので調整必要かも。


ジェネレータ

  • ジェネレータは実際に音のシグナルを出力するためのオブジェクト
  • player.play(generator) を実行すると、generator.next が定期的に呼ばれる
  • generator.next は player.STREAM_FULL_SIZE の大きさの Float32Array (-1.0 <= signal <= +1.0)を返す
  • チャンネル数が 2 のときは [L, R, L, R, .. ] という配列を返せばよい


以下はウェーブテーブル方式のジェネレータのサンプル。
あらかじめ音の波形の配列を用意しておいて、配列の要素を飛ばす大きさで音の高さをコントロールしてる。

var TABLE_SIZE = 1024;
var sinetable = new Float32Array(TABLE_SIZE);
for (var i = 0; i < TABLE_SIZE; i++) {
    sinetable[i] = Math.sin(2 * Math.PI * (i / TABLE_SIZE));
}

var ToneGenerator = function(player, wavelet, frequency) {
    this.wavelet = wavelet;
    this.frequency = frequency;
    this.phase = 0;
    this.phaseStep = TABLE_SIZE * frequency / player.SAMPLERATE;
    this.stream_full_size = player.STREAM_FULL_SIZE;
};
ToneGenerator.prototype.next = function() {
    var wavlet = this.wavelet;
    var phase = this.phase;
    var phaseStep = this.phaseStep;
    var table_size = TABLE_SIZE;
   
    var stream = new Float32Array(this.stream_full_size);
    for (var i = 0, imax = this.stream_full_size; i < imax; i++) {
        stream[i] = wavelet[(phase|0) % table_size];
        phase += phaseStep;
    }
    this.phase = phase;
    return stream;
};


// 440Hzのサイン波を再生して 2秒後に止まる
var player = pico.getplayer();
var gen = new ToneGenerator(player, sinetable, 440);

player.play(gen);
setTimeout(function() { player.stop(); }, 2000);


ONE-LINER-ORCHESTRAでは上記のジェネレータみたいなやつを複数管理して加算合成して返すジェネレータを player に与えて複数の音を出している。大量の音を合成している例


上記のサンプルはここで確認できる。
pico-player.js / pico-player.coffee

ちょっと便利

オプション引数に slice を与えると STREAM_CELL_SIZE というのが計算される。
STREAM_CELL_SIZE は STREAM_FULL_SIZE/slice で計算される値で、player 自体の動作には何の影響もないんだけど、あると便利なので入れている。


たとえばモノフォニックなアルペジエータを作っているとき、音程の切り替わるサンプル数は

sample = samplecount = (60/BPM) * SAMPLERATE * (4/LENGTH)

で計算できるので、samplecount-- していって 0 になったら、音程を切り替えれば良いんだけど、1サンプルごとに if で条件判定するのは非効率で、ある程度大雑把(耳では判別できない)に処理したほうが計算量が減って良い。音量とか低位とかも同じ。その大雑把な数字に使えるのが STREAM_CELL_SIZE。

var samplerate = player.SAMPLERATE;
var stream_cell_size = player.STREAM_CELL_SIZE;

var stream = new Float32Array(player.STREAM_FULL_SIZE);
var k = 0;
for (var i = 0, imax = player.STREAM_CELL_COUNT; i < imax; i++) {
    samplecount -= STREAM_CELL_SIZE;
    if (samplecount <= 0) {
       // 音程を切り替えるための処理
       // phaseStep = TABLE_SIZE * new_frequency / samplerate; // みたいなの
       samplecount += sample;
    }
    for (var j = 0; j < stream_cell_size; j++) {
        stream[k++] = wavelet[(phase|0)&TABLE_SIZE];
        phase += phaseStep;
    }
}
return stream;

ブラウザ戦争の結果、JavaScript がいくら高速になったといってもDSP処理は結構重いので計算を省略するのが重要で、最初の例のサイン波も再生時には Math.sin みたいな複雑な計算はリアルタイムに行わないような工夫をしたり、STREAM_CELL_SIZEを使った例みたいに処理単位を減らしたり地道な努力が必要っぽい。

メモ

Operaでは Float32Array が使えないっぽい (pico-player.js内で偽Float32Arrayをでっち上げている)
Operaと同じやり方はSafariでは固まる。ナゾ。
iOS, Androidもダメ。
・上記の例で phaseStep は毎回計算しているけど、 MIDIノート番号の細かい版みたいなのを用意すると効率よい。

# 半音をresolution個に分割した音番号->phseStepのテーブルをつくる
def calc_steps(size=8192, resolution=32):
    center = size >> 1
    def calcStep(i):
        freq = 440.0 * ((2.0**(1.0/(12*resolution)))**(i-center))
        return TABLE_LENGTH * freq / SAMPLERATE
    return tuple(calcStep(i) for i in xrange(size))