Native MIDI API

AMidi API は Android NDK r20b 以降で使用できます。アプリ デベロッパーは、C / C++ コードを使用して MIDI データを送受信できます。

通常、Android MIDI アプリは midi API を使用して Android MIDI サービスと通信します。MIDI アプリは主に MidiManager を利用して、1 つ以上の MidiDevice オブジェクトの検出、開始、終了を行います。また、デバイスの MIDI 入力ポートおよび出力ポートを介して各デバイスとデータの送受信を行います。

AMidi を使用するには、JNI 呼び出しによって MidiDevice のアドレスをネイティブ コード レイヤに渡します。次に、AMidi は MidiDevice のほとんどの機能を持つ AMidiDevice への参照を作成します。ネイティブ コードは AMidi 関数を使用して AMidiDevice と直接通信します。AMidiDevice は MIDI サービスに直接接続します。

AMidi 呼び出しを使用すると、アプリの C / C++ の音声 / 制御ロジックを MIDI 送信と密接に統合できます。これにより、JNI 呼び出しやアプリの Java サイドへのコールバックを行う必要性が低くなります。たとえば、C コードで実装されたデジタル シンセサイザーは、JNI 呼び出しで Java サイドからイベントが送信されるのを待つのではなく、直接 AMidiDevice から主要イベントを受け取ることができます。または、主要イベントの送信のために Java サイドにコールバックすることなく、アルゴリズムの作成プロセスによって MIDI パフォーマンスを AMidiDevice に直接送信することもできます。

AMidi によって MIDI デバイスへの直接接続は改善されますが、アプリが MidiDevice オブジェクトを検出して開始するには、引き続き MidiManager を使用する必要があります。その後、AMidi がオブジェクトを取得できます。

場合によっては、UI レイヤからネイティブ コードに情報を渡す必要があります。たとえば、画面上のボタンに応答して MIDI イベントを送信するときなどです。この場合は、ネイティブ ロジックへのカスタム JNI 呼び出しを作成します。データを送り返して UI を更新する必要がある場合は、通常どおりネイティブ レイヤからコールバックできます。

このドキュメントでは、AMidi ネイティブ コード アプリの設定方法を紹介して、MIDI コマンドを送受信する例を示します。 完全に機能する例については、NativeMidi サンプルアプリをご確認ください。

AMidi を使用する

AMidi を使用するすべてのアプリのセットアップ手順と終了手順は、MIDI を送信または受信する場合も、送受信ともに行う場合も同じです。

AMidi を開始する

アプリは Java サイドで付属の MIDI ハードウェアを検出し、対応する MidiDevice を作成してネイティブ コードに渡す必要があります。

  1. Java MidiManager クラスで MIDI ハードウェアを検出します。
  2. MIDI ハードウェアに対応する Java MidiDevice オブジェクトを取得します。
  3. JNI を使用して、Java MidiDevice をネイティブ コードに渡します。

ハードウェアとポートの検出

入力ポートと出力ポートのオブジェクトはアプリに属しているのではなく、MIDI デバイスのポートを指しています。MIDI データをデバイスに送信するために、アプリは MIDIInputPort を開いてデータを書き込みます。逆にデータを受信するときは MIDIOutputPort を開きます。正常に送受信するためには、アプリが正しいタイプのポートを開く必要があります。デバイスとポートの検出は Java サイドで行われます。

各 MIDI デバイスを検出してそのポートを見るメソッドを下記に示します。このメソッドは、データ受信用の出力ポートを持つデバイスのリストか、データ送信用の入力ポートを持つデバイスのリストを返します。1 つの MIDI デバイスが入力ポートと出力ポートの両方を持つことがあります。

Kotlin

private fun getMidiDevices(isOutput: Boolean) : List {
    if (isOutput) {
        return mMidiManager.devices.filter { it.outputPortCount > 0 }
    } else {
        return mMidiManager.devices.filter { it.inputPortCount > 0 }
    }
}

Java

private List getMidiDevices(boolean isOutput){
  ArrayList filteredMidiDevices = new ArrayList<>();

  for (MidiDeviceInfo midiDevice : mMidiManager.getDevices()){
    if (isOutput){
      if (midiDevice.getOutputPortCount() > 0) filteredMidiDevices.add(midiDevice);
    } else {
      if (midiDevice.getInputPortCount() > 0) filteredMidiDevices.add(midiDevice);
    }
  }
  return filteredMidiDevices;
}

C コードまたは C++ コードで AMidi 関数を使用するには、AMidi/AMidi.h を含めて amidi ライブラリにリンクさせる必要があります。これらはどちらも Android NDK に用意されています。

Java サイドは、JNI 呼び出しを介して 1 つ以上の MidiDevice オブジェクトとポート番号をネイティブ レイヤに渡す必要があります。次に、ネイティブ レイヤは以下の手順を実行する必要があります。

  1. Java の MidiDevice ごとに、AMidiDevice_fromJava() を使用して AMidiDevice を取得します。
  2. AMidiInputPort_open() または AMidiOutputPort_open()(あるいはその両方)を使用して、AMidiDevice から AMidiInputPort または AMidiOutputPort(あるいはその両方)を取得します。
  3. 取得したポートを使用して、MIDI データの送受信を行います。

AMidi の停止

Java アプリは、MIDI デバイスを使用しなくなったら、リソースを解放するようネイティブ レイヤに通知する必要があります。その原因となるのは、MIDI デバイスとの接続が切れた場合や、アプリが終了した場合です。

MIDI リソースを解放するには、以下の作業を行うコードが必要です。

  1. MIDI ポートに対する読み取りや書き込みを停止します。入力のポーリングに読み取りスレッドを使用していた場合(下記のポーリング ループの実装を参照)、そのスレッドを停止します。
  2. AMidiInputPort_close() 関数または AMidiOutputPort_close() 関数(あるいはその両方)を使用して、開いている AMidiInputPort オブジェクトまたは AMidiOutputPort オブジェクト(あるいはその両方)を閉じます。
  3. AMidiDevice_release() を使用して AMidiDevice を解放します。

MIDI データの受信

MIDI を受信する MIDI アプリの典型的な例として、MIDI パフォーマンス データを受信して音声合成を制御する「仮想シンセサイザー」があります。

MIDI データは非同期的に受信されるため、1 つ以上の MIDI 出力ポートを継続的にポーリングする別個のスレッドで MIDI を読み取ることをおすすめします。これはバックグラウンド スレッドでも、音声スレッドでも構いません。AMidi はポートからの読み取りをブロックしないので、音声コールバック内で使用すると安全です。

MidiDevice と出力ポートの設定

アプリはデバイスの出力ポートから受信する MIDI データを読み取ります。アプリの Java サイドで、使用するデバイスとポートを決定する必要があります。

下記のスニペットでは、Android の MIDI サービスから MidiManager を作成し、最初に検出したデバイスの MidiDevice を開始します。MidiDevice が開始されると、MidiManager.OnDeviceOpenedListener() のインスタンスがコールバックを受け取ります。このリスナーの onDeviceOpened メソッドが呼び出されると、さらにこのメソッドが startReadingMidi() を呼び出して、デバイスの出力ポート 0 を開きます。これは、AppMidiManager.cpp で定義されている JNI 関数です。この関数については次のスニペットをご覧ください。

Kotlin

//AppMidiManager.kt
class AppMidiManager(context : Context) {
  private external fun startReadingMidi(midiDevice: MidiDevice,
  portNumber: Int)
  val mMidiManager : MidiManager = context.getSystemService(Context.MIDI_SERVICE) as MidiManager

  init {
    val midiDevices = getMidiDevices(true) // method defined in snippet above
    if (midiDevices.isNotEmpty()){
      midiManager.openDevice(midiDevices[0], {
        startReadingMidi(it, 0)
      }, null)
    }
  }
}

Java

//AppMidiManager.java
public class AppMidiManager {
  private native void startReadingMidi(MidiDevice device, int portNumber);
  private MidiManager mMidiManager;
  AppMidiManager(Context context){
    mMidiManager = (MidiManager)
      context.getSystemService(Context.MIDI_SERVICE);
    List midiDevices = getMidiDevices(true); // method defined in snippet above
    if (midiDevices.size() > 0){
      mMidiManager.openDevice(midiDevices.get(0),
        new MidiManager.OnDeviceOpenedListener() {
        @Override
        public void onDeviceOpened(MidiDevice device) {
          startReadingMidi(device, 0);
        }
      },null);
    }
  }
}

ネイティブ コードは、Java サイドの MIDI デバイスとそのポートを AMidi 関数で使用する参照に変換します。

以下の JNI 関数は、AMidiDevice_fromJava() を呼び出して AMidiDevice を作成してから、AMidiOutputPort_open() を呼び出してデバイスの出力ポートを開きます。

AppMidiManager.cpp

AMidiDevice midiDevice;
static pthread_t readThread;

static const AMidiDevice* midiDevice = AMIDI_INVALID_HANDLE;
static std::atomic<AMidiOutputPort*> midiOutputPort(AMIDI_INVALID_HANDLE);

void Java_com_nativemidiapp_AppMidiManager_startReadingMidi(
        JNIEnv* env, jobject, jobject deviceObj, jint portNumber) {
    AMidiDevice_fromJava(j_env, deviceObj, &midiDevice);

    AMidiOutputPort* outputPort;
    int32_t result =
      AMidiOutputPort_open(midiDevice, portNumber, &outputPort);
    // check for errors...

    // Start read thread
    int pthread_result =
      pthread_create(&readThread, NULL, readThreadRoutine, NULL);
    // check for errors...

}

ポーリング ループの実装

MIDI データを受信するアプリは、出力ポートをポーリングし、AMidiOutputPort_receive() がゼロより大きい数値を返したときに応答する必要があります。

MIDI スコープのような低帯域幅のアプリの場合は、優先順位の低いバックグラウンド スレッドで(適切なスリープを指定して)ポーリングできます。

音声を生成する、リアルタイム パフォーマンス要件が厳しいアプリの場合は、メインの音声生成コールバック(OpenSL ES の BufferQueue コールバック、AAudio の AudioStream データ コールバック)でポーリングできます。AMidiOutputPort_receive() は非ブロック性であるため、パフォーマンスへの影響はほとんどありません。

上記の startReadingMidi() 関数で呼び出される readThreadRoutine() 関数は次のようになります。

void* readThreadRoutine(void * /*context*/) {
    uint8_t inDataBuffer[SIZE_DATABUFFER];
    int32_t numMessages;
    uint32_t opCode;
    uint64_t timestamp;
    reading = true;
    while (reading) {
        AMidiOutputPort* outputPort = midiOutputPort.load();
        numMessages =
              AMidiOutputPort_receive(outputPort, &opCode, inDataBuffer,
                                sizeof(inDataBuffer), &timestamp);
        if (numMessages >= 0) {
            if (opCode == AMIDI_OPCODE_DATA) {
                // Dispatch the MIDI data….
            }
        } else {
            // some error occurred, the negative numMessages is the error code
            int32_t errorCode = numMessages;
        }
  }
}

ネイティブ オーディオ API(OpenSL ES や AAudio など)を使用するアプリは、次のように音声生成コールバックに MIDI 受信コードを追加できます。

void bqPlayerCallback(SLAndroidSimpleBufferQueueItf bq, void */*context*/)
{
    uint8_t inDataBuffer[SIZE_DATABUFFER];
    int32_t numMessages;
    uint32_t opCode;
    uint64_t timestamp;

    // Read MIDI Data
    numMessages = AMidiOutputPort_receive(outputPort, &opCode, inDataBuffer,
        sizeof(inDataBuffer), &timestamp);
    if (numMessages >= 0 && opCode == AMIDI_OPCODE_DATA) {
        // Parse and respond to MIDI data
        // ...
    }

    // Generate Audio…
    // ...
}

MIDI 読み取りアプリのフローを次の図に示します。

MIDI データの送信

MIDI 書き込みアプリの典型的な例は、MIDI コントローラや MIDI シーケンサです。

MidiDevice と入力ポートの設定

アプリは送信 MIDI データを MIDI デバイスの入力ポートに書き込みます。アプリの Java サイドで、使用する MIDI デバイスとポートを決定する必要があります。

以下のセットアップ コードは、上記の受信例のバリエーションです。Android の MIDI サービスから MidiManager を作成し、検出した最初の MidiDevice を開きます。次に startWritingMidi() を呼び出して、デバイス上で最初の入力ポートを開きます。これは、AppMidiManager.cpp で定義されている JNI 呼び出しです。この関数については次のスニペットをご覧ください。

Kotlin

//AppMidiManager.kt
class AppMidiManager(context : Context) {
  private external fun startWritingMidi(midiDevice: MidiDevice,
  portNumber: Int)
  val mMidiManager : MidiManager = context.getSystemService(Context.MIDI_SERVICE) as MidiManager

  init {
    val midiDevices = getMidiDevices(false) // method defined in snippet above
    if (midiDevices.isNotEmpty()){
      midiManager.openDevice(midiDevices[0], {
        startWritingMidi(it, 0)
      }, null)
    }
  }
}

Java

//AppMidiManager.java
public class AppMidiManager {
  private native void startWritingMidi(MidiDevice device, int portNumber);
  private MidiManager mMidiManager;

  AppMidiManager(Context context){
    mMidiManager = (MidiManager)
      context.getSystemService(Context.MIDI_SERVICE);
    List midiDevices = getMidiDevices(false); // method defined in snippet above
    if (midiDevices.size() > 0){
      mMidiManager.openDevice(midiDevices.get(0),
        new MidiManager.OnDeviceOpenedListener() {
        @Override
        public void onDeviceOpened(MidiDevice device) {
          startWritingMidi(device, 0);
        }
      },null);
    }
  }
}

以下の JNI 関数は、AMidiDevice_fromJava() を呼び出して AMidiDevice を作成してから、AMidiInputPort_open() を呼び出してデバイスの入力ポートを開きます。

AppMidiManager.cpp

void Java_com_nativemidiapp_AppMidiManager_startWritingMidi(
       JNIEnv* env, jobject, jobject midiDeviceObj, jint portNumber) {
   media_status_t status;
   status = AMidiDevice_fromJava(
     env, midiDeviceObj, &sNativeSendDevice);
   AMidiInputPort *inputPort;
   status = AMidiInputPort_open(
     sNativeSendDevice, portNumber, &inputPort);

   // store it in a global
   sMidiInputPort = inputPort;
}

MIDI データの送信

アプリ自体が MIDI データを送信するタイミングを十分に理解して制御するため、MIDI アプリのメインスレッドでデータ送信を実行できます。 ただし、シーケンサの場合など、パフォーマンス上の理由から、MIDI の生成と送信を別のスレッドで行うこともあります。

アプリは、必要なときはいつでも MIDI データを送信することができます。データの書き込み時に AMidi はブロックします。

次に、MIDI コマンドのバッファを受け取って書き出す JNI メソッドの例を示します。

void Java_com_nativemidiapp_TBMidiManager_writeMidi(
JNIEnv* env, jobject, jbyteArray data, jint numBytes) {
   jbyte* bufferPtr = env->GetByteArrayElements(data, NULL);
   AMidiInputPort_send(sMidiInputPort, (uint8_t*)bufferPtr, numBytes);
   env->ReleaseByteArrayElements(data, bufferPtr, JNI_ABORT);
}

MIDI 書き込みアプリのフローを次の図に示します。

Callbacks

厳密には AMidi の機能ではありませんが、ネイティブ コードでデータを Java サイドに返す必要が生じることがあります(たとえば UI の更新)。この場合は、Java サイドとネイティブ レイヤでコードを記述する必要があります。

  • Java サイドでコールバック メソッドを作成する。
  • コールバックを呼び出すために必要な情報を格納する JNI 関数を記述する。

コールバックするときになったら、ネイティブ コードで構築できます。

Java サイドのコールバック メソッド onNativeMessageReceive() を次に示します。

Kotlin

//MainActivity.kt
private fun onNativeMessageReceive(message: ByteArray) {
  // Messages are received on some other thread, so switch to the UI thread
  // before attempting to access the UI
  runOnUiThread { showReceivedMessage(message) }
}

Java

//MainActivity.java
private void onNativeMessageReceive(final byte[] message) {
        // Messages are received on some other thread, so switch to the UI thread
        // before attempting to access the UI
        runOnUiThread(new Runnable() {
            public void run() {
                showReceivedMessage(message);
            }
        });
}

以下は、MainActivity.onNativeMessageReceive() へのコールバックを設定する JNI 関数の C コードです。Java の MainActivity は起動時に initNative() を呼び出します。

MainActivity.cpp

/**
 * Initializes JNI interface stuff, specifically the info needed to call back into the Java
 * layer when MIDI data is received.
 */
JNICALL void Java_com_example_nativemidi_MainActivity_initNative(JNIEnv * env, jobject instance) {
    env->GetJavaVM(&theJvm);

    // Setup the receive data callback (into Java)
    jclass clsMainActivity = env->FindClass("com/example/nativemidi/MainActivity");
    dataCallbackObj = env->NewGlobalRef(instance);
    midDataCallback = env->GetMethodID(clsMainActivity, "onNativeMessageReceive", "([B)V");
}

データを Java に送り返す際、ネイティブ コードはコールバック ポインタを取得してコールバックをビルドします。

AppMidiManager.cpp

// The Data Callback
extern JavaVM* theJvm;              // Need this for allocating data buffer for...
extern jobject dataCallbackObj;     // This is the (Java) object that implements...
extern jmethodID midDataCallback;   // ...this callback routine

static void SendTheReceivedData(uint8_t* data, int numBytes) {
    JNIEnv* env;
    theJvm->AttachCurrentThread(&env, NULL);
    if (env == NULL) {
        LOGE("Error retrieving JNI Env");
    }

    // Allocate the Java array and fill with received data
    jbyteArray ret = env->NewByteArray(numBytes);
    env->SetByteArrayRegion (ret, 0, numBytes, (jbyte*)data);

    // send it to the (Java) callback
    env->CallVoidMethod(dataCallbackObj, midDataCallback, ret);
}

参考情報