Natywny interfejs API MIDI

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

Aplikacje na Androida MIDI zwykle komunikują się z usługą Android MIDI za pomocą interfejsu API midi. Aplikacje MIDI zależą przede wszystkim od MidiManager, aby wykrywać, otwierać i zamykać obiekty MidiDevice oraz przekazywać dane do i z każdego urządzenia przez porty wejściowe i wyjściowe MIDI:

Gdy używasz AMidi, przekazujesz adres MidiDevice do natywnej warstwy kodu za pomocą wywołania JNI. Następnie Amidi tworzy odniesienie do AMidiDevice, który ma większość funkcji MidiDevice. Twój kod natywny korzysta z 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ę dźwięku/sterowania aplikacji C/C++ z transmisją MIDI. Wywołania JNI i wywołania zwrotne do strony aplikacji w języku Java są mniejsze. Na przykład cyfrowy syntezator zaimplementowany w kodzie C może na przykład odbierać kluczowe zdarzenia bezpośrednio z AMidiDevice, a nie czekać na wywołanie JNI, które wyśle je po stronie Javy. Z kolei algorytmiczny proces komponowania może wysyłać wydajność MIDI bezpośrednio do AMidiDevice bez wywoływania strony Javy w celu przesyłania kluczowych zdarzeń.

Choć AMidi ulepsza 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 to stamtąd zabrać.

Czasami konieczne może być przekazanie informacji z warstwy interfejsu do kodu natywnego. Na przykład zdarzenia MIDI są wysyłane w odpowiedzi na przyciski na ekranie. W tym celu utwórz niestandardowe wywołania JNI do logiki natywnej. Jeśli musisz przesłać dane z powrotem, aby zaktualizować interfejs, możesz jak zwykle wywołać odpowiedź z warstwy natywnej.

W tym dokumencie pokazujemy, jak skonfigurować aplikację do kodu natywnego AMidi. Zawiera on przykłady zarówno wysyłania, jak i odbierania poleceń MIDI. Pełny przykład działania znajdziesz w przykładowej aplikacji NativeMidi.

Użyj AMidi

Wszystkie aplikacje korzystające z AMidi mają te same kroki konfiguracji i zamykania, niezależnie od tego, czy wysyłają i odbierają MIDI, czy oba te tryby.

Uruchom AMidi

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

  1. Odkryj sprzęt MIDI z klasą MidiManager w języku Java.
  2. Uzyskaj obiekt Java MidiDevice odpowiadający sprzętowi MIDI.
  3. Przekaż kod Java MidiDevice do kodu natywnego za pomocą JNI.

Odkryj sprzęt i porty

Obiekty portów wejściowych i wyjściowych nie należą do aplikacji. Reprezentują porty urządzenia Midi. Aby wysłać dane MIDI na urządzenie, aplikacja otwiera MIDIInputPort i zapisuje na nim dane. Aby odbierać 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 dodać AMidi/AMidi.h i połączyć z biblioteką amidi. Te materiały znajdziesz w pakiecie Android NDK.

Strona Javy powinna przekazywać co najmniej 1 obiekt MidiDevice i portować numery portów do warstwy natywnej za pomocą wywołania JNI. Warstwa natywna powinna następnie wykonać te czynności:

  1. Dla każdej biblioteki Java MidiDevice uzyskaj AMidiDevice za pomocą AMidiDevice_fromJava().
  2. Uzyskaj AMidiInputPort lub AMidiOutputPort z AMidiDevice z AMidiInputPort_open() lub AMidiOutputPort_open().
  3. Używaj uzyskanych portów do wysyłania i/lub odbierania danych MIDI.

Zatrzymaj AMidi

Aplikacja w Javie powinna sygnalizować warstwę natywną o zwolnieniu zasobów, gdy nie korzysta już z urządzenia MIDI. Przyczyną mogło być odłączenie urządzenia MIDI lub zamykanie aplikacji.

Aby zwolnić zasoby MIDI, kod powinien wykonać te czynności:

  1. Zatrzymaj odczytywanie i/lub zapisywanie w portach MIDI. Jeśli ankieta dotycząca danych wejściowych jest wykonywana za pomocą wątku czytania (zobacz poniżej Implementowanie pętli odpytywania), zatrzymaj wątek.
  2. Zamknij wszystkie otwarte obiekty AMidiInputPort lub AMidiOutputPort z funkcjami 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 wydajności MIDI, by sterować syntezą dźwięku.

Przychodzące dane MIDI są odbierane asynchronicznie. Dlatego najlepiej odczytywać MIDI w osobnym wątku, który stale sonduje 1 port wyjściowy lub MIDI. Może to być wątek w tle lub wątek audio. AMidi nie blokuje odczytu z portu i 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. Aplikacja po stronie Javy musi określać, których urządzeń i portów ma używać.

Ten fragment tworzy plik MidiManager z usługi MIDI na Androidzie i otwiera MidiDevice dla pierwszego znalezionego urządzenia. Po otwarciu MidiDevice następuje wywołanie zwrotne do instancji MidiManager.OnDeviceOpenedListener(). Wywoływana jest metoda onDeviceOpened tego odbiornika, która wywołuje metodę startReadingMidi(), aby otworzyć port wyjściowy 0 na urządzeniu. To jest funkcja JNI zdefiniowana w AppMidiManager.cpp. Funkcja ta została objaśniona w następnym fragmencie.

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 Java i jego porty na odwołania używane przez funkcje AMidi.

Oto funkcja JNI, która tworzy AMidiDevice przez wywołanie AMidiDevice_fromJava(), a następnie wywołuje AMidiOutputPort_open(), by 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 otrzymujące dane MIDI muszą sondować port wyjściowy i odpowiadać, gdy AMidiOutputPort_receive() zwraca liczbę większą niż 0.

W przypadku aplikacji o niskiej przepustowości, np. zakresu MIDI, możesz przeprowadzać sondowanie w wątku w tle o niskim priorytecie (z odpowiednimi uśpieniem).

W przypadku aplikacji generujących dźwięk i mających bardziej rygorystyczne wymagania dotyczące wydajności w czasie rzeczywistym można przeprowadzać sondowanie w głównym wywołaniu zwrotnym generowania dźwięku (wywołaniu zwrotnym BufferQueue dla OpenSL ES, czyli wywołania zwrotnego danych AudioStream w AAudio). Parametr AMidiOutputPort_receive() nie blokuje elementów, więc ma bardzo niewielki wpływ na wydajność.

Funkcja readThreadRoutine() wywołana z powyższej funkcji startReadingMidi() 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 używająca natywnego interfejsu API audio (np. OpenSL ES lub AAudio) może dodać kod odbioru 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…
    // ...
}

Poniższy diagram przedstawia przepływ aplikacji do czytania MIDI:

Wysyłaj dane MIDI

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

Skonfiguruj urządzenie MidiDevice i jego porty wejściowe

Aplikacja zapisuje wychodzące dane MIDI do portów wejściowych urządzenia MIDI. Aplikacja po stronie Javy musi określać, których urządzeń MIDI i portów używać.

Kod konfiguracji widoczny poniżej jest odmianą kodu odebranego powyżej. Utworzy to MidiManager z usługi MIDI na Androidzie. Następnie otwiera pierwszy MidiDevice, który znajdzie i wywołuje metodę startWritingMidi(), by otworzyć pierwszy port wejściowy urządzenia. Jest to wywołanie JNI zdefiniowane w AppMidiManager.cpp. Funkcja została omówiona w następnym fragmencie.

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 AMidiDevice przez wywołanie AMidiDevice_fromJava(), a następnie wywołuje AMidiInputPort_open(), by 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łaj dane MIDI

Ponieważ czas wychodzących danych MIDI jest dobrze zrozumiały i kontrolowany przez samą aplikację, przesyłanie danych można przeprowadzać w głównym wątku aplikacji MIDI. Jednak ze względu na wydajność (jak w przypadku sekwencera) generowanie i przesyłanie MIDI można przeprowadzić w osobnym wątku.

Aplikacje mogą wysyłać dane MIDI, gdy jest to wymagane. Pamiętaj, że AMidi blokuje podczas zapisywania danych.

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

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 przedstawia przepływ aplikacji do pisania MIDI:

Wywołania zwrotne

Chociaż nie jest to wyłącznie funkcja AMidi, Twój kod natywny może wymagać przekazywania danych z powrotem na stronę Javy (np. w celu zaktualizowania interfejsu użytkownika). W tym celu musisz napisać kod po stronie Javy i w warstwie natywnej:

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

W chwili wywołania zwrotnego kod natywny tworzy

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(). Java MainActivity wywołuje initNative() przy uruchamianiu:

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 przyjdzie czas na wysłanie danych z powrotem do Javy, natywny kod pobierze 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