Natywny interfejs MIDI API

Interfejs AMidi jest dostępny w Android NDK w wersji r20b i nowszych. Umożliwia deweloperom aplikacji wysyłanie i odbieranie danych MIDI za pomocą kodu C/C++.

Aplikacje MIDI na Androida zwykle używają interfejsu midi API do komunikacji z usługą MIDI na Androidzie. Aplikacje MIDI zależą głównie od interfejsu MidiManager, który umożliwia wykrywanie, otwieranie i zamykanie co najmniej jednego obiektu MidiDevice oraz przesyłanie danych do i z każdego urządzenia za pomocą portów wejściowychwyjściowych MIDI:

Gdy używasz AMidi, przekazujesz adres MidiDevice do warstwy kodu natywnego za pomocą wywołania JNI. Następnie AMidi tworzy odwołanie do AMidiDevice, które ma większość funkcji MidiDevice. Twój kod natywny używa funkcji AMidi, które komunikują się bezpośrednio z AMidiDevice. AMidiDevice łączy się bezpośrednio z usługą MIDI:

Za pomocą wywołań AMidi możesz ściśle zintegrować logikę audio/sterowania w C/C++ aplikacji z transmisją MIDI. Nie ma potrzeby wywoływania JNI ani wywołań zwrotnych do części aplikacji napisanej w języku Java. Na przykład syntezator cyfrowy zaimplementowany w kodzie C może otrzymywać zdarzenia klawiszy bezpośrednio z AMidiDevice, zamiast czekać na wywołanie JNI, które przesyła zdarzenia z części napisanej w języku Java. Proces kompozycji algorytmicznej może też wysyłać wykonanie MIDI bezpośrednio do AMidiDevice bez wywoływania z powrotem strony Java w celu przesłania kluczowych zdarzeń.

Chociaż AMidi poprawia bezpośrednie połączenie z urządzeniami MIDI, aplikacje nadal muszą używać interfejsu MidiManager do wykrywania i otwierania obiektów MidiDevice. AMidi może zająć się resztą.

Czasami może być konieczne przekazanie informacji z warstwy interfejsu do kodu natywnego. Na przykład gdy zdarzenia MIDI są wysyłane w odpowiedzi na naciśnięcie przycisków na ekranie. Aby to zrobić, utwórz niestandardowe wywołania JNI do logiki natywnej. Jeśli musisz odesłać dane, aby zaktualizować interfejs, możesz jak zwykle wywołać funkcję z warstwy natywnej.

W tym dokumencie pokazujemy, jak skonfigurować aplikację z kodem natywnym AMidi, podając przykłady wysyłania i odbierania poleceń MIDI. Kompletny przykład działania znajdziesz w przykładowej aplikacji NativeMidi.

Korzystanie z AMidi

Wszystkie aplikacje korzystające z AMidi mają te same kroki konfiguracji i zamykania, niezależnie od tego, czy wysyłają, odbierają czy wykonują obie te czynności.

Uruchom AMidi

Po stronie Javy aplikacja musi wykryć podłączony sprzęt MIDI, utworzyć odpowiedni obiekt MidiDevice i przekazać go do kodu natywnego.

  1. Odkrywanie sprzętu MIDI za pomocą klasy Java MidiManager.
  2. Uzyskaj obiekt Java MidiDevice odpowiadający sprzętowi MIDI.
  3. Przekazywanie obiektu Java MidiDevice do kodu natywnego za pomocą JNI.

Poznaj sprzęt i porty

Obiekty portów wejściowych i wyjściowych nie należą do aplikacji. Reprezentują one porty na urządzeniu MIDI. Aby wysłać dane MIDI do urządzenia, aplikacja otwiera MIDIInputPort, a następnie zapisuje w nim dane. Aby otrzymać dane, aplikacja otwiera MIDIOutputPort. Aby aplikacja działała prawidłowo, musi mieć pewność, że otwierane przez nią porty są odpowiedniego typu. Wykrywanie urządzeń i portów odbywa się po stronie Javy.

Oto metoda, która wykrywa każde urządzenie MIDI i sprawdza jego porty. Zwraca listę urządzeń z portami wyjściowymi do odbierania danych lub listę urządzeń z portami wejściowymi do wysyłania danych. Urządzenie MIDI może mieć zarówno porty wejściowe, jak i wyjściowe.

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

Aby używać funkcji AMidi w kodzie C/C++, musisz dołączyć AMidi/AMidi.h i połączyć się z biblioteką amidi. Oba te elementy znajdziesz w Android NDK.

Po stronie Javy należy przekazać co najmniej 1 obiekt MidiDevice i numery portów do warstwy natywnej za pomocą wywołania JNI. Warstwa natywna powinna następnie wykonać te czynności:

  1. Dla każdego obiektu Java MidiDevice uzyskaj obiekt AMidiDevice za pomocą metody AMidiDevice_fromJava().
  2. Uzyskaj AMidiInputPort lub AMidiOutputPort od AMidiDevice za pomocą AMidiInputPort_open() lub AMidiOutputPort_open().
  3. Użyj uzyskanych portów do wysyłania lub odbierania danych MIDI.

Stop AMidi

Gdy aplikacja Java nie będzie już używać urządzenia MIDI, powinna wysłać do warstwy natywnej sygnał o zwolnieniu zasobów. Może to być spowodowane odłączeniem urządzenia MIDI lub zamknięciem aplikacji.

Aby zwolnić zasoby MIDI, kod powinien wykonać te zadania:

  1. Zatrzymanie odczytu lub zapisu na portach MIDI. Jeśli do sprawdzania danych wejściowych używasz wątku odczytu (patrz Implementowanie pętli sprawdzania poniżej), zatrzymaj go.
  2. Zamknij wszystkie otwarte obiekty AMidiInputPort lub AMidiOutputPort za pomocą funkcji AMidiInputPort_close() lub AMidiOutputPort_close().
  3. Zwolnij AMidiDevice za pomocą AMidiDevice_release().

Odbieranie danych MIDI

Typowym przykładem aplikacji MIDI, która odbiera MIDI, jest „wirtualny syntezator”, który odbiera dane o wykonaniu MIDI w celu sterowania syntezą dźwięku.

Przychodzące dane MIDI są odbierane asynchronicznie. Dlatego najlepiej jest odczytywać MIDI w osobnym wątku, który stale odpytuje jeden lub więcej portów wyjściowych MIDI. Może to być wątek w tle lub wątek audio. AMidi nie blokuje odczytu z portu, dlatego można go bezpiecznie używać w wywołaniu zwrotnym audio.

Konfigurowanie urządzenia MidiDevice i jego portów wyjściowych

Aplikacja odczytuje przychodzące dane MIDI z portów wyjściowych urządzenia. Część aplikacji napisana w Javie musi określić, które urządzenie i porty mają być używane.

Ten fragment kodu tworzy MidiManager z usługi MIDI na Androidzie i otwiera MidiDevice dla pierwszego znalezionego urządzenia. Gdy MidiDevice zostanie otwarty, wywołanie zwrotne zostanie odebrane w instancji MidiManager.OnDeviceOpenedListener(). Wywoływana jest metoda onDeviceOpened tego odbiornika, która następnie wywołuje metodę startReadingMidi(), aby otworzyć port wyjściowy 0 na urządzeniu. Jest to funkcja JNI zdefiniowana w pliku AppMidiManager.cpp. Funkcja ta jest wyjaśniona w następnym fragmencie kodu.

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

Kod natywny tłumaczy urządzenie MIDI po stronie Javy i jego porty na odwołania używane przez funkcje AMidi.

Oto funkcja JNI, która tworzy obiekt AMidiDevice, wywołując funkcję AMidiDevice_fromJava(), a następnie wywołuje funkcję AMidiOutputPort_open(), aby otworzyć port wyjściowy na urządzeniu:

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...

}

Wdrażanie pętli odpytywania

Aplikacje, które otrzymują dane MIDI, muszą odpytywać port wyjściowy i odpowiadać, gdy funkcja AMidiOutputPort_receive() zwraca liczbę większą od zera.

W przypadku aplikacji o niskiej przepustowości, takich jak zakres MIDI, możesz odpytywać w wątku w tle o niskim priorytecie (z odpowiednimi przerwami).

W przypadku aplikacji, które generują dźwięk i mają bardziej rygorystyczne wymagania dotyczące wydajności w czasie rzeczywistym, możesz odpytywać w głównym wywołaniu zwrotnym generowania dźwięku (wywołanie zwrotne BufferQueue w OpenSL ES, wywołanie zwrotne danych AudioStream w AAudio). Funkcja AMidiOutputPort_receive() nie blokuje działania innych funkcji, więc ma bardzo niewielki wpływ na wydajność.

Funkcja readThreadRoutine() wywoływana z funkcji startReadingMidi() powyżej może wyglądać tak:

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

Aplikacja korzystająca z natywnego interfejsu API audio (np. OpenSL ES lub AAudio) może dodać kod odbierania MIDI do wywołania zwrotnego generowania dźwięku w ten sposób:

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
    // ...
}

Diagram poniżej przedstawia przepływ danych w aplikacji do odczytywania MIDI:

Wysyłanie danych MIDI

Typowym przykładem aplikacji do pisania MIDI jest kontroler lub sekwencer MIDI.

Konfigurowanie urządzenia MIDI i jego portów wejściowych

Aplikacja zapisuje wychodzące dane MIDI na portach wejściowych urządzenia MIDI. Część aplikacji napisana w Javie musi określić, których urządzeń i portów MIDI ma używać.

Poniższy kod konfiguracji jest odmianą przykładu odbioru podanego powyżej. Tworzy MidiManager z usługi MIDI na Androidzie. Następnie otwiera pierwsze znalezione urządzenieMidiDevice i wywołuje funkcjęstartWritingMidi(), aby otworzyć pierwszy port wejściowy na urządzeniu. Jest to wywołanie JNI zdefiniowane w AppMidiManager.cpp. Funkcja jest wyjaśniona w następnym fragmencie kodu.

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

Oto funkcja JNI, która tworzy obiekt AMidiDevice, wywołując metodę AMidiDevice_fromJava(), a następnie wywołuje metodę AMidiInputPort_open(), aby otworzyć port wejściowy na urządzeniu:

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

Wysyłanie danych MIDI

Czas wysyłania danych MIDI jest dobrze znany i kontrolowany przez samą aplikację, więc transmisję danych można przeprowadzić w głównym wątku aplikacji MIDI. Ze względu na wydajność (np. w przypadku sekwencera) generowanie i przesyłanie MIDI może odbywać się w osobnym wątku.

Aplikacje mogą wysyłać dane MIDI w dowolnym momencie. Pamiętaj, że AMidi blokuje zapisywanie danych.

Oto przykładowa metoda JNI, która odbiera bufor poleceń MIDI i zapisuje go:

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

Poniższy diagram ilustruje przepływ w aplikacji do pisania MIDI:

Wywołania zwrotne

Chociaż nie jest to ściśle funkcja AMidi, kod natywny może wymagać przekazywania danych z powrotem do strony Java (np. w celu zaktualizowania interfejsu). Aby to zrobić, musisz napisać kod po stronie Javy i w warstwie natywnej:

  • Utwórz metodę wywołania zwrotnego po stronie Javy.
  • Napisz funkcję JNI, która przechowuje informacje potrzebne do wywołania zwrotnego.

Gdy nadejdzie czas wywołania zwrotnego, kod natywny może utworzyć

Oto metoda wywołania zwrotnego po stronie Javy: 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);
            }
        });
}

Oto kod C funkcji JNI, która konfiguruje wywołanie zwrotne do MainActivity.onNativeMessageReceive(). Wywołania Java MainActivityinitNative() podczas uruchamiania:

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

Gdy trzeba przesłać dane z powrotem do Javy, kod natywny pobiera wskaźniki wywołania zwrotnego i tworzy wywołanie zwrotne:

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

Dodatkowe materiały