Natywny interfejs API MIDI

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

Aplikacje MIDI na Androida komunikują się z usługą Android MIDI za pomocą interfejsu API midi. Aplikacje MIDI wykorzystują przede wszystkim MidiManager do wykrywania, otwierania i zamykania 1 lub większej liczby obiektów MidiDevice oraz przekazywania danych do i z każdego urządzenia przez porty wejściowe i wyjściowe urządzenia MIDI:

Jeśli korzystasz z AMidi, przekazujesz adres MidiDevice do warstwy kodu natywnego za pomocą wywołania JNI. Następnie AMidi tworzy odniesienie do elementu AMidiDevice, który ma większość funkcji MidiDevice. Twój kod natywny korzysta z funkcji AMidi, które komunikują się bezpośrednio z interfejsem AMidiDevice. AMidiDevice łączy się bezpośrednio z usługą MIDI:

Za pomocą połączeń AMidi możesz ściśle zintegrować logikę dźwięku i sterowania C/C++ aplikacji z transmisją MIDI. Nie potrzeba już wywołań JNI ani wywołań zwrotnych po stronie Java aplikacji. Na przykład syntezator cyfrowy zaimplementowany w kodzie C może odbierać kluczowe zdarzenia bezpośrednio z interfejsu AMidiDevice, zamiast czekać na wywołanie JNI w celu wysłania zdarzeń ze strony Javy. Również algorytmiczny proces tworzenia może wysyłać wydajność MIDI bezpośrednio do interfejsu AMidiDevice bez wywoływania po stronie Javy w celu przekazywania kluczowych zdarzeń.

Chociaż AMidi poprawia bezpośrednie połączenie z urządzeniami MIDI, aplikacje nadal muszą używać MidiManager do wykrywania i otwierania obiektów MidiDevice. AMidi może zrobić to stamtąd.

Czasami konieczne może być przekazanie informacji z warstwy interfejsu do kodu natywnego. np. gdy zdarzenia MIDI są wysyłane w odpowiedzi na przyciski na ekranie. Aby to zrobić, utwórz niestandardowe wywołania JNI swojej logiki natywnej. Jeśli musisz wysłać dane z powrotem, aby zaktualizować interfejs, możesz wykonać wywołanie zwrotne z warstwy natywnej.

Ten dokument pokazuje, jak skonfigurować aplikację do obsługi kodu natywnego AMidi z przykładami wysyłania i odbierania poleceń MIDI. Pełny 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ą i odbierają MIDI czy oba te sposoby.

Uruchom AMidi

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

  1. Odkryj sprzęt MIDI dzięki klasie Java MidiManager.
  2. Uzyskaj obiekt Java MidiDevice odpowiadający sprzętowi MIDI.
  3. Przekaż MidiDevice do kodu natywnego w Javie 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 element MIDIInputPort, a potem zapisuje w nim dane. I odwrotnie, 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 umieścić w kodzie AMidi/AMidi.h i połączyć z biblioteką amidi. Oba rodzaje danych znajdziesz w pakiecie NDK dla Androida.

Strona Java powinna przekazywać co najmniej 1 obiekt MidiDevice i numer portu do warstwy natywnej za pomocą wywołania JNI. Warstwa natywna powinna następnie wykonać te czynności:

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

Zatrzymaj AMidi

Aplikacja w Javie powinna sygnalizować warstwę natywną, aby zwalniała zasoby, gdy już nie korzysta z urządzenia MIDI. Może to wynikać z tego, że urządzenie MIDI zostało odłączone lub aplikacja się zamyka.

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

  1. Zatrzymaj odczyt lub zapis na portach MIDI. Jeśli używasz wątku odczytu do odpytywania danych wejściowych (patrz sekcja Wdrażanie pętli odpytywania poniżej), zatrzymaj wątek.
  2. Zamknij wszystkie otwarte obiekty AMidiInputPort lub AMidiOutputPort za pomocą funkcji AMidiInputPort_close() bądź AMidiOutputPort_close().
  3. Zwolnij AMidiDevice, używając: AMidiDevice_release().

Odbieranie danych MIDI

Typowym przykładem aplikacji MIDI, która odbiera dźwięk MIDI, jest „wirtualny syntezator”, który odbiera dane o wydajności MIDI, aby sterować syntezą dźwięku.

Przychodzące dane MIDI są odbierane asynchronicznie. Dlatego najlepiej czytać MIDI w osobnym wątku, który stale odpytuje jeden port wyjściowy 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.

Skonfiguruj urządzenie MidiDevice i jego porty wyjściowe

Aplikacja odczytuje przychodzące dane MIDI z portów wyjściowych urządzenia. Strona aplikacji w języku Java musi określać używane urządzenie i porty.

Ten fragment tworzy polecenie MidiManager z usługi MIDI na Androidzie i otwiera MidiDevice dla pierwszego znalezionego urządzenia. Po otwarciu interfejsu MidiDevice zostanie odebrane wywołanie zwrotne do instancji MidiManager.OnDeviceOpenedListener(). Wywoływana jest metoda onDeviceOpened tego nasłuchującego, która wywołuje metodę startReadingMidi(), aby otworzyć port wyjściowy 0 na urządzeniu. Jest to funkcja JNI zdefiniowana w AppMidiManager.cpp. Została ona omówiona 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 Javy i jego porty na odniesienia wykorzystywane przez funkcje AMidi.

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

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 odbierające dane MIDI muszą sondować port wyjściowy i reagować, gdy AMidiOutputPort_receive() zwraca liczbę większą niż 0.

W przypadku aplikacji o niskiej przepustowości, takich jak zakres MIDI, możesz przeprowadzić ankietę w wątku w tle o niskim priorytecie (z odpowiednią fazą snu).

W przypadku aplikacji generujących dźwięk i mających bardziej rygorystyczne wymagania dotyczące wydajności w czasie rzeczywistym możesz przeprowadzić ankietę w głównym wywołaniu zwrotnym generowania dźwięku (wywołanie zwrotne BufferQueue w przypadku OpenSL ES, wywołanie zwrotne danych AudioStream w AAudio). AMidiOutputPort_receive() nie blokuje blokowania, 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 interfejsu API do obsługi natywnej dźwięku (np. OpenSL ES lub AAudio) może dodać kod odbierania MIDI do wywołania zwrotnego generującego dźwięk 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 odczytu MIDI:

Wyślij dane MIDI

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

Skonfiguruj urządzenie MidiDevice i jego porty wejściowe

Aplikacja zapisuje wychodzące dane MIDI na porty wejściowe urządzenia MIDI. Strona aplikacji w języku Java musi określać, których urządzeń i portów MIDI używać.

Ten kod konfiguracji jest wariantem z przykładem konfiguracji powyżej. Tworzy MidiManager z usługi MIDI na Androidzie. Następnie otwiera pierwszy znaleziony port MidiDevice i wywołuje metodę startWritingMidi(), aby otworzyć pierwszy port wejściowy na urządzeniu. Jest to wywołanie JNI zdefiniowane w AppMidiManager.cpp. Funkcję tę objaśniamy 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, wywołując AMidiDevice_fromJava(), a następnie wywołując AMidiInputPort_open(), aby otworzyć port wejściowy urządzenia:

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

Wyślij dane MIDI

Ponieważ dane MIDI są dobrze znane i kontrolowane przez samą aplikację, przesyłanie danych może odbywać się w wątku głównym aplikacji MIDI. Jednak ze względu na wydajność (jak w przypadku sekwencera) generowanie i przesyłanie MIDI może być wykonywane w oddzielnym wątku.

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

Oto przykładowa metoda JNI, która odbiera bufor poleceń MIDI i go 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

Choć nie jest to tylko funkcja AMidi, Twój kod natywny może wymagać przekazywania danych z powrotem do języka Java (np. w celu zaktualizowania interfejsu użytkownika). 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 przechowującą informacje potrzebne do wywołania 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 initNative() w Javie MainActivity 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 trzeba wysł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