カクタスソフトウェア
カクタスソフトウェア
サウンド MIDI マルチメディア アプリケーション

C#.NET環境からASIOを使う(NAudioで公開されたマーシャリング技術を解説)

このセクションでは、ASIOシステムを.NET環境から利用するためのプログラミング技術について解説します。すべてのコードは、C#のみで記述され、C++やヘルパーDLLなどは用いません。

マネージ環境からASIOを使う

ASIOシステムを構築するには、スタインバーグが公開しているSDKを利用します。しかしこのSDKはC++で書かれており、しかもプロジェクトファイルがVC++(Ver6.0)対応と、かなり古いものです。開発はすでにVer2.2で止まっていて、.NETに対応したマネージドバージョンは存在しません。

一般的にアンマネージ環境のバイナリをマネージ環境から利用するときは、マーシャリング技術を用います。ASIOシステムもマーシャリングコードを書けば、C#で開発できるはずです。しかし、ASIOのバイナリは、特殊な構造をしているため、オーソドックスな手法でコーディングしても動作しません。

ASIOのデバイスドライバは、一応COMで作成されていますが、マイクロソフトのコンパイラーを用いていないため、クエリーインターフェースが失敗します。このため、インターフェースを取得するという通常の手法が使えません。またAPIは、個々に公開されていないため、DllImportでアクセスすることもできません。これらの問題があるため、今までは、マイクロソフト仕様のCOMでラップする、アンマネージ環境でDLLを作って仲介させる、などの対策を取っています。

NAudioで公開された手法

NAudioで公開されたソースは、Vテーブルを直接読み出す方法を用いて、見事にマーシャリングを行っています。本セクションでは、この手法について鍵となる部分を取り出して解説します。

NAudioのASIODriver.csの冒頭で、「This is the first ASIODriver binding fully implemented in C#!」という記述があります。

以下にGUIDからVテーブルを抽出する部分を引用します。

/// <summary>
/// Inits the vTable method from GUID. This is a tricky part of this class.
/// </summary>
/// <param name="ASIOGuid">The ASIO GUID.</param>
private void initFromGuid(Guid ASIOGuid)
{
    const uint CLSCTX_INPROC_SERVER = 1;
    // Start to query the virtual table a index 3 (init method of ASIODriver)
    const int INDEX_VTABLE_FIRST_METHOD = 3;

    // Pointer to the ASIO object
    // USE CoCreateInstance instead of builtin COM-Class instantiation,
    // because the ASIODriver expect to have the ASIOGuid used for both COM Object and COM interface
    // The CoCreateInstance is working only in STAThread mode.
    int hresult = CoCreateInstance(ref ASIOGuid, IntPtr.Zero, CLSCTX_INPROC_SERVER, ref ASIOGuid, out pASIOComObject);
    if ( hresult != 0 )
    {
        throw new COMException("Unable to instantiate ASIO. Check if STAThread is set",hresult);
    }

    // The first pointer at the adress of the ASIO Com Object is a pointer to the
    // C++ Virtual table of the object.
    // Gets a pointer to VTable.
    IntPtr pVtable = Marshal.ReadIntPtr(pASIOComObject);

    // Instantiate our Virtual table mapping
    asioDriverVTable = new ASIODriverVTable();

    // This loop is going to retrieve the pointer from the C++ VirtualTable
    // and attach an internal delegate in order to call the method on the COM Object.
    FieldInfo[] fieldInfos =  typeof (ASIODriverVTable).GetFields();
    for (int i = 0; i < fieldInfos.Length; i++)
    {
        FieldInfo fieldInfo = fieldInfos[i];
        // Read the method pointer from the VTable
        IntPtr pPointerToMethodInVTable = Marshal.ReadIntPtr(pVtable, (i + INDEX_VTABLE_FIRST_METHOD) * IntPtr.Size);
        // Instantiate a delegate
        object methodDelegate = Marshal.GetDelegateForFunctionPointer(pPointerToMethodInVTable, fieldInfo.FieldType);
        // Store the delegate in our C# VTable
        fieldInfo.SetValue(asioDriverVTable, methodDelegate);
    }
}

デバイスのインスタンスを作成する

C#でCOMオブジェクトを作成するときは、Activator.CreateInstance関数の利用が推奨されています。しかしASIOドライバーの場合、CreateInstance関数ではうまく動きません。ここではWin32APIのCoCreateInstance関数を用います。なお、ここで作成したインスタンスの開放は、Marshal.ReleaseComObjectではなく、Marshal.Releaseを用います。

int hresult = CoCreateInstance(ref ASIOGuid, IntPtr.Zero, CLSCTX_INPROC_SERVER, ref ASIOGuid, out pASIOComObject);

Vテーブルを読み出す

作成したCOMオブジェクトからVテーブルの内容を読み出します。最初にVテーブルの先頭アドレスを取得します。

IntPtr pVtable = Marshal.ReadIntPtr(pASIOComObject);

Vテーブルの内容を格納するためのクラス(ASIODriverVTable)を用意します。これは、各APIに対応するメンバーを列挙したものです。以下にASIODriverVTableクラス(抜粋)を引用します。

/// <summary>
/// Internal VTable structure to store all the delegates to the C++ COM method.
/// </summary>
[StructLayout(LayoutKind.Sequential, Pack = 2)]
private class ASIODriverVTable
{
    //3  virtual ASIOBool init(void *sysHandle) = 0;
    [UnmanagedFunctionPointer(CallingConvention.ThisCall)]
    public delegate int ASIOInit(IntPtr _pUnknown, IntPtr sysHandle);
    public ASIOInit init = null;
    //4  virtual void getDriverName(char *name) = 0;
    [UnmanagedFunctionPointer(CallingConvention.ThisCall)]
    public delegate void ASIOgetDriverName(IntPtr _pUnknown, StringBuilder name);
    public ASIOgetDriverName getDriverName = null;

    // 紙面の都合で5番以降を省略しました。詳しくは、NAudioのソースを参照してください。
}

各デリゲートの定義に、UnmanagedFunctionPointer(CallingConvention.ThisCall)ディレクティブが設定されています。これによって最初の引数がthisポインターに代入されます。ここで定義されている関数は、本来IASIOインターフェースのメソッドにあたるものです。メソッドの呼び出しは、C/C++では以下のようになります。

void* asioDrv;
CoCreateInstance(clsID, 0, CLSCTX_INPROC_SERVER, clsID, &asioDrv);
IASIO* iAsio = (IASIO*)asioDrv;

// 途中を省略

if (iAsio->init(info->sysHandle) == ASIOFalse)
{
    iAsio->getErrorMessage(info->errorMessage);
    return ASE_NotPresent;
}

C#コード中のpASIOComObjectは、上記コードのiAsioに対応し、IASIOへのポインターの役目を果たします。delegate文ではIntPtr _pUnknownで表現されています。

次にループ処理を行いながら、順次APIのアドレスを取得します。

for (int i = 0; i < fieldInfos.Length; i++)
{
    FieldInfo fieldInfo = fieldInfos[i];
    IntPtr pPointerToMethodInVTable = Marshal.ReadIntPtr(pVtable, (i + INDEX_VTABLE_FIRST_METHOD) * IntPtr.Size);
    object methodDelegate = Marshal.GetDelegateForFunctionPointer(pPointerToMethodInVTable, fieldInfo.FieldType);
    fieldInfo.SetValue(asioDriverVTable, methodDelegate);
}
FieldInfoは、クラスで定義された変数について、属性の取得や値の設定などを行います。動的な運用が可能になるため、すべての変数をループ処理することができます。ここではFieldInfoを、関数のタイプの取得と値の設定に使っています。

関数のアドレスを読み出すとき、インデックスに「INDEX_VTABLE_FIRST_METHOD=3」というオフセットが入っています。これは、COMオブジェクトにIUnknown由来の3つの関数があり、それをスキップするためです。

pPointerToMethodInVTableは、アンマネージ関数のポインタですが、これをMarshal.GetDelegateForFunctionPointerを用いてデリゲートに変換します。変換時に必要な関数のタイプは、fieldInfo.FieldTypeで取得します。

最後にfieldInfo.SetValueを用いてデリゲートをASIODriverVTableに書き込みます。

構造体の配列をマーシャリングする

ASIOBufferInfo構造体のオリジナルの定義は以下の通りです。スイッチング処理のため、2つのバッファを格納する配列を持ちます。

typedef struct ASIOBufferInfo
{
    ASIOBool isInput;           // on input:  ASIOTrue: input, else output
    long channelNum;            // on input:  channel index
    void *buffers[2];           // on output: double buffer addresses
} ASIOBufferInfo;

ASIOでは、バッファを作成するためにCreateBuffers関数を用います。このとき引数に、入出力のチャンネル数から成るASIOBufferInfo構造体の配列を指定します。ここで構造体のマーシャリングが必要になりますが、残念ながらマーシャラーは、配列を持つクラス・構造体の配列、つまり配列の入れ子を支援しません。上記の定義のままですと、アンマネージ型への変換に失敗します。そこで以下のように配列をメンバーに展開して定義します。

[StructLayout(LayoutKind.Sequential, Pack = 4)]
internal struct ASIOBufferInfo
{
    public bool isInput;            // on input:  ASIOTrue: input, else output
    public int channelNum;          // on input:  channel index
    public IntPtr pBuffer0;     // on output: double buffer addresses
    public IntPtr pBuffer1;     // on output: double buffer addresses

    public IntPtr Buffer(int bufferIndex)
    {
        return (bufferIndex == 0) ? pBuffer0 : pBuffer1;
    }
}

なお、引数付きの文脈でバッファアドレスを取得できるよう、Buffer関数を定義しています。

コールバック関数のマーシャリング

コールバック関数と言えば、Windowsでは「__stdcall」を思い浮かべます。しかしASIOで用いる4つのコールバック関数は、「__cdecl」で定義されています。そのためこれらの関数のデリゲートを定義するときは、UnmanagedFunctionPointer(CallingConvention.Cdecl)ディレクティブを付けなければなりません。

以下にASIOCallbacks構造体の定義を引用します。

[StructLayout(LayoutKind.Sequential, Pack = 4)]
internal struct ASIOCallbacks
{
    [UnmanagedFunctionPointer(CallingConvention.Cdecl)]
    internal delegate void ASIOBufferSwitchCallBack(int doubleBufferIndex, bool directProcess);
    [UnmanagedFunctionPointer(CallingConvention.Cdecl)]
    internal delegate void ASIOSampleRateDidChangeCallBack(double sRate);
    [UnmanagedFunctionPointer(CallingConvention.Cdecl)]
    internal delegate int ASIOAsioMessageCallBack(ASIOMessageSelector selector, int value, IntPtr message, IntPtr opt);
    // return ASIOTime*
    [UnmanagedFunctionPointer(CallingConvention.Cdecl)]
    internal delegate IntPtr ASIOBufferSwitchTimeInfoCallBack(IntPtr asioTimeParam, int doubleBufferIndex, bool directProcess);
    //        internal delegate IntPtr ASIOBufferSwitchTimeInfoCallBack(ref ASIOTime asioTimeParam, int doubleBufferIndex, bool directProcess);

    //  void (*bufferSwitch) (long doubleBufferIndex, ASIOBool directProcess);
    public ASIOBufferSwitchCallBack pbufferSwitch;
    //    void (*sampleRateDidChange) (ASIOSampleRate sRate);
    public ASIOSampleRateDidChangeCallBack psampleRateDidChange;
    //  long (*asioMessage) (long selector, long value, void* message, double* opt);
    public ASIOAsioMessageCallBack pasioMessage;
    //  ASIOTime* (*bufferSwitchTimeInfo) (ASIOTime* params, long doubleBufferIndex, ASIOBool directProcess);
    public ASIOBufferSwitchTimeInfoCallBack pbufferSwitchTimeInfo;
}

ASIOBufferSwitchTimeInfoCallBack

これは、TimeInfoが支援されているときに呼び出されるBufferSwitch関数です。BufferSwitch関数とは、新しいバッファが到着したとき呼び出される関数です。この関数では、ASIOTime構造体が引数と戻り値に使われています。

NAudioの実装では、ASIOTimeへの参照をIntPtrで代用しています。ソースコードを見ると、ASIOBufferSwitchTimeInfoCallBackは、入れ物だけで内容は実装されていないので、簡略化のためIntPtrを用いたものと思われます。

アンマネージアドレスを引数とする

createBuffers関数の第1引数は、ASIOBufferInfo配列へのポインターということになっています。マネージオブジェクトのアドレスを求めるとき注意しなければならないのは、マネージオブジェクトは、ガベージコレクタによっていつ場所が移動させられるか分からない、という点です。そのため、NAudioの実装では、unsafeのfixed構文を用いて配列の先頭アドレスを固定しています。

ASIOBufferInfo[] outputBufferInfos = new ASIOBufferInfo[nbTotalChannels];

// ここでoutputBufferInfosの初期化を行う

unsafe
{
    fixed (ASIOBufferInfo* infos = &outputBufferInfos[0])
    {
        IntPtr pOutputBufferInfos = new IntPtr(infos);

        // Create the ASIO Buffers with the callbacks
        driver.createBuffers(pOutputBufferInfos, nbTotalChannels, bufferSize, ref callbacks);
    }
}

しかし、Marshal.UnsafeAddrOfPinnedArrayElement関数を用いることで、同様のことが簡単にできるため、こちらの実装の方が良いと思われます。特にunsafeを指定しなくても良いというメリットがあります。

IntPtr pOutputBufferInfos = Marshal.UnsafeAddrOfPinnedArrayElement(outputBufferInfos, 0);

コールバックアドレスを固定する

上記createBuffers関数に設定されているcallbacks構造体は、ドライバーに伝えるべきコールバック関数のアドレスを保持しています。

ドライバーに設定するアドレスは、アンマネージ環境からアクセスできなければなりません。また、デバイスを運用している間、決して移動したり失われたりしてはいけません。

NAudioの実装では、アドレス情報の作成をasioDriverVTable.createBuffers関数の直前で行い、アロケートした領域の開放をasioDriverVTable.disposeBuffers関数の直後で行っています。

アンマネージ関数に渡す構造体を作成するときは、以下の手順をとります。

  1. Marshal.AllocHGlobal関数を用いて、アンマネージメモリをアロケートします。
  2. Marshal.StructureToPtr関数を用いてマネージ構造体の内容をアンマネージメモリへマーシャリングします。
  3. 利用が終わったら、Marshal.FreeHGlobal関数でアンマネージメモリを開放します。

以下にNAudioの実装を引用します。

IntPtr pinnedcallbacks;

public void createBuffers(IntPtr bufferInfos, int numChannels, int bufferSize, ref ASIOCallbacks callbacks)
{
    pinnedcallbacks = Marshal.AllocHGlobal(Marshal.SizeOf(callbacks));
    Marshal.StructureToPtr(callbacks, pinnedcallbacks, false);
    handleException(asioDriverVTable.createBuffers(pASIOComObject, bufferInfos, numChannels, bufferSize, pinnedcallbacks), "createBuffers");
}

public ASIOError disposeBuffers()
{
    ASIOError result = asioDriverVTable.disposeBuffers(pASIOComObject);
    Marshal.FreeHGlobal(pinnedcallbacks);
    return result;
}
アンマネージ側からマネージオブジェクトにアクセスする手法として、GCHandle.Alloc関数を利用する方法があります。ハンドルをAddrOfPinnedObject関数で固定して用います。この手法は、データがすべてblittableである必要があります。コールバック関数のアドレスを含むASIOCallbacks構造体は、GCHandle.Alloc関数実行時にマーシャリングできないというエラーが発生するため、ここでは利用できません。

64ビットの数値の扱い

ASIO SDKでは、64ビット型の変数を支援しない環境のために、64ビットを扱う構造体を作って対応しています。

実際には、ASIO SDKが設計された時代には、まだ64ビットを支援する環境が無かったため、「asiosys.h」の中で、すべてのOSについてNATIVE_INT64は0と定義されています。

以下にASIOSamplesの例を示します。

#if NATIVE_INT64
    typedef long long int ASIOSamples;
#else
    typedef struct ASIOSamples {
        unsigned long hi;
        unsigned long lo;
    } ASIOSamples;
#endif

この例から分かるように、数値はビッグエンディアンであることが暗黙の了解となっています。Windowsはリトルエンディアンで運用されるため、C#でlongを用いると、上下32ビットが逆転してしまいます。そこで64ビットを扱う文脈では、以下の構造体を用います。

[StructLayout(LayoutKind.Sequential, Pack = 4)]
internal struct ASIO64Bit
{
    public uint hi;
    public uint lo;
    // TODO: IMPLEMENT AN EASY WAY TO CONVERT THIS TO double  AND long
};

NAudioについて

URL

http://naudio.codeplex.com/

ASIO関連のソースの位置

上記のサイトからアーカイブをダウンロードし、以下のディレクトリを参照してください。

NAudio\NAudio\Wave\Asio

ドキュメントの先頭へ

カクタスソフトウェア 技術協力 資料室 資料室の広場 SourceForge.jp お問い合わせ