Native MIDI API

Die AMidi API ist ab Android NDK r20b verfügbar. Damit können App-Entwickler 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-Anwendungen sind in erster Linie vom MidiManager abhängig, um ein oder mehrere MidiDevice-Objekte zu erkennen, zu öffnen und zu schließen und Daten über die MIDI-Eingabe- und ‐Ausgabeports des Geräts an und von jedem Gerät weiterzugeben:

Wenn Sie AMidi verwenden, übergeben Sie mit einem JNI-Aufruf die Adresse eines MidiDevice an die native Codeebene. Von dort erstellt AMidi einen Verweis auf ein AMidiDevice, das die meisten Funktionen einer MidiDevice hat. Ihr nativer Code verwendet AMidi-Funktionen, die direkt mit einem AMidiDevice kommunizieren. Das AMidiDevice stellt eine direkte Verbindung zum MIDI-Dienst her:

Mit AMidi-Aufrufen können Sie die C/C++-Audio-/Steuerlogik Ihrer App eng mit der MIDI-Übertragung integrieren. Es sind weniger JNI-Aufrufe oder Callbacks an die Java-Seite Ihrer Anwendung erforderlich. So könnte beispielsweise ein in C-Code implementierter digitaler Synthesizer Schlüsselereignisse direkt von einem AMidiDevice empfangen, anstatt auf einen JNI-Aufruf zu warten, um die Ereignisse von der Java-Seite nach unten zu senden. Oder ein algorithmischer Zusammensetzungsprozess könnte eine MIDI-Leistung direkt an ein AMidiDevice senden, ohne die Java-Seite aufzurufen, um die Schlüsselereignisse zu übertragen.

AMidi verbessert zwar die direkte Verbindung zu MIDI-Geräten, Apps müssen jedoch weiterhin MidiManager verwenden, um MidiDevice-Objekte zu erkennen und zu öffnen. AMidi macht es von da an weiter.

Manchmal müssen Sie möglicherweise Informationen von der UI-Ebene an den nativen Code übergeben. Das ist beispielsweise der Fall, 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 UI zu aktualisieren, können Sie wie gewohnt von der nativen Ebene aus einen Callback ausführen.

In diesem Dokument wird gezeigt, wie Sie eine Anwendung mit nativem AMidi-Code einrichten. Anhand von Beispielen wird gezeigt, wie MIDI-Befehle gesendet und empfangen werden. Ein Praxisbeispiel finden Sie in der NativeMidi-Beispiel-App.

AMidi verwenden

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

AMidi starten

Auf der Java-Seite muss die Anwendung eine angehängte MIDI-Hardware erkennen, eine entsprechende MidiDevice erstellen und an den nativen Code übergeben.

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

Hardware und Ports entdecken

Die Eingabe- und Ausgabe-Port-Objekte gehören nicht zur App. Sie stellen Ports auf dem Midi-Gerät dar. Zum Senden von MIDI-Daten an ein Gerät öffnet eine App ein MIDIInputPort und schreibt dann Daten darauf. Umgekehrt öffnet eine Anwendung ein MIDIOutputPort, um Daten zu empfangen. Damit die Anwendung richtig funktioniert, muss sie prüfen, ob die von ihr geöffneten Ports den richtigen Typ haben. Die Geräte- und Porterkennung erfolgt auf Java-Seite.

Mit der folgenden Methode wird jedes MIDI-Gerät erkannt und die Ports überprüft. Sie gibt entweder eine Liste von Geräten mit Ausgabeports für den Empfang von Daten oder eine Liste von Geräten mit Eingabeports zum Senden von Daten zurück. Ein MIDI-Gerät kann sowohl Eingangs- als auch Ausgabeports 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 einfügen und mit der amidi-Bibliothek verknüpfen. Sie finden beide im Android NDK.

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 eine AMidiDevice mithilfe von AMidiDevice_fromJava() ab.
  2. Rufen Sie AMidiInputPort und/oder AMidiOutputPort aus dem AMidiDevice mit AMidiInputPort_open() und/oder AMidiOutputPort_open() ab.
  3. Verwenden Sie die abgerufenen Ports zum Senden und/oder Empfangen von MIDI-Daten.

AMidi anhalten

Die Java-App sollte der nativen Ebene signalisieren, dass sie Ressourcen freigeben soll, wenn sie das MIDI-Gerät nicht mehr verwendet. Möglicherweise wurde die Verbindung zum MIDI-Gerät getrennt oder die Anwendung wird beendet.

Zum Freigeben von MIDI-Ressourcen sollte Ihr Code die folgenden Aufgaben ausführen:

  1. Beenden Sie das Lesen und/oder Schreiben auf MIDI-Ports. Wenn Sie einen Lesethread zum Abfragen von Eingaben verwendet haben (siehe Abfrageschleife implementieren), beenden Sie den Thread.
  2. Schließen Sie alle geöffneten AMidiInputPort- und/oder AMidiOutputPort-Objekte mit AMidiInputPort_close()- und/oder AMidiOutputPort_close()-Funktionen.
  3. Geben Sie AMidiDevice mit AMidiDevice_release() frei.

MIDI-Daten empfangen

Ein typisches Beispiel für eine MIDI-Anwendung, die MIDI empfängt, ist ein "virtueller Synthesizer", der MIDI-Leistungsdaten zur Steuerung der Audiosynthese empfängt.

Eingehende MIDI-Daten werden asynchron empfangen. Daher ist es am besten, MIDI in einem separaten Thread zu lesen, der kontinuierlich einen oder MIDI-Ausgabeports abfragt. Dies kann ein Hintergrund- oder ein Audiothread sein. AMidi blockiert beim Lesen aus einem Port nicht und kann daher ohne Bedenken in einem Audio-Callback verwendet werden.

MidiDevice und zugehörige Ausgabeports einrichten

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

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

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-MIDI-Gerät und seine Ports in Referenzen, die von AMidi-Funktionen verwendet werden.

Dies 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

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

Bei Anwendungen mit niedriger Bandbreite, z. B. einem MIDI-Bereich, können Sie einen Hintergrundthread mit niedriger Priorität abfragen (mit entsprechenden Ruhezeiten).

Bei Anwendungen, die Audio generieren und strengere Anforderungen an die Leistung in Echtzeit haben, können Sie den Haupt-Callback für die Audiogenerierung (BufferQueue-Callback für OpenSL ES, der AudioStream-Daten-Callback in AAudio) abfragen. Da AMidiOutputPort_receive() nicht blockierend ist, hat dies nur wenige Auswirkungen auf die Leistung.

Die Funktion readThreadRoutine(), die mit der obigen startReadingMidi()-Funktion 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 zur Audiogenerierung MIDI-Empfangscode wie folgt hinzufügen:

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-Schreibanwendung ist ein MIDI-Controller oder -Sequencer.

MidiDevice und zugehörige Eingabeports einrichten

Eine App schreibt ausgehende MIDI-Daten in die Eingabeports eines MIDI-Geräts. Die Java-Seite Ihrer Anwendung muss bestimmen, welche MIDI-Geräte und -Ports verwendet werden sollen.

Der unten stehende Einrichtungscode ist eine Variation des obigen Beispiels. Damit wird das MidiManager aus dem MIDI-Dienst von Android erstellt. Dann wird das erste gefundene MidiDevice geöffnet und startWritingMidi() aufgerufen, um den ersten Eingabeport auf dem Gerät zu öffnen. Dies ist ein in AppMidiManager.cpp definierter JNI-Aufruf. 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);
    }
  }
}

Dies 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 der Zeitpunkt der ausgehenden MIDI-Daten von der Anwendung selbst verstanden und gesteuert wird, kann die Datenübertragung im Hauptthread der MIDI-Anwendung erfolgen. Aus Leistungsgründen (wie bei einem Sequencer) kann die Generierung und Übertragung von MIDI jedoch in einem separaten Thread erfolgen.

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

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

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 zeigt den Ablauf einer MIDI-Schreib-App:

Rückrufe

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

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

Beim Callback 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 für MainActivity.onNativeMessageReceive() einrichtet. Java ruft MainActivity 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 die Daten zurück an Java gesendet werden sollen, ruft der native Code die Callback-Zeiger 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);
}

Weitere Informationen