ホームテクニクビートを録音しよ〜!<サウンドトリガー機能の実装

ソフトウェア編
その8 サウンドトリガー機能の実装


 報告1「最初のテストでわかったテクニクビートの誤差」で、リトライから再スタートすると輪を反応させるタイミングに許容できない誤差が出ることがわかりました。この原因ははっきりとはしませんが、テクニクビートがリトライを始めてからスタートするまでに色々と処理をしているのではないかと想像しています。原因が何にせよ、ズレるわけですから現行の方法は改善が必要になります。

 音ゲーであるテクニクビートは、音楽が鳴り始めてからはズレないように慎重な設計がされているはずです。これは、これまでのARIKA製作のゲーム(怒首領蜂大往生、エスプガルータ等)に見られるタイミングの妙を見る限り間違いありません。ということは、音が鳴ってからのタイミングは絶妙に合うはずです。

 そこで、スタートのタイミングを「音が鳴り始めた瞬間」に変更することにしました。いわゆる「サウンドトリガー」です。トリガー(trigger)とは銃の引き金の事で、転じて物事が起こるきっかけを意味します。音のなり始めを感知するには、サウンドボードに入力されたPS2の音をソフトウェアで読み取る必要があります。


@ Win32APIかDirectSoundか・・・

 「音」を取り扱う方法はいくつか考えられます。マルチメディアを使うのもありますが、妥当なところはWin32APIの音関連関数群を使うか、DirectSoundを使うかです。

 Win32APIによる音の取り扱いは、多少面倒なところがあります。何より気がかりなのは遅延(レイテンシ: latency)です。今回のようなサウンドトリガーの扱いについて、遅れることは特に問題ではないのですが、それがばらつくと本末転倒です。「Windowsサウンドプログラミング」(田辺義和著)によりますと、リアルタイム処理を行う場合にWin32APIだと数10ms〜数100msの遅延が出る場合があると記述されています。これは、ゲームの1フレームである1/60secを軽く超えるバラつきです。よって、Win32APIの選択はちょっと考えにくくなります。

 一方DirectSoundはハードウェアをほぼ直接的に叩く仕組みになっており、低レイテンシのトリガーが期待できます。扱いも、実は結構簡単です。よって今回はDirectSoundによるサウンドトリガーを実現してみたいと思います。



A DirectSoundをざっと洗う

 DirectSoundは別の箇所で詳しくやるつもりだったのですが、今回急遽必要になってしまったので、簡単にざざっと洗ってみることにします。DirectSoudはwaveデータを取り扱って音の再生や録音を行う専門のコンポーネントです。DirectX8でDirectMusic(wavとMIDIを結合する専門コンポーネント)と共に「DirectX Audio」というくくりになりましたが、統合というより機能の整理をしたような感じなので、DirectX9のマニュアルでもDirectSoundという名前でちゃんと残っています。多くの書籍では再生に重点が置かれています(当然ですね)が、今回は「録音」に関する情報を要します。

 手元にDirectX9のサウンドに関する書籍が無いので、マニュアルをかじってみます。 

 waveを再生する機能はDirectMusicにもありますが、録音(キャプチャー)を行えるのはDirectSoundだけのようです。サウンドをキャプチャーする機能はIDirectSoundCapture8インターフェイスとそこから作成される各種インターフェイスにまとめられているようです。

 DirectSoundは「環状バッファ」という方式で音を再生したり録音したりします。これは、メモリの最後尾と先頭を擬似的に繋げ、再生しながら新しいデータをどんどん書き込んでいく「ストリーミング」によって少ないメモリで効率的に長い音を繋げていく技術です。サウンドトリガーも、この環状バッファに逐一書き込まれていく音の情報を読み取って実現します。

 録音までの簡単な流れは次の通りです。

@ キャプチャデバイスの列挙
 必要ならば入力端子の付いたサウンドカードを列挙します。今回はデフォルトで選択しているサウンドカードを使用するので、特に列挙の必要は無いようです。
A キャプチャデバイスオブジェクトの作成
 音を録音するためのオブジェクトを作成します。具体的にはDirectSoundCaptureCreate8関数を用いてIDirectSoundCapture8インターフェイスを取得する作業です。
B キャプチャバッファの作成
 入力音を保持するための環状バッファの作成を次に行います。これはIDirectSoundCaputureBuffer8というバッファ専門のインターフェイスの取得から始まります。バッファ作成時にはどういう質の音を録音するのかと言う情報も設定します。当然のことながら高音質にすると書き込む情報が多くなるわけですから、それだけレイテンシが発しやすくなると想像します。
C バッファへの書き込み
 実際にバッファへ録音する作業です。これはIDirectSoundCaputureBuffer8::Start関数を実行するだけのようです。
D バッファのロックとデータの読み込み
 録音されバッファに書き込まれたデータはロックして読み込むことが出来ます。安全に読み込める位置を取得するIDirectSoundCaptureBuffer8::GetCurrentPosition関数と、ロックするIDirectSoundCaptureBuffer8::Lock、アンロックするIDirectSoundCaptureBuffer8::Unlock関数をうまく組み合わせるようです。

 それほど難しい部分は無いようですね。

 VC++でDirectX9.0cのDirectSoundを使う時、DWORD_PTR等が無いというエラーが出ることがあります。これは、PlatformSDKが古いために起こる・・・と各サイトが教えてくれています。ただ、typedefするべき型が無いというエラーなので、適当なヘッダーファイルを作って、

#if !defined DSOUND_ADDITIONAL_HEADER_100
#define DSOUND_ADDITIONAL_HEADER_100
typedef unsigned int DWORD_PTR;
typedef long LONG_PTR;
#endif

としてDSound.hの前に入れれば解決します。



B YAGNIの原則を発揮して作る

 今回は急遽必要なものですから、しっかりとしたサウンドクラスを作るつもりはありません。こういう時はYAGNIの原則が大活躍します。必要な機能は次の通りです。

 ・ トリガー開始で監視を開始する(ループ突入)
 ・ トリガーとなる音が発せられたら、直ちに監視を終了する(ループを抜ける)

 これを実現するCSoundTriggerクラスを作リましょう。



C CSoundTriggerクラス

 CSoundTriggerクラスはBの仕様を満たすクラスです。DirectSoundCapture系インターフェイスを用いて実装します。
 最初にDirectSoundCapture関連の初期化を行い、すぐに録音できる体制にまで持って行きます。これはInitメンバ関数で行うことにしましょう。トリガーの開始はSetTriggerメンバ関数で行います。これはあるレベル以上の音が入ってきた段階でトリガーをはずす関数です。今のところこれらの関数が必須ですが、必要になったら新しい関数を追加していくようにします。YAGNIの原則です。

 最初にInit関数です。これはキャプチャデバイスオブジェクトの生成に始まって、バッファの確保で終わります。

 キャプチャデバイスオブジェクトは次のように作成します。

CSoundTrigger::Init関数
HRESULT hres = DirectSoundCaptureCreate8(
                      NULL,
                      &m_lpDSC,
                      NULL
                );

第1引数にはサウンドデバイスのGUIDを指定しますが、デフォルトのデバイスで良い場合はNULLで良いそうです。指定のデバイスがある場合は、第2引数のLPDIRECTSOUNDCAPTURE8型ポインタにデバイスオブジェクトが格納されます。第3引数はNULLにしなければなりません。

 デバイスオブジェクトを取得できたら、次に環状バッファを作成するためにIDirectSoundCaptureBufffer8インターフェイスを取得します。これはIDirectSoundCapture::CreateCaptureBuffer関数を用います。

IDirectSoundCapture8::CreateCaptureBuffer
HRESULT CreateCaptureBuffer(
      LPCDSCBUFFERDESC pcDSCBufferDesc,
       LPDIRECTSOUNDCAPTUREBUFFER * ppDSCBuffer,
       LPUNKNOWN pUnkOuter
);

 第1引数にバッファの特性をDSCBUFFERDESC構造体で指定して、成功すれば第2引数にIDirectSoundCaptureBuffer8インターフェイスが返ります。

 DSCBUFFERDESC構造体の中身を見てみましょう。

DSCBUFFERDESC構造体
typedef struct {

    DWORD dwSize;
    DWORD dwFlags;
    DWORD dwBufferBytes;
    DWORD dwReserved;
    LPWAVEFORMATEX lpwfxFormat;
    DWORD dwFXCount;
    LPDSCEFFECTDESC lpDSCFXDesc;

} DSCBUFFERDESC, *LPDSCBUFFERDESC;

dwSizeはこの構造体の大きさです。sizeof演算子で与えてください。
dwFlagsはデバイスの付加能力を指定するフラグですが、今回は特に指定しないので0にします。dwBufferByttesは環状バッファのサイズをバイト単位で指定します。この大きさと音質で取る時間が決まります。
dwReservedは予約域ですから0にします。
lpwfxFormatはキャプチャフォーマットをWAVEFORATEX構造体で指定します。これは後述します。
dwFXCountはエフェクトを使用しない場合は0にします。
lpDSCFXDescはハードウェアサポートのエフェクトを指定するようですがNULLで大丈夫のようです。

 WAVEFORMATEX構造体は次のようになっています。

WAVEFORMATEX構造体
typedef struct {

    WORD  wFormatTag;
    WORD  nChannels;
    DWORD nSamplesPerSec;
    DWORD nAvgBytesPerSec;
    WORD  nBlockAlign;
    WORD  wBitsPerSample;
    WORD  cbSize;

} WAVEFORMATEX;

wFormatTagはウェーブフォームオーディオフォーマットのタイプを指定します。いわゆるオーディオの圧縮技術のフォーマットタグを指定するのですが、非圧縮で良い場合はWAVE_FORMAT_PCMを与えます。
nChannnelsはステレオ・モノラルの選択です。ステレオにしたいのでここは2です。
nSamplesPerSecはサンプリングレートをHz単位で指定します。今回は音質は必要としないのですが、レートが低いと立ち上がりの検査が甘くなるので44100(Hz)を指定しておきます。
nAvgBytesPerSecは平均データ転送速度を指定するのですが、wFormatTagにWAVE_FORMAT_PCMが指定されている場合はnSamplePerSec*nBlockAlineにしなければならないようです。
nBlockAlineはwFormatTagで指定したフォーマット形式の最小単位を指定します。wFormatTagにWAVE_FORMAT_PCMを指定した場合はnChannels*wBitsPerSample/8だそうです。
wBitsPerSamplesはサンプリングビットを指定します。今回は8(bit)です。
cbSizeはこの構造体の後に付加的な情報がある場合にそのサイズを指定します。wFormatTagがWAVE_FORMAT_PCMの場合は無視されるので0を入れておきます。

 以上から、WAVEFORMATEXは次のように与えることにしましょう。

m_WFmt.wFormatTag = WAVE_FORMAT_PCM;
m_WFmt.nChannels = 2;
m_WFmt.nSamplesPerSec = 44100;
m_WFmt.wBitsPerSample = 16;
m_WFmt.nBlockAlign = m_WFmt.nChannels * m_WFmt.wBitsPerSample / 8;
m_WFmt.nAvgBytesPerSec = m_WFmt.nSamplesPerSec * m_WFmt.nBlockAlign;
m_WFmt.cbSize = 0;

 トリガー監視の時間は変数m_TrgTimeとして与えることにします。バッファの初期値は次のようになります。

CSoundTrigger::Init関数
m_DBD.dwSize = sizeof(DSCBUFFERDESC);
m_DBD.dwFlags = 0;
m_DBD.dwBufferBytes =
          m_WFmt.nSamplesPerSec
*
          m_WFmt.wBitsPerSample *
          m_WFmt.nChannels *
          m_trgTime
;
m_DBD.dwReserved = 0;
m_DBD.lpwfxFormat = &m_WFmt;
m_DBD.dwFXCount = 0;
m_DBD.lpDSCFXDesc = NULL;

 さて初期化ではここまでにしておきます。バッファの実際の作成はトリガーを監視する直前にします。というのは、一つのバッファが一つのトリガーを担当した方が実装が簡単になるからです。



D トリガーの開始

 初期化でバッファを作成したら、IDirectSoundCaptureBuffer::Start関数を呼び出すことでいつでもキャプチャが出来るようになります。この時DSCBSTART_LOOPINGフラグを指定すると環状バッファでキャプチャをしてくれます。ある意味、このトリガーは一定時間だけ監視してもらいたいわけで、このフラグを指定しないのが良い気がします。そうすれば無限ループに陥ることもなくなります。

 監視するメンバ関数はCSoundTrigger::SetTrigger関数です。この関数内では録音された音を取得して大きさをチェックし、特定の音量以上になっていたらトリガーを直ちにはずします。安全に読み込める位置をIDirectSoundCaptureBuffer8::GetCurrentPosition関数で取得し、IDirectSoundCaptureBuffer8::Lock関数でロックし読み込み、音量を計算した後、直ちにIDirectSoundCaptureBuffer8::Unlock関数でアンロックします。トリガーが外れたり、バッファの最後まで達したらIDirectSoundCaptureBuffer8::Stop関数で録音を中止します。

 少し長いのですが、下がそのコードです。

CSoundTrigger::SetTrigger関数
int CSoundTrigger::SetTrigger()
{
    // バッファを作成
    IDirectSoundCaptureBuffer *tmpbuffer;
    HRESULT hres = m_cpDSC.GetPtr()->CreateCaptureBuffer(
                                            &m_DBD,
                                            &tmpbuffer,
                                            NULL
                                     );

    if(FAILED(hres))
       return 0;

    // バッファを格納
    // スマートポインタなので前のバッファはここで削除される
    m_cpDSCBuf = tmpbuffer;

    // 監視開始
    HRESULT hres = m_cpDSCBuf->Start(0);
    if(FAILED(hres))
        return 0;

    DWORD Pos;                 // 録音済み終端オフセット値
    DWORD RecPos=0;            // 録音取得オフセット値
    WORD *pLevel1, *pLevel2;   // 録音部取得ポインタ
    DWORD Size1, Size2;        // 録音部取得サイズ(Byte)
    vector<WORD*> RecPtr;

    //               ↓RecPos                  ↓Pos
    // ▲▲▲▲▲▲▲■■■■■■■■■▲▲▲▲▲△△
    //               ←   m_RecSize  →

    Sleep(100); // ちょっとだけ休む

    // トリガー音をチェック
    while(1)
    {
        // 現在の録音位置を取得
        m_cpDSCBuf->GetCurrentPosition(NULL, &Pos);

        // ロックしようとしている範囲内に未書き込み部分が
       // 含まれる場合は何もせず待機
       if(RecPos+m_RecSize > Pos && Pos != 0){
           Sleep(1);
           continue;
        }

        // 指定範囲をロックできるかチェック
        // ロック出来ない条件
       // ・書き込み中部分を指定
       // ・バッファをオーバーした指定
        if(DS_OK != m_cpDSCBuf->Lock(RecPos, m_RecSize,
            (void**)&pLevel1, &Size1,(void**)&pLevel2, &Size2, NULL))
       {
           // 書き込み中部分だった場合は
           // 書き込みできるまでループ待機
           if(Pos != 0){
               Sleep(10);
               continue;
           }
       else
           // LatePosがバッファをオーバーしているので終了
           break;
       }

       ////////////////////////
       // ロック中
       //////

       // 指定範囲の平均音量をチェック
       long Ave = 0;
       for(int i=0; i<Size1; i++)
       Ave += (pLevel1[i]-0x8fff)&(0x8fff);
       Ave /= Size1*2; // ステレオなので2で割る

       // ロック解除
       HRESULT hres = m_cpDSCBuf->Unlock(pLevel1, Size1, pLevel2, Size2);
       if(FAILED(hres))
           break;

       // 平均音量がm_TrgLevel以上だったらトリガーをはずす
       if(Ave >= m_TrgLevel)
           break;

       // 取得位置の更新
       RecPos += m_RecSize;
   }

   // バッファリングストップ
   m_cpDSCBuf->Stop();

   return 1;
}

 トリガーを監視する前にバッファを作成します。Comスマートポインタなので、上書きした瞬間に前のバッファは自動的に削除されます。便利です(^-^)。データを取得する時に読み込み可能な場所で無い場合は待つようにしています。
 上の太文字になっている部分が音量を測定している部分です。Size1(取り出した音の数)分の音量を取り出して、その平均値を取っています。これは、パルス的な雑音によってトリガーがはずされない工夫です。下の太文字がトリガーをはずす判定をしている部分です。m_TrgLevelを小さくしすぎると、雑音でも外れてしまうので、それなりの大きさにする必要があるでしょう。いくつにするかは実際に試して調節です。

 これで、サウンドトリガーは一応完成です。