Únete a ⁠ #Android11: The Beta Launch Show el 3 de junio.

API de MIDI nativa

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

Las apps MIDI de Android por lo general 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 transfiera los datos hacia y desde cada dispositivo a través de los puertos de entrada y salida MIDI de cada dispositivo:

Cuando usas AMidi, transfieres 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. Tu código nativo usa funciones AMidi que se comunican directamente con un objeto AMidiDevice. El objeto 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 tu app con la transmisión de MIDI. Las llamadas de JNI, o las devoluciones de llamada al lado Java de tu app, son menos necesarias. Por ejemplo, un sintetizador digital implementado en código C podría recibir eventos clave directamente de un objeto 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 objeto 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 elemento 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 de 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 y se proporcionan 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.

Usa AMidi

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

Inicia AMidi

En Java, la app debe descubrir una parte adjunta de hardware MIDI, crear un objeto MidiDevice correspondiente y transferirlo 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 en el dispositivo MIDI. Para enviar datos de MIDI a un dispositivo, una app abre un objeto MIDIInputPort y escribe datos en él. Por el contrario, para recibir datos, una app abre un objeto MIDIOutputPort. Para que funcione correctamente, la app debe asegurarse de que los puertos que abre son 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 bien una lista de dispositivos con puertos de entrada para 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 el objeto 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 esté usando el dispositivo MIDI. Esto podría deberse a que el dispositivo MIDI estaba desconectado o porque la app se estaba cerrando.

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

  1. Debe detener la lectura o escritura en los puertos MIDI. Si se estaba usando un subproceso de lectura para sondear la entrada (consulta Cómo implementar un bucle de sondeo debajo), es necesario detener el subproceso.
  2. Cierra cualquier objeto AMidiInputPort o AMidiOutputPort abierto con las funciones AMidiInputPort_close() o AMidiOutputPort_close().
  3. Actualiza el objeto AMidiDevice con AMidiDevice_release().

Recibe 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 por separado que sondear continuamente uno o varios puertos MIDI de salida. Esto 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 tu app debe determinar qué dispositivos y puertos se usarán.

Este fragmento crea el objeto MidiManager desde el servicio MIDI de Android y abre un objeto MidiDevice para el primer dispositivo que encuentra. Cuando se abre el objeto 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. Esta es una función JNI definida en AppMidiManager.cpp. En el siguiente fragmento, se explica su función.

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.

Aquí se encuentra la función JNI que crea un objeto AMidiDevice al llamar a AMidiDevice_fromJava() y, luego, llama a AMidiOutputPort_open() para 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 recibe 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 realizar 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 de BufferQueue para OpenSL ES, o bien 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:

Envío de 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 dispositivo MIDI se usarán.

El código de configuración que aparece debajo 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 objeto MidiDevice que encuentra y llama a startWritingMidi() para abrir el primer puerto de entrada del dispositivo. Esta es una llamada JNI definida en AppMidiManager.cpp. En el siguiente fragmento, se explica su función.

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

Aquí se encuentra la función JNI que crea un objeto AMidiDevice al llamar a AMidiDevice_fromJava() y, luego, llama a AMidiInputPort_open() para 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;
    }
    

Envío de datos MIDI

Como la app comprende y controla el envío de datos MIDI, esta transmisión puede realizarse en el subproceso principal de la app. Sin embargo, por razones de rendimiento (como en un secuenciador), la generación y 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 enviar datos de vuelta al código Java (por ejemplo, para actualizar la IU). Para ello, debes escribir lo siguiente en el código 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);
                }
            });
    }
    

Aquí se encuentra 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() al 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