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で書く意味は特に無い。実用的な何かを作ろうとしているわけではなくて、それが可能な気がしたのでやってみた、という話である。