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

なんとなく続き。

SoundTestをクラス化する

全体のコードはgistに置いておいた。今回はDirectSoundについては特に何もしてなくて、主にRubyに関する部分の修正となる。
これを全部説明するのは大変なので、ポイントに絞って順番に行ってみよう。

クラス生成

とりあえずクラスを作るところから。

// RubyのSoundTestクラス
static VALUE cSoundTest;

としてグローバル変数Rubyのクラス変数を入れるようにする。入れるだけでGC関連は何もしていないが、Cで作ったクラスはGCに回収されないようになっているので無問題。これは前回までのモジュールや例外についても同様である。
では次。

// 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, 2);
    rb_define_method(cSoundTest, "dispose", SoundTest_im_dispose, 0);

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

クラス生成はrb_define_classで行う。どっかのモジュールやクラスの下に定義する場合はrb_define_class_underを使う。initializeはプライベートメソッドとして定義する。他、前回より増えているのはstopとdisposeだが、それぞれ再生停止とバッファの解放である。
rb_define_alloc_funcというのはSoundTest.newした時に最初に呼ばれる関数を定義する機能で、SoundTest.allocateのことである。基本的にはこの関数(SoundTest_allocate)でRubyのオブジェクトを生成して中身からっぽの状態にする。この状態でinitialize以外のメソッドが呼ばれた場合、SoundTestの場合はDirectSoundのバッファが無いのでエラーにするべき、となる。ちなみにDXRubyのSpriteは外部リソースを持っていないのでその状態でも動作する。全部初期値になるだけである。
SoundTest.newはallocateを呼んだ後でinitializeを呼び出す。が、Rubyはallocateもinitializeも個別に呼ぶことができてしまうので、そういうことがあってもリソースリークしたりコケたりしないように気をつけておかなければならない。DXRubyはそのへん怪しい。

C構造体をラップする

SoundTestはインスタンスごとにDirectSoundのバッファを持ちたいので、基本的にはinitializeでバッファを生成し、GCに回収されるタイミングで解放する。従って、DirectSoundバッファをRubyオブジェクトに持たせる必要がある。
RubyではC構造体を持ったオブジェクトを作ることができ、これをTypedDataと呼ぶ。TypedDataという区分はRuby内部のデータ型の話であり、例えば配列はT_ARRAY、文字列はT_STRINGという型が用意されていて、C構造体を持つオブジェクトはT_DATAという。T_DATAはさらに古いstruct RDataと新しいstruct RTypedDataに分かれていて、いまどきなら新しいほうを使いましょう、ということで、今回はTypedDataで行ってみる。

// TypedData用の型データ
const rb_data_type_t SoundTest_data_type = {
    "SoundTest",
    {
        NULL, // VALUE値を構造体に持たないのでマーク関数は無い
        SoundTest_free, // 解放関数
        SoundTest_memsize, // サイズ関数
    },
    NULL, NULL
};

TypedDataを作るにはまず、const rb_data_type_t構造体でデータを作る。T_DATAオブジェクトにはこの構造体のアドレスが格納されるので、これを使って型チェックができる。間違ってもローカル変数に作ったりしないように。
1つ目の値はクラスの名前である。例外が出たときなどにこの名前がメッセージに使われることがあるようだ。よくわかってない。Rubyのクラスと関連付けしないTypedDataというのも作れるみたいなので、その時に使うのかもしれない。
2つ目の束になっているものは、前から順にマーク関数、解放関数、サイズ関数である。マーク関数はこのオブジェクトがGCでマークされたときに呼び出される。このオブジェクト配下に他のRubyオブジェクトを持っていた場合はそこからrb_gc_markを呼んでやらないとマーク漏れが発生してコケたりするので気をつける。今回は持ってないので省略。解放関数はGCにより回収されるときに呼ばれる。基本はinitializeでバッファを作って解放関数で解放、である。サイズ関数はObjectSpaceがサイズを調べるときに呼ばれる。無くてもいいが、ちょっとはあったほうがいい。DirectSoundのバッファオブジェクトが具体的に何バイトのメモリを使っているのかは調べる手段が無いので、適当になるのはしょうがない。
あとの2つのNULLは特別なことが無い限りは今のところNULLでよい。
肝心のC構造体を出してなかった。

// RubyのSoundTestオブジェクトが持つC構造体
struct SoundTest {
    LPDIRECTSOUNDBUFFER8 pDSBuffer8;
    size_t bufsize;
};

こんな感じで、バッファとサイズを持つ。ほんとはDirectSoundバッファからサイズを取得できるからサイズを持たなくてもいいはずなのだが、なぜか0を返してくるので自分で持つことにした。
で、これを定義した上でallocate関数をこんな感じにする。

// SoundTest.newするとまずこれが呼ばれ、次にinitializeが呼ばれる
static VALUE SoundTest_allocate(VALUE klass)
{
    VALUE obj;
    struct SoundTest *st;

    // RubyのTypedData型オブジェクトを生成する
    obj = TypedData_Make_Struct(klass, struct SoundTest, &SoundTest_data_type, st);

    // allocate時点ではバッファサイズが不明なのでバッファは作らない
    st->pDSBuffer8 = NULL;

    // 生成したSoundTestオブジェクトを返す
    return obj;
}

TypedDataオブジェクトを生成するにはTypedData_Make_Structマクロを使う。このように使う。
んでinitialize。

// SoundTest#initialize
static VALUE SoundTest_initialize(VALUE self, VALUE vsample)
{
    DSBUFFERDESC desc;
    WAVEFORMATEX pcmwf;
    LPDIRECTSOUNDBUFFER pDSBuffer;
    HRESULT hr;

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

    // SoundTest#__send__ :initializeで呼ばれるとリークするのでチェックしてバッファがあれば解放
    if (st->pDSBuffer8) {
        SoundTest_release(st);
    }

    // フォーマット設定
    pcmwf.wFormatTag = WAVE_FORMAT_PCM;
    pcmwf.nChannels = 1;                                              // モノラル
    pcmwf.nSamplesPerSec = 44100;                                     // 44.1kHz
    pcmwf.wBitsPerSample = 16;                                        // 16bit
    pcmwf.nBlockAlign = pcmwf.nChannels * pcmwf.wBitsPerSample / 8;   // 1sampleで2byte
    pcmwf.nAvgBytesPerSec = pcmwf.nSamplesPerSec * pcmwf.nBlockAlign; // 1secに必要なbyte数
    pcmwf.cbSize = 0;

    // DirectSoundバッファ設定
    desc.dwSize = sizeof(desc);
    desc.dwFlags = DSBCAPS_GLOBALFOCUS | DSBCAPS_GETCURRENTPOSITION2;
    desc.dwBufferBytes = NUM2INT(vsample) * pcmwf.nBlockAlign; // 引数で渡されたサンプル数でバッファを作る
    desc.dwReserved = 0;
    desc.lpwfxFormat = &pcmwf;
    desc.guid3DAlgorithm = DS3DALG_DEFAULT;

    // DirectSoundバッファ生成
    hr = g_pDSound->lpVtbl->CreateSoundBuffer(g_pDSound, &desc, &pDSBuffer, NULL);
    if (FAILED(hr)) rb_raise(eSoundTestError, "create buffer error");
    hr = pDSBuffer->lpVtbl->QueryInterface(pDSBuffer, &IID_IDirectSoundBuffer8, (void**)&st->pDSBuffer8);
    if (FAILED(hr)) rb_raise(eSoundTestError, "query interface error");
    pDSBuffer->lpVtbl->Release(pDSBuffer);

    st->bufsize = desc.dwBufferBytes;
    g_refcount++;

    return self;
}

allocate以外は引数で渡されたオブジェクトを操作することになるので、RTYPEDDATA_DATAマクロを使ってオブジェクトからC構造体を取り出す。
普通に生成したSoundTestに対してhoge.__send__ :initializeでこれが呼ばれると前のバッファがリークしてしまうので一応対策をしてある。
あとは前回Init_soundtestに書いてあったDirectSoundバッファ生成をここに引っ越しただけ。g_refcountなるものが出現しているが、これは後述する。

解放処理

TypedDataの型設定でSoundTest_freeと定義したので、SoundTest_freeが解放処理となる。

// GCで回収されたときに呼ばれる解放関数
static void SoundTest_free(void *st)
{
    // バッファ解放
    SoundTest_release((struct SoundTest *)st);

    // SoundTest解放
    xfree(st);
}

バッファ解放はinitializeやdisposeからも呼ばれるから別の関数に切り出していて、ここではその呼び出しと、C構造体そのものの解放をしている。xfreeというのはRubyのfreeである。TypedData_Make_Structマクロで確保したメモリはxfreeで解放する。

// DirectSoundバッファを開放する内部用関数
static void SoundTest_release(struct SoundTest *st)
{
    if (st->pDSBuffer8) {
        st->pDSBuffer8->lpVtbl->Stop(st->pDSBuffer8);
        st->pDSBuffer8->lpVtbl->Release(st->pDSBuffer8);
        st->pDSBuffer8 = NULL;
        st->bufsize = 0;

        // shutduwn+すべてのSoundTestが解放されたらDirectSound解放
        g_refcount--;
        if (g_refcount == 0) {
            g_pDSound->lpVtbl->Release(g_pDSound);
            CoUninitialize();
        }
    }
}

こっちが解放処理の実体。pDSBuffer8が入っている時だけ処理をするようにしているのは、disposeされた後にGCで解放された場合にコケないようにである。
また、後半になにやら怪しげな処理がいて、これもさっきのg_refcountを使っている。
ではこいつの説明に入ろう。

RubyDirectXを組み合わせるときの注意点

今回のコードではshutdown関数がこのように変わった。

// 終了時に実行されるENDブロックに登録する関数
static void SoundTest_shutdown(VALUE obj)
{
    // shutduwn+すべてのSoundTestが解放されたらDirectSound解放
    g_refcount--;
    if (g_refcount == 0) {
        g_pDSound->lpVtbl->Release(g_pDSound);
        CoUninitialize();
    }
}

DirectXWindows95から脈々とWindowsの中核を担い続けるCOMという仕組みで作られている。COMは使い始めるときにCoInitialize関数を呼び、使い終わったらCoUninitialize関数を呼ぶ決まりになっている。CoInitializeを呼ぶ前や、CoUninitializeを呼んだ後にCOMオブジェクトを操作するとコケる。
普通に考えたらInit_soundtestでCoInitializeしてSoundTest_shutdownでCoUninitializeすればいいような気がするが、実際にはそのような簡単な話ではない。Rubyのrb_set_end_procで登録する関数はENDブロックと同様の扱いであり、これが動いている時点ではRubyオブジェクトはまだ解放されていないのである。なので、ここでDirectSoundオブジェクトを解放してCoUninitializeしてしまうと、GCが動いた後の解放処理でコケる。普通に動くけど終わらせるとエラーが出る、という現象が出た場合、これが原因の可能性がある。
この対策としては2通りあって、SoundTestオブジェクトをすべてどっかに格納しておいて、SoundTest_shutdownでまとめてdisposeしてやるか、SoundTest_shutdown含めてすべての終了処理がすべて終わったタイミングでDirectSoundオブジェクトを解放するか、である。んで、今回は後者の方法でやっている。そのタイミングをはかるのがg_refcount、ということだ。

あとは特に言及すべきなものは無くて、前回同様のロジックをちょっといじったものばっかりなのだが、ひとつだけ。

// 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);

    // 再生。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;
}

SoundTest#playである。Init_soundtest_class内でrb_define_methodするときにplayだけは引数の数が-1だった。引数の数を-1にすると可変長引数になる。RubyCAPIでは、可変長引数はほんとに可変長で、受け取ってから数や中身を調べてエラーにすべきはエラーにする。メソッド側が自分でやらなければならない。
argcが引数の数、argvは引数の配列のポインタ、selfがselfである。引数を調べるサポート関数としてrb_scan_argsがあり、これの第3引数は2文字の数字文字列で1つ目が必須引数の数、2つ目が省略可能引数の数となる。オーバーしたり足りなかったりすると例外を投げてくれる。今回の例では引数が1つあるかないかで、vloopには省略されていたらQnilが格納される。これが意味するところは、rb_scan_argsを使うと楽だが、省略されたのかnilが指定されたのかがわからない、ということであり、argcもチェックして区別する必要が本来はある。

クラス化したSoundTestを使う

クラス化したのでこれを継承してごにょごにょしてやれば波形を生成して再生するクラスが作れるし、2つ同時に別の音を鳴らすこともできる。

require 'dxruby'
require_relative 'soundtest'

class RectSound < SoundTest
  attr_reader :i, :pos

  def initialize(hz)
    super(44100) # バッファ1秒ぶん
    @i = @pos = 0
    @prc = Proc.new do
      @i += 1
      if @i % (44100 / hz) > (44100 / hz) / 2
        Math.sin(2 * Math::PI * @i / 44100 / 1.5)
      else
        -Math.sin(2 * Math::PI * @i / 44100 / 1.5)
      end
    end
    self.writebuf(0, self.size, &@prc) # とりあえずバッファを埋める
  end

  # 空いたバッファを埋める
  def update
    playpos, writepos = self.getpos
    self.writebuf(@pos, @pos < playpos ? playpos - @pos : self.size - @pos + playpos, &@prc)
    @pos = playpos
  end
end

rs1 = RectSound.new(440)
rs2 = RectSound.new(660)

rs1.play(true) # ループ再生する場合はtrue
rs2.play(true)

Window.loop do
  rs1.update
  rs2.update

  Window.draw_font(0, 0, rs1.i.to_s, Font.default)
  Window.draw_line(0, 100, 639, 100, C_WHITE)

  playpos, writepos = rs1.getpos
  Window.draw_font(668.0 * playpos / rs1.size - 12, 100, '', Font.default, color:C_GREEN)
  Window.draw_font(668.0 * writepos / rs1.size - 12, 100, '', Font.default, color:C_BLUE)
  Window.draw_font(668.0 * rs1.pos / rs1.size - 12, 100, '', Font.default, color:C_RED)
end

おしまい

以上。このシリーズではRubyの拡張ライブラリでDirectSoundを使うというネタを扱っている。Rubyの拡張ライブラリの作り方とDirectSoundの使い方、どっちか一方の需要は予想できるのだが、それを両方同時にやるところに魅力を感じる人は果たして存在するのかどうかが怪しい。まあ、とりあえず俺は楽しいのでよしとする。どっちにとっても実際に動作するコードなのでどっちかだけでも参考になるかもしれない。
DirectSoundについてもRuby拡張ライブラリについても、もっと色々作っていくともっと色々なネタが出てくるので、まだしばらくはこのネタで遊べそうである。