原生 MIDI API

AMidi API 適用於 Android NDK r20b 和以上版本,可讓應用程式開發人員透過 C/C++ 程式碼來傳送及接收 MIDI 資料。

Android MIDI 應用程式通常會使用 midi API 與 Android MIDI 服務通訊。MIDI 應用程式主要仰賴 MidiManager 來尋找、開啟及關閉一或多個 MidiDevice 物件,並使用裝置的 MIDI 輸入輸出通訊埠,在裝置間傳遞資料:

使用 AMidi 時,您可以透過 JNI 呼叫將 MidiDevice 的位址傳送至原生程式碼層。接著,AMidi 會建立 AMidiDevice 的參照,這個項目具有 MidiDevice 的大部分功能。您的原生程式碼會使用直接與 AMidiDevice 通訊的 AMidi 函式AMidiDevice 則會直接連接至 MIDI 服務:

使用 AMidi 呼叫時,您可以將應用程式的 C/C++ 音訊/控制邏輯與 MIDI 傳輸方式緊密整合,這樣就能減少使用 JNI 呼叫或對應用程式 Java 端發出回呼的需要。例如,以 C 程式碼進行實作的數位合成器可以直接從 AMidiDevice 接收按鍵事件,而不需等待 JNI 呼叫從 Java 端將事件向下傳送。或者,演算法運算程序可以將 MIDI 效能直接傳送至 AMidiDevice,而不需向上呼叫 Java 端來傳輸按鍵事件。

雖然 AMidi 改善了與 MIDI 裝置的直接連接,但應用程式仍須使用 MidiManager 來尋找與開啟 MidiDevice 物件,而 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 裝置並查看其通訊埠。透過此方法,系統會傳回具有輸出通訊埠 (用於接收資料) 的裝置清單,或具有輸入通訊埠 (用於傳送資料) 的裝置清單,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 呼叫,將一或多個 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 資料。因此,建議在持續輪詢一或多個 MIDI 輸出通訊埠的獨立執行緒中讀取 MIDI。該執行緒可以是背景執行緒或音訊執行緒。從通訊埠讀取資料時 AMidi 不會阻塞,因此可以放心地在音訊回呼中使用。

設定 MidiDevice 及其輸出通訊埠

應用程式會從裝置的輸出通訊埠讀取收到的 MIDI 資料。應用程式的 Java 端必須決定要使用的裝置和通訊埠。

這段程式碼會使用 Android 的 MIDI 服務建立 MidiManager,並為找到的第一部裝置開啟 MidiDeviceMidiDevice 開啟後,系統會收到對 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 Scope),您可以在低優先順序的背景執行緒 (具適當睡眠) 中進行輪詢。

如果應用程式會產生音訊,而且有更嚴格的即時效能要求,您可以在主要的音訊產生回呼中執行輪詢,這類回呼包括 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 寫入應用程式例子。

設定 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 寫入應用程式的流程:

回呼

雖然原生程式碼嚴格上來說不是 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);
}

其他資源