原生 MIDI API

AMidi API 可在 Android NDK r20b 及更高版本中使用。它使应用开发者能够使用 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 在写入数据时会阻塞。

这是一个接收 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);
            }
        });
}

这是为 JNI 函数设置回调到 MainActivity.onNativeMessageReceive() 的 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);
}

其他资源