DirectSoundとRubyのプログラミング その2

なんとなく続き。気が向いたので書いてみた。

再生カーソルと書き込みカーソル

DirectSoundでポーリングないしイベント受信でバッファを書き換えようとした場合、まずバッファ内でDirectSoundが使おうとしている領域をチェックしなければならない。と言うのは、CPUと同時にサウンドハードウェアがそこにアクセスしているからで、実際リアルタイムに参照しているのだが、1バイト単位で見ているわけではなくて、ある程度の塊単位でバッファをロック→再生ということを繰り返している。従って、CPUのアクセスとサウンドハードウェアのアクセスが衝突しないように、こっちが気をつけてあげる必要がある。
DirectSoundではバッファオブジェクトのGetCurrentPositionを呼ぶことで再生カーソルと書き込みカーソルの位置を知ることができる。そのようなメソッドを作ってみよう。

static VALUE SoundTest_getpos(VALUE obj)
{
    unsigned long playpos = 0;
    unsigned long writepos = 0;

    pDSBuffer8->lpVtbl->GetCurrentPosition(pDSBuffer8, &playpos, &writepos);

    return rb_ary_new3(2, INT2NUM(playpos), INT2NUM(writepos));
}


static void Init_soundtest_module(void)
{
    /* SoundTestモジュール生成 */
    mSoundTest = rb_define_module("SoundTest");
    rb_define_singleton_method(mSoundTest, "play", SoundTest_play, 0);
    rb_define_singleton_method(mSoundTest, "getpos", SoundTest_getpos, 0);
}

SoundTest.getposで返ってくるのは整数の配列で、1つ目の値が再生カーソル、2つ目が書き込みカーソルとなる。表しているのはバッファ内の位置でバイト単位である。

require_relative 'soundtest'
SoundTest.play do |i|
  if i % 100 > 50
    1
  else
    -1
  end
end
p  SoundTest.getpos
sleep 0.1
p  SoundTest.getpos
sleep 0.1
p  SoundTest.getpos
sleep 0.1
p  SoundTest.getpos
sleep 0.1
p  SoundTest.getpos
sleep 0.1
p  SoundTest.getpos
sleep 0.1
p  SoundTest.getpos
sleep 0.1
p  SoundTest.getpos
sleep 0.1
p  SoundTest.getpos
sleep 0.1
p  SoundTest.getpos
sleep 0.1
p  SoundTest.getpos
#=> [0, 0]
    [7056, 9702]
    [15876, 18522]
    [24696, 27342]
    [33516, 36162]
    [41454, 44100]
    [50274, 52920]
    [59976, 62622]
    [68796, 71442]
    [77616, 80262]
    [0, 0]

再生直後は両方とも0から始まるが、再生が進むと再生カーソルよりも書き込みカーソルのほうが常に2500〜3000ほど後ろにある。この差がサウンドハードウェアがロックしている範囲だろう。CPUからバッファを読み書きする際はロックしてやらないといけないのだが、この間の領域はロックできない、という話になる。再生カーソルより前か、書き込みカーソルより後ろをロックすることができる。

ストリーミング再生を考える

再生カーソルと書き込みカーソルというものがあるのはわかった。この2つは書き込み可能な範囲を教えてくれるものだ。ストリーミング再生をするときは、次々〜ってデータを書き込んでいく必要があるので、書き込むときは再生カーソルの場所まで書き込んで、その場所を覚えておいて、次のタイミングでは覚えておいた場所からその時の再生カーソルまで書き込む、ということになる。もし次のタイミングで覚えておいた場所が再生カーソルと書き込みカーソルの間に入っていたとしたら、それはデータの生成が間に合わなくて音が戻って再生されてしまっていることを表す。そういうことが無いようにしなければならない。
ポーリングで処理する場合、チェックする間隔が長いとそういうことが起きることになるので、チェックする間隔の想定時間の最大値よりも大きなバッファを用意しておく必要がある。例えばDXRubyだと60fpsで動かすとしたら最低でも17msぶんのバッファが無いとまずい。処理落ちや生成時間、ロックされる範囲まで考えると3フレーム分ぐらいは欲しいか。とすると生成する音は3フレーム先の音ということになるので、アクションゲームの効果音をこの手で作るのはちょっと厳しい。という話になる。
イベントを受けて別スレッドで、というのもあるが、CRubyの場合GVLにより1スレッドしか同時に動かないので、眠らせたスレッドがすぐに起きて動いてくれるのかというと怪しい。結局、Ruby3.0で並列動作がサポートされる(かどうかはわからないけど)のを待つか、Cなどで書く拡張ライブラリ側でそのへんをサポートするしか今のところ手は無さそうだ。音の世界はシビアである。

Rubyからバッファを書き込む

いや、既にバッファに書くデータをRubyで作るようにはなっているのだが、位置とサイズを指定して書き込めるようにしてみよう、という話。
とりあえずC側をこのように変更する。playは再生するだけにして、バッファサイズを取得するsizeと、書き込みを行うwritebufを追加した。あと、Rubyからバッファを指定するのでエラーを検出して例外を出すようにもしておいた。ちなみにplayはループ再生するようにしてある。stopは作ってないのでプログラムを終了させて止める。

VALUE eSoundTestError;

static VALUE SoundTest_writebuf(VALUE obj, VALUE start, VALUE size)
{
    unsigned short *block1 = NULL;
    unsigned short *block2 = NULL;
    unsigned long blockSize1 = 0;
    unsigned long blockSize2 = 0;
    unsigned long i;
    HRESULT hr;

    /* バッファロック */
    hr = pDSBuffer8->lpVtbl->Lock(pDSBuffer8, NUM2INT(start) * 2, NUM2INT(size) * 2, (void**)&block1, &blockSize1, (void**)&block2, &blockSize2, 0);
    if (FAILED(hr)) rb_raise( eSoundTestError, "lock error" );

    for(i = 0; i < blockSize1 / 2; i++) {
        *(block1 + i) = NUM2DBL(rb_yield(INT2NUM(i))) * 32767;
    }

    if (block2 && blockSize2) {
        for(i = 0; i < blockSize2 / 2; i++) {
            *(block2 + i) = NUM2DBL(rb_yield(INT2NUM(i + blockSize1 / 2))) * 32767;
        }
    }

    /* バッファアンロック */
    hr = pDSBuffer8->lpVtbl->Unlock(pDSBuffer8, block1, blockSize1, block2, 0);
    if (FAILED(hr)) rb_raise( eSoundTestError, "unlock error" );

    return Qnil;
}

static VALUE SoundTest_size(VALUE obj)
{
    return INT2NUM(datasize / 2);
}

static VALUE SoundTest_getpos(VALUE obj)
{
    unsigned long playpos = 0;
    unsigned long writepos = 0;
    HRESULT hr;

    hr = pDSBuffer8->lpVtbl->GetCurrentPosition(pDSBuffer8, &playpos, &writepos);
    if (FAILED(hr)) rb_raise( eSoundTestError, "getpos error" );

    return rb_ary_new3(2, INT2NUM(playpos / 2), INT2NUM(writepos / 2));
}

static VALUE SoundTest_play(VALUE klass)
{
    HRESULT hr;

    hr = pDSBuffer8->lpVtbl->Play(pDSBuffer8, 0, 0, DSBPLAY_LOOPING);
    if (FAILED(hr)) rb_raise( eSoundTestError, "play error" );

    return Qnil;
}

static void Init_soundtest_module(void)
{
    /* 例外定義 */
    eSoundTestError = rb_define_class( "SoundTestError", rb_eRuntimeError );

    /* SoundTestモジュール生成 */
    mSoundTest = rb_define_module("SoundTest");
    rb_define_singleton_method(mSoundTest, "play", SoundTest_play, 0);
    rb_define_singleton_method(mSoundTest, "getpos", SoundTest_getpos, 0);
    rb_define_singleton_method(mSoundTest, "size", SoundTest_size, 0);
    rb_define_singleton_method(mSoundTest, "writebuf", SoundTest_writebuf, 2);
}

バッファサイズや書き込み位置についてはRubyでバイト数を考えるのが面倒なのでサンプル数単位(2byteで1サンプル)にしてある。

require_relative 'soundtest'
p SoundTest.size #=> 44100

肝はwritebufで、DirectSoundのバッファロック関数Lockは開始位置とロックサイズを渡すとバッファの位置とサイズを2セット返してくる。バッファはループしているので、指定サイズが開始位置から最後までに入りきらない場合、残りの部分は最初から書かなければならないからである。開始位置から最後までに入りきった場合、2個目のサイズは0で返ってくる。なので、バッファ書き込み処理は2つに分けて書く必要があり、場合によっては2つ目は動かない、ということもあるわけだ。
今回のSoundTestは以下のように使う。

require_relative 'soundtest'

i = 0
SoundTest.writebuf(0, SoundTest.size) do
  i += 1
  if i % 100 > 50
    1
  else
    -1
  end
end
SoundTest.play
sleep(2)

バッファは1秒分しかないがループ再生されているので2秒sleepしても音が鳴り続ける。
writebuf内のyieldでは位置を返してきてはいるのだが、この位置は単純にバッファ内の開始位置からの相対値なので、この例に限って言えば使っても問題ないが、これを使ってストリーミング再生しようとすると意味の無い値になる。どこまで再生していて、どこのデータを書き込むべきかというのはこの値じゃなく別に保存した値を使う必要があるからだ。

リアルタイム生成でストリーミング再生

さて、getposでカーソル位置を取得して、writebufでバッファを書き込むことができるのだから、これをループさせてバッファを書き込み続ければリアルタイムにバッファを生成することができるはず。
やってみよう。

require 'dxruby'
require_relative 'soundtest'

i = 0
prc = Proc.new do
  i += 1
  if i % 100 > 50
    Math.sin(2 * Math::PI * i / 44100 / 1.5)
  else
    -Math.sin(2 * Math::PI * i / 44100 / 1.5)
  end
end

SoundTest.writebuf(0, SoundTest.size, &prc)
SoundTest.play

pos = 0
Window.loop do
  playpos, writepos = SoundTest.getpos

  SoundTest.writebuf(pos, pos < playpos ? playpos - pos : SoundTest.size - pos + playpos, &prc)
  pos = playpos

  Window.draw_font(0, 0, i.to_s, Font.default)
  Window.draw_line(0, 100, 639, 100, C_WHITE)
  Window.draw_font(668.0 * playpos / SoundTest.size - 12, 100, '', Font.default)
end

バッファ内の再生位置が目に見えるようにDXRubyで矢印を表示している。バッファは1秒分なので右端まで行けば1秒である。単純に同じ音が鳴り続けるとちゃんとバッファが生成できているのかわからないので、適当に音量を変化させている。この変化はバッファ1週の区切りとはズレているので、バッファの繰り返し部分でそれがちゃんと継続されているように聞こえていれば成功である。
この例では始めに1秒分の波形を生成し、その後は常に再生位置までのバッファを生成するようにしている。あるべき形としては、書き込み位置やサイズはC側に隠蔽して、必要なぶんだけRubyのコードを呼び出して生成するような感じじゃないかと思う。単純なブロックやProcじゃなくて、Enumrator渡しにするのがイメージに近い。

ということで今回はここまで。何がどうなったら完成なのかも考えてないのでまた気が向いたら続き書く。