Natywny interfejs MIDI API

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

Aplikacje MIDI na Androida zwykle do komunikacji z usługą MIDI na Androida używają interfejsu API midi. Aplikacje MIDI zależą głównie od MidiManager, aby wykrywać, otwierać i zamykać co najmniej jeden obiekt MidiDevice oraz przesyłać dane do i z każdego urządzenia przez porty MIDI wejścia i wyjścia:

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 zintegrować logikę sterowania dźwiękiem/kontroli w C/C++ z transmisją MIDI. Nie trzeba już tak często wywoływać metod JNI ani wywołań zwrotnych po stronie aplikacji w języku Java. Na przykład cyfrowy syntezator zaimplementowany w kodzie C może otrzymywać kluczowe zdarzenia bezpośrednio z AMidiDevice, zamiast czekać na wywołanie metody JNI, aby wysłać zdarzenia z poziomu Javy. Albo algorytmiczne tworzenie kompozycji może wysyłać dane MIDI bezpośrednio do AMidiDevice bez wywoływania ponownie kodu Java w celu przesłania kluczowych zdarzeń.

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

Czasami trzeba przekazać informacje z poziomu interfejsu do kodu natywnego. Na przykład wtedy, 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 chcesz przesłać dane, aby zaktualizować interfejs, możesz to zrobić z poziomu natywnej warstwy.

Ten dokument pokazuje, jak skonfigurować aplikację natywnych kodów AMidi, podając przykłady wysyłania i odbierania poleceń MIDI. Pełny przykład działania znajdziesz w przykładowej aplikacji NativeMidi.

Używanie AMidi

Wszystkie aplikacje korzystające z AMidi mają takie same czynności konfiguracyjne i zamykania, niezależnie od tego, czy wysyłają lub odbierają dane MIDI, czy też robią jedno i drugie.

Rozpocznij AMidi

Na stronie Javy aplikacja musi wykryć podłączone urządzenie MIDI, utworzyć odpowiedni obiekt MidiDevice i przekazać go do kodu natywnego.

  1. Poznaj sprzęt MIDI na zajęciach z Java MidiManager.
  2. Uzyskaj obiekt Java MidiDevice odpowiadający sprzętowi MIDI.
  3. Przekazywanie za pomocą JNI kodu Java MidiDevice do kodu natywnego.

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 na urządzenie, aplikacja otwiera MIDIInputPort, a następnie zapisuje w nim dane. Aby natomiast otrzymać dane, aplikacja otwiera MIDIOutputPort. Aby działać prawidłowo, aplikacja musi mieć pewność, że porty, które otwiera, 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ć porty wejściowe 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 uwzględnić plik AMidi/AMidi.h i utworzyć do niego link za pomocą biblioteki amidi. Oba te pliki znajdziesz w NDK Androida.

Po stronie Javy należy przekazać co najmniej 1 obiekt MidiDevice i numery portów do warstwy natywnej za pomocą wywołania JNI. Warstwę natywną należy wykonać w taki sposób:

  1. W przypadku każdego obiektu Java MidiDevice pobierz obiekt AMidiDevice za pomocą funkcji AMidiDevice_fromJava().
  2. Uzyskaj AMidiInputPort lub AMidiOutputPortAMidiDeviceAMidiInputPort_open() lub AMidiOutputPort_open().
  3. Używaj uzyskanych portów do wysyłania i odbierania danych MIDI.

Zatrzymaj AMidi

Aplikacja w języku Java powinna sygnalizować warstwie natywnej, aby zwalniała zasoby, gdy nie używa już urządzenia MIDI. Może to być spowodowane odłączeniem urządzenia MIDI lub zamknięciem aplikacji.

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

  1. Zatrzymaj odczyt lub zapis do portów MIDI. Jeśli do odczytu danych używasz wątku odczytu (patrz Implement a polling loop [Wdrażanie pętli odczytu]) zatrzymaj ten wątek.
  2. Zamknij wszystkie otwarte obiekty AMidiInputPort lub AMidiOutputPort za pomocą funkcji AMidiInputPort_close() lub AMidiOutputPort_close().
  3. Zwolnij AMidiDevice w usłudze AMidiDevice_release().

Odbieranie danych MIDI

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

Dane MIDI przychodzące są odbierane asynchronicznie. Dlatego najlepiej odczytywać dane MIDI w osobnym wątku, który stale odczytuje jeden lub więcej portów wyjściowych MIDI. Może to być wątek w tle lub wątek audio. AMidi nie blokuje się podczas odczytu z portu, dlatego można go bezpiecznie używać w obsługiwanych przez niego wywołaniach zwrotnych.

Konfigurowanie obiektu MidiDevice i jego portów wyjściowych

Aplikacja odczytuje przychodzące dane MIDI z portów wyjściowych urządzenia. Strona Java aplikacji musi określić, którego urządzenia i których portów używać.

Ten fragment kodu tworzy obiekt MidiManager z usługi MIDI w Androidzie i otwiera obiekt MidiDevice dla pierwszego znalezionego urządzenia. Gdy MidiDevice zostanie otwarty, zostanie wywołane wywołanie zwrotne do instancji MidiManager.OnDeviceOpenedListener(). Wywoływana jest metoda onDeviceOpened tego listenera, która następnie wywołuje startReadingMidi(), aby otworzyć port wyjściowy 0 na urządzeniu. Jest to funkcja JNI zdefiniowana w AppMidiManager.cpp. Ta funkcja 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 przekształca urządzenie MIDI i jego porty po stronie Javy w odniesienia 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ą sprawdzać port wyjściowy i reagować, gdy AMidiOutputPort_receive() zwraca liczbę większą od zera.

W przypadku aplikacji o niskiej przepustowości, takich jak zakres MIDI, możesz przeprowadzać ankiety w niskiej priorytetowej nici w tle (z odpowiednimi opóźnieniami).

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

Funkcja readThreadRoutine() wywoływana przez funkcję 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 korzystająca z natywnego interfejsu API dźwięku (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
    // ...
}

Ten diagram przedstawia przepływ danych w aplikacji do odczytu MIDI:

Wysyłanie danych MIDI

Typowym przykładem aplikacji do tworzenia muzyki MIDI jest kontroler MIDI lub sekwenser.

Konfigurowanie obiektu MidiDevice i jego portów wejściowych

Aplikacja zapisuje wychodzące dane MIDI do portów wejściowych urządzenia MIDI. Strona Java aplikacji musi określić, którego urządzenia MIDI i których portów używać.

Ten kod konfiguracji poniżej jest odmianą przykładu odbierania powyżej. Tworzy MidiManagerz usługi MIDI na Androidzie. Następnie otwiera pierwszy znaleziony MidiDevice i wywołuje startWritingMidi(), aby otworzyć pierwszy port wejściowy na urządzeniu. To jest wywołanie JNI zdefiniowane w AppMidiManager.cpp. Funkcję tę omawiamy 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 funkcję AMidiDevice_fromJava(), a następnie wywołuje funkcję 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

Ze względu na to, że czas wysyłania danych MIDI jest dobrze rozumiany i kontrolowany przez samą aplikację, transmisja danych może odbywać się w głównym wątku aplikacji MIDI. Jednak ze względu na wydajność (jak w sekwencjomacie) generowanie i przesyłanie danych MIDI może odbywać się w osobnym wątku.

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

Oto przykładowa metoda JNI, która otrzymuje 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);
}

Ten diagram przedstawia przepływ danych w aplikacji do tworzenia plików MIDI:

Wywołania zwrotne

Mimo że nie jest to funkcja AMidi, kod natywny może wymagać przekazania danych z powrotem do strony Java (np. w celu zaktualizowania interfejsu użytkownika). W tym celu musisz napisać kod po stronie Java i natywnej:

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

Gdy nadejdzie czas wywołania zwrotnego, Twój 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 funkcji MainActivity.onNativeMessageReceive(). Java MainActivity wywołuje initNative() 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 nadejdzie czas na przesłanie danych 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