Android AMidi (Native MIDI) APIについて

概要

Android Qで新しく追加されるNative MIDI APIは、Android NDKを利用してビルドされるネイティブコードで、Androidシステム上に登録されている「MIDIバイス」にアクセスするためのAPIだ。ネイティブコードで記述することで、ある種のMIDI関連タスクの処理負荷が軽減され、リアルタイム性が向上することが期待される(「リアルタイム性」についてはこの過去記事などを参照)。

Native MIDI APIはC APIとして公開されており、amidi/AMidi.hをインクルードして、libamidi.soを共有ライブラリとして参照してビルドする。毎回Native MIDI APIと書くのは長いので、以降はAMidiと記す。

Android MIDI API (Java) のおさらい

Android MIDI APIAndroid 6.0(Marshmallow)で追加された。AndroidMIDIバイスには、USBデバイス、BLEデバイス、ユーザー/アプリケーション実装によるMIDIバイスサービスの3種類がある。よくiOSMIDIバイスとして販売されているBLE製品は、Appleの仕様に基づいてMMAMIDI Manifacturers Association)で規定されたMIDI-BLE仕様を実装しているAndroidでも使えるはずだ。

JavaAndroid MIDI APIandroid.media.midiパッケージにある。Android上でシステムに登録されたMIDIバイスを利用するためには、Androidのシステムサービスとして登録されたMidiManagerにアクセスして、Javaオブジェクトとして存在するMidiDeviceを取得する必要があり、ART上のJavaオブジェクトを経由せずにAndroidシステムが管理するMIDIバイスにアクセスすることはできない(C/C++のコードだけで完結するとしてもJNIEnvオブジェクトやMidiManagerオブジェクト、MidiDeviceオブジェクトを扱う必要がある)。

Androidのシステム登録と無関係にMIDIバイスを接続するのであれば、システムが実装しているUSB接続やBLE接続に相当するコードを自分で用意しなければならない。仮想MIDIバイスなら特に難しいことはないだろう。*1

機能

AMidiはJavaandroid.media.midiパッケージのAPIに相当する機能の全てを提供しているわけではない。AMidiの機能の範囲は、次のように、ほぼMIDIアプリケーション(クライアント)側の、入出力ポートにおけるMIDIメッセージの送受信に限定されている。

  • MIDIバイスをあらわす構造体AMidiDeviceの入出力ポートAMidiInputPortAMidiOutputPortを開閉する
  • ポートの数は取得できるが、ポート情報の詳細は一切取得できない
  • MIDIバイスの詳細情報は一切取得できない
  • 入力ポートAMidiOutputPortからMIDIメッセージを受け取る
  • 出力ポートAMidiInputPortMIDIメッセージを送る

AMidi.hで宣言されている型は以下の3つ(内容は未公開)、関数は以下の13件しかない。

型:

typedef struct AMidiDevice AMidiDevice
typedef struct AMidiInputPort AMidiInputPort
typedef struct AMidiOutputPort AMidiOutputPort

関数:

media_status_t AMIDI_API AMidiDevice_fromJava(
    JNIEnv *env, jobject midiDeviceObj, AMidiDevice **outDevicePtrPtr)
media_status_t AMIDI_API AMidiDevice_release(
    const AMidiDevice *midiDevice)
int32_t AMIDI_API AMidiDevice_getType(
    const AMidiDevice *device)
ssize_t AMIDI_API AMidiDevice_getNumInputPorts(
    const AMidiDevice *device)
ssize_t AMIDI_API AMidiDevice_getNumOutputPorts(
    const AMidiDevice *device)
media_status_t AMIDI_API AMidiOutputPort_open(
    const AMidiDevice *device, int32_t portNumber, AMidiOutputPort **outOutputPortPtr)
void AMIDI_API AMidiOutputPort_close(
    const AMidiOutputPort *outputPort)
ssize_t AMIDI_API AMidiOutputPort_receive(
    const AMidiOutputPort *outputPort, int32_t *opcodePtr, uint8_t *buffer, size_t maxBytes,
    size_t* numBytesReceivedPtr, int64_t *outTimestampPtr)
media_status_t AMIDI_API AMidiInputPort_open(
    const AMidiDevice *device, int32_t portNumber, AMidiInputPort **outInputPortPtr)
ssize_t AMIDI_API AMidiInputPort_send(
    const AMidiInputPort *inputPort, const uint8_t *buffer, size_t numBytes)
ssize_t AMIDI_API AMidiInputPort_sendWithTimestamp(
    const AMidiInputPort *inputPort, const uint8_t *buffer, size_t numBytes, int64_t timestamp)
media_status_t AMIDI_API AMidiInputPort_sendFlush(
    const AMidiInputPort *inputPort)
void AMIDI_API AMidiInputPort_close(
    const AMidiInputPort *inputPort)

バイス詳細情報やポート詳細情報など、文字列が関連してくるAPIについては、C/C++のコードでどのようなデータ表現を用いるのか、定まった解がなく、AMidi APIはこれらをうまいこと回避しているともいえる。

ひとつ重要なことを指摘しておく必要があろう。MidiDeviceインスタンスは、アプリケーション側であるMidiManagerを経由してのみ取得できるものなので、MidiDeviceServiceの実装でこれを取得することはできない。つまり、MidiDeviceServiceではAMidiは使用できないということである。後述するfluidsynth-midi-serviceでこれを利用できる可能性はゼロだ。

Javaコードとの棲み分け

AMidiは処理速度が重要になるMIDIメッセージの送受信に特化したネイティブライブラリであり、それ以外の機能についてはJavaAPIが使われることが想定される。 AMidiのエントリーポイントはAMidiDevice_fromJava()関数であり、AMidiを使うためには少なくともJava側でandroid.media.midi.MidiDeviceインスタンスを取得する必要がある。

そこから先は、MIDIアプリケーションの目的によって、利用できるAPIの向きが変わる。

  • (1) 外部MIDIバイスからのMIDIメッセージを受け取って処理するアプリケーションでは、MIDI入力ポートからのメッセージの受け取りをandroid.media.midi.MidiOutputPortからではなくAMidiOutputPort_receive()で受け取って処理するようにすると、Native MIDI APIによってMIDIメッセージの送受信が軽量化される可能性がある。(MIDI音源と接続したり仮想MIDI音源を操作するタイプのMIDIバイスサービス実装では、MidiDeviceインスタンスを取得できないので、この可能性は無い。)

  • (2) 外部MIDI音源MIDIメッセージを送るアプリケーションでは、MIDI出力ポートへのメッセージの送信をandroid.media.midi.MidiInputPortではなくAMidiInputPort_send()あるいはAMidiInputPort_sendWithTimestamp()で処理するようにすると、内部的に軽量化される可能性がある。(カスタムMIDIバイスと接続するMIDIバイスサービス実装では、MidiDeviceインスタンスを取得できないので、この可能性は無い。)

いずれの場合も、従来のJavaandroid.media.midiAPIでは、対象MIDIバイスとの接続にはAndroidのServiceの仕組みに基づいてメッセージがParcelにシリアライズされ、Parcelとして送受信され、Parcelから元のデータがデシリアライズされる必要があった。この部分がAMidiで最適化されている可能性はある。

この節で何度も「可能性がある」と留保付きの表現を用いているのは、ここはプレビュー時点でソース未公開のAndroid Qのフレームワーク実装の部分なので情報がないためである。一般的には異なるプロセス同士でメモリエリアを共有することはできないが、Parcelが内部的に使用しているbinderの仕組みでサービス側とメモリを直接やり取りしている可能性はある。Android Qで新たに追加された機能ということは、フレームワークのレベルで改良される必要があったということがうかがわれる。

Android 8.0から、binderのドライバーがrealtimeの優先度で動作できるようになっている。これがMidiDeviceServiceを経由するMIDIメッセージのやり取りに使用されている可能性もある、と筆者は想像している。

実用未遂: fluidsynth mdriverのAMidiサポート

筆者は(実のところさほど明確な目的もなく)Fluidsynthにパッチを送り続けているのだが、今回もFluidsynthにAMidiを応用するMIDIドライバーを作ってみた。自前のgithub fork上で公開しており、公式にもissueとして登録してあった。(ただ、前述のとおりMidiDeviceServiceで使える目処が立たなかったので、このissueは閉じてある。)

Fluidsynthは、その起源はLinux用のソフトウェア・シンセサイザーだが、クロスプラットフォームで動作するように、プラットフォーム固有の「ドライバー」の上に音声合成の共通コードが乗っかっているかたちになっている。ドライバーには、プラットフォームのオーディオAPIにアクセスするオーディオドライバー(以降ソースコードの名前に合わせてadriver)と、MIDI APIにアクセスしてそこから受け取ったMIDIメッセージをそのまま合成エンジンに高速に渡すことができるMIDIドライバー(以降mdriver)がある。mdriverは、fluidsynth(.exe)で内部的に利用できるオプション機能だ(使わなくてもよい)。

mdriverを使わない場合、合成エンジンに対してnote on, program change, control changeなどの機能を実装するC APIを呼び出すかたちになる。この場合、MIDIメッセージとして受信したバイト列をアプリケーション側が自前で解析して、対応するAPIを呼び出す必要がなる。

mdriverはnew_fluid_midi_driver()という関数で生成するが、このときコールバック関数としてhandle_midi_event_func_tを渡すことになる。この中で、別途生成したsynthエンジン(fluid_synth_t)で直接合成させてもよいし(fluidsynth(.exe)が行っているのはこれだ)、別の処理を挟んでもよい。Android上では、OpenSLESやOboeが有効になっていないsynthオブジェクトにMIDIドライバーが受け取ったMIDIメッセージを処理させても、あまり意味がない(MIDIメッセージのフィルタリングくらいはできる)が、幸いなことに最新のfluidsynth masterには筆者のOboe/OpenSLESドライバーが含まれているので、恩恵を受けることが出来る。

今回考えたのはNative MIDI APIを応用して、AMidiのmdriverを実装する、というものである。Android MIDI API経由でFluidsynthのMidiReceiverが受信したMIDIメッセージを、JNIマーシャリングコストなしでfluidsynthの合成エンジンが処理できるか試してみる、という感じである。しかしMidiDeviceServiceからAMidiへのエントリーポイントが全く存在しないため、実現可能性は無かった。

潜在的な需要: オーディオプラグイン機構のサポート

前述した話だが、Android 8.0ではリアルタイム優先度のbinderドライバーが利用可能になっている。これによって潜在的に大きな恩恵を受けることが出来るのがオーディオルーティングを伴うアプリケーションだ。オーディオ編集アプリケーションが、オーディオエフェクトをサービスとして実装するアプリケーションを、オーディオプラグインとして利用できたら、なかなかおもしろいことになるのではないかと筆者は想像している。(前節でも重ねて「可能性がある」と書いてきたのと同じで、まだソースが公開されていないので、ここは想定でしかない。)

Android 8.0未満の環境では、binderを経由してデータを渡して処理させたら、リアルタイム優先度を維持することが不可能だった。リアルタイム優先度を維持できないと、オーディオ処理に遅延が生じることになる。それを受け容れるというのも一つのあり方だが、レコーディング時やライブコーディング時など、限りなく「絶対に」に近いかたちで遅延を回避したい状況というのはあるだろう。

オーディオ? Native MIDI APIMIDIAPIではないのか? という疑問が湧いてくると思うが、音楽制作で一般的に使われるVSTAU (Audio Unit)、LADSPAなどオーディオプラグインの世界では、一般的にMIDIメッセージがプラグインの制御に使われている(もう少し正確にいえば、MIDIメッセージを変換して各オーディオプラグインのパラメーターを制御するようになっている。オーディオプラグインの制御範囲はMIDIの表現できる範囲に限定されない)。このメッセージング機構がリアルタイム優先度で行われるようになれば(もちろんオーディオ処理のほうがはるかに大きな制約になるわけだが)、オーディオホスト/プラグイン機構を実現するための壁がひとつ取り払われることになるのではないか、と思っている。

ついでに自分の予言者ごっこtweetも貼っておこう。

既存プラットフォームをサポートしつつamidiサポートを追加する

AMIDIサポートをネイティブコードで実装してするとなると、amidi.soをshared libraryとしてビルド時に参照することになる。これは、実行時にAndroid P以前のプラットフォームでは「使わない」ようにする必要がある。Javaのコードであれば、android.os.Build.VERSIONなどを使って条件分岐すれば足りるが、ネイティブの共有ライブラリの場合はそこまで単純ではないので注意が必要だ。

dlopen()で共有ライブラリがロードされるとき、デフォルトではそのライブラリが参照している共有ライブラリが連動してロードされる。Cの呼び出しを直接制御しているのであれば、dlopenの引数にRTLD_LAZYを指定すれば、シンボルの解決だけは先送りすることができるが、参照ライブラリのロード自体は即時に行われるので、Build.VERSIONの比較によって「実行されない」ようにコードを書いていても、JavaでいうところのUnsatisfiedLinkErrorが生じることは避けられない。

また、筆者のfluidsynthjnaのように、JNAeratorのような自動バインディング機構を活用していると、シンボル解決もロード時に全て行われる実装になるため、RTLD_LAZYにも効果がない。ダミーとなるライブラリを用意するとしても、シンボル自体が全て存在していなければならないことになる。

実のところこの問題には先例となるソリューションがある。GoogleはOboeでaaudioをサポートしているが、aaudioをサポートしたoboeがAndroid 27以前の端末でもUnsatisfiedLinkErrorを起こさずに実行できるのは、共有ライブラリとしてlibaaudio.soにリンクすることなく、dlopen()を使ってAAudioの関数を動的にロードしているためである。

https://github.com/google/oboe/blob/c278091a/src/aaudio/AAudioLoader.h#L67

古いプラットフォームでも動作するようにamidiを部分的にサポートするライブラリをビルドしようと思ったら、同様の仕組みを実現しなければならない。というわけで、自分で実装してみた。ただし一度も使っていないコードなので動作しない可能性が割とある。

https://github.com/atsushieno/fluidsynth-fork/blob/d92cbc5/src/drivers/fluid_android_amidi.c#L40

*1:たとえば、Google/music-synthesizer-for-AndroidにはMIDIメッセージを処理する機能があるが、Android MIDI APIとは無関係だ。