原生 MIDI API

AMidi API 在 Android NDK r20b 及更高版本中可用。借助该 API,应用开发者可以使用 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 端向下发送事件。或者,算法作曲进程可以直接向 AMidiDevice 发送 MIDI 演奏,而无需向上回调至 Java 端来传输按键事件。

虽然 AMidi 改进了与 MIDI 设备的直接连接,但应用仍必须使用 MidiManager 来发现和打开 MidiDevice 对象。AMidi 可以负责处理后续事务。

有时候,您可能需要将信息从界面层向下传递到原生代码。例如,发送 MIDI 事件来响应屏幕上的按钮时。为此,请创建对原生逻辑的自定义 JNI 调用。如果需要发送回数据来更新界面,您可以像往常一样从原生层回调。

本文档介绍了如何设置 AMidi 原生代码应用,同时提供了发送和接收 MIDI 命令的示例。如需获取可正常工作的完整示例,请查看 NativeMidi 示例应用。

使用 AMidi

无论是要发送 MIDI、接收 MIDI,还是这两种操作都涉及,所有使用 AMidi 的应用都具有相同的设置和关闭步骤。

启动 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 Scope),您可以在低优先级的后台线程(具有适当的休眠)中进行轮询。

对于用于生成音频并具有更严格实时性能要求的应用,您可以采用主音频生成回调(OpenSL ES 的 BufferQueue 回调,AAudio 中的 AudioStream 数据回调)进行轮询。由于 AMidiOutputPort_receive() 是非阻塞的,因此对性能造成的影响非常小。

在上面的 readThreadRoutine() 函数中调用的 startReadingMidi() 函数可能如下所示:

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 端(例如为了更新界面)。为此,您必须在 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);
}

其他资源