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

あけましておめでとうございます。

続き。これはたぶん、RubyでDirectSoundを使って何をどこまでできるのか実験してるという感じで、続けていった結果として直接的に何かが完成するというわけではなさそうな気がする。

解放について

前々回の記事に通りすがりの人がコメントを付けてくれた件。気になったのでちょっと調べてみた。
普通に考えればDirectSoundBufferは親のDirectSoundオブジェクトを参照しているので、そいつらがDirectSoundオブジェクトのAddRefとReleaseを内部で呼ぶべきなのではないか、それならshutdownでDirectSoundオブジェクトを普通に解放しても大丈夫なのではないか、というふうに思ったわけだ。ところが、やってみたらうまく動かない。MSDNを見てもDirectSoundオブジェクトを解放したらバッファは全部解放されると書いてあって、どうやらそのようなことはしていないらしい。
RubyのSoundTestオブジェクトはDirectSoundオブジェクトの参照を取得しているわけではないから、DirectSoundオブジェクトに対してAddRef/Releaseをするのはなんとなく気持ち悪い。shutdownでReleaseしたことを示すフラグを用意してSoundTestの解放でバッファを解放するかどうかを決めるというのも、まあ、理屈としてはそれでもいいんだけど、どうしたものか、と。
あと、どうやらDirectSoundだけを使う場合はCoInitialize/CoUninitializeはしなくても大丈夫なようだ。これがいらないならDirectSoundオブジェクトのAddRef/Releaseをすればカウントもフラグも必要なくなりそうではある。ただ、将来的にはDMO(DirectX Media Object)を使ったエフェクトも試したくて、これをするためにはCoInitialize/CoUninitializeが必要になるので、残しておいてもよさげである。
ちなみにAddRef/ReleaseはCOMのトップレベルのクラスIUnknownが持つ関数で、リファレンスカウントGCを実現している。なので必要なAddRefをしないと他がReleaseしたときに突然解放されてコケたりするし、必要なReleaseを忘れるとリークする。そういう意味で言えばDirectSoundBufferがDirectSoundオブジェクトにAddRef/Releaseしてないのはおかしな話なのだが、親を解放しただけでバッファがすべて解放できるのであれば、バッファを放置しても大丈夫という利点がある。逆にRubyのような処理順では親や子をいつ解放すべきかで悩むという欠点もある。そういう話だろう。

DirectSoundバッファのタイプ

DirectSoundのバッファは生成するときにWAVEFORMATEX構造体でそのタイプを指定するわけだが、いまは44100Hz、16bit、モノラルで固定にしている。これをRubyから指定できるようにしてみよう。
SoundTest_initializeのフォーマット設定のところを以下のように変える。

    rb_scan_args(argc, argv, "13", &vsample, &vhz, &vbit_per_sample, &vchannels);

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

省略不可の引数はサンプル数、省略可能引数として周波数、サンプル単位のbit数、チャンネル数を指定できるようにした。これらの値とそこから算出した1サンプルあたりのbyte数をSoundTest構造体に

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

入れておいて、

    st->buffer_size = desc.dwBufferBytes;
    st->hz = pcmwf.nSamplesPerSec;
    st->channels = pcmwf.nChannels;
    st->bit_per_sample = pcmwf.wBitsPerSample;
    st->sample_size = pcmwf.nBlockAlign;

後で使う。省略可能にしなくてもよかったかもしれない。
なお、pcmwf.wFormatTagに設定しているWAVE_FORMAT_PCMというのはリニアPCMすなわちベタなPCMデータという意味で、これ以外に多種多様な圧縮形式が存在するらしい。そういうのを指定してバッファに圧縮データを書き込んだら何が起こるのかはよくわからない。展開しながらプライマリバッファにミキシングするのかな?

バッファの書き込み

サンプルあたりのbit数とチャンネル数を変えるとバッファの構造が変わる。bit数は当然Cで言うところのchar型かshort型かが変わるし、チャンネルが2つになるとバッファがL、Rの順番で並ぶようになる。つまり、16bitのステレオになると1サンプルあたり4byteになるわけだ。
バッファ書き込み時のRuby側の処理はサンプル単位に呼び出されるので、ステレオの場合は[L, R]の配列を返してもらうようにしておこう。
SoundTest_writebufのバッファ書き込み処理は以下のようになる。ちょっとベタだけどしょうがない。

    // バッファへのデータ書き込み
    if (st->channels == 1) {
        if (st->bit_per_sample == 8) {
            // モノラル8bit
            char *block = (char *)block1;
            for(i = 0; i < blockSize1; i++) {
                *(block + i) = (char)(NUM2DBL(rb_yield(INT2NUM(i))) * 127);
            }
        } else {
            // モノラル16bit
            short *block = (short *)block1;
            for(i = 0; i < blockSize1 / 2; i++) {
                *(block + i) = (short)(NUM2DBL(rb_yield(INT2NUM(i))) * 32767);
            }
        }
    } else {
        if (st->bit_per_sample == 8) {
            // ステレオ8bit
            char *block = (char *)block1;
            for(i = 0; i < blockSize1; i += 2) {
                VALUE ary = rb_yield(INT2NUM(i));
                Check_Type(ary, T_ARRAY);
                *(block + i    ) = (char)(NUM2DBL(rb_ary_entry(ary, 0)) * 127);
                *(block + i + 1) = (char)(NUM2DBL(rb_ary_entry(ary, 1)) * 127);
            }
        } else {
            // ステレオ16bit
            short *block = (short *)block1;
            for(i = 0; i < blockSize1 / 2; i += 2) {
                VALUE ary = rb_yield(INT2NUM(i));
                Check_Type(ary, T_ARRAY);
                *(block + i    ) = (short)(NUM2DBL(rb_ary_entry(ary, 0)) * 32767);
                *(block + i + 1) = (short)(NUM2DBL(rb_ary_entry(ary, 1)) * 32767);
            }
        }
    }

ループした場合のblock2のほうも同様である。ベタすぎる。配列のアクセスは昔はRARRAY_PTRマクロでやっていたが、Ruby2.1以降ではRARRAY_PTRマクロはパフォーマンス的に使うべきではなく、代わりに定義されたRARRAY_AREFマクロはRuby2.0に定義されていないので、rb_ary_entryを使って取得するのが最も汎用性が高い。
なお、ここまでのコードはまたgistに置いておいた。

Ruby側処理

せっかくなのでステレオ16bitで音を出してみよう。

require 'dxruby'
require_relative 'soundtest'

class RectSound < SoundTest
  attr_reader :i, :pos

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

  def play
    self.stop if @th
    super(true) # ループ再生
    @th = Thread.new do
      loop do
        break if self.wait
        playpos, writepos = self.getpos
        self.writebuf(@pos, @pos < playpos ? playpos - @pos : self.size - @pos + playpos, &@prc)
        @pos = playpos
      end
    end
    @th.priority = 1
  end
end

rs1 = RectSound.new(220, 660)
rs1.play

Window.loop do
  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

低い音が左から、高い音が右から出ていれば成功である。

wavストリーミング再生

せっかくなのでもう少しやってみよう。PCMパラメータが設定できるようになったので、任意のリニアPCMフォーマットのwavファイルを再生することもできるはずだ。
さくっとwavファイルローダーを作る。

class RiffFormatError < StandardError; end

class RiffFormat
  attr_accessor :file, :id, :size

  def initialize(filename)
    @file = open(filename, 'rb')
    @id = @file.read(4)
    rb_raise(RiffFormatError) if @id != 'RIFF'
    @size = @file.read(4).unpack('V')[0]
  end

  def close
    @file.close
  end
end

class WavFormatError < StandardError; end

class WavFormat < RiffFormat
  attr_accessor :form_type, :chunk_id, :chunk_size, :format_id, :channel, :sample_rate
  attr_accessor :byte_per_sec, :block_size, :bit_per_sample, :data_size, :data_pos

  def initialize(filename)
    # RIFFヘッダ読み込み
    super

    # wavヘッダ読み込み
    @form_type = @file.read(4)
    rb_raise(WavFormatError) if @form_type != 'WAVE'
    @chunk_id = @file.read(4)
    rb_raise(WavFormatError) if @chunk_id != 'fmt '
    @chunk_size = @file.read(4).unpack('V')[0]
    @format_id = @file.read(2).unpack('v')[0]
    rb_raise(WavFormatError) if @format_id != 1
    @channel = @file.read(2).unpack('v')[0]
    @sample_rate = @file.read(4).unpack('V')[0]
    @byte_per_sec = @file.read(4).unpack('V')[0]
    @block_size = @file.read(2).unpack('v')[0]
    @bit_per_sample = @file.read(2).unpack('v')[0]
    @file.seek(@chunk_size - 16, IO::SEEK_CUR)

    # dataチャンクのデータの位置まで進める
    tmp = @file.read(4)
    size = @file.read(4).unpack('V')[0]
    while(tmp != 'data') do
      @file.seek(size, IO::SEEK_CUR)
      tmp = @file.read(4)
      size = @file.read(4).unpack('V')[0]
    end
    @data_size = size
    @data_pos = @file.pos
  end
end

WavFormatクラスはファイルを開いてフォーマットチェックをして、各種パラメータを解析し、ファイルをPCMデータの位置まで読み込みを進めてくれる。WavFormat#fileに対してreadすれば続きのデータを読める、ストリーミング再生用のクラスである。
wavファイルのフォーマットはここが参考になる。
次にこれを使うコードを用意する。

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

class WavStream < SoundTest
  attr_reader :i, :pos

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

    @prc = Proc.new do
      @i += 1
      if @i == @wav.data_size
        @i = 0
        @wav.file.pos = @wav.data_pos
      end
      if @wav.bit_per_sample == 8
        if @wav.block_size == 1
          @wav.file.read(1).unpack('c')[0] / 128.0
        else
          @wav.file.read(2).unpack('v')[0] / 32768.0
        end
      else
        if @wav.block_size == 1
          [@wav.file.read(1).unpack('c')[0] / 128.0,
           @wav.file.read(1).unpack('c')[0] / 128.0]
        else
          [@wav.file.read(2).unpack('v')[0] / 32768.0,
           @wav.file.read(2).unpack('v')[0] / 32768.0]
        end
      end
    end
    self.writebuf(0, self.size, &@prc) # とりあえずバッファを埋める
  end

  def play
    self.stop if @th
    super(true) # ループ再生
    @th = Thread.new do
      loop do
        break if self.wait
        playpos, writepos = self.getpos
        self.writebuf(@pos, @pos < playpos ? playpos - @pos : self.size - @pos + playpos, &@prc)
        @pos = playpos
      end
    end
    @th.priority = 1
  end
end

rs1 = WavStream.new('test.wav')
rs1.play

Window.fps = nil
Window.loop do
  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

変わったのはinitializeで、バッファを要求されるたびにファイルを読み込んでunpackして-1.0〜1.0の値に変換して返している。
でもこれはうちではうまく動かない。それっぽい音は出るのでパラメータ指定はできているらしいのだが、バッファの更新が間に合っていない。ファイルを少しずつ読んでunpackしていくのは遅すぎるということらしい。readするたびにブロックしてるせいかなーと思ってreadpartialとかread_nonblockも試してみたが状況は変わらず。後はSoundTestのインターフェイスを大きく変えて、必要なバッファサイズをブロックに渡して配列でまとめて返してもらうようにするぐらいか。あとうちでの実験は8bitモノラルのファイルでしか試していないので他がうまく動くかはわからない。バグってたら教えて欲しい。
ところでwavファイルのストリーミング再生などはどうせ固定処理なのでC側で全部書いてしまうのがよくて、Rubyで書く意味は特に無い。実用的な何かを作ろうとしているわけではなくて、それが可能な気がしたのでやってみた、という話である。