SDL_mixerのラッパを考える その2

前回、SDL_mixerのChannelの問題点を指摘し、その解決法の一部を実装した。残っている問題はChannelに空きが無かった場合の処理である。それを作る前に少し検討しておかないといけないことがある。

効果音の重要度と処理方式

SDL_mixerではMusicとChunkしか区別が無いが、効果音は単純に一律処理すればいいというものではなさそうだ。
例えば、爆発音や移動音その他、鳴ってたら雰囲気出るけど鳴って無くても問題ないというもの(重要度低)もあれば、時間切れ間近を意味するアラームなどのシステム的な意味のあるもの(重要度高)もある。これらを同列に扱うわけにはいかない。
Channelに空きが無い場合の処理として、最も古いものから強制的に中断していく方式と、最新の音をエラーにして再生しない方式があり、SDL_mixerはデフォルトでは後者である。何もしなければ重要度の高い音がどうでもいい音どものために再生されないということが起こりうる。とは言え、古いものを中断するようにすると、RGSSで言うところのBGS(ずっと再生し続けられる環境音)のようなものが中断されてしまう恐れがある。つまり、単純なこの2つの方式はどちらもダメなのだ。

Group系関数群

SDL_mixerにはそのへんをサポートするためのGroupという概念が導入されている。GroupはChannelと同様にint型の整数で表現され、1つのGroupに複数のChannelを関連付けることができる。これにより、Group全体を停止/フェードアウトさせることや、Group内の一番古くから再生されているChannelを検出したり、空いているChannelを調べたり、ということができるようになる。
Groupに所属しているChannelが勝手に使われると困るので、再生の自動Channel検索で使われないように予約することもできる。これは0番から何個予約する、という指定しかできないので、普通に考えればシステム全体でGroupをどのように使うかを最初に設計、設定しておく必要がある。
んで、どのChannelがどのGroupに所属するのかを設定する。再生時のChannel指定で-1を使うと予約されたものは選択されないので、Groupに所属するChannelは直接指定する必要がある。Group系関数にはグループ番号を指定してグループ内での空きChannelを調べるものがあるので、それで返ってきたChannelを使うことになる。

Groupをどう使うか

重要度が高い再生と低い再生を区別し、重要度が低いものをGroupに割り当てる。Group内の空きが無い場合、Group内で最も古い音を強制的に中断し、そのChannelを使用して再生する。重要度が高いものはGroup外で再生するが、予約していないChannelが足りない場合はエラーになる。しかしこれはそもそも重要度が高い音が想定外にたくさん再生される、という異常事態なので、コケておけばいいのではないかと思う。必要な数のChannelを最初に確保しておけばよいだけである。当然超えたらエラーになるべきだ。

作ってみる

Channel全体の数はMix_AllocateChannelsで設定することができるが、Channelの予約は0からの連番でしかできないので、すべてのChannelを何かしらのGroupに入れてしまうという実装をしなければおそらく動的にGroupを作ったり拡張したりということは難しいだろう。今回はオーバースペックなのでそこまではしない。
なので最初にAudio.set_channelsで重要度の高いものと低いものの数を設定して、Group0だけ設定するようにしておく。

module Audio
  def self.set_channels(h, l)
    SDL::Mix.allocate_channels(h + l)
    SDL::Mix.reserve_channels(l)
    SDL::Mix.group_channels(0, l-1, 0)
  end
end

んで、Sound#newの第2引数で重要度の高いものはtrueを渡すようにしておく。デフォルトはfalse。重要度の低いもののほうが再生回数が多いだろうからである。playじゃなくinitializeにこの設定を追加するのは、同じ音で重要度が変化することはたぶん無くて、音によって変わると思われるからだ。

module Audio
  class Sound
    def initialize(filename, high_priority=false)
      @chunk = SDL::Mix.load_wav(filename)
      @high_priority = high_priority
    end
  end
end

sound#playでは重要度によって処理を変える。高いものはGroup外のChannelを使って再生し、低いものはGroup内で探して、足りなければ古いChannelを強制的に終了して再利用する。

module Audio
  class Sound
    def play
      if @high_priority
        ch = SDL::Mix.play_channel(-1, @chunk, 0)
        if ch == -1 # 無かった
          raise SDL2RError, "Not enough channel"
        end
      else
        ch = SDL::Mix.group_available(0) # 空いているChannelを探す
        if ch == -1 # 無かった
          ch = SDL::Mix.group_oldest(0) # 最も古いChannelを探す
          SDL::Mix.halt_channel(ch) # 止める
        end
        SDL::Mix.play_channel(ch, @chunk, 0)
      end
      @@channels[ch].ch = nil if @@channels[ch]
      @@channels[ch] = Channel.new(ch)
    end
  end
end

まあ、こんな感じか。

おしまい

本格的に作るのであればもっと練る必要があるだろうが、とりあえずこの程度に作られていればシンプルなゲームに使うには十分だろう。
前回と合わせて考えてきたこの問題はSDL_mixerを使う上で避けることはできず、おそらく使う人すべてが悩んで自前で解決することになる。この程度は実装しておいて欲しかったというのが正直なところである。
ちなみにRuby/SDLの人が作っているRuby/SDL2もMixer関連はほぼそのままの実装となっているので、っていうか、Ruby/SDLはもともとSDLの機能をRubyのクラス・モジュールに振り分けて実装しているだけなので、この手の問題は未解決であり、それがリリースされたと言ってもこの手の問題は残ったままになるはずだ。
なんにせよ、このへんの話で悩んでいる人に何かしらの参考になれば幸いである。
最後に全ソースを置いておく。が、1回も動かしていないので動かないかもしれない。

require 'sdl2r'

module Audio
  @@channels = []

  def self.set_channels(h, l)
    SDL::Mix.allocate_channels(h + l)
    SDL::Mix.reserve_channels(l)
    SDL::Mix.group_channels(0, l-1, 0)
  end

  class Sound
    def initialize(filename, high_priority=false)
      @chunk = SDL::Mix.load_wav(filename)
      @high_priority = high_priority
    end

    def play
      if @high_priority
        ch = SDL::Mix.play_channel(-1, @chunk, 0)
        if ch == -1 # 無かった
          raise SDL2RError, "Not enough channel"
        end
      else
        ch = SDL::Mix.group_available(0) # 空いているChannelを探す
        if ch == -1 # 無かった
          ch = SDL::Mix.group_oldest(0) # 最も古いChannelを探す
          SDL::Mix.halt_channel(ch) # 止める
        end
        SDL::Mix.play_channel(ch, @chunk, 0)
      end
      @@channels[ch].ch = nil if @@channels[ch]
      @@channels[ch] = Channel.new(ch)
    end
  end

  class Channel
    attr_accessor :ch

    def initialize(ch)
      @ch = ch
    end

    def stop
      return if @ch == nil
      if SDL::Mix.Playing(@ch) == 1
        SDL::Mix.halt_channel(@ch)
      end
      @@channel[@ch] = nil
      @ch = nil
    end
  end
end