Native MIDI API

AMidi API доступен в Android NDK r20b и более поздних версиях. Он позволяет разработчикам приложений отправлять и получать данные MIDI с помощью кода C/C++.

Приложения Android MIDI обычно используют API midi для связи с сервисом 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 can take it from there.

Иногда вам может потребоваться передать информацию с уровня пользовательского интерфейса в нативный код. Например, когда события MIDI отправляются в ответ на нажатие кнопок на экране. Для этого создайте пользовательские вызовы JNI к нативной логике. Если вам нужно отправить данные обратно, чтобы обновить интерфейс, вы можете вызвать обратный вызов из нативного уровня, как обычно.

В этом документе показано, как настроить приложение с нативным кодом AMidi, и приведены примеры отправки и получения команд MIDI. Полный рабочий пример можно найти в примере приложения NativeMidi.

Использование AMidi

Все приложения, использующие AMidi, имеют одинаковые шаги настройки и закрытия, независимо от того, отправляют ли они или получают MIDI, или и то, и другое.

Запуск AMidi

В Java-коде приложение должно обнаружить подключенное MIDI-устройство, создать соответствующий объект MidiDevice и передать его в нативный код.

  1. Обнаружение MIDI-устройств с помощью класса Java MidiManager.
  2. Получите объект Java MidiDevice, соответствующий MIDI-устройству.
  3. Передайте объект MidiDevice Java в нативный код с помощью JNI.

Обнаружение оборудования и портов

Объекты входного и выходного портов не принадлежат приложению. Они представляют порты на 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;
}

Чтобы использовать функции AMidi в коде C/C++, необходимо включить AMidi/AMidi.h и связать с библиотекой amidi. Оба инструмента входят в состав Android NDK.

Java-код должен передавать один или несколько объектов MidiDevice и номера портов на нативный уровень через вызов JNI. Затем на нативном уровне необходимо выполнить следующие действия:

  1. Для каждого объекта MidiDevice Java получите объект AMidiDevice с помощью AMidiDevice_fromJava().
  2. Получите AMidiInputPort и/или AMidiOutputPort от AMidiDevice с AMidiInputPort_open() и/или AMidiOutputPort_open().
  3. Используйте полученные порты для отправки и/или получения MIDI-данных.

Остановить AMidi

Приложение Java должно сигнализировать нативному уровню о необходимости освободить ресурсы, когда оно больше не использует MIDI-устройство. Возможно, устройство MIDI было отключено или приложение закрывается.

Чтобы освободить ресурсы MIDI, ваш код должен выполнить следующие задачи:

  1. Остановить чтение и/или запись в MIDI-порты. Если вы использовали поток чтения для опроса ввода (см. раздел Реализация цикла опроса ниже), остановите поток.
  2. Закройте все открытые объекты AMidiInputPort и/или AMidiOutputPort с помощью функций AMidiInputPort_close() и/или AMidiOutputPort_close().
  3. Выпустите AMidiDevice с AMidiDevice_release().

Получение данных MIDI

Типичный пример MIDI-приложения, которое получает MIDI-данные, – виртуальный синтезатор, который получает данные об исполнении MIDI для управления синтезом звука.

Входящие данные MIDI принимаются асинхронно. Поэтому лучше всего считывать MIDI в отдельном потоке, который непрерывно опрашивает один или несколько выходных портов MIDI. Это может быть фоновый поток или поток аудио. AMidi не блокирует чтение из порта, поэтому его можно безопасно использовать в обратном вызове аудио.

Настройте MidiDevice и его выходные порты.

Приложение считывает входящие MIDI-данные из выходных портов устройства. Java-код приложения должен определить, какие устройства и порты использовать.

Этот фрагмент кода создает MidiManager из MIDI-сервиса Android и открывает MidiDevice для первого найденного устройства. Когда MidiDevice открывается, экземпляр MidiManager.OnDeviceOpenedListener() получает обратный вызов. Вызывается метод onDeviceOpened этого прослушивателя, который затем вызывает startReadingMidi(), чтобы открыть выходной порт 0 на устройстве. Это функция JNI, определенная в AppMidiManager.cpp. Эта функция объясняется в следующем фрагменте.

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

Нативный код преобразует MIDI-устройство Java и его порты в ссылки, используемые функциями AMidi.

Ниже приведена функция JNI, которая создает объект AMidiDevice, вызывая метод AMidiDevice_fromJava(), а затем вызывает метод 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-осциллограф, вы можете выполнять опрос в фоновом потоке с низким приоритетом (с соответствующими задержками).

Для приложений, которые генерируют аудио и имеют более строгие требования к производительности в реальном времени, вы можете выполнять опрос в основном обратном вызове генерации аудио (обратный вызов BufferQueue для OpenSL ES, обратный вызов данных AudioStream в AAudio). Поскольку 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-устройства и порты использовать.

Приведенный ниже код настройки – это вариант примера получения, приведенного выше. Он создает MidiManager из MIDI-сервиса Android. Затем он открывает первое найденное устройство MidiDevice и вызывает startWritingMidi(), чтобы открыть первый входной порт на устройстве. Это вызов JNI, определенный в AppMidiManager.cpp. Функция объясняется в следующем фрагменте.

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, вызывая AMidiDevice_fromJava(), а затем вызывает 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 (например, для обновления пользовательского интерфейса). Для этого вам нужно написать код на стороне Java и на уровне нативного кода.

  • Создайте метод обратного вызова на стороне Java.
  • Напишите функцию JNI, которая сохраняет информацию, необходимую для вызова обратного вызова.

Когда придет время обратного вызова, нативный код может создать

Ниже приведен метод обратного вызова onNativeMessageReceive() на стороне Java.

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

Ниже приведен код C для функции JNI, которая настраивает обратный вызов для 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);
}

Дополнительные ресурсы