API Native MIDI

A AMidi é uma API do Android NDK que oferece aos desenvolvedores de apps a habilidade de enviar e receber dados MIDI com código C/C++.

Os apps Android MIDI costumam usar a API midi para se comunicarem com o serviço Android MIDI. Esses apps dependem principalmente do MidiManager para descobrir, abrir e fechar um ou mais objetos MidiDevice, além de passar dados de e para cada dispositivo por meio das portas de entrada e saída MIDI.

Ao usar a AMidi, você passa o endereço de um MidiDevice para a camada de código nativo com uma chamada JNI. A partir daí, a AMidi cria uma referência a um AMidiDevice, que tem a maior parte da funcionalidade de um MidiDevice. Seu código nativo usa funções da AMidi que se comunicam diretamente com um AMidiDevice. O AMidiDevice se conecta diretamente ao serviço MIDI.

Usando chamadas AMidi, você pode integrar a lógica de áudio/controle C/C++ do seu app de forma mais próxima à transmissão MIDI. Há menos necessidade para chamadas JNI ou callbacks para a parte em Java do seu app. Por exemplo, um sintetizador digital implementado em código C poderia receber eventos-chave diretamente de um AMidiDevice, em vez de esperar por uma chamada JNI para enviar os eventos de uma parte em Java. Ou, um processo de composição algorítmico poderia enviar um desempenho MIDI diretamente a um AMidiDevice sem fazer callback para a parte em Java para transmitir os eventos-chave.

Embora a AMidi melhore a conexão direta a dispositivos MIDI, os apps ainda precisam usar o MidiManager para descobrir e abrir objetos MidiDevice. A AMidi pode assumir a partir de então.

Às vezes, pode ser necessário passar informações da camada de IU para o código nativo. Por exemplo, quando eventos MIDI são enviados em resposta a botões na tela. Para isso, crie chamadas JNI personalizadas para sua lógica nativa. Se precisar enviar dados de volta para atualizar a IU, você poderá fazer um callback da camada nativa, como de costume.

Este documento mostra como configurar um app de código nativo AMidi, com exemplos de envio e recebimento de comandos MIDI. Para um exemplo completo, veja o app de exemplo NativeMidi (link em inglês).

Como usar a AMidi

Todos os apps que usam a AMidi têm as mesmas etapas de configuração e fechamento, quer eles enviem ou recebam MIDI, quer façam as duas coisas.

Iniciar a AMidi

Na parte em Java, o app precisa descobrir uma peça do hardware MIDI, criar um MidiDevice correspondente e passá-lo para o código nativo.

  1. Descobrir o hardware MIDI com a classe MidiManager do Java.
  2. Conseguir um objeto MidiDevice do Java correspondente ao hardware MIDI.
  3. Passar o MidiDevice do Java para o código nativo com JNI.

Descobrir hardware e portas

Os objetos de porta de entrada e saída não pertencem ao app. Eles representam portas no dispositivo midi. Para enviar dados MIDI para um dispositivo, um app abre uma MIDIInputPort e, em seguida, grava dados nela. Por outro lado, para receber dados, um app abre uma MIDIOutputPort. Para funcionar corretamente, o app precisa ter certeza de que as portas do tipo correto serão abertas. A descoberta de dispositivos e portas é feita na parte em Java.

Veja um método que descobre cada dispositivo MIDI e analisa as portas deles. Ele retorna uma lista de dispositivos com portas de saída para receber dados ou uma lista de dispositivos com portas de entrada para enviar dados. Um dispositivo MIDI pode ter portas de entrada e de saída.

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 as funções da AMidi no seu código C/C++, você precisa incluir AMidi/AMidi.h e vincular à biblioteca amidi. Esses itens podem ser encontrados no Android NDK.

O lado Java precisa passar um ou mais objetos MidiDevice e números de porta para a camada nativa por meio de uma chamada JNI. A camada nativa precisa executar as seguintes etapas:

  1. Para cada MidiDevice Java consiga um AMidiDevice usando AMidiDevice_fromJava().
  2. Consiga um AMidiInputPort e/ou AMidiOutputPort a partir do AMidiDevice com AMidiInputPort_open() e/ou AMidiOutputPort_open().
  3. Use as portas recebidas para enviar e/ou receber dados MIDI.

Parar a AMidi

O app Java sinalizará a camada nativa para liberar recursos quando não estiver mais usando o dispositivo MIDI. Isso pode ocorrer porque o dispositivo MIDI foi desconectado ou porque o app está saindo.

Para liberar recursos MIDI, seu código precisa executar estas tarefas:

  1. Parar de ler e/ou gravar em portas MIDI. Se você estava usando uma linha de execução de leitura para pesquisar entradas (consulte abaixo Implementar um loop de pesquisa), interrompa a linha.
  2. Feche todos os objetos AMidiInputPort e/ou AMidiOutputPort abertos com as funções AMidiInputPort_close() e/ou AMidiOutputPort_close().
  3. Libere o AMidiDevice com AMidiDevice_release().

Receber dados MIDI

Um exemplo típico de app que recebe MIDI é um "sintetizador virtual", que recebe dados de desempenho MIDI para controlar a síntese de áudio.

Os dados MIDI são recebidos de forma assíncrona. Portanto, recomendamos ler MIDI em uma linha de execução diferente que monitore continuamente um deles ou as portas de saída MIDI. Pode ser uma linha de execução em segundo plano ou uma linha de execução de áudio. O AMidi não é bloqueado durante a leitura de uma porta, então é seguro usá-lo dentro de um callback de áudio.

Configurar um MidiDevice e as portas de saída dele

Um app lê dados MIDI de entrada a partir das portas de saída de um dispositivo. A parte em Java do seu app precisa determinar quais dispositivos e portas usar.

Esse snippet cria o MidiManager a partir do serviço MIDI Android e abre um MidiDevice para o primeiro dispositivo que detectar. Quando o MidiDevice tiver sido aberto, um callback será recebido para uma instância do MidiManager.OnDeviceOpenedListener(). O método onDeviceOpened desse listener é chamado e, em seguida, chama startReadingMidi() para abrir a porta de saída 0 no dispositivo. Essa é uma função JNI definida em AppMidiManager.c. Essa função é explicada no snippet a seguir.

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

O código nativo traduz o dispositivo MIDI na parte em Java e as portas dele em referências usadas por funções AMidi.

Aqui está a função JNI que cria um AMidiDevice ao chamar AMidiDevice_fromJava() e, em seguida, chama AMidiOutputPort_open() para abrir uma porta de saída no dispositivo:

AppMidiManager.c

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

    }
    

Implementar um loop de pesquisa

Apps que recebem dados MIDI precisam pesquisar a porta de saída e responder quando AMidiOutputPort_receive() retornar um número maior que zero.

Para apps com baixa largura de banda, como um escopo MIDI, é possível pesquisar em uma linha de execução de prioridade baixa em segundo plano (com suspensões apropriadas).

Para apps que geram áudio e têm requisitos de desempenho em tempo real mais rigorosos, é possível pesquisar no callback de geração de áudio principal (o callback BufferQueue para OpenSL ES, o callback de dados AudioStream em AAudio). Como o AMidiOutputPort_receive() não realiza bloqueios, há muito pouco impacto no desempenho.

A função readThreadRoutine(), chamada pela função startReadingMidi() acima, pode ter esta aparência:

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

Um app que usa uma API de áudio nativa (como OpenSL ES ou AAudio) pode adicionar código de recebimento MIDI ao callback de geração de áudio, desta forma:

void bqPlayerCallback(SLAndroidSimpleBufferQueueItf bq, void */*context*/)
    {
        uint8_t inDataBuffer[SIZE_DATABUFFER];
        int32_t numMessages;
        uint32_t opCode;
        uint64_t timestamp;

        // Generate Audio…
        // ...

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

O diagrama a seguir ilustra o fluxo de um app de leitura de MIDI:

Enviar dados MIDI

Um exemplo comum de app de gravação de MIDI é um controlador ou sequenciador MIDI.

Configurar um MidiDevice e as portas de entrada dele

Um app grava dados MIDI de saída nas portas de entrada de um dispositivo. A parte em Java do seu app precisa determinar quais dispositivos e portas MIDI usar.

O código de configuração abaixo é uma variação do exemplo de recebimento acima. Ele cria o MidiManager a partir do serviço MIDI do Android. Ele abre o primeiro MidiDevice que encontra e chama startWritingMidi() para abrir a primeira porta de entrada no dispositivo. Essa é uma chamada JNI definida em AppMidiManager.c. A função é explicada no snippet a seguir.

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

Aqui está a função JNI que cria um AMidiDevice ao chamar AMidiDevice_fromJava() e, em seguida, chama AMidiInputPort_open() para abrir uma porta de entrada no dispositivo:

AppMidiManager.c

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

Enviar dados MIDI

Como o tempo dos dados MIDI de saída é conhecido e controlado pelo próprio app, a transmissão de dados pode ser feita na linha de execução principal do app MIDI. No entanto, para melhorar o desempenho (como em um sequenciador), a geração e a transmissão de MIDI podem ser realizadas em uma linha de execução separada.

Os apps podem enviar dados MIDI sempre que necessário. Observe que a AMidi é bloqueada quando grava dados.

A seguir, há um exemplo de método JNI que recebe um buffer de comandos MIDI e o grava:

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

O diagrama a seguir ilustra o fluxo de um app de gravação de MIDI:

Callbacks

Embora não seja estritamente um recurso AMidi, seu código nativo pode precisar passar dados de volta para a parte em Java (para atualizar a IU, por exemplo). Para fazer isso, você precisa adicionar um código à parte em Java e à camada nativa:

  • Crie um método de callback na parte em Java.
  • Escreva uma função JNI que armazene as informações necessárias para invocar o callback.

Na hora do callback, seu código nativo pode criar

Este é o método callback da parte em 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 é o código C para a função JNI que configura o callback para MainActivity.onNativeMessageReceive(). A MainActivity Java chama initNative() na inicialização:

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

Na hora de enviar dados de volta para o Java, o código nativo recupera os indicadores do callback e cria o 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);
    }
    

Exemplo de app

Veja o app de exemplo Native MIDI completo no Github (link em inglês).