API MIDI native

L'API AMidi est disponible dans le NDK Android r20b et les versions ultérieures. Il permet aux développeurs d'applications d'envoyer et de recevoir des données MIDI avec du code C/C++.

Les applications Android MIDI utilisent généralement l'API midi pour communiquer avec le service Android MIDI. Les applications MIDI dépendent principalement de MidiManager pour découvrir, ouvrir et fermer un ou plusieurs objets MidiDevice, et transmettent les données vers et depuis chaque appareil via les ports d'entrée et de sortie MIDI :

Lorsque vous utilisez AMidi, vous transmettez l'adresse d'un MidiDevice à la couche de code native à l'aide d'un appel JNI. Ensuite, AMidi crée une référence à un objet AMidiDevice qui fonctionne en grande partie comme un objet MidiDevice. Votre code natif utilise des fonctions AMidi qui communiquent directement avec un AMidiDevice. AMidiDevice se connecte directement au service MIDI :

Les appels AMidi vous permettent d'intégrer étroitement la logique audio et de contrôle C/C++ de votre application dans la transmission MIDI. Les appels JNI ou les rappels côté Java de votre application sont moins nécessaires. Par exemple, un synthétiseur numérique implémenté dans le code C peut recevoir des événements clés directement d'un AMidiDevice, plutôt que d'attendre un appel JNI pour envoyer les événements depuis le côté Java. Ou encore, un processus de composition algorithmique peut envoyer une performance MIDI directement à un AMidiDevice sans rappeler le côté Java pour transmettre les événements clés.

Bien qu'AMidi améliore la connexion directe aux appareils MIDI, les applications doivent toujours utiliser MidiManager pour détecter et ouvrir les objets MidiDevice. Puis, AMidi s'occupe du reste.

Vous devrez parfois transmettre des informations de la couche UI au code natif (par exemple, lorsque des événements MIDI sont envoyés en réponse à des boutons à l'écran). Pour ce faire, créez des appels JNI personnalisés vers votre logique native. Si vous devez renvoyer des données pour mettre à jour l'interface utilisateur, vous pouvez effectuer le rappel depuis la couche native comme d'habitude.

Ce document explique comment configurer une application de code natif AMidi, et fournit des exemples d'envoi et de réception de commandes MIDI. Pour consulter un exemple complet, reportez-vous à l'application exemple NativeMidi.

Utiliser AMidi

Toutes les applications qui utilisent AMidi ont les mêmes étapes de configuration et de finalisation, qu'elles envoient ou reçoivent des fonctions MIDI, ou les deux.

Démarrer AMidi

Du côté Java, l'application doit détecter un appareil MIDI associé, créer un MidiDevice correspondant et le transmettre au code natif.

  1. Détectez le matériel MIDI avec la classe Java MidiManager.
  2. Obtenez un objet MidiDevice Java correspondant au matériel MIDI.
  3. Transmettez le MidiDevice Java au code natif avec JNI.

Détecter le matériel et les ports

Les objets des ports d'entrée et de sortie n'appartiennent pas à l'application. Ils représentent les ports de l'appareil MIDI. Pour envoyer des données MIDI à un appareil, l'application ouvre un objet MIDIInputPort, puis y écrit des données. À l'inverse, pour recevoir des données, l'application ouvre un objet MIDIOutputPort. Pour fonctionner correctement, l'application doit s'assurer que les ports qu'elle ouvre sont du type approprié. La détection des appareils et des ports s'effectue du côté Java.

Voici une méthode qui permet de détecter chaque appareil MIDI et d'examiner ses ports. Elle renvoie soit une liste d'appareils avec les ports de sortie pour la réception de données, soit une liste d'appareils avec les ports d'entrée pour l'envoi de données. Les appareils MIDI peuvent avoir à la fois des ports d'entrée et des ports de sortie.

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

Pour utiliser les fonctions AMidi dans votre code C/C++, vous devez inclure AMidi/AMidi.h et associer la bibliothèque amidi. Vous les trouverez dans le NDK Android.

Le côté Java doit transmettre un ou plusieurs objets MidiDevice et des numéros de port à la couche native via un appel JNI. La couche native doit ensuite effectuer les étapes suivantes :

  1. Pour chaque objet MidiDevice Java, générez un AMidiDevice avec AMidiDevice_fromJava().
  2. Générez un objet AMidiInputPort et/ou un objet AMidiOutputPort à partir du AMidiDevice avec AMidiInputPort_open() et/ou AMidiOutputPort_open().
  3. Utilisez les ports générés pour envoyer et/ou recevoir des données MIDI.

Arrêter AMidi

L'application Java doit signaler à la couche native de libérer les ressources lorsqu'elle n'utilise plus l'appareil MIDI. Cela se produit, par exemple, lorsque l'appareil MIDI a été déconnecté ou que l'application se ferme.

Pour libérer des ressources MIDI, effectuez les tâches suivantes via le code :

  1. Arrêtez la lecture et/ou l'écriture sur les ports MIDI. Si vous utilisiez un thread de lecture pour interroger les entrées (consultez Implémenter une boucle d'interrogation ci-dessous), arrêtez-le.
  2. Fermez tous les objets AMidiInputPort et/ou AMidiOutputPort ouverts avec les fonctions AMidiInputPort_close() et/ou AMidiOutputPort_close().
  3. Libérez le AMidiDevice avec AMidiDevice_release().

Recevoir des données MIDI

Un exemple typique d'application MIDI recevant des données MIDI est un "synthétiseur virtuel" qui reçoit des données de performances MIDI pour contrôler la synthèse audio.

Les données MIDI entrantes sont reçues de manière asynchrone. Par conséquent, il est préférable de lire les données MIDI dans un thread distinct qui interroge en continu ce thread ou les ports de sortie MIDI. Il peut s'agir d'un thread d'arrière-plan ou d'un thread audio. AMidi ne bloque pas la lecture depuis un port et peut donc être utilisé sans risque dans un rappel audio.

Configurer un MidiDevice et ses ports de sortie

Une application lit les données MIDI entrantes à partir des ports de sortie d'un appareil. Le côté Java de votre application doit déterminer l'appareil et les ports à utiliser.

Cet extrait crée le MidiManager à partir du service MIDI d'Android et ouvre un MidiDevice pour le premier appareil qu'il trouve. Une fois que le MidiDevice est ouvert, un rappel est reçu vers une instance de MidiManager.OnDeviceOpenedListener(). La méthode onDeviceOpened de cet écouteur est appelée, puis appelle startReadingMidi() pour ouvrir le port de sortie 0 sur l'appareil. Il s'agit d'une fonction JNI définie dans AppMidiManager.cpp. Cette fonction est expliquée dans l'extrait suivant.

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

Le code natif traduit l'appareil MIDI côté Java et ses ports en références utilisées par les fonctions AMidi.

Voici la fonction JNI qui crée un AMidiDevice en appelant AMidiDevice_fromJava(), puis qui appelle AMidiOutputPort_open() pour ouvrir un port de sortie sur l'appareil :

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

}

Implémenter une boucle d'interrogation

Les applications qui reçoivent des données MIDI doivent interroger le port de sortie et répondre quand AMidiOutputPort_receive() renvoie un nombre supérieur à zéro.

Pour les applications à faible bande passante, telles qu'un champ d'application MIDI, vous pouvez interroger un thread en arrière-plan de faible priorité (avec les veilles appropriées).

Pour les applications qui génèrent du contenu audio et dont les exigences de performances en temps réel sont plus strictes, vous pouvez interroger le rappel de génération audio principal (rappel BufferQueue pour OpenSL ES, rappel de données AudioStream dans AAudio). Comme AMidiOutputPort_receive() n'est pas bloquant, l'impact sur les performances est très faible.

La fonction readThreadRoutine() appelée à partir de la fonction startReadingMidi() ci-dessus peut se présenter comme suit :

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

Une application qui utilise une API audio native (telle qu'OpenSL ES ou AAudio) peut ajouter du code de réception MIDI au rappel de génération audio, comme suit :

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

Le schéma suivant illustre le flux d'une application de lecture de données MIDI :

Envoyer des données MIDI

Un contrôleur ou séquenceur MIDI est un exemple typique d'application d'écriture MIDI.

Configurer un MidiDevice et ses ports d'entrée

Une application écrit les données MIDI sortantes au niveau des ports d'entrée d'un appareil MIDI. Le côté Java de votre application doit déterminer l'appareil et les ports MIDI à utiliser.

Le code de configuration ci-dessous est une variante de l'exemple de réception présenté plus haut. Il crée le MidiManager à partir du service MIDI d'Android. Il ouvre ensuite le premier MidiDevice qu'il trouve et appelle startWritingMidi() pour ouvrir le premier port d'entrée sur l'appareil. Il s'agit d'un appel JNI défini dans AppMidiManager.cpp. Cette fonction est expliquée dans l'extrait suivant.

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

Voici la fonction JNI qui crée un AMidiDevice en appelant AMidiDevice_fromJava(), puis qui appelle AMidiInputPort_open() pour ouvrir un port d'entrée sur l'appareil :

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

Envoyer des données MIDI

Comme le temps de transfert des données sortantes MIDI est bien compris et contrôlé par l'application elle-même, la transmission des données peut s'effectuer dans le thread principal de l'application MIDI. Toutefois, pour des raisons de performances (comme dans un séquenceur), la génération et la transmission des données MIDI peuvent avoir lieu dans un thread distinct.

Les applications peuvent envoyer des données MIDI si nécessaire. Notez qu'AMidi bloque les données lors de l'écriture.

Voici un exemple de méthode JNI qui reçoit un tampon de commandes MIDI et l'écrit :

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

Le schéma suivant illustre le flux d'une application d'écriture de données MIDI :

Rappels

Bien qu'il ne s'agisse pas strictement d'une fonctionnalité AMidi, votre code natif devra peut-être transmettre des données vers le côté Java (pour mettre à jour l'interface utilisateur, par exemple). Pour ce faire, vous devez écrire du code côté Java et dans la couche native :

  • Créez une méthode de rappel côté Java.
  • Écrivez une fonction JNI qui stocke les informations nécessaires pour appeler ce rappel.

Au moment du rappel, votre code natif peut créer

Voici la méthode de rappel côté Java, 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);
            }
        });
}

Voici le code C de la fonction JNI qui configure le rappel vers MainActivity.onNativeMessageReceive(). L'objet MainActivity Jave appelle initNative() au démarrage :

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

Au moment de renvoyer les données à Java, le code natif récupère les pointeurs de rappel et construit le rappel :

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

Ressources supplémentaires