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

続き。
いくつか非常に気になるバグがあるのだが、それを直すのはまた今度として、今回はそれよりも気になる超基本機能の欠落をどうにかしよう。

再生中かどうか

SoundTest#playing?を実装する。

// SoundTest#playing?
// 再生中かどうかを返す
static VALUE SoundTest_im_get_playing(VALUE self)
{
    unsigned long status;
    HRESULT hr;

    // 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->GetStatus(st->pDSBuffer8, &status);
    if (FAILED(hr)) rb_raise(eSoundTestError, "get_status error");

    return status & DSBSTATUS_PLAYING ? Qtrue : Qfalse;
}

DirectSonudにはステータスを取得する関数があるのでそれを使って状態を取得する。このステータスではループ再生しているかどうかも返ってきたりするが、ループ再生については音をループしているのかバッファをループしているのかで扱いが変わるのでいまいち使えない。
ついでにストリーミング再生バッファかどうかを返すメソッドとdisposeされたかどうかを返すメソッドも作っておいた。

// SoundTest#streaming?
// ストリーミング再生用バッファかどうかを返す
static VALUE SoundTest_im_get_streaming(VALUE self)
{
    // selfからSoundTest構造体を取り出す
    struct SoundTest *st = (struct SoundTest *)RTYPEDDATA_DATA(self);

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

    return st->streaming_flg ? Qtrue : Qfalse;
}

// SoundTest#disposed?
static VALUE SoundTest_im_get_disposed(VALUE self)
{
    // selfからSoundTest構造体を取り出す
    struct SoundTest *st = (struct SoundTest *)RTYPEDDATA_DATA(self);

    // st->pDSBuffer8がNULLの場合はdispose済み
    return st->pDSBuffer8 ?  Qfalse : Qtrue;
}

ボリュームとパン

音量と左右の位置の設定を実装する。

// SoundTest#set_volume
// ボリュームを設定する
static VALUE SoundTest_im_set_volume(VALUE self, VALUE vvolume)
{
    HRESULT hr;
    double volume = NUM2DBL(vvolume);

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

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

    if (volume > 100) {
        volume = 0;
    } else if (volume < 0) {
        volume= -10000;
    } else {
        volume = -powf(100.0f, log10f(100.0f - volume));
    }

    hr = st->pDSBuffer8->lpVtbl->SetVolume(st->pDSBuffer8, (long)volume);
    if (FAILED(hr)) rb_raise(eSoundTestError, "set_volume error");

    return vvolume;
}

// SoundTest#set_pan
// パンを設定する
static VALUE SoundTest_im_set_pan(VALUE self, VALUE vpan)
{
    HRESULT hr;
    double pan = NUM2DBL(vpan);

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

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

    if (pan > 100) {
        pan = 10000;
    } else if (pan < -100) {
        pan = -10000;
    } else if (pan > 0) {
        pan = +powf(100.0f, log10f(pan));
    } else if (pan < 0) {
        pan = -powf(100.0f, log10f(-pan));
    }

    hr = st->pDSBuffer8->lpVtbl->SetPan(st->pDSBuffer8, (long)pan);
    if (FAILED(hr)) rb_raise(eSoundTestError, "set_pan error");

    return vpan;
}

微妙に処理が入っているが、DirectSoundの音量とパンの設定は1/100デシベル単位の減衰を表現するので、そのまま使うとイマイチ思ったように変化しない。線形に変化して欲しいので変換する計算を入れている。Ayameも同様の計算をしているっていうかAyameからパクった。設定する値は音量は0〜100、パンは-100〜100である。
取得するメソッドは作っていない。これは、変換する処理を逆算するのが面倒だったからで、最後に設定した値が返ってこればそれでいいのでRuby側で実装する。

ステレオとパン

例えばステレオ音源で左のみから音が出るようなデータを作って再生し、パンを右いっぱいに振ると無音になる。DirectSoundのパンは左右のスピーカの減衰で表現するので、真ん中は両方MAX、左に振ると右を減衰し、右に振ると左を減衰する。よってそのような挙動になる。
そもそもパンというのはモノラル音源に対して左右の音量差を作って好みの位置に定位させるテクニックであるからして、音源に位置情報が含まれたステレオ音源に対して使うのが間違っているんじゃないか。
などと思ってみたり。

フェード

playing?とvolume、panの各メソッドが揃ったので、これを使ってフェード処理ができるようになった。作ってみよう。ちょっと大きくなるがMySound全体を載せる。大きくなってきたのでこれをmysound.rbとしてわけて保存する。

require 'stringio'
require_relative 'soundtest'
require_relative 'wav'

class MySound < SoundTest
  attr_reader :volume, :pan
  FADING_LIST = {}
  PANNING_LIST = {}

  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, klass = nil)
    if klass
      @decoder = klass.new(io)
    else
      begin
        @decoder = SoundWav.new(io)
      rescue
        begin
          io.seek(0)
          @decoder = SoundOgg.new(io)
        rescue
          raise SoundTestError, 'format error'
        end
      end
    end

    @loop = false # play時のループ指定
    @end_count = 2
    if @decoder.size && @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
    @volume = 100
    @pan = 0
  end

  def play(lp=false)
    @loop = lp
    super
  end

  def volume=(v)
    @volume = v
    super
  end

  def pan=(v)
    @pan = v
    super
  end

  FadingStatus = Struct.new(:from, :to, :time_from, :sec)
  def fade(volume, time)
    FADING_LIST[self] = FadingStatus.new(self.volume, volume, Time.now, time)
  end

  PanningStatus = Struct.new(:from, :to, :time_from, :sec)
  def panning(pan, time)
    PANNING_LIST[self] = PanningStatus.new(self.pan, pan, Time.now, time)
  end

  Thread.new do
    loop do
      FADING_LIST.delete_if do |k, v|
        if !k.playing?
          true
        elsif Time.now > v.time_from + v.sec
          k.volume = v.to
          true
        else
          k.volume = (v.to - v.from) * ((Time.now - v.time_from) / v.sec) + v.from
          false
        end
      end
      PANNING_LIST.delete_if do |k, v|
        if !k.playing?
          true
        elsif Time.now > v.time_from + v.sec
          k.pan = v.to
          true
        else
          k.pan = (v.to - v.from) * ((Time.now - v.time_from) / v.sec) + v.from
          false
        end
      end
      sleep(0.02)
    end
  end
end

MySoundクラスにフェード中のオブジェクトとパン移動中のオブジェクトを保持し、無造作に作ったスレッド内で実時間を使って処理する。音はアナログなので厳密に1フレーム1回という処理である必要は特に無く、適当に20msスリープするようにしている。20msできちんと起きるわけではないのでたぶんかなりアバウトな処理になると思われる。でも動かすと普通に変化して聞こえるので問題はなさそうだ。
使う側はこんな感じになる。

require 'dxruby'
require_relative 'mysound'

rs1 = MySound.load("test.ogg")
rs1.play

Window.loop do
  rs1.panning(-100, 1) if Input.key_push?(K_LEFT)
  rs1.panning(100, 1) if Input.key_push?(K_RIGHT)
  rs1.panning(0, 1) if Input.key_push?(K_SPACE)
  rs1.fade(0, 1) if Input.key_push?(K_DOWN)
  rs1.fade(100, 1) if Input.key_push?(K_UP)

  Window.draw_font(0, 0, "playing? : " + rs1.playing?.to_s, Font.default)
  Window.draw_font(0, 24, "pan : " + rs1.pan.to_i.to_s, Font.default)
  Window.draw_font(0, 48, "volume : " + rs1.volume.to_i.to_s, Font.default)
  Window.draw_line(0, 100, 639, 100, C_WHITE)

  Window.draw_font(320.0 * rs1.pan / 100 + 320 - 12, 100, '', Font.default, color:C_GREEN)
end

カーソルキーとスペースで移動できる。

おしまい

とりあえずフェードとパン移動ができた。よく考えたらplayメソッドでフェードインの指定とか、stopでフェードアウトの指定ができたほうがいい気がするが、そういうのを実装するのは簡単な話だろう。
今回のソースもgistに追加しておいた。