API de Native MIDI

La API de AMidi está disponible en el NDK de Android r20b y versiones posteriores. Les proporciona a los desarrolladores de apps la capacidad de enviar y recibir datos MIDI con código C/C++.

Por lo general, las apps MIDI de Android usan la API de midi para comunicarse con el servicio MIDI de Android. Las apps MIDI dependen principalmente de que MidiManager descubra, abra y cierre uno o más objetos MidiDevice y pase los datos hacia y desde cada dispositivo a través de los puertos MIDI de entrada y salida de cada dispositivo:

Cuando usas AMidi, pasas la dirección de un objeto MidiDevice a la capa de código nativo con una llamada de JNI. A partir de allí, AMidi crea una referencia a un objeto AMidiDevice, que tiene la mayoría de la funcionalidad de un objeto MidiDevice. El código nativo usa funciones AMidi que se comunican directamente con un AMidiDevice. El AMidiDevice se conecta directamente con el servicio MIDI:

Cuando usas las llamadas de AMidi, puedes integrar la lógica de control y de audio de C o C++ de la app con la transmisión de MIDI. Las llamadas de JNI, o las devoluciones de llamada al lado Java de la app, son menos necesarias. Por ejemplo, un sintetizador digital implementado en código C podría recibir eventos clave directamente de un AMidiDevice, en lugar de esperar una llamada de JNI para enviar los eventos desde el lado de Java. O bien, un proceso de redacción algorítmica podría enviar un rendimiento MIDI directamente a un AMidiDevice sin volver a llamar al lado de Java para transmitir eventos clave.

Si bien AMidi mejora la conexión directa con dispositivos MIDI, las apps todavía deben usar el MidiManager para descubrir y abrir objetos MidiDevice. Luego, AMidi continuará con el proceso.

Es posible que, a veces, necesites transmitir información desde la capa de IU hasta el código nativo. Por ejemplo, cuando se envían eventos MIDI como respuesta a los botones en la pantalla. Para ello, debes crear llamadas JNI personalizadas según tu lógica nativa. Si tienes que devolver datos para actualizar la IU, puedes devolver la llamada desde la capa nativa como lo haces normalmente.

En este documento, se muestra cómo configurar una app de código nativo AMidi con ejemplos del envío y la recepción de comandos MIDI. Para obtener un ejemplo de cómo funciona, consulta la app de muestra NativeMidi.

Cómo usar AMidi

Todas las apps que usan AMidi tienen los mismos pasos de configuración y cierre, sin importar si envían o reciben datos MIDI, o si realizan ambas acciones.

Cómo iniciar AMidi

En Java, la app debe descubrir una parte adjunta de hardware MIDI, crear un MidiDevice correspondiente y pasarlo al código nativo.

  1. Descubre el hardware MIDI con la clase MidiManager de Java.
  2. Obtén un objeto MidiDevice de Java correspondiente al hardware MIDI.
  3. Transfiere el objeto MidiDevice de Java al código nativo con JNI.

Detecta puertos y hardware

Los objetos de puerto de entrada y salida no pertenecen a la app. Representan los puertos del dispositivo MIDI. Para enviar datos de MIDI a un dispositivo, una app abre un MIDIInputPort y escribe datos en él. Por el contrario, para recibir datos, una app abre un MIDIOutputPort. Para que funcione correctamente, la app debe asegurarse de que los puertos que abre sean del tipo correcto. La detección de puertos y dispositivos se realiza en el código Java.

A continuación, se muestra un método que detecta cada dispositivo MIDI y analiza sus puertos. Muestra una lista de dispositivos con puertos de salida para recibir datos o una lista de dispositivos con puertos de entrada a fin de enviar datos. Un dispositivo MIDI puede tener puertos de entrada y puertos de salida.

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

Para usar funciones de AMidi en tu código C/C++, debes incluir AMidi/AMidi.h y vincularlo con la biblioteca de amidi. Puedes encontrarlos en el NDK de Android.

El lado de Java debería transferir uno o más objetos MidiDevice y números de puerto a la capa nativa a través de una llamada de JNI. Luego, la capa nativa debería realizar los siguientes pasos:

  1. Para cada objeto MidiDevice de Java, obtén un objeto AMidiDevice con AMidiDevice_fromJava().
  2. Obtén un objeto AMidiInputPort o AMidiOutputPort de AMidiDevice con AMidiInputPort_open() o AMidiOutputPort_open().
  3. Usa los puertos detectados para enviar o recibir datos MIDI.

Cómo detener AMidi

La app de Java debería indicarle a la capa nativa que libere recursos cuando ya no use el dispositivo MIDI. Esto podría ser porque el dispositivo MIDI estaba desconectado o la app se estaba cerrando.

Para liberar recursos MIDI, el código debe realizar lo siguiente:

  1. Detener la lectura o escritura en los puertos MIDI; si usabas un subproceso de lectura para sondear la entrada (consulta Cómo implementar un bucle de sondeo abajo), detén el subproceso
  2. Cerrar cualquier objeto AMidiInputPort o AMidiOutputPort abierto con las funciones AMidiInputPort_close() o AMidiOutputPort_close()
  3. Liberar el objeto AMidiDevice con AMidiDevice_release()

Cómo recibir datos MIDI

Un ejemplo típico de una app de MIDI que recibe datos MIDI es un "sintetizador virtual" que recibe datos MIDI de rendimiento para controlar la síntesis de audio.

Los datos MIDI entrantes se reciben de forma asíncrona. Por lo tanto, es mejor leerlos en un subproceso separado que sondea continuamente uno o varios puertos MIDI de salida. Podría ser un subproceso de segundo plano o un subproceso de audio. AMidi no se bloquea cuando se leen datos de un puerto y, por lo tanto, es seguro usarlo en una devolución de llamada de audio.

Configura un MidiDevice y sus puertos de salida

Una app lee datos MIDI entrantes de los puertos de salida de un dispositivo. El código Java de la app debe determinar qué dispositivos y puertos se usarán.

Este fragmento crea el MidiManager desde el servicio MIDI de Android y abre un MidiDevice para el primer dispositivo que encuentra. Cuando se abre MidiDevice, se recibe una devolución de llamada en una instancia de MidiManager.OnDeviceOpenedListener(). Se llama al método onDeviceOpened de este objeto de escucha, que luego llama a startReadingMidi() para abrir el puerto de salida 0 del dispositivo. Es es una función JNI definida en AppMidiManager.cpp. La función se explica en el siguiente fragmento.

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

El código nativo convierte el dispositivo MIDI con código Java y sus puertos en referencias que usan las funciones de AMidi.

Esta es la función JNI que llama a AMidiDevice_fromJava() para crear un AMidiDevice y, luego, llama a AMidiOutputPort_open() a fin de abrir un puerto de salida en el 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...

}

Implementa un bucle de sondeo

Las apps que reciben datos MIDI deben sondear el puerto de salida y responder cuando AMidiOutputPort_receive() muestre un número superior a cero.

En el caso de las apps con ancho de banda bajo, como un alcance de MIDI, puedes hacer el sondeo en un subproceso de baja prioridad en segundo plano (con las suspensiones correspondientes).

Para las apps que generan audio y tienen requisitos de rendimiento en tiempo real más estrictos, puedes sondear la devolución de llamada de generación de audio principal (la devolución de llamada BufferQueue de OpenSL ES o la de datos de AudioStream en AAudio). Como AMidiOutputPort_receive() no genera bloqueos, el impacto en el rendimiento es ínfimo.

La función readThreadRoutine() que se llamó desde la función startReadingMidi() anterior podría parecerse a lo siguiente:

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

Una app que usa una API de audio nativa (como OpenSL Es o AAudio) puede agregar código de recepción MIDI a la devolución de llamada de generación de audio de la siguiente manera:

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

El siguiente diagrama ilustra el flujo de una app de lectura de MIDI:

Cómo enviar datos MIDI

Un típico ejemplo de una app de escritura de MIDI es un controlador o secuenciador MIDI.

Configura un MidiDevice y sus puertos de entrada

Una app escribe datos MIDI salientes en los puertos de entrada de un dispositivo MIDI. El código Java de tu app debe determinar qué puertos y dispositivos MIDI se usarán.

El código de configuración abajo es una variación del ejemplo de recepción que se muestra más arriba. Crea el objeto MidiManager desde el servicio MIDI de Android. Luego, abre el primer MidiDevice que encuentra y llama a startWritingMidi() para abrir el primer puerto de entrada del dispositivo. Es una llamada JNI definida en AppMidiManager.cpp. La función se explica en el siguiente fragmento.

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

Esta es la función JNI que llama a AMidiDevice_fromJava() para crear un objeto AMidiDevice y, luego, llama a AMidiInputPort_open() a fin de abrir un puerto de entrada en el 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;
}

Cómo enviar datos MIDI

Como la app comprende y controla los tiempos de los datos MIDI salientes, la transmisión de datos puede realizarse en el subproceso principal de la app de MIDI. Sin embargo, por razones de rendimiento (como en un secuenciador), la generación y la transmisión de MIDI puede realizarse en un subproceso separado.

Las apps pueden enviar datos MIDI siempre que sea necesario. Sin embargo, ten en cuenta que AMidi se bloquea durante la escritura de datos.

A continuación, se muestra un ejemplo de método JNI que recibe un búfer de comandos MIDI y lo escribe:

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

El siguiente diagrama ilustra el flujo de una app de escritura de MIDI:

Devoluciones de llamadas

Si bien no es estrictamente una función de AMidi, es posible que tu código nativo deba pasar datos de vuelta al código Java (por ejemplo, para actualizar la IU). Para ello, debes escribir código en el lado de Java y en la capa nativa:

  • Crea un método de devolución de llamada en el código Java.
  • Escribe una función JNI que almacene la información necesaria para invocar la devolución de llamada.

Cuando sea el momento de devolver la llamada, tu código nativo podrá construir lo siguiente.

Aquí se encuentra el método de devolución de llamada de 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);
            }
        });
}

Este es el código C para la función JNI que configura la devolución de llamada en MainActivity.onNativeMessageReceive(). MainActivity de Java llama a initNative() en el inicio:

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

Cuando llega el momento de enviar datos de vuelta a Java, el código nativo recupera los punteros de devolución de llamada y construye la devolución de llamada:

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

Recursos adicionales