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

続き。

前回のバグについて

色々やってたらいくつかバグを見つけた。
まず、8bitのwavファイルのデータは0〜255の符号無しで波形を表現するのでRuby側のunpack文字列は小文字のcじゃなくて大文字のCになる。C側の計算も間違っている。
それから、16bitのwavファイルのデータは符号有りなのでRuby側のunpack文字列は小文字のvじゃなくて小文字のsである。
新年早々バグだらけで始まったこのブログは先行き不安と言えよう。

データをまとめて渡す

前回、wavファイルのストリーミング再生がうまくいかなかったが、それの対策としてC側で必要なデータの数をRubyのブロックに渡して、まとめて生成して配列で返すようにしてみよう。
まとめて渡すためにはC側が開始位置を保持して、再生カーソルを見つつ必要なバッファサイズを計算する必要がある。なのでSoundTest#writebufは引数がいらなくなる。

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;
};

SoundTest構造体にposを追加した。何気に型を符号無しに変えて、bit_per_sampleは計算が楽なようにbyte_per_sampleに変えてある。
writebufは以下のようになった。

// SoundTest#writebuf
// 必要なバッファサイズをRubyに渡し、ブロックを呼びだして返ってきたデータ(Array)をバッファ書き込む
static VALUE SoundTest_im_writebuf(VALUE self)
{
    void *block1;
    void *block2;
    unsigned long blockSize1;
    unsigned long blockSize2;
    unsigned long i, j;
    HRESULT hr;
    unsigned long playpos;
    VALUE vbuf;

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

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

    // 再生カーソルを取得する
    hr = st->pDSBuffer8->lpVtbl->GetCurrentPosition(st->pDSBuffer8, &playpos, 0);
    if (FAILED(hr)) rb_raise(eSoundTestError, "get current position error");

    // Rubyブロックに必要なバッファサイズを渡してバッファをもらう
    vbuf = rb_yield(UINT2NUM((st->pos < playpos ? playpos - st->pos : st->buffer_size - st->pos + playpos) / st->sample_size));
    Check_Type(vbuf, T_ARRAY);

    // バッファロック
    hr = st->pDSBuffer8->lpVtbl->Lock(st->pDSBuffer8, st->pos, RARRAY_LEN(vbuf) * st->byte_per_sample, &block1, &blockSize1, &block2, &blockSize2, 0);
    if (FAILED(hr)) rb_raise(eSoundTestError, "lock error");

    // バッファへのデータ書き込み
    if (st->byte_per_sample == 1) {
        // 8bit
        unsigned char *block = (unsigned char *)block1;
        for(i = 0; i < blockSize1; i++) {
            double tmp = NUM2DBL(rb_ary_entry(vbuf, i));
            *(block + i) = (unsigned char)((tmp + 1) / 2 * 255);
        }
        // バッファがループした場合の処理
        if (block2 && blockSize2) {
            block = (unsigned char *)block2;
            for(j = 0; j < blockSize2; j++, i++) {
                double tmp = NUM2DBL(rb_ary_entry(vbuf, i));
                *(block + j) = (unsigned char)((tmp + 1) / 2 * 255);
            }
        }
    } else {
        // 16bit
        short *block = (short *)block1;
        for(i = 0; i < blockSize1 / 2; i++) {
            double tmp = NUM2DBL(rb_ary_entry(vbuf, i));
            *(block + i) = (short)(tmp * 32767);
        }
        // バッファがループした場合の処理
        if (block2 && blockSize2) {
            block = (short *)block2;
            for(j = 0; j < blockSize2 / 2; j++, i++) {
                double tmp = NUM2DBL(rb_ary_entry(vbuf, i));
                *(block + j) = (short)(tmp * 32767);
            }
        }
    }

    // st->posがバッファを越えたらループさせる
    st->pos += blockSize1 + blockSize2;
    if (st->buffer_size <= st->pos) {
        st->pos -= st->buffer_size;
    }

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

    return self;
}

Rubyにサンプル数を渡して配列を受け取る。配列の中身は波形データ1個ずつになるので、ステレオの場合は1サンプルに2個のデータが返ることになる。配列で2個ずつまとめると配列生成の負荷が怖いのでこのようにした。要注意点である。
データの形が特殊じゃなくなるのでチャンネル数による分岐は無くなった。
コード全体はgistに置いておいた。

Ruby側の処理

class RectSound < SoundTest
  attr_reader :pos

  def initialize(l, r)
    super(44100/1, 44100, 16, 2) # 1秒ぶんのバッファ、44100Hz、16bit、2channels
    @pos = 0
    tmp = 2 * Math::PI / 44100 / 1.5
    @prc = Proc.new do |size| # サンプル数を渡してくる
      ary = Array.new(size * 2) # ステレオの場合はサンプル数の倍の配列を返す
      lhz = 44100 / l
      rhz = 44100 / r

      # 配列を埋める
      0.step(size * 2 - 1, 2) do |i|
        @pos += 1
        ary[i    ] = (@pos % lhz > lhz / 2) ? Math.sin(tmp * @pos) : -Math.sin(tmp * @pos)
        ary[i + 1] = (@pos % rhz > rhz / 2) ? Math.sin(tmp * @pos) : -Math.sin(tmp * @pos)
      end
      ary
    end
    self.writebuf(&@prc) # とりあえずバッファを埋める
  end

  def play
    self.stop if @th
    super(true) # ループ再生
    @th = Thread.new do
      loop do
        break if self.wait
        self.writebuf(&@prc)
      end
    end
    @th.priority = 1
  end
end

RectSoundクラスだけ。
この例ではステレオ16bitのデータを生成しているが、最初にまとめて配列を確保してから、あとでその中身を埋めている。なんにせよ、ステレオの場合はサンプル数の倍の配列を返すということである。このへんはやっぱりちょっとわかりにくいので悩むところ。

wavストリーミング再生

wavファイルをストリーミング再生するコードは以下のようになる。

class WavStream < SoundTest
  attr_reader :pos

  def initialize(filename)
    @wav = WavFormat.new(filename)
    super(@wav.sample_rate/10, @wav.sample_rate, @wav.bit_per_sample, @wav.channel)
    @pos = 0

    @prc = Proc.new do |size|
      buf = nil

      # 読み込みサイズがwavファイルを超える場合
      byte_per_sample = @wav.bit_per_sample / 8 * @wav.channel
      if (@pos + size) * byte_per_sample >= @wav.data_size
        buf = @wav.file.read(@wav.data_size - @pos * byte_per_sample) # とりあえず最後まで読む
        @pos = @pos + size - @wav.data_size / byte_per_sample # ループ後の場所算出
        @wav.file.pos = @wav.data_pos # シークして先頭まで戻す
        buf += @wav.file.read(@pos * byte_per_sample) # @posの位置まで読む
      else
        buf = @wav.file.read(size * byte_per_sample)
        @pos += size
      end

      # 読み込んだ文字列を変換して配列を返す
      if @wav.block_size == 1
        buf.unpack('C*').map{|v|v/255.0*2-1} # -1〜1に変換
      else
        buf.unpack('s*').map{|v|v/32768.0}
      end
    end
    self.writebuf(&@prc) # とりあえずバッファを埋める
  end

WavStremクラスだけ。
昨日のやつは激しくバグってたわけだが、今回のはパフォーマンスもよくなってちゃんと再生できるようになった。あくまでもうちのPCの話。シークのところのややこしそうな計算はサンプル数とバイト数の変換と、読み込むバイト数の計算である。
ところでC側のコードにSoundTest#writebuf_strという、配列じゃなくて文字列でバイナリを返すメソッドをひそかに追加してあって、これはDirectSoundのバッファと読み込んだデータが同じフォーマットならいちいち計算しなくてもそのまま渡せばいいじゃない、という理由で、処理が簡単で速くなるようにと作ってみた。wavと同じフォーマットを指定してバッファを生成できるので、wavの再生であればこっちを使ったほうがよいだろう。Rubyで波形を生成するならwritebufのほうを使うのがよい。
writebuf_strを使うコードは以下。こっちは受け取るサイズがバイト単位になるので、@posもバイト単位にして、シーク周りの計算が簡単になった。

  def initialize(filename)
    @wav = WavFormat.new(filename)
    super(@wav.sample_rate/10, @wav.sample_rate, @wav.bit_per_sample, @wav.channel)
    @pos = 0

    @prc = Proc.new do |size|
      # 読み込みサイズがwavファイルを超える場合
      if @pos + size >= @wav.data_size
        buf = @wav.file.read(@wav.data_size - @pos) # とりあえず最後まで読む
        @pos = @pos + size - @wav.data_size # ループ後の場所算出
        @wav.file.pos = @wav.data_pos # シークして先頭まで戻す
        buf += @wav.file.read(@pos) # @posの位置まで読む
      else
        @pos += size
        buf = @wav.file.read(size)
      end
    end
    self.writebuf_str(&@prc) # とりあえずバッファを埋める
  end

おしまい

wavストリーミング再生編はこれで終わりである。すばやい反応は無理っぽいが、Rubyでもストリーミング再生はできるということがわかった。
wavファイルを使う場合、サイズが大きくなってしまうのでBGMのようなものには使わないが、ちょっとした効果音程度にストリーミング再生を使うことは無い。メモリに全部読み込んで再生させればよいからだ。また、ストリーミング再生をしていては読み込みとコピーが増えてしまうから効果音の多重再生は厳しい。ようするに、今まで作ってきたものは使い道が無い(だめじゃん)。
でもここまでできていれば、例えばoggファイルを読んでデコードしてバッファに流し込んでストリーミング再生というのも大きな変更無くできるだろうし、wavファイル全体を読み込んでDuplicateSoundBufferを使って多重再生を軽い負荷で行うのもできそうだ。バッファにDMO(DirectX Media Object)を関連付けてエフェクトを加えるというのも、インターフェイスだけ作ってやれば可能かもしれない。夢がひろがりんぐ。
実際にやるかどうかはまた別の話。