DirectSoundとRubyのプログラミング その11
続き。
シーク機能
現状、ogg再生中にループすることができないのは、シーク機能が無いからである。いや、vorbisfileにはあるのだが、インターフェイスを作っていない。これを作ってみよう。
vorbisfileには大量のシーク関数が用意されていて、サンプル単位、圧縮データのバイト数単位、ページ単位、時間単位という種類があって、それぞれノイズ除去処理付きがある。なぜか展開後のバイト単位が無いので今回はサンプル単位を使ってみよう。
使うのはさほど難しくない。
// SoundOgg#seek static VALUE SoundOgg_im_seek(VALUE self, VALUE vpos) { struct SoundOgg *so = (struct SoundOgg *)RTYPEDDATA_DATA(self); int result; if (!so->info) rb_raise(eSoundOggError, "disposed object"); result = ov_pcm_seek(&so->ovf, NUM2LL(vpos)); if (result < 0) rb_raise(eSoundOggError, "seek error"); return self; }
こんな感じである。これでvorbisfileにより元データのシーク位置が計算され、callbacksの指定によりRubyのIOのseekが呼ばれることになる。wavのほうはファイルフォーマットもわかっていて単純計算でシークすればよかったが、oggは中身を知らないし、ファイルのどこに移動すればデータのどこに移動できるのかがさっぱりわからないので、vorbisfileにやってもらうのが基本となる。下手にシークするとデータが壊れていると判断されかねない。
ともあれ、これでogg再生でもループできるようになった。サンプル単位でシークできるので、ループ時の戻り位置を指定することもできそうだ。
他、足りない機能を追加しておく
SoundOggはかなり手抜きなので、サンプルあたりのバイト数を返すbyte_per_sample、総サンプル数を取得するsize、データの終了を表すeof?、使うかどうかわからないがcloseを追加しておく。
// SoundOgg#close static VALUE SoundOgg_im_close(VALUE self) { struct SoundOgg *so = (struct SoundOgg *)RTYPEDDATA_DATA(self); if (!so->info) rb_raise(eSoundOggError, "disposed object"); ov_clear(&so->ovf); so->info = NULL; return Qnil; } // SoundOgg#size static VALUE SoundOgg_im_size(VALUE self) { struct SoundOgg *so = (struct SoundOgg *)RTYPEDDATA_DATA(self); ogg_int64_t result; if (!so->info) rb_raise(eSoundOggError, "disposed object"); result = ov_pcm_total(&so->ovf, -1); if (result < 0) rb_raise(eSoundOggError, "size error"); return LL2NUM(result); } // SoundOgg#byte_per_sample static VALUE SoundOgg_im_byte_per_sample(VALUE self) { struct SoundOgg *so = (struct SoundOgg *)RTYPEDDATA_DATA(self); if (!so->info) rb_raise(eSoundOggError, "disposed object"); return INT2NUM(2); } // SoundOgg#eof? static VALUE SoundOgg_im_eof(VALUE self) { struct SoundOgg *so = (struct SoundOgg *)RTYPEDDATA_DATA(self); if (!so->info) rb_raise(eSoundOggError, "disposed object"); return ov_pcm_total(&so->ovf, -1) == ov_pcm_tell(&so->ovf) ? Qtrue : Qfalse; }
wavのほうも手直し
SoundOggに合わせてwavのほうもSoundWavに名前を変えて、あと、アクセサの名前もSoundOggと互換にして、足りないメソッドを追加する。全体はこうなる。
class RiffFormatError < StandardError; end class RiffFormat attr_accessor :file, :id, :size def initialize(io) @file = io @id = @file.read(4) rb_raise(RiffFormatError) if @id != 'RIFF' @size = @file.read(4).unpack('V')[0] end end class SoundWavError < StandardError; end class SoundWav < RiffFormat attr_accessor :channels, :rate, :byte_per_sample, :size def initialize(io) # 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 @channels = @file.read(2).unpack('v')[0] @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_pos = @file.pos @byte_per_sample = @bit_per_sample / 8 @size = size / @byte_per_sample / @channels @data_size = size end def read(size) buf = "" if @file.pos + size > @data_pos + @data_size buf = @file.read(@data_pos + @data_size - @file.pos) else buf = @file.read(size) end buf = "" unless buf buf end def seek(pos) @file.pos = @data_pos + pos nil end def eof? @file.pos - @data_pos == @data_size end def close @file.close nil end end
SoundOggをもうちょっといじっておく
SoundWavのreadは音データの最後まで呼んだら足りなくてもそこまでを返すし、最後まで行かなかったら一発でまとめて返す。SoundOggのreadはそうなっていないので、読み込みのループ処理をC側に移して動作の互換を取っておく。とはいえやっぱりデコード処理の単位のせいでちょっと足りなくはなる。これをきっちり返すようにするには多めにデータを要求して余った分は残しておいて次で返すような処理が必要になるのでちょっとそこまではやってられない。
// SoundOgg#read static VALUE SoundOgg_im_read(VALUE self, VALUE vsize) { struct SoundOgg *so = (struct SoundOgg *)RTYPEDDATA_DATA(self); int result; int readsize = 0; int reqsize = NUM2INT(vsize); char *buf = ALLOCA_N(char, reqsize); ogg_int64_t restsize; if (!so->info) rb_raise(eSoundOggError, "disposed object"); restsize = (ov_pcm_total(&so->ovf, -1) - ov_pcm_tell(&so->ovf)) * so->info->channels * 2; if (restsize < reqsize) { reqsize = restsize; } do { result = ov_read(&so->ovf, buf + readsize, reqsize > 4096 ? 4096 : reqsize, 0, 2, 1, NULL); readsize += result; reqsize -= result; } while(result != 0 && reqsize > 0); if (result < 0) rb_raise(eSoundOggError, "read error"); return rb_str_new(buf, readsize); }
SoundOggとSoundWavの統合
さて、ここからが本題。SoundOggとSoundWavのインターフェイスに互換が取れたので、こいつらはどっちを渡しても透過的に処理ができるはずだ。クラスは全く別だし実装もぜんぜん違うけどRubyはダックタイピングする言語である。
これらを使って統合するコードの始めのほうはこんな感じ。
require 'dxruby' require 'stringio' require_relative 'soundtest' require_relative 'wav' class MySound < SoundTestStr def self.load(filename) MySound.new(open(filename, 'rb')) end def self.load_from_memory(str) MySound.new(StringIO.new(str)) end def initialize(io) begin @decoder = SoundWav.new(io) rescue begin io.seek(0) @decoder = SoundOgg.new(io) rescue raise SoundTestError, 'format error' end end
SoundTestStrを継承してMySoundクラスを作る。クラスメソッドのloadとload_from_memoryは前と同じだ。その後のinitializeはIOを受け取り、SoundWavの生成を試し、例外が出たらSoundOggを試す。いちいち例外でチェックせずに戻り値で見るようにしたほうが実行は速いのだが、まあ、気になるほど時間がかかるわけでもないと思うのでこれでよしとする。直すほうが大変だし。なんにせよ、このようにすればMySoundにはリニアPCMフォーマットのwavでもoggでもどっちを渡しても大丈夫ということになる。
んで続き。
@loop = false # play時のループ指定 @end_count = 2 if @decoder.size < @decoder.rate super(@decoder.size, ->size{@decoder.read(size)}, false, @decoder.rate, @decoder.byte_per_sample, @decoder.channels) else # 大きなデータなら1秒ぶんのバッファを作ってストリーミング再生する prc = ->size{ if @end_count == 1 @end_count = 0 return (@decoder.byte_per_sample == 1 ? 128.chr : 0.chr) * size elsif @end_count == 0 return nil end buf = @decoder.read(size) if @decoder.eof? if @loop @decoder.seek(0) tmp = @decoder.read(size - buf.size) buf += tmp else @end_count = 1 return buf + (@decoder.byte_per_sample == 1 ? 128.chr : 0.chr) * (size - buf.size) end end buf } super(@decoder.rate, prc, true, @decoder.rate, @decoder.byte_per_sample, @decoder.channels) end end def play(lp=false) @loop = lp super end end
@decoderのsizeとrateを比較して、sizeのほうが小さかったら(つまり1秒未満だったら)全体を読み込み、そうじゃなかったら1秒ぶんのバッファを作ってストリーミング再生をする。@loopを見てループ指定されていたらシークで先頭に戻すようにしている。シークもOggとWavで互換インターフェイスにしたのでこれが可能になった。
ストリーミング再生の終了問題
さて、このコードでは@end_countでごにょごにょして0になったらnilを返すようにしているが、実はwritebufのほうにnilが返って来たら停止するコードを追加してある。
// Rubyブロックに必要なバッファサイズを渡してバッファをもらう vbuf = rb_proc_call(st->vproc, rb_ary_new3(1, UINT2NUM(st->pos < playpos ? playpos - st->pos : st->buffer_size - st->pos + playpos))); if (vbuf == Qnil) { SoundTest_im_stop(self); return self; } Check_Type(vbuf, T_STRING);
@end_countが必要になった理由としては、バッファを2分割して空いたほうを埋めるタイミングでデータが最後まで行った場合に、すぐ音を止めるわけにはいかなくて、つまり、そのタイミングではバッファ1周分のデータがまだ残っていて、次の呼び出しでもまだ残っていて、データがなくった次の次の呼び出しで停止する必要があるからである。
また、バッファを無音データで埋めているので、データが終わってもイベント通知があるまでは無音で再生し続ける。ほんとはデータの終わり位置にイベントを埋めてバシっと止めたかったのだが、イベント設定は再生中にはできないので、致し方なくこのような形になった。バッファを1秒じゃなく1/10秒ぐらいにしてやれば再生中表示を画面に出していてもほとんどわからないぐらいにはなるのではないかと思う。
現状の考え方
物理的なデータの抽象化についてはRubyのIOが受け持つ。これをどうにかすれば読み込む対象は何にでもできる。読み込んだあとのデコードはSoundOggとSoundWavが担当する。ここを増やしていけばmp3やflacその他のファイルフォーマットにも対応することができるだろう。DirectSoundを扱う低レベルレイヤはSoundTestで、それらを繋ぐ処理をRubyで書いている。こういうのをC++でガッツリ書いてあるのがAyameで、構成的にはAyameを参考にしている。
ただ、Ayameと違ってRuby用拡張ライブラリとして作っているし、ところどころにRubyのコードが出てくる構造になっているので、拡張や修正は非常にやりやすい。代償としてマルチスレッドで並列動作できない&Rubyで書かれているのでパフォーマンス的には劣る。SoundTestを使って動的バッファ生成をする機能はさすがにAyameには無いのでこの部分は特徴的である。
おしまい
今回のMySoundクラスはOggもWavも同様に扱えるので使い勝手はよくなったのではないかと思う。サイズを自動判別してストリーミング再生するし、小さいなら事前にデコードして再生される。ちょっとだけそれっぽくなってきた。無論、まだ足りない機能や問題のある処理が山ほどあるので実用できるレベルではないのだが。
現状のコードのsoundtest.cとsoundogg.cはgistに置いておいた。