原生 MIDI API

在 Android NDK r20b 及更高版本中提供了 AMidi API。它使应用开发者能够使用 C/C++ 代码发送和接收 MIDI 数据。

Android MIDI 应用通常使用 midi API 与 Android MIDI 服务通信。MIDI 应用主要依赖于 MidiManager 来发现、打开和关闭一个或多个 MidiDevice 对象,并通过设备的 MIDI 输入输出 端口传递数据。

使用 AMidi 时,您将 MidiDevice 的地址通过 JNI 调用传递到原生代码层。在此,AMidi 创建对 AMidiDevice 的引用,该引用具有 MidiDevice 的大部分功能。您的原生代码使用 AMidi 函数 直接与 AMidiDevice 通信。 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,并为找到的第一个设备打开一个 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 控制器或音序器。

设置 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 在写入数据时会阻塞。

以下是一个 JNI 方法示例,它接收 MIDI 命令缓冲区并将其写出

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);
            }
        });
}

以下是 JNI 函数的 C 代码,该函数将回调设置为 MainActivity.onNativeMessageReceive()。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);
}

其他资源