Native MIDI API

Die AMidi API ist ab Android NDK r20b verfügbar. App-Entwickler können damit MIDI-Daten mit C/C++-Code senden und empfangen.

Android-MIDI-Apps verwenden in der Regel die midi API, um mit dem Android-MIDI-Dienst zu kommunizieren. MIDI-Apps sind hauptsächlich auf MidiManager angewiesen, um ein oder mehrere MidiDevice-Objekte zu finden, zu öffnen und zu schließen und Daten über die MIDI-Eingangs- und Ausgangsports des Geräts an jedes Gerät zu senden und von jedem Gerät zu empfangen:

Wenn du AMidi verwendest, übergibst du die Adresse eines MidiDevice mit einem JNI-Aufruf an die native Codeebene. Anschließend erstellt AMidi einen Verweis auf ein AMidiDevice, das die meisten Funktionen eines MidiDevice hat. Ihr nativer Code verwendet AMidi-Funktionen, die direkt mit einem AMidiDevice kommunizieren. Der AMidiDevice stellt eine direkte Verbindung zum MIDI-Dienst her:

Mithilfe von AMidi-Aufrufen kannst du die Audio-/Steuerungslogik deiner App in C/C++ eng mit der MIDI-Übertragung verknüpfen. Es sind weniger JNI-Aufrufe oder Callbacks zur Java-Seite deiner App erforderlich. Ein in C-Code implementierter digitaler Synthesizer könnte beispielsweise Tastaturereignisse direkt von einem AMidiDevice empfangen, anstatt auf einen JNI-Aufruf zu warten, um die Ereignisse von der Java-Seite zu senden. Oder ein algorithmischer Kompositionsprozess könnte eine MIDI-Performance direkt an einen AMidiDevice senden, ohne die Java-Seite aufzurufen, um die Tastenereignisse zu übertragen.

AMidi verbessert zwar die direkte Verbindung zu MIDI-Geräten, Apps müssen aber weiterhin die MidiManager verwenden, um MidiDevice-Objekte zu finden und zu öffnen. AMidi übernimmt dann.

Manchmal müssen Sie Informationen von der UI-Ebene an den nativen Code weitergeben. Beispiel: Wenn MIDI-Ereignisse als Reaktion auf Schaltflächen auf dem Bildschirm gesendet werden. Erstellen Sie dazu benutzerdefinierte JNI-Aufrufe für Ihre native Logik. Wenn Sie Daten zurücksenden müssen, um die Benutzeroberfläche zu aktualisieren, können Sie wie gewohnt einen Callback von der nativen Ebene ausführen.

In diesem Dokument wird beschrieben, wie Sie eine AMidi-Anwendung mit nativem Code einrichten. Außerdem finden Sie Beispiele für das Senden und Empfangen von MIDI-Befehlen. Ein vollständiges, funktionierendes Beispiel findest du in der Beispiel-App NativeMidi.

AMidi verwenden

Alle Apps, die AMidi verwenden, haben dieselben Einrichtungs- und Schließschritte, unabhängig davon, ob sie MIDI senden oder empfangen oder beides.

AMidi starten

Auf der Java-Seite muss die App ein angeschlossenes MIDI-Hardwaregerät erkennen, ein entsprechendes MidiDevice erstellen und an den nativen Code übergeben.

  1. MIDI-Hardware mit der Java-Klasse MidiManager erkennen.
  2. Rufen Sie ein Java-MidiDevice-Objekt ab, das der MIDI-Hardware entspricht.
  3. Übergeben Sie die Java-MidiDevice mit JNI an nativen Code.

Hardware und Anschlüsse

Die Eingabe- und Ausgabeportobjekte gehören nicht zur App. Sie stellen Ports auf dem MIDI-Gerät dar. Wenn eine App MIDI-Daten an ein Gerät senden möchte, öffnet sie einen MIDIInputPort und schreibt dann Daten in diesen. Umgekehrt öffnet eine App einen MIDIOutputPort, um Daten zu empfangen. Damit die App ordnungsgemäß funktioniert, muss sie sicher sein, dass die von ihr geöffneten Ports den richtigen Typ haben. Die Geräte- und Port-Erkennung erfolgt auf der Java-Seite.

Hier ist eine Methode, die jedes MIDI-Gerät erkennt und seine Ports prüft. Es wird entweder eine Liste von Geräten mit Ausgabeports zum Empfangen von Daten oder eine Liste von Geräten mit Eingabeports zum Senden von Daten zurückgegeben. Ein MIDI-Gerät kann sowohl Eingangs- als auch Ausgangsports haben.

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

Wenn Sie AMidi-Funktionen in Ihrem C/C++-Code verwenden möchten, müssen Sie AMidi/AMidi.h einbinden und mit der amidi-Bibliothek verknüpfen. Beide sind im Android NDK enthalten.

Die Java-Seite sollte ein oder mehrere MidiDevice-Objekte und Portnummern über einen JNI-Aufruf an die native Ebene übergeben. Die native Ebene sollte dann die folgenden Schritte ausführen:

  1. Rufen Sie für jede Java-MidiDevice mit AMidiDevice_fromJava() eine AMidiDevice ab.
  2. Rufen Sie eine AMidiInputPort und/oder AMidiOutputPort aus der AMidiDevice mit AMidiInputPort_open() und/oder AMidiOutputPort_open() ab.
  3. Verwenden Sie die abgerufenen Ports, um MIDI-Daten zu senden und/oder zu empfangen.

AMidi beenden

Die Java-Anwendung sollte der nativen Ebene signalisieren, dass Ressourcen freigegeben werden sollen, wenn sie das MIDI-Gerät nicht mehr verwendet. Dies kann daran liegen, dass das MIDI-Gerät getrennt wurde oder die App beendet wird.

Damit MIDI-Ressourcen freigegeben werden, sollte Ihr Code die folgenden Aufgaben ausführen:

  1. Lesen und/oder Schreiben von MIDI-Ports beenden. Wenn du einen Lesethread zum Abfragen von Eingaben verwendet hast (siehe Abfrageschleife implementieren unten), beende den Thread.
  2. Schließen Sie alle offenen AMidiInputPort- und/oder AMidiOutputPort-Objekte mit den Funktionen AMidiInputPort_close() und/oder AMidiOutputPort_close().
  3. Lassen Sie die AMidiDevice mit AMidiDevice_release() los.

MIDI-Daten empfangen

Ein typisches Beispiel für eine MIDI-App, die MIDI-Daten empfängt, ist ein „virtueller Synthesizer“, der MIDI-Leistungsdaten empfängt, um die Audiosynthese zu steuern.

Eingehende MIDI-Daten werden asynchron empfangen. Daher ist es am besten, MIDI in einem separaten Thread zu lesen, der kontinuierlich einen oder mehrere MIDI-Ausgabeports abfragt. Dies kann ein Hintergrund- oder ein Audio-Thread sein. AMidi blockiert nicht, wenn von einem Port gelesen wird, und kann daher sicher in einem Audio-Callback verwendet werden.

MidiDevice und zugehörige Ausgabeports einrichten

Eine App liest eingehende MIDI-Daten von den Ausgabeports eines Geräts. Die Java-Seite Ihrer App muss bestimmen, welches Gerät und welche Ports verwendet werden sollen.

Dieses Snippet erstellt das MidiManager aus dem MIDI-Dienst von Android und öffnet ein MidiDevice für das erste gefundene Gerät. Wenn die MidiDevice geöffnet wurde, wird ein Callback an eine Instanz von MidiManager.OnDeviceOpenedListener() gesendet. Die Methode onDeviceOpened dieses Listeners wird aufgerufen, die dann startReadingMidi() aufruft, um den Ausgabeport 0 auf dem Gerät zu öffnen. Dies ist eine JNI-Funktion, die in AppMidiManager.cpp definiert ist. Diese Funktion wird im nächsten Snippet erklärt.

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

Der native Code übersetzt das Java-seitige MIDI-Gerät und seine Ports in Referenzen, die von AMidi-Funktionen verwendet werden.

Hier ist die JNI-Funktion, die durch Aufrufen von AMidiDevice_fromJava() ein AMidiDevice erstellt und dann AMidiOutputPort_open() aufruft, um einen Ausgabeport auf dem Gerät zu öffnen:

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

}

Abfrageschleife implementieren

Apps, die MIDI-Daten empfangen, müssen den Ausgabeport abfragen und reagieren, wenn AMidiOutputPort_receive() eine Zahl größer als null zurückgibt.

Bei Apps mit geringer Bandbreite, z. B. einem MIDI-Scope, kannst du in einem Hintergrundthread mit niedriger Priorität (mit entsprechenden Ruhephasen) abfragen.

Bei Apps, die Audio generieren und strengere Anforderungen an die Echtzeitleistung haben, kannst du im Haupt-Callback für die Audiogenerierung (dem BufferQueue-Callback für OpenSL ES, dem AudioStream-Daten-Callback in AAudio) abfragen. Da AMidiOutputPort_receive() nicht blockierend ist, sind die Auswirkungen auf die Leistung sehr gering.

Die Funktion readThreadRoutine(), die von der obigen Funktion startReadingMidi() aufgerufen wird, könnte so aussehen:

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

Eine App, die eine native Audio-API wie OpenSL ES oder AAudio verwendet, kann dem Callback für die Audiogenerierung MIDI-Empfangscode hinzufügen, z. B. so:

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

Das folgende Diagramm veranschaulicht den Ablauf einer MIDI-Lese-App:

MIDI-Daten senden

Ein typisches Beispiel für eine MIDI-Schreib-App ist ein MIDI-Controller oder ‑Sequenzer.

MidiDevice und seine Eingangsports einrichten

Eine App schreibt ausgehende MIDI-Daten in die Eingangsports eines MIDI-Geräts. Auf der Java-Seite Ihrer App muss festgelegt werden, welche MIDI-Geräte und -Ports verwendet werden sollen.

Der folgende Einrichtungscode ist eine Variante des obigen Beispiels für den Empfang. Es erstellt den MidiManager aus dem MIDI-Dienst von Android. Anschließend wird das erste gefundene MidiDevice geöffnet und startWritingMidi() aufgerufen, um den ersten Eingabeport auf dem Gerät zu öffnen. Dies ist ein JNI-Aufruf, der in AppMidiManager.cpp definiert ist. Die Funktion wird im nächsten Snippet erläutert.

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

Hier ist die JNI-Funktion, die durch Aufrufen von AMidiDevice_fromJava() ein AMidiDevice erstellt und dann AMidiInputPort_open() aufruft, um einen Eingabeport auf dem Gerät zu öffnen:

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-Daten senden

Da das Timing der ausgehenden MIDI-Daten von der App selbst gut verstanden und gesteuert wird, kann die Datenübertragung im Hauptthread der MIDI-App erfolgen. Aus Leistungsgründen (wie bei einem Sequenzer) kann die Generierung und Übertragung von MIDI jedoch in einem separaten Thread erfolgen.

Apps können MIDI-Daten bei Bedarf senden. Beachte, dass AMidi beim Schreiben von Daten blockiert.

Hier ist ein Beispiel für eine JNI-Methode, die einen Puffer mit MIDI-Befehlen empfängt und ausgibt:

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

Das folgende Diagramm veranschaulicht den Ablauf einer MIDI-Schreib-App:

Callbacks

Auch wenn es sich nicht um eine AMidi-Funktion im engeren Sinne handelt, muss Ihr nativer Code möglicherweise Daten an die Java-Seite zurückgeben, um beispielsweise die Benutzeroberfläche zu aktualisieren. Dazu müssen Sie Code auf der Java-Seite und auf der nativen Ebene schreiben:

  • Erstellen Sie eine Callback-Methode auf der Java-Seite.
  • Schreiben Sie eine JNI-Funktion, die die Informationen speichert, die zum Aufrufen des Callbacks erforderlich sind.

Wenn es Zeit für den Callback ist, kann Ihr nativer Code

Hier ist die Java-seitige Callback-Methode 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);
            }
        });
}

Hier ist der C-Code für die JNI-Funktion, die den Callback zu MainActivity.onNativeMessageReceive() einrichtet. Java MainActivity ruft beim Start initNative() auf:

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

Wenn Daten an Java zurückgesendet werden sollen, ruft der native Code die Callback-Verweise ab und erstellt den Callback:

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

Zusätzliche Ressourcen