API Native MIDI

L'API AMidi è disponibile su Android NDK r20b e versioni successive. Offre agli sviluppatori di app la possibilità di inviare e ricevere dati MIDI con C/C++code.

Le app Android MIDI in genere utilizzano l'API midi per comunicare con il servizio Android MIDI. Le app MIDI dipendono principalmente dalla MidiManager per rilevare, aprire e chiudere uno o più oggetti MidiDevice e per trasferire dati da e verso ciascun dispositivo tramite le porte di ingresso e output MIDI:

Quando usi AMidi, passi l'indirizzo di MidiDevice al livello di codice nativo con una chiamata JNI. Da qui, AMidi crea un riferimento a un AMidiDevice che ha la maggior parte delle funzionalità di un MidiDevice. Il tuo codice nativo utilizza le funzioni AMidi che comunicano direttamente con un AMidiDevice. AMidiDevice si connette direttamente al servizio MIDI:

Con le chiamate AMidi, puoi integrare da vicino la logica di controllo e audio C/C++ della tua app con la trasmissione MIDI. È minore la necessità di chiamate JNI o callback al lato Java della tua app. Ad esempio, un sintetizzatore digitale implementato nel codice C potrebbe ricevere eventi chiave direttamente da AMidiDevice, invece di attendere una chiamata JNI per inviare gli eventi dal lato Java. Oppure, un processo di composizione algoritmica potrebbe inviare un'esecuzione MIDI direttamente a un AMidiDevice senza richiamare il lato Java per trasmettere gli eventi chiave.

Anche se AMidi migliora la connessione diretta ai dispositivi MIDI, le app devono comunque usare MidiManager per rilevare e aprire gli oggetti MidiDevice. AMidi può riprendere da lì.

A volte potrebbe essere necessario trasferire le informazioni dal livello UI al codice nativo. Ad esempio, quando eventi MIDI vengono inviati in risposta ai pulsanti sullo schermo. Per farlo, crea chiamate JNI personalizzate alla logica nativa. Se devi inviare i dati per aggiornare l'interfaccia utente, puoi richiamare dal livello nativo, come di consueto.

Questo documento mostra come configurare un'app di codice nativa AMidi, con esempi di comandi MIDI di invio e ricezione. Per un esempio funzionante completo, guarda l'app di esempio nativeMidi.

Utilizza AMidi

Tutte le app che usano AMidi prevedono gli stessi passaggi di configurazione e chiusura, indipendentemente dal fatto che inviino o ricevano MIDI o entrambi.

Avvia AMidi

Sul lato Java, l'app deve rilevare un componente hardware MIDI collegato, creare un elemento MidiDevice corrispondente e passarlo al codice nativo.

  1. Scopri l'hardware MIDI con la classe MidiManager Java.
  2. Ottieni un oggetto MidiDevice Java corrispondente all'hardware MIDI.
  3. Passa il codice Java MidiDevice al codice nativo con JNI.

Scopri hardware e porte

Gli oggetti delle porte di input e output non appartengono all'app. Rappresentano le porte sul dispositivo MIDI. Per inviare dati MIDI a un dispositivo, un'app apre un MIDIInputPort e vi scrive dati. Al contrario, per ricevere dati, un'app apre un MIDIOutputPort. Per funzionare correttamente, l'app deve assicurarsi che le porte che apre siano del tipo corretto. Il rilevamento di dispositivi e porte viene eseguito sul lato Java.

Ecco un metodo che rileva ogni dispositivo MIDI e controlla le relative porte. Restituisce un elenco di dispositivi con porte di output per la ricezione di dati o un elenco di dispositivi con porte di ingresso per l'invio di dati. Un dispositivo MIDI può avere sia porte di ingresso che porte di uscita.

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

Per utilizzare le funzioni AMidi nel codice C/C++, devi includere AMidi/AMidi.h e collegarti alla libreria amidi. Puoi trovare entrambi i metodi, nell'NDK di Android.

Il lato Java deve passare uno o più oggetti MidiDevice e numeri di porta al livello nativo tramite una chiamata JNI. Il livello nativo dovrebbe quindi eseguire i seguenti passaggi:

  1. Per ogni MidiDevice Java, ottieni un AMidiDevice utilizzando AMidiDevice_fromJava().
  2. Ottieni un AMidiInputPort e/o un AMidiOutputPort da AMidiDevice con AMidiInputPort_open() e/o AMidiOutputPort_open().
  3. Usa le porte ottenute per inviare e/o ricevere dati MIDI.

Interrompi AMidi

L'app Java dovrebbe indicare al livello nativo di rilasciare risorse quando non è più in corso l'utilizzo del dispositivo MIDI. Il motivo potrebbe essere che il dispositivo MIDI è stato disconnesso o perché l'app sta per uscire.

Per rilasciare risorse MIDI, il tuo codice deve eseguire queste attività:

  1. Interrompi lettura e/o scrittura sulle porte MIDI. Se utilizzavi un thread di lettura per eseguire il polling dell'input (vedi Implementare un loop di polling di seguito), interrompi il thread.
  2. Chiudi tutti gli oggetti AMidiInputPort e/o AMidiOutputPort aperti con le funzioni AMidiInputPort_close() e/o AMidiOutputPort_close().
  3. Rilascia AMidiDevice con AMidiDevice_release().

Ricevi dati MIDI

Un esempio tipico di app MIDI che riceve dati MIDI è un "sintetizzatore virtuale" che riceve dati sulle prestazioni MIDI per controllare la sintesi audio.

I dati MIDI in entrata vengono ricevuti in modo asincrono. Ti consigliamo quindi di leggere i dati MIDI in un thread separato che esegue continuamente il polling di una porta di output o MIDI. Potrebbe essere un thread in background o un thread audio. AMidi non si blocca durante la lettura da una porta ed è quindi sicuro da usare all'interno di un callback audio.

Configurare un dispositivo MidiDevice e le relative porte di output

Un'app legge i dati MIDI in arrivo dalle porte di output di un dispositivo. Il lato Java della tua app deve determinare il dispositivo e le porte da utilizzare.

Questo snippet crea l'elemento MidiManager dal servizio MIDI di Android e apre una MidiDevice per il primo dispositivo trovato. Dopo l'apertura di MidiDevice, viene ricevuto un callback su un'istanza di MidiManager.OnDeviceOpenedListener(). Viene chiamato il metodo onDeviceOpened di questo listener, che a sua volta chiama startReadingMidi() per aprire la porta di output 0 sul dispositivo. Questa è una funzione JNI definita in AppMidiManager.cpp. Questa funzione è spiegata nello snippet successivo.

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

Il codice nativo traduce il dispositivo MIDI lato Java e le sue porte in riferimenti utilizzati dalle funzioni AMidi.

Ecco la funzione JNI che crea un AMidiDevice chiamando AMidiDevice_fromJava(), quindi chiama AMidiOutputPort_open() per aprire una porta di output sul dispositivo:

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

}

Implementare un loop di polling

Le app che ricevono dati MIDI devono eseguire il polling della porta di output e rispondere quando AMidiOutputPort_receive() restituisce un numero maggiore di zero.

Per le app con larghezza di banda ridotta, ad esempio un ambito MIDI, puoi eseguire il polling in un thread di background a bassa priorità (con periodi di sospensione appropriati).

Per le app che generano audio e hanno requisiti di prestazioni in tempo reale più rigidi, puoi eseguire il polling nel callback di generazione audio principale (il callback BufferQueue per OpenSL ES, il callback dei dati AudioStream in AAudio). Poiché AMidiOutputPort_receive() non blocca, l'impatto sulle prestazioni è minimo.

La funzione readThreadRoutine() chiamata dalla funzione startReadingMidi() riportata sopra potrebbe avere il seguente aspetto:

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

Un'app che utilizza un'API audio nativa (come OpenSL ES o AAudio) può aggiungere il codice di ricezione MIDI al callback di generazione audio nel seguente modo:

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

Il seguente diagramma illustra il flusso di un'app di lettura MIDI:

Invia dati MIDI

Un esempio tipico di app di scrittura MIDI è un controller o un Sequenziatore MIDI.

Configurare un dispositivo MidiDevice e le relative porte di ingresso

Un'app scrive i dati MIDI in uscita nelle porte di ingresso di un dispositivo MIDI. Il lato Java della tua app deve determinare il dispositivo MIDI e le porte da utilizzare.

Il codice di configurazione riportato di seguito è una variante dell'esempio riportato sopra. Crea l'elemento MidiManager dal servizio MIDI di Android. Apre la primaMidiDevice che trova e chiama startWritingMidi() per aprire la prima porta di ingresso sul dispositivo. Questa è una chiamata JNI definita in AppMidiManager.cpp. La funzione è spiegata nello snippet successivo.

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

Ecco la funzione JNI che crea un AMidiDevice chiamando AMidiDevice_fromJava(), quindi chiama AMidiInputPort_open() per aprire una porta di input sul dispositivo:

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

Invia dati MIDI

Poiché la tempistica dei dati MIDI in uscita è ben compresa e controllata dall'app stessa, la trasmissione dei dati può essere eseguita nel thread principale dell'app MIDI. Tuttavia, per motivi di prestazioni (come in un sequenziatore), la generazione e la trasmissione di MIDI possono essere eseguite in un thread separato.

Le app possono inviare dati MIDI quando necessario. Tieni presente che AMidi si blocca durante la scrittura di dati.

Ecco un esempio di metodo JNI che riceve un buffer di comandi MIDI e lo scrive:

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

Il seguente diagramma illustra il flusso di un'app di scrittura MIDI:

Callback

Anche se non è strettamente una funzionalità AMidi, il tuo codice nativo potrebbe dover ritrasmettere i dati al lato Java (ad esempio per aggiornare l'interfaccia utente). Per farlo, devi scrivere codice nel lato Java e nel livello nativo:

  • Creare un metodo di callback sul lato Java.
  • Scrivi una funzione JNI che memorizzi le informazioni necessarie per richiamare il callback.

Quando è il momento di eseguire il callback, il codice nativo può creare

Ecco il metodo di callback lato 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);
            }
        });
}

Ecco il codice C per la funzione JNI che imposta il callback a MainActivity.onNativeMessageReceive(). Chiamate MainActivity Java initNative() all'avvio:

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

Quando è il momento di restituire i dati a Java, il codice nativo recupera i puntatori di callback e crea il 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);
}

Risorse aggiuntive