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

続き。
現状ではストリーミング再生するときにRuby側でスレッドを作っているが、これは固定処理なのでC側に移してみよう。

その前にちょっとクラス構成を

固定処理と言ってもバッファをArrayで返すかStringで返すかで呼ぶメソッドがwritebufになったりwritebuf_strになったりしているので現実として固定されていない。これを何とかしないと固定処理にならない。
どのように対策するかは迷うところだが、今回はSoundTestStrというクラスを追加してwritebufをオーバーライドする。SoundTestStr#writebufは以前のSoundTest#writebuf_strの動作をする。SoundTest#writebuf_strは削除となる。

// Rubyのクラス定義
static void Init_soundtest_class(void)
{
    // 例外定義
    eSoundTestError = rb_define_class( "SoundTestError", rb_eRuntimeError );

    // SoundTestクラス生成
    cSoundTest = rb_define_class("SoundTest", rb_cObject);
    rb_define_private_method(cSoundTest, "initialize", SoundTest_initialize, -1);
    rb_define_method(cSoundTest, "play", SoundTest_im_play, -1);
    rb_define_method(cSoundTest, "stop", SoundTest_im_stop, 0);
    rb_define_method(cSoundTest, "getpos", SoundTest_im_getpos, 0);
    rb_define_method(cSoundTest, "size", SoundTest_im_size, 0);
    rb_define_method(cSoundTest, "writebuf", SoundTest_im_writebuf, 0);
    rb_define_method(cSoundTest, "dispose", SoundTest_im_dispose, 0);
    rb_define_method(cSoundTest, "wait", SoundTest_im_wait, 0);

    // SoundTestオブジェクトを生成した時にinitializeの前に呼ばれるメモリ割り当て関数登録
    rb_define_alloc_func(cSoundTest, SoundTest_allocate);

    // SoundTestStrクラス生成(SoundTestを継承してwritebufをオーバーライド)
    cSoundTestStr = rb_define_class("SoundTestStr", cSoundTest);
    rb_define_method(cSoundTestStr, "writebuf", SoundTest_im_writebuf_str, 0);
}

これでよい。rb_define_class関数の第2引数のスーパークラスを指定することで継承することができる。変更部分だけ定義して後は何もしなくてもよい、というのはRubyで書いた場合と同じである。

Procを受け取る

現状writebufはRubyのブロックを受け取ってそれを呼び出すことでバッファを編集しているが、スレッド生成をCで書くということはCからwritebufを呼び出すことになるので、何を渡すかが固定されていないとダメである。といってもバッファ生成ブロックは固定すべきではないので、あらかじめProcオブジェクトとしてRubyから受け取っておいて、それを渡すことにする。
あらかじめ受け取るためにSoundTest.newの引数を1つ追加して、ブロックで受け取ってもいいけど、どのみちProcに変換して保持する必要があるのでProcで受け取ることにする。
この変更は意外にあちこち変えなければならない。まずC構造体にVALUE値を追加する。RubyのProcを保存する場所だ。

// RubyのSoundTestオブジェクトが持つC構造体
struct SoundTest {
    LPDIRECTSOUNDBUFFER8 pDSBuffer8;
    HANDLE event[3];
    size_t buffer_size;
    unsigned long hz;
    unsigned long channels;
    unsigned long byte_per_sample;
    unsigned long sample_size;
    unsigned long pos;
    int duplicate_flg;
    VALUE vproc;
};

そしてC構造体にVALUEを持つ場合はGCのマーク処理でそいつをマークしてやる必要がある。TypedDataの構造体にマーク関数を指定して、

// TypedData用の型データ
const rb_data_type_t SoundTest_data_type = {
    "SoundTest",
    {
        SoundTest_mark, // マーク関数
        SoundTest_free, // 解放関数
        SoundTest_memsize, // サイズ関数
    },
    NULL, NULL
};

マーク関数を定義する。

// GCのマークで呼ばれるマーク関数
static void SoundTest_mark(void *s)
{
    struct SoundTest *st = (struct SoundTest *)s;

    // マーク
    rb_gc_mark(st->vproc);
}

これを忘れるとGCでProcが回収されてしまい、呼び出そうとしたときにコケることになる。GC関連でバグるとコケるタイミングや位置から原因を突き止めるのが難しいので気をつける。
あと、allocateやreleaseでvprocにQnilを入れておくとかもあるけど省略。
SoundTest_initializeの中のrb_scan_argsを書き換える。

    rb_scan_args(argc, argv, "23", &vsample, &vproc, &vhz, &vbit_per_sample, &vchannels);
    if (!RTEST(rb_obj_is_proc(vproc))) rb_raise(rb_eTypeError, "wrong argument type %s (expected Proc)", rb_obj_classname(vproc));

省略されては困るので省略不可の第2引数に足しておいた。また、この後のProcをcallする関数はProcかどうかをチェックしてくれないので、ここでチェックしておく。

Proc#callする

今まではwritebuf内でrb_yieldしていたが、今度はSoundTestオブジェクトが保持するProcに対してcallする必要がある。RubyのC用APIにはrb_proc_call関数が用意されているのでそれを使う。

    vbuf = rb_proc_call(st->vproc, rb_ary_new3(1, UINT2NUM((st->pos < playpos ? playpos - st->pos : st->buffer_size - st->pos + playpos) / st->sample_size)));

第1引数にProcオブジェクト、第2引数はProcに渡す引数でRubyの配列に格納して渡す。writebuf_strも同様に直しておく。
Ruby側はSoundTest#initializeの中でsuperしているところを変更し、superの第2引数にProcを渡す。これによりwritebufの引数は必要なくなる。
昨日のSoundGroupの例では以下のようになる。

require 'dxruby'
require_relative 'soundtest'
require_relative 'wav'
require_relative 'soundgroup'

class WavSound < SoundTestStr
  def initialize(filename)
    wav = WavFormat.new(filename)
    super(wav.data_size / wav.channel * 8 / wav.bit_per_sample,
          Proc.new{|size|wav.file.read(size)},
          wav.sample_rate,
          wav.bit_per_sample,
          wav.channel)
    self.writebuf
  end
end

group = SoundGroup.new(WavSound.new('test.wav'), 8)

Window.loop do
  group.play if Input.key_push?(K_Z)
end

しかし、superにProcを渡しているのだから、バッファを埋めるためのwritebufはsuperの中でやってしまえばいい。SoundTest_initializeの最後に以下の行を追加する。

    rb_funcall(self, rb_intern_const("writebuf"), 0);

rb_funcallがRubyのシステム経由でメソッドを呼び出す関数で、レシーバ、id、引数の数、引数...という感じに渡す。これで呼び出せばSoundTestの場合はSoundTest#writebuf、SoundTestStrの場合はSoundTestStr#writebufが呼ばれる。idはRubyの内部で使われる番号で、メソッドの指定に使う。rb_intern_constを使えばCの文字列リテラルをidに変換することができる。
これでself.writebufの記述も必要なくなった。

  def initialize(filename)
    wav = WavFormat.new(filename)
    super(wav.data_size / wav.channel * 8 / wav.bit_per_sample,
          Proc.new{|size|wav.file.read(size)},
          wav.sample_rate,
          wav.bit_per_sample,
          wav.channel)
  end

これでRubyスレッド内の処理を固定化する準備はできた。

ループ再生について

ちょっとループ再生について考えてみよう。現状、SoundTest#playに渡す引数はtrueでループ再生、falseで単発の再生である。ここでいうループ再生というのはバッファをループさせるという意味であり、wavファイル全体を読み込んだ場合はひたすら繰り返して再生する意味になるが、ストリーミング再生をする場合は進み具合によってバッファを書き換えながら再生するという意味になるので、ユーザレベルから見れば意味合いが違う。ストリーミング再生では音をループさせるためにはバッファ処理のほうで対応する必要がある。例えば前々回のogg再生のロジックはバッファはループさせているが音をループさせる仕組みが無い。
この2種類のループはいままで適当にしてきたが、きちんと分けて考えるようにしなければならない。すなわち、音をループさせたいのか、ストリーミング再生したいのか、の設定は別にするべきである。

ストリーミング再生のフラグを追加する

SoundTest#initializeの引数をさらに追加して、ストリーミング再生をするかどうかをtrue/falseで与えるようにする。引数が増えてきてややこしいが、これは基本的に内部用であり、ユーザが直接引数を指定することは無いはずなので大きな問題ではない。

    rb_scan_args(argc, argv, "33", &vsample, &vproc, &vstreaming, &vhz, &vbit_per_sample, &vchannels);
    st->streaming_flg = RTEST(vstreaming);

という感じで保存しておく。

playメソッドでストリーミング再生をサポートする

C拡張ライブラリ内でRubyのスレッドを作って、いままでRubyでやっていたwait呼び出し、writebuf呼び出しをC側でやるようにする。Rubyスレッドを生成するにはrb_thread_createを呼ぶが、これの引数がCの関数なので、まず関数を作る。

// 内部ストリーミング再生用スレッド関数
static VALUE SoundTest_streaming_thread(void *arg)
{
    VALUE self = (VALUE)arg;
    while(!RTEST(rb_funcall(self, rb_intern_const("wait"), 0))) {
        rb_funcall(self, rb_intern_const("writebuf"), 0);
    }
    return Qnil;
}

まあ、Rubyでやっていたのとまったく同じである。次にSoundTest#playを修正する。

// SoundTest#play
// 再生する
static VALUE SoundTest_im_play(int argc, VALUE *argv, VALUE self)
{
    HRESULT hr;
    VALUE vloop;

    // selfからSoundTest構造体を取り出す
    struct SoundTest *st = (struct SoundTest *)RTYPEDDATA_DATA(self);

    // st->pDSBuffer8がNULLの場合はdispose済み
    if (!st->pDSBuffer8) rb_raise(eSoundTestError, "disposed object");

    // 引数取得。vloopは省略可能引数
    rb_scan_args(argc, argv, "01", &vloop);

    ResetEvent(st->event[0]);
    ResetEvent(st->event[1]);
    ResetEvent(st->event[2]);

    // ストリーミング再生する場合はThreadを生成してループ再生する
    if (st->streaming_flg) {
        // ストリーミング再生用スレッドを生成する
        st->vstreaming_thread = rb_thread_create(SoundTest_streaming_thread, (void *)self);

        // ストリーミング再生用スレッドでコケたら全体がコケるように設定する
        rb_funcall(st->vstreaming_thread, rb_intern_const("abort_on_exception="), 1, Qtrue);

        // 再生
        hr = st->pDSBuffer8->lpVtbl->Play(st->pDSBuffer8, 0, 0, DSBPLAY_LOOPING);
    } else {
        // とりあえず停止する
        hr = st->pDSBuffer8->lpVtbl->Stop(st->pDSBuffer8);
        if (FAILED(hr)) rb_raise(eSoundTestError, "stop error");

        // カーソルを先頭にセット
        hr = st->pDSBuffer8->lpVtbl->SetCurrentPosition(st->pDSBuffer8, 0);
        if (FAILED(hr)) rb_raise(eSoundTestError, "setpos error");

        // 再生。vloopがtrueの場合ループ再生する
        hr = st->pDSBuffer8->lpVtbl->Play(st->pDSBuffer8, 0, 0, RTEST(vloop) ? DSBPLAY_LOOPING : 0);
    }

    if (FAILED(hr)) rb_raise(eSoundTestError, "play error");

    return self;
}

ストリーミング再生と通常の再生に分けておく。通常の再生では再生中の再playは停止してから開始しなおすことにする。引数でloopが指定されていたらループ再生となる。ストリーミング再生のほうではrb_thread_createでRubyスレッドを生成して変数に保存しておく。こっちのほうは再生中の再playは何もしていないが、最初から再生するためにはRubyコード側でシーク処理を作っておいてそれを呼ぶ必要があり、そのへんのインターフェイスがまた未定なのでできない。loop指定も無視するが、これもインターフェイスがないのでどうにもできない。
あと、Thread#abort_on_exception=trueを呼んでおいてスレッドでコケた場合に全体がコケるようにしておく。RubyのThreadオブジェクトは例外が出ても何も言わずに終わってしまうので、気づくように。
今回修正したCコードはまたgistに置いておいた。

Ruby側コード

この間のogg用コードを例にすると、このようになる。

class OggStream < SoundTestStr
  def initialize(filename)
    @ogg = SoundOgg.new(filename)

    prc = Proc.new do |size|
      buf = ""
      reqsize = size
      while(buf.size < size) do
        tmp = @ogg.read(reqsize)
        if tmp.size == 0
          break
        end
        reqsize -= tmp.size
        buf.concat(tmp)
      end
      buf
    end

    super(@ogg.rate, prc, true, @ogg.rate, 16, @ogg.channels)
  end
end

シンプルになった。ちなみにこのコードはoggの再生が最後までいくとコケるので例外が発生して終わる。

おしまい

ストリーミング再生するならやっぱりシーク処理は必要なので、そのへんをどのように実装するかを考えなければならないだろう。基本的には再生する対象によって処理は変わるはずなので現状ではRubyで書くことになる。play時に先頭に戻すなどはRuby側でplayメソッドが復活して、そこでごにょごにょ、という感じだ。さっき消したとこなんだけども。あと、データの終了を検出して先頭や途中に戻すような処理も必要だ。これもまた再生する対象ごとに違うので、initializeに渡すProcの中でやることになるだろう。
最終的にはopenやread、seek、closeなどを持つインターフェイスを定義して、C側から必要な時に呼び出すといった感じにすれば汎用的なものがきっちりできるし、wavやoggなどは固定処理としてCで書いてしまえばGVLを取得せずに完全に別スレッドで動作させることもできそうだ。どう作ればいいのかはよくわからないので今後の課題。