SDL2のオーディオ再生

NESエミュレータはどうにか三角波がまともに鳴るようになりました。どうもSDL_QueueAudio()を使っていたのが良くなかったようで、従来のコールバック方式にしたら改善しました。そこで一応コールバック方式での再生について書いてみます(知識ゼロから調べたので誤っていたりもっといい方法があるかもしれません)。

まず正弦波を再生するコードを貼っておきます(boost, SDL2が必要。SDL1は不可)。引数でSDL側のバッファサイズを指定するようになっています。*1

概念的には単純で、以下の処理を行っているだけです:

  • SDL側のオーディオスレッドで呼び出されるコールバック関数を登録する
  • メインスレッドで波形データを作る
  • オーディオスレッドは定期的にコールバックを呼び出し、波形データを消費して実際の再生を行う

しかし実際は波形データを蓄えるバッファのunderflow(読み出し時のデータ不足), overflow(書き込み時の容量オーバー)という問題があります。これらが発生した場合は以下のように対処します:

  • underflowによりコールバックがデータを読めない場合、残りは無音を再生する
  • overflowによりメインスレッドがデータを書けない場合、残りのデータを捨てる

ところでこれはマルチスレッド処理になるため、バッファへのアクセスをどう制御するかという問題が生じます(当初コールバック方式を避けていたのはこれを考えるのが面倒だったからです)。この手のバッファはリングバッファで実装するのが定番みたいですが、複数スレッドからの読み書きがあっても正しく動くように書けるかというと怪しい感じでした。

そこでFCEUXbjneのソースを見てみましたが、どちらも特にその辺りは意識していないように見えました。FCEUXは一応volatile変数を使っていましたが、これだけでは多分不十分と思われます。でもどちらもそんなに破綻してる感じはしないんですけどね…。

調べてみると、今回のように生産者と消費者が1:1のケースを"single-producer single-consumer"と呼び、このための専用のキューの実装が既にあるようです。探した範囲ではboost::lockfree::spsc_queue, cameron314/readerwriterqueueが良さそうな感じでした(どちらもlock-free)。今回はユーザが多そうなboostの実装を使うことにしました。

spsc_queueには初期サイズを与える必要があります(サイズ固定)。理論上は1Fの全サンプル分だけのサイズがあれば足りるはずですが、それだとSDL側のバッファサイズと一致しないのと、コールバックの呼び出し間隔やFPSがブレるためにunderflow/overflowが生じます。いろいろ実験してみましたが、サンプリングレート44.1KHz, 60FPS弱であれば8F分のサイズを用意してやれば足りるようです。キューサイズが大きくて困ることは別にないのでもう少し余裕を見てもいいと思います。

考え方としては、とにかくunderflowだけは起こらないようにキューサイズを大きく取り、メインスレッドは最低限のFPSを維持して書き込みが追いつかない事態を避ける、ということになるのかなと思います。overflowするときというのは要するにメインスレッドの処理が速すぎるわけで、これはウェイト処理を改善すれば容易に対処可能ですし、最悪でも倍速っぽい音になるだけです(NESエミュレータSDL_Delay()を省略して実験済み)。

ちなみにSDL_QueueAudio()を使っていたときはどうやってもunderflowが起こる感じでした(ノンウェイトで書き込んでもデータがあるはずなのに音がブツ切れになる)。ソースを読んでないので詳細はよくわかりませんが…。

*1:SDL_AudioSpec::samples の値になる。SDL2のドキュメントではこの値はサンプル数となっているが、実際はバッファサイズだと思う。