RemoteIO の使い方

以前、iPhoneサウンド再生に関するエントリーを書きました.(id:It_lives_vainly:20081207)

このときには、 AVAudioPlayer の使い方を書いたのですが、実際の(ゲーム)アプリでサウンド周りを実装してみると色々と不都合があることが分かったので、その時のまとめを軽くしておこうかと思います.

...AVAudioPlayer は便利だけれども、結局のところ(やはりというか)遅くて使い物にならなかったり、ちょっとしたバグがあったりして、レスポンス良くSEを鳴らす等と行った用途には、使い物にならないことがわかりました.

また、 AVAudioPlayer では非力だったため、AudioQueueService を利用したテスト実装なんかを行ってみたんですけど、こちらも AVAudioPlayer と同じ不具合が出てしまったので、使い物になりませんでした.
(恐らく、AVAudioPlayer は AudioQueueService のヘルパークラスなんでしょう)

これら二つのクラスが使い物に鳴らないと判断した理由は二つあって、

  • サウンド再生時のレイテンシがかなり気になる

(よって、アクションゲームなんかの"反応"に対するSE再生の手段としては利用できない)

  • 0.1秒などの短いサウンドを再生した場合、正しく再生されない

こんなかんじ.

正しく再生されないってのは、特定のサウンドファイルだけが、(サウンド後半部分が)変なループ再生になってしまうという問題でした.(44100Hz/16bit ADPCMを利用)

しばらく、この不具合の原因究明をしようとがんばってみたんですけど、エンコードを変えても同じ現象が出るし、何を条件に発生するのか(恐らく、ごく短いサイズのサウンド再生を行う場合発生程度しか)分からないため、あきらめることにしました.

AudioQueueService を使ってサウンド再生を行ってみると分かるんですけど、プログラム側で(システムから渡された)サウンドのバッファを埋める必要があるんですよね.

このバッファを埋める作業は、エンコーダを経由してバッファを埋めることになるんですけど、恐らく、初期に埋めるべきバッファに満たないようなサウンドが正しく再生されないんじゃないかと、推察しておくことにしました.

で、サウンド再生手段の結論として、たどり着いたのが RemoteIO を利用したサウンド再生になります.

基本的に(システム側からみてプル型に)サウンドバッファを波形で埋めるって作業を繰り返すことになります.

この方法だと、mp3などの圧縮形式はどうにかしてデコードした波形を創る必要が出てきてしまいますが、所詮はSEなのでさほどサイズも大きくないので、わざわざ mp3 を利用する必要も無いだろうってことで、wav再生だけに絞って実装することにしました.

BGMなんかは、AVAudioPlayer を利用して再生したのでmp3形式です.

AVAudioPlayer から mp3でBGMを再生しながら、RemoteIO をつかって wav のSEを鳴らすというのが、今回実装したサウンドシステムの概要になるわけです.

それでは、以下解説

RemoteIO ってなんなの?

前回のエントリーでは、名前すら出てこなかった RemoteIO ですが、どうも CoreAudio の中の AudioUnit と呼ばれる部分に該当する.
mac開発の文化に明るくないので、詳細は把握していないのですが、iPhoneでサポートされているAudioUnitには

  • 3D Mixer Unit
  • Converter Unit
  • IO Unit
  • iPod EQ Unit
  • Stereo Mixer Unit

があり、RemoteIO は、この IO Unit に該当している.

プログラミングガイドの説明を抜粋すると

RemoteIO 型のIOユニットを利用すると、オーディオ入出力ハードウェアに接続でき、リアルタイムI/Oをサポートします.

ということで、後のコードを観てもらえれば分かるように、波形データを直接ハードウェアへ書き込むことでサウンド再生を行う.

初期化

初期時のコードの一部を下に記す.
足りない部分は、特に重要な部分ではないので割愛する.重要ではない箇所だと思うので、適当に補完してください.

class SndPlayer
{
// (中略)
  private:
   AudioUnit m_output_unit;
};

/// 初期化処理
bool SndPlayer::Init()
{
    if( m_output_unit != NULL ){
	Term();
    }

    AudioComponentDescription a_desc;

    a_desc.componentType = kAudioUnitType_Output;
    a_desc.componentSubType = kAudioUnitSubType_RemoteIO;
    a_desc.componentManufacturer = kAudioUnitManufacturer_Apple;
    a_desc.componentFlags = 0;
    a_desc.componentFlagsMask = 0;

    AudioComponent a_component;
    a_component = AudioComponentFindNext( NULL, &a_desc );
    AudioComponentInstanceNew( a_component, &m_output_unit );
    AudioUnitInitialize( m_output_unit );

	
    AURenderCallbackStruct a_callback;

#if IPHONE_SDK_VERSION == IPHONE_SDK_30
    a_callback.inputProc = OutputCallback_SDK30;
    m_device_volume = 32768.0f;
#elif IPHONE_SDK_VERSION == IPHONE_SDK_21
    a_callback.inputProc = OutputCallback_SDK21;
    m_device_volume = 1 << kAudioUnitSampleFractionBits;
#endif

    a_callback.inputProcRefCon = this;
    
    AudioUnitSetProperty( m_output_unit, 
                          kAudioUnitProperty_SetRenderCallback, 
                          kAudioUnitScope_Global, 
                          0,
                          &a_callback, 
                          sizeof(AURenderCallbackStruct));


#if 1
    OSStatus status;
    AudioStreamBasicDescription audioFormat;
    audioFormat.mSampleRate		= 44100.00;
    audioFormat.mSampleRate		= 11025.00;
    audioFormat.mFormatID		= kAudioFormatLinearPCM;
    audioFormat.mFormatFlags		= kAudioFormatFlagIsSignedInteger | kAudioFormatFlagIsPacked;
    audioFormat.mFormatFlags		= kAudioFormatFlagsAudioUnitCanonical;
    audioFormat.mFramesPerPacket	= 1;
    audioFormat.mChannelsPerFrame	= 2;
    audioFormat.mBitsPerChannel		= 16;
    audioFormat.mBytesPerPacket		= 2;
    audioFormat.mBytesPerFrame		= 2;
    status = AudioUnitSetProperty( m_output_unit,
				   kAudioUnitProperty_StreamFormat,
				   kAudioUnitScope_Output,
				   0,
				   &audioFormat,
				   sizeof(audioFormat));
#endif
    AudioStreamBasicDescription a_stream_desc;
    UInt32 a_size = sizeof( a_stream_desc );
    AudioUnitGetProperty( m_output_unit, 
                          kAudioUnitProperty_StreamFormat, 
                          kAudioUnitScope_Global, 
                          0, 
                          &a_stream_desc, 
                          &a_size);

    return true;
}

ポイントをいくつかまとめておきます.

まず第一に気を付けて置かなければいけない点として、iPhone SDK 3.x と iPhone SDK 2.x では、RemoteIO の出力バッファの形式が
変わるということです.

iPhone SDK 3.x では、16bit 固定小数インターリーブなのに対して、iPhone SDK 2.x では、32bit 固定小数 ノンインターリーブとなっている.


再生開始

初期化が終了したあとは、 AudioSession の設定をして AudioOutputUnitStart() を呼び出せば、
出力のコールバックが開始されるから、バッファに書き込みを行えば良い.

コードとしては、こんな感じ

void SndPlayer::EndInterruptionSession()
{
    if( IsBegin() && m_isInterruption ){
        AudioSessionSetActive(true);
        AudioOutputUnitStart( m_output_unit );
        m_isInterruption = false;
    }
}

void CT_SndPlayer::BeginInterruptionSession()
{
    if( IsBegin() && m_isInterruption == false ){
        AudioOutputUnitStop( m_output_unit );
        m_isInterruption = true;
    }
}

void InterruptionListener(void *inUserData,
                          UInt32 inInterruption)
{
    SndPlayer* a_pPlayer = (SndPlayer*)inUserData;
	
    if (inInterruption == kAudioSessionEndInterruption) {
        a_pPlayer->EndInterruptionSession();
    }

    if (inInterruption == kAudioSessionBeginInterruption) {
        a_pPlayer->BeginInterruptionSession();
    }
}

/// バッファへの書き込み開始
bool SndPlayer::Begin()
{
    if( m_output_unit ){
        AudioSessionInitialize(NULL, NULL, InterruptionListener, this );
        AudioSessionSetActive(true);

        Float64 sampleRate;
        UInt32 size = sizeof(sampleRate);
        AudioSessionGetProperty(kAudioSessionProperty_CurrentHardwareSampleRate, &size, &sampleRate);

        UInt32 a_channels;
        size = sizeof( a_channels );
        AudioSessionGetProperty( kAudioSessionProperty_CurrentHardwareOutputNumberChannels, &size, &a_channels );
    
        Float32 bufferSize = 512./sampleRate;
        size = sizeof(bufferSize);
        AudioSessionSetProperty(kAudioSessionProperty_PreferredHardwareIOBufferDuration, size, &bufferSize);

        AudioOutputUnitStart( m_output_unit );
        m_is_begin = true;
        return true;
    }
    return false;
}

AudioSession ってのは、要するにサウンドにまつわる作成基準みたいなもので、サウンドをどのように扱うのかを設定する項目になる.

特に重要なのは、 Interuption の部分でiPhoneサスペンドした時にサウンドの制御をシステムに返したり、レジュームする時に再設定したりするためのコードを書く.

こいつを忘れてしまうと、サスペンド/レジューム時に、サウンドの挙動がおかしくなってしまうので注意が必要.

上記した実装では、 m_isInterruption って変数で、割り込みが行われたか判断をしているが、これは(恐らく)iPhone 側の不具合を回避するためのコードとなっている.

と、いうのも、サスペンド/レジューム周りのデバッグをしてみると分かるが、条件によっては kAudioSessionEndInterruption やkAudioSessionBeginInterruption が連続で飛んできたりするので、このような回避コードが必要になってしまう.

再生(SDK2.xの場合)

AudioOutputUnitStart() を呼び出した後は、初期化時に設定したバッファ書き込み用のコールバック関数が呼ばれるので、必要に応じてがしがし書き込みを行えば良い.

今回は、SE再生のレイテンシを少なくしたいというのがスタート地点なので、アプリケーション開始時にRemoteIOを初期化して開始しておき、再生するタイミングでバッファの書き込みを開始するように実装した.
(再生を行わない間は、バッファに0を書き込んでおく)

注意点は、先にも書いたが、SDK 3.x と SDK 2.x でバッファの形式が違うこと、SDK 3.x ではインターレースになっている点に注意が必要になる.

まずは、SDK 2.x版の実装を示す.

/// 波形出力のコールバック(SDK2.1.x 用)
static OSStatus OutputCallback_SDK21(
                               void* i_ref,
                               AudioUnitRenderActionFlags* i_action_flags,
                               const AudioTimeStamp* i_time_stamp,
                               UInt32 i_bus_number,
                               UInt32 i_number_frames,
                               AudioBufferList* i_data
                               )
{
    OSStatus err = noErr;
    SndPlayer* a_player = reinterpret_cast< SndPlayer* >(i_ref);

    bool a_isPlay = false;
    for( s32 i = 0; i < a_player->GetPortCount(); i++ ){
        if( a_player->GetState( i ) == kPORT_PLAY ){
            a_isPlay = true;
            break;
        }
    }

    SInt32* a_ptr_left = (SInt32*)i_data->mBuffers[ 0 ].mData;
    SInt32* a_ptr_right = (SInt32*)i_data->mBuffers[ 1 ].mData;

    if( a_isPlay ){
        s32 a_bits = kAudioUnitSampleFractionBits;
        float a_volume = 1 << kAudioUnitSampleFractionBits;

        a_player->writeFrame_S32( a_ptr_left, i_number_frames );
        memcpy( a_ptr_right, a_ptr_left, sizeof( SInt32 ) * i_number_frames );
    }else{
        for( s32 i = 0; i < i_number_frames; i++ ){
            a_ptr_left[ i ] = 0;
            a_ptr_right[ i ] = 0;
        }
    }

    return err;
}

SND_Payer を利用しているけれども、ここでは都合上詳細な実装は示さない.
RemoteIO の使い方だったら、上記コードを眺めるだけで十分なはずだし、(今回は)自前のライブラリを公開するのが目的ではないからだ.

簡単に設計の説明を書いておくと、SND_Player は複数の Port を持ち、そのPortに蓄えられておいた波形を合成して書き込む様になっている.

さて、RemoteIOの解説に戻ると、SDK2.xではノンインターレースなため、i_data に設定されているバッファは左右のチャンネルが
別々となり、2本のバッファが得られる.
(本来なら、 i_data->mNumberBuffers を調べて、バッファを取得するのが良いと思う.)

引数の、 i_number_frames には、バッファが必要としているフレーム数が入っている.

バッファには、44100Hz 32bit 固定小数で書き込めば良い.

固定小数用(?)に、kAudioUnitSampleFractionBits という定数が用意されているので、波形データが、-1.0 〜 1.0 のとした場合、

±1.0 = ±1 << kAudioUnitSampleFractionBits

となるように、変換を行いながらバッファを埋めればよい.

再生(SDK3.xの場合)

こんな感じ.

/// 波形出力のコールバック(SDK3.0 用)
static SDKStatus OutputCallback_SDK30(
                               void* i_ref,
                               AudioUnitRenderActionFlags* i_action_flags,
                               const AudioTimeStamp* i_time_stamp,
                               UInt32 i_bus_number,
                               UInt32 i_number_frames,
                               AudioBufferList* i_data
                               )
{
    OSStatus err = noErr;

    SndPlayer* a_player = reinterpret_cast< SndPlayer* >(i_ref);

    bool a_isPlay = false;
    for( s32 i = 0; i < a_player->GetPortCount(); i++ ){
        if( a_player->GetState( i ) == kPORT_PLAY ){
            a_isPlay = true;
            break;
        }
    }

    SInt16* a_ptr = NULL;

    if( i_data->mNumberBuffers > 0 ){
        a_ptr = (SInt16*)i_data->mBuffers[ 0 ].mData;

        if( a_isPlay ){
            a_player->writeFrame_S16_IL( a_ptr, i_number_frames );
        }else{
            for( s32 i = 0; i < i_number_frames; i++ ){
                a_ptr[ (i * 2) + 0 ] = 0;
                a_ptr[ (i * 2) + 1 ] = 0;
            }
        }
    }

    for( s32 i = 1; i < i_data->mNumberBuffers; i++ ){
        SInt32* a_buf = (SInt32*)i_data->mBuffers[ i ].mData;
        memcpy( a_buf, a_ptr, sizeof( SInt16 ) * i_number_frames * 2);
    }

    return err;
}

SDK3.x では、16bit 固定小数 インターレース でデータを書き込む.
つまり、1本のバッファに、 左(16bit),右,左,右,左..... とデータを書き込む必要がある.

...SDK3.x だと、こんな地味なところで速度向上をはかっているんでしょう(多分)

基本的に、バッファは1本の筈なので、i_data->mNumberBuffers の数分バッファを埋めるのは、実行されない
ループになる.

終了

再生の必要が無くなったら、 AudioOutputUnit を終了させる.

注意点は特にないだろう

/// バッファへの書き込み終了
void CT_SndPlayer::End()
{
    if( m_output_unit != NULL ){
        AudioOutputUnitStop( m_output_unit );
        m_output_unit = NULL;
        m_is_begin = false;
    }
}

おまけ(サスペンドとレジュームに関して)

基本的には、サスペンド/レジューム処理に関しては、 AudioSession に設定する InterruptionListener に任せてしまっても良い筈なのだが、
ここでもOS側の不具合で、細かなデバッグをしてみると挙動がおかしい点に気がつく.

気付いてしまったからには、OS側の不具合といえども対応しておかないと納得できない人たちがいるので、(変な)対応を施しておく.

/// アプリケーションサスペンド時に呼ばれる
- (void)applicationWillResignActive:(UIApplication *)application
{
    m_pSndPlayer->BeginInterruptionSession();
}

/// アプリケーションレジューム時に呼ばれる
- (void)applicationDidBecomeActive:(UIApplication *)application
{
    m_pSndPlayer->EndInterruptionSession();
}

上記のコードは、疑似コードになっている.
(実際には、 applicationWillResignActive / applicationDidBecomeActive は、サスペンド/レジューム時以外にも呼ばれる(例えば、アプリケーション開始時など)ので、
いろいろな条件判定が必要になる)

要するに、レジューム/サスペンド時に、 Interruption が正しく行われないケースを考えて、自分で AudioSession の解放/再設定を行う必要があるということだ.

この辺りは、OSのバージョンが上がれば解決されるかもしれないが、こういった泥臭い情報はなかなか出てこないので、メモとして残しておく.

おわりに

(サウンドに限らず)メディアファイルの再生については、あまり情報がまとめられていないようなので、簡単ではありますが、私が書いたコードの説明なんぞをしてみました.

AudioUnit 関連のコードに関しては、 mac の開発と同じようなものらしいので、mac開発を続けていた人には常識なところが多いんだろうと思います.

最後に、参考サイトを上げさせていただきます.

[Objective-Audio]
http://objective-audio.jp/2008/10/remoteio.html

サウンドの右も左も分からない時に、このページの存在を知りました.
...恐らくこのページを知らなかったら、未だに RemoteIO を使うことすらできなかったと思います.

大変参考にさせていただきました.
私のエントリーよりも、有益な情報が詰まっていると思いますので、参考にしてみると良いと思います.
ありがとうございました