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.csoundogg.cはgistに置いておいた。