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. Os snippets de código são do 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.

Aqui está o código do app de exemplo que descobre cada dispositivo e analisa as portas deles. Ele retorna duas listas: uma para dispositivos com portas de entrada para os quais você pode enviar dados, e uma para dispositivos com portas de saída para receber dados. Um dispositivo pode aparecer nas duas listas.

AppMidiManager.java

public class AppMidiManager {

        /**
         * Scan attached Midi devices forcefully from scratch
         * @param sendDevices, container for send devices
         * @param receiveDevices, container for receive devices
         */
        public void ScanMidiDevices(ArrayList<MidiDeviceInfo> sendDevices,
                                     ArrayList<MidiDeviceInfo> receiveDevices) {
            sendDevices.clear();
            receiveDevices.clear();
            MidiDeviceInfo[] devInfos = mMidiManager.getDevices();
            for(MidiDeviceInfo devInfo : devInfos) {
                int numInPorts = devInfo.getInputPortCount();
                String deviceName =
                        devInfo.getProperties().getString(MidiDeviceInfo.PROPERTY_NAME);
                if (deviceName == null) {
                    continue;
                }
                if (numInPorts > 0) {
                    sendDevices.add(devInfo);
                }

                int numOutPorts = devInfo.getOutputPortCount();
                if (numOutPorts > 0) {
                    receiveDevices.add(devInfo);
                }
            }
        }
    

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.

A parte em Java passará referências de um ou mais dispositivos midi e números de portas à camada nativa por uma chamada JNI. O código que implementa a função JNI precisa realizar estas etapas:

  1. Conseguir um AMidiDevice com AMidiDevice_fromJava().
  2. Receber um AMidiInputPort e/ou AMidiOutputPort com AMidiInputPort_open() e/ou AMidiOutputPort_open().
  3. Usar as portas recebidas para enviar e/ou receber dados MIDI.

Parar a AMidi

O app Java envia um sinal para a camada nativa liberar recursos e desligar.

Quando o app é encerrado, o código precisa executar estas tarefas:

  1. Se você estiver usando uma linha de execução de leitura para pesquisar entradas (consulte Implementar um loop de pesquisa abaixo), desligue e junte-se à linha de execução de leitura.
  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.

Dados MIDI são recebidos de forma assíncrona. Portanto, recomendamos ler MIDI em uma linha de execução em segundo plano que monitore continuamente um deles ou as portas de saída MIDI. A AMidi não é bloqueada durante a leitura de uma porta.

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.

O snippet abaixo pode ser encontrado em AppMidiManager.java. Ele cria o MidiManager a partir do serviço MIDI do Android e abre um MidiDevice para o primeiro dispositivo que encontra. O gerenciador OpenMidiReceiveDeviceListener() chama startReadingMidi() para abrir a porta de saída 0 do dispositivo. Essa é uma chamada JNI personalizada definida em AppMidiManager.c. A função é explicada no snippet a seguir.

AppMidiManager.java

package com.nativemidiapp;
    public native void startReadingMidi(MidiDevice device, int portNumber);

    class AppMidiManager {
        // System MIDI Manager
        MidiManager mMidiManager =
            (MidiManager)getSystemService(Context.MIDI_SERVICE);
        // Open a device
        MidiDeviceInfo[] devInfos = mMidiManager.getDevices();
        MidiDeviceInfo devInfo = devInfos[0];
        mMidiManager.openDevice(devInfo,
                       new OpenMidiReceiveDeviceListener(), null);

    class AppMidiManager {
      public class OpenMidiReceiveDeviceListener
        implements MidiManager.OnDeviceOpenedListener {
          @Override
          public void onDeviceOpened(MidiDevice device) {
            appMidiManager.startReadingMidi(mReceiveDevice, 0);
          }
      }
    }
    

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 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 seleciona e abre um MidiDevice para o primeiro dispositivo que encontra. O gerenciador OpenMidiReceiveDeviceListener() chama startReadingMidi() para abrir a porta de entrada 1 do dispositivo. Essa é uma chamada JNI personalizada definida em AppMidiManager.c. A função é explicada no snippet a seguir.

AppMidiManager.java

package com.nativemidiapp;
    public native void startReadingMidi(MidiDevice device, int portNumber);

    class AppMidiManager {
        // System MIDI Manager
        MidiManager mMidiManager =
            (MidiManager)getSystemService(Context.MIDI_SERVICE);
        // Open a device
        MidiDeviceInfo[] devInfos = mMidiManager.getDevices();
        MidiDeviceInfo devInfo = devInfos[0];
        mMidiManager.openDevice(devInfo,
                       new OpenMidiSendDeviceListener(), null);

    public class OpenMidiSendDeviceListener implements MidiManager.OnDeviceOpenedListener {
          @Override
          public void onDeviceOpened(MidiDevice device) {
              mSendDevice = device;
              startWritingMidi(mSendDevice, 1/*mPortNumber*/);
          }
      }
    

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_TBMidiManager_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():

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