libsoundio-sharpとPInvokeGeneratorについて

このエントリはC# Advent Calendar 2017の7日目のエントリです。6日目のあめいさん @amay077 のエントリからのバトンを引き継いでいます。

まえがき

.NETエコシステムに圧倒的に足りないもののひとつが、クロスプラットフォームのサウンド系APIです。サウンド系APIは伝統的にプラットフォーム固有のものであり(例: NAudioCSCore)、SharpDX)、これらはクロスプラットフォーム アプリケーションで使うことはできません。これではC#クロスプラットフォームのサウンド系アプリケーションが書かれる日は未来永劫来ないでしょう。

クロスプラットフォームのサウンド系アプリケーションなんてあるんでしょうか? あるんですよ。本格的なのが。

Bitwig Studio 2.2: Bitwig Studio 2.2

Renoise 3.1: Renoise 3.1

Tracktion Waveform8: Waveform8

これ全部Ubuntu 17.04のデスクトップで動いている最先端の商用DAWです(Renoiseはtrackerというべきか…?)。

DAWの多くはVSTをサポートしているのですが、VSTWindowsMacにしか存在しないので、クロスプラットフォームにしても無駄です。Linuxでは代わりにDSSIとかLADSPA、Lv2 (LADSPA v2)が使われますが、独自のエコシステムになっています。…と言われて納得しそうになったでしょう?

VSTは今やフリーソフトウェアなんです。GPLv3で公開されているんです。 https://github.com/steinbergmedia/vst3sdk

一方でVSTクロスプラットフォームで開発するには、JUCEなどに頼りながらC++で行うのが一般的でしょう。これがC#で出来るようになったら開発が捗ると思いませんか? アレ? あんま思わねーな…まあでもUnityで作りたい、みたいな話は、定期的にForumに上がってくるみたいですね。

例によってVST.NETのようなプロジェクトはWindows onlyなので、クロスプラットフォームで実現できるような基盤がほしいところです。vst3sdkのC# wrapperがほしいところですが、それ以前にVST3で生成したオーディオストリームを再生する手段も無いのでは、何を作っても足元がおぼつかないことになります。

というわけで、まずはクロスプラットフォームのオーディオI/O APIから作っていきましょう。

クロスプラットフォームオーディオAPIの構築方法

クロスプラットフォームAPIを構築する方法はごく大まかに分けて2種類の方法があります:

前者は全体的な作業量が多くなりますが、もしプラットフォーム ネイティブAPIを低レベルで呼び出す必要が生じた場合には有利な選択肢です。また、通常はネイティブAPIのライブラリを自前で提供する必要がないので、マネージドライブラリのみを配布すれば良い可能性が高いです。問題は、オーディオI/OのAPIはプラットフォームごとに全然違うので、これらの違いを吸収するようなライブラリは設計も実装も難しいということです。

後者はラッパーをひとつ作成するだけで済むので手軽であり、またクロスプラットフォームAPIの設計にはそれなりの知見が集まっているものなので、高度な機能に依存することでもない限り、そこに便乗したほうが安心感があります。一方で、足りないAPIやサポートされていないプラットフォームがあった場合は、あきらめるか、ネイティブライブラリのほうに手を加える必要が生じてきます。

もっとも、これらは複合的に構築できます。一部のプラットフォームではクロスプラットフォームAPIのラッパーを使い、一部のプラットフォームではプラットフォーム固有のAPIを使用する、といった組み方も現実的でしょう(特にUWPに対応する場合)。

いずれの場合も、ネイティブライブラリを呼び出すにあたっては、そもそもC#のP/Invokeで対応できるライブラリであることが求められ、対応できないものは切り捨てるしかありません。C++のライブラリであれば、CのラッパーAPIを自前で用意するか(作業量が増える)、CppSharpなどのフレームワークに依存する(中間レイヤーが増え、さらに対応可能範囲が狭まる)というややこしい対応が必要になります。(ここでmanaged C++とかC++/CLIを持ち出すと、クロスプラットフォーム対応とは何だったのか…という話になってしまうので割愛します。)

C/C++クロスプラットフォーム オーディオAPIの選択肢

C/C++クロスプラットフォーム オーディオAPIは、多くはありませんが複数存在しています。

  • portaudio: Cで実装されたクロスプラットフォーム オーディオAPIで歴史が古いものはportaudioでしょう。Audacityで使われており、デスクトップ プラットフォームとしてはWindwos, Mac, Linuxで動作します。やや歴史が古いので、OSS (open sound system)などもカバーしています。
  • SDL: ゲーム開発用のライブラリで、主にグラフィックス用だと思うのですが、オーディオAPIも含まれていて、対象プラットフォームは幅広いので、クロスプラットフォーム オーディオの話題でもしばしば登場します。
  • OpenAL: 3Dオーディオ用のAPIです。一般的なオーディオI/OのAPIというわけではないのですが、Android/iOSも含め、守備範囲が広いので、クロスプラットフォーム オーディオAPIの選択肢としてよく挙がってきます。最近だとkotlinconf-spinnerでも使われていましたね。
  • RtAudio: STK (the synthesis toolkit)で使われているライブラリです。RtMidiも含めSSM (single source module)なのが特徴でしょうか(オーディオとは関係ない話ですが)。
  • JUCE: ROLIを中心に開発されており、VST開発で特に重宝されているライブラリです。

いくつかはthe horror of audio outputというblog postに詳しく悪口が書かれている(!)ので、読みたい人は参考にしてもいいかもしれません。3年前なのでいささか情報が古いですが、どのライブラリも古いのでそれなりに今でも当てはまることでしょう…

libsoundio

これらに比べると、libsoundioは比較的新しいライブラリです。libsoundioのサイトでは、portaudioやRtAudio、JUCEなどとの比較を載せています。Windowsではwasapi、MacではCoreAudio、Linuxではjack、ALSA、pulseaudioと、必要最小限のバックエンドをサポートしています。多様なチャネル形態のサポートと、オーディオデバイスの接続イベントなどが取得できるのが、モダンなところでしょうか。

libsoundioのサイトが比較ページで毎回のように挙げている「短所」は、ASIOに対応していないことです。libsoundioのページでは、ASIOよりwasapiのほうが優れているからサポートしないんだ、ということを明言しており、実際にそのような意見が他でも見られます。…と、それだけなら単純なのですが、ASIOが無いと商用サウンドカードの多くが困る、みたいなissueもあったりして、この辺は今後もどうなるか分からないところです。(作者はLinuxで生きているようなので正直どうでもいいのだろうと思います。わたしもLinuxで生きているので正直どうでもいいです…)

もうひとつのlibsoundioの欠点は…というか、ほぼ全てのクロスプラットフォーム オーディオAPIについて言えるのことなのですが…モバイル プラットフォームのサポートがほぼ存在していないことです。iOSはCoreAudioなのでもしかしたらビルドするかもしれませんが、AndroidはOpenSLESやAAudioを使うことになるので、実装は全く存在していません。

libsoundioのさらにもうひとつの懸念点は、開発者の主要なプロジェクトがzig-langであって、libsoundioやその利用事例である自作DAWではなくなっていそうだということでしょうか…。まあいったん作ってしまえばしばらく寝かせておける類のライブラリではあります。

libsoundio-sharpの実装

そういうわけで(?)、今回はlibsoundioのC#バインディングlibsoundio-sharpを作ることにしました。

github.com

ちなみに以前にportaudio-sharpというプロジェクトも作っているのですが、実用例が何一つ無いままに放り投げている状態です(!)。今回は応用事例まで作れるといいなあ…(時間切れで出来ませんでした)

P/Invokeレイヤーの完全自動生成

libsoundioはpure C APIです。これはC#を含む他言語から非常にバインドしやすいものになっていると言えます。のはずなのですが…少々トリッキーなことをやることにはなりました。

C APIの呼び出しなので、libsoundioに手を加えること無く、P/Invokeだけで全て実現しました。さらに、libsoundio-sharpでは、P/Invoke部分は、自作のclangバインディングnclangを利用して完全自動生成しています。もちろん、完全自動生成するために、nclangのサンプルとして作り置きしてあった自動生成ツールPInvokeGenerator.exeには、いくつか手を加えました。PInvokeGeneratorを利用したのは今回が初めてではありませんが、これまでは生成後のファイルに手作業でいろいろ修正を加えて利用していました。今回は生成後のファイルを無修正で使用しています。

ひとつ明確にしておきたいことがありますが、ネイティブライブラリのC#バインディングの作成というのは、C APIのP/Invokeだけ定義すれば終わり、ということにはなりません。それならCでコーディングしているほうがマシです。実のところPInvokeGeneratorでは、P/Invokeメソッドを含むクラスはinternalで定義されます。生成された(C APIをうまいこと包摂した)C#らしいAPIを定義して、初めて実用的なバインディングAPIが出来ると考えるべきでしょう。

さて、P/Invokeレイヤーを完全自動化なんて出来るんでしょうか? 以降は実現を妨げそうなメソッド定義を各論的に取り上げていきます。

refになる引数、outになる引数

libsoundioの出力ストリームへの書き出しを行う関数は、次のように定義されています:

int soundio_outstream_begin_write (
    struct SoundIoOutStream *outstream,
    struct SoundIoChannelArea **areas,
    int *frame_count)

areasは処理結果を格納するポインタへのポインタで、frame_countは入力値が参照され、かつ値が代入されて返される、C#でいうところのrefで修飾された引数です。areasのポインタの示す先には、SoundIoChannelArea構造体が(ここでは定義が見えない)「チャネル数」の分だけ含まれています(毎回メモリ確保されるのではなく、デバイス情報を格納した固定アドレスが返ってくるのだと思います)。Cでは割とよくあるスタイルのAPIですね。類似のパターンとしては、新しいオブジェクトを生成する関数が、引数で渡されたポインタへのポインタに、新しく生成されたオブジェクトを格納したりすることがありますね(戻り値はエラーコードに使われたりとか。int foobarlib_create_foo(Foo** result)みたいな感じで)。

PInvokeGeneratorはこれらをIntPtrにマッピングします:

internal static extern int soundio_outstream_begin_write(
    IntPtr outstream, IntPtr areas, IntPtr frame_count);

こんなAPIで大丈夫か? ちゃんと使えるのでしょうか? 使っている部分を見てみましょう:

public WriteResults BeginWrite (ref int frameCount)
{
    IntPtr ptrs = default (IntPtr);
    int nativeFrameCount = frameCount;
    unsafe {
        var frameCountPtr = &nativeFrameCount;
        var ptrptr = &ptrs;
        var ret = (SoundIoError)Natives.soundio_outstream_begin_write (
            handle, (IntPtr) ptrptr, (IntPtr) frameCountPtr);
        frameCount = *frameCountPtr;
        if (ret != SoundIoError.SoundIoErrorNone)
            throw new SoundIOException (ret);
        return new WriteResults (ptrs, Layout.ChannelCount, frameCount);
    }
}

soundio_outstream_begin_write()の呼び出しで渡されている2番目の引数には、IntPtr型の変数ptrsのアドレス&ptrsを保持しているptrptrが渡されています。ptrptrの型はIntPtrです。2番目の引数はIntPtrなので、IntPtrにキャストします。3番目の引数には、frameCountを格納したnativeFrameCountというint変数へのアドレスframeCountPtrを渡しています。frameCountPtrの型はintなので、これもメソッド定義に合うようIntPtrにキャストします。 &* などの演算子を使えれば何とかなりそうです。

呼び出し後は、frameCountPtrの値をポインタの参照先から取り出します(*frameCountPtr)。ptrptrのほうはもう少しややこしいのですが、これはパフォーマンスを考慮してWriteResultsという独自の構造体で処理しています。この構造体の中ではSoundIOChannelAreaという構造体を取得するGetArea(int channel)というメソッドがあり、これは、(non-publicな)SoundIoChannelArea構造体へのポインタのみを含む、(publicな)SoundIOChannelArea構造体を生成して返します。当初はクラスだったのですが、頻繁に生成して返すことがわかった時点で構造体にしました。

もちろん、C#でrefやoutを使えば、こんなことをする必要はありません。しかしP/Invokeメソッドを全て定期的に自動生成したい場合は、この手法が便利です。自動生成ツールは、どの関数のどの引数がref/outを使うのか判断することが出来ません。swigみたいにマッピング定義ファイルに基づいて行うのも良いでしょうが、それでは手軽さが失われてしまいます。とりあえず今回はマッピング加工無しでどこまでいけるかという実験としました。

構造体へのポインタからメンバーの値を取り出す

P/Invokeと切っても切り離せないMarshalクラスには、ポインタと文字列や構造体の間でマッピングやらマネージド型の生成やらを行うメソッドが数多く含まれています。Marshal.PtrToStructure<T>()Marshal.PtrToStringAnsi()などが典型的な例でしょう。

Marshalクラス、P/Invokeに必要そうな機能はたいてい揃っているように見えるのですが、構造体へのポインタからその構造体メンバーの値を設定するのは容易ではありません:

// C
struct SoundIoChannelArea {
    void *ptr;
    int count;
}

// C#
struct SoundIoChannelArea {
    public IntPtr ptr;
    public int count;
}

struct SoundIOChannelArea {
    IntPtr handle; // SoundIoChannelArea*
}

SoundIOChannelAreaオブジェクトに、Cコードのcountをget/setするCountプロパティを定義するにはどうすればよいでしょうか?

getterは実のところ簡単です:

get { return Marshal.PtrToStructure<SoundIoChannelArea> (handle).count; }

PtrToStructure<T>()でポインタから構造体に変換するだけです。(ただし、(後述しますが)このメソッドの利用はお勧めしません。)

setterはそうはいきません。PtrToStructure<T>()で取得したメモリは、マネージドコードで処理しても呼び出し元に影響を与えないコピーです。コピーの値を変更してもオリジナルには反映されないので意味がないですね。こういう場合には、Marshal.WriteXxx()を使います:

public IntPtr Pointer {
    get { return Marshal.ReadIntPtr (handle, ptr_offset); }
    set { Marshal.WriteIntPtr (handle, ptr_offset, value); }
}
static readonly int ptr_offset = (int) Marshal.OffsetOf<SoundIoChannelArea> ("ptr");

Marshal.OffsetOf<T>(handle)というメソッドで、C#構造体のメンバーのポインタからのオフセットを取得できます。WriteIntPtr()ではこれを利用します。

構造体に含まれる構造体へのポインタを取得する

応用問題として、構造体の中に構造体が(ポインタではなくそのまま)入っている場合があります:

struct SoundIoOutStream // soundio.h (497, 8)
{
    public IntPtr device;
    public SoundIoFormat format;
    public int sample_rate;
    public SoundIoChannelLayout layout;
    ...
}

SoundIoOutStreamをラップするSoundIOOutStreamクラスから、layoutフィールドの値…としてSoundIoChannelLayoutをラップするSoundIOChannelLayoutを返す方法は、自明ではありません。SoundIOChannelLayoutを返すには、SoundIoChannelLayoutのポインタが必要になりますが、Marshal.PtrToStructure<T>()SoundIoOutStreamの構造体のコピーを取得してしまうと、SoundIoChannelLayoutは(いくらオフセットを持っていても)このコピーの中のメモリにしかアクセスできないので意味がありません。

PtrToStructure<T>()がさらにいただけないのは、コレ、必ず引数をboxingするんですよね。boxing不要なメソッドシグネチャの意味とは…(ひとつ考えられる理由としては、P/Invokeではジェネリクスが使えないのでこうなってしまったのだろうと思います。internal callでも制約は同じなのでしょう。)

ともあれ、こういうときは、親はポインタのまま、オブジェクトのハンドルにメンバーのoffsetを加算すれば、子もポインタのまま取得できます:

public SoundIOChannelLayout Layout {
    get { return new SoundIOChannelLayout (handle, layout_offset); }
}
static readonly int layout_offset = (int) Marshal.OffsetOf<SoundIoOutStream> ("layout");

構造体内部にある構造体を値として外部から設定する

上記の類似問題として、今度は値を取得ではなく設定する場合はどうでしょうか。

public SoundIOChannelLayout Layout {
    set { Marshal.WriteOMGWHATDOINEEDHERE<SoundIoOutStream> (handle,  layout_offset, valueOMGWHATSHOULDIDOHERE); }
}
static readonly int layout_offset = (int) Marshal.OffsetOf<SoundIoOutStream> ("layout");

任意の構造体を読み書きできるようなメソッドはMarshalクラスには無いんですね。こういう時はBuffer.MemoryCopy()を使うとよいでしょう:

unsafe {
    Buffer.MemoryCopy ((void*)((IntPtr)handle + layout_offset), (void*)value.Handle,
               Marshal.SizeOf<SoundIoChannelLayout> (), Marshal.SizeOf<SoundIoChannelLayout> ());
}

corefxではBuffer.MemoryCopy()みたいな古いAPIでも今年になって最適化が図られていたりするので、monoもこっちベースにならんかなあとか思うわけですが、とりあえず今のところはreferencesourceベースのようです。まあでもそれなりの速度は出るでしょう。

あとcorefxにはSystem.Runtime.CompilerServices.Unsafeというクラスが追加されていて、ReadWriteReadUnalignedWriteUnalignedなど便利そうなやつを持っているので、これを使うという手もありそうです。ilasmのソースで実装されていてP/Invokeやinternal callはしていなさそうなので、多分monoでも使えるのではないかと思いますが(古いUnityとか無理そう)、今回は依存しないことにしました。

C#配列にIntPtrからデータをコピーする

SoundIOInStreamのread callbackから取得したデータはIntPtrでチャネルごとに格納されてきますが、これをたとえばSystem.IO APIを使ってファイルに保存したい場合は、byte配列などに格納してやらなければならなくなります。これはちょっと面倒ですね。

Marshal.UnsafeAddressOfPinnedArrayElement()を使ってポインタにしてしまえば、Buffer.MemoryCopy()が使えるのですが、いったんpinned objectにしないといけないはずなので、このメソッドにそのまま渡すだけではダメでしょう。こういうときは単に配列にfixedを使うとよいでしょう(使えます):

var arr = new byte [capacity];
unsafe {
    fixed (void* arrptr = arr) {
        int fill_bytes = ring_buffer.FillCount;
        IntPtr read_buf = ring_buffer.ReadPointer;
        Buffer.MemoryCopy ((void*)read_buf, arrptr, fill_bytes, fill_bytes);

その他のP/Invokeトラブルシューティング

  • memset()でゼロ化する方法が無い。
    • これはmonoランタイム限定の裏技ですが、kernel32 APIのCopyMemory, FillMemory, MoveMemory, ZeroMemoryに限って言えば、kernel32.dllを(擬似的に)DllImportすることで、これらに相当するmonoランタイム内にある代替実装が機能するかもしれません。mono/data/config.inを参照。あるいはdll.configではplatformによる条件指定や名前の再マッピングもできるようになっているので、必要だったらそれらを駆使すればいけます(と断言していますが未検証)。
    • Unsafeの中にInitBlock()なるメソッドがあるのを発見しました。CILのinitblock命令を呼び出すだけに近い、かなりシンプルな内容ですが、目的はこれで果たせそうです。

monoと.NET Coreの同時サポート

今回のlibsoundio-sharpは、.NET Coreプロジェクトとしてもビルドできます。実体は極めてシンプルで、ライブラリ用の.csprojとNUnitテスト用の.csprojの2ファイルが存在するだけです。どちらも、"SDK"を指定する新しい方式の.csprojで、ソースファイルは(サブディレクトリには無いので自動化はできず)ワイルドカードで指定しているだけです。これだけなら5分もあれば.NET Core対応できますね。サンプルは対応していませんが…まあ5分もあればできるんじゃないですか? 誰にでも。

テストには NUnit3TestAdapter を使っています。dotnet test のみでビルド・実行できるようになっています。最初はxunitで独自にテストを書いてdotnet xunitで実行していたのですが、NUnitと両方メンテするのはバカバカしいのですぐに止めました。いったん2つの*.csprojを作成した後は、無変更でテストできています。

ちなみに「monoと…」と書いていますが、.NET Framework + Windowsでも当然使えるはずです(動作確認はしていないけど、.NET Coreでは動いているし…)。

System.IntPtrPointer<T>の使い分け

PInvokeGeneratorは、JNAやBridJなどでもよく使われる「ポインタを表現する型」としてPointer<T>という型を追加するのですが(ちなみに全部生成します。「ランタイム」を作りたくないので)、これはDllImportされるメソッドの定義などでは使われていません。.NET (Framework / Core) のP/Invokeジェネリック型を受け付けないんですね。monoランタイムの環境だと通ったりするので、.NET Coreで動かしてみて初めて気づいたりしました。

実用上はimplicit演算子の実装を追加しているので、あまり気にする場面は無いと思います。

Pointer<T>みたいな基本的な型を定義してP/Invokeで使えない、というのは、C#の重大な欠点のひとつだと思います。Kotlin/Nativeのcinteropを見てみましょうCPointer<T>CValuesRef<T>CValue<T>など、ジェネリクスが過剰な制限に足を引っ張られること無くふんだんに活用されています。

C# 7.2は値型をある程度柔軟に扱えるようになりましたが、この辺りにはまだまだ他の言語を見習って改善する余地がありますね。

次の課題

PInvokeGeneratorでC#のコードは自動生成できて楽になった(?)のですが、サウンド関連のコードは前述のvst3sdkも含め、けっこうC++のものが多いんですよね…というわけで、今度はC++APIから一旦CのAPIを自動生成して、それをP/Invokeできるような仕組みを考えています。CppSharpで出来そうな領域ですが、CppSharpがなかなか安定してLinux上でビルドできない/動作しないので自分で作りそうです(nclangともだいぶ役割がかぶっている)。まあ何かしら成果が出たら公開すると思います。

以上で7日目の分はおしまいです。明日は増田さん @moonmile のターンです(なにこのXamarinシーケンス)。よろしくお願いします。