네이티브 MIDI API

AMidi API는 Android NDK r20b 이상에서 사용할 수 있습니다. 이 API를 통해 앱 개발자는 C/C++ 코드로 MIDI 데이터를 주고받을 수 있습니다.

Android MIDI 앱은 대개 midi API를 사용해 Android MIDI 서비스와 통신합니다. MIDI 앱은 주로 MidiManager를 사용해 하나 이상의 MidiDevice 객체를 검색하고 열고 닫으며, 기기의 MIDI 입력출력 포트를 통해 각 기기로 데이터를 전달하거나 기기에서 데이터를 전달받습니다.

AMidi를 사용하면 JNI 호출로 네이티브 코드 레이어에 MidiDevice의 주소를 전달합니다. 이 위치에서 AMidi는 MidiDevice의 기능 대부분을 가진 AMidiDevice의 참조를 생성합니다. 네이티브 코드는 AMidiDevice와 직접 통신하는 AMidi 함수를 사용합니다. AMidiDevice는 MIDI 서비스에 직접 연결됩니다.

AMidi 호출을 사용하면 앱의 C/C++ 오디오/컨트롤 논리를 MIDI 전송과 밀접하게 통합할 수 있습니다. 앱의 자바 측으로 JNI 호출이나 콜백할 필요가 줄어듭니다. 예를 들어 C 코드에 구현된 디지털 신시사이저는 자바 측에서 이벤트를 송신하기 위해 JNI 호출을 기다리는 대신 AMidiDevice에서 직접 주요 이벤트를 수신할 수 있습니다. 또는 알고리즘 작성 프로세스는 주요 이벤트를 전송하기 위해 자바 측을 다시 호출하지 않아도 AMidiDevice에 직접 MIDI 성능을 보낼 수 있습니다.

AMidi에서는 MIDI 기기로의 직접 연결을 개선하지만 앱에서 MidiDevice 객체를 검색하고 열려면 MidiManager를 계속 사용해야 합니다. 그 후부터는 AMidi에서 처리할 수 있습니다.

UI 레이어에서 네이티브 코드로 정보를 전달해야 하는 경우도 있을 수 있습니다. 예를 들어 화면의 버튼에 응답하여 MIDI 이벤트가 전송되는 경우가 있습니다. 이렇게 하려면 네이티브 논리에 관한 맞춤 JNI 호출을 만듭니다. UI를 업데이트하기 위해 데이터를 다시 보내야 하는 경우 평소와 같이 네이티브 레이어에서 다시 호출할 수 있습니다.

이 문서는 AMidi 네이티브 코드 앱을 설정하는 방법과 MIDI 명령어를 송수신하는 예를 보여 줍니다. 완벽하게 작동하는 예제는 NativeMidi 샘플 앱에서 확인하세요.

AMidi 사용

AMidi를 사용하는 모든 앱은 MIDI를 보내거나 받거나 주고받는지 여부에 상관없이 설정 및 종료 단계가 동일합니다.

AMidi 시작

자바 측에서 앱은 MIDI 하드웨어의 연결된 부분을 검색하고 상응하는 MidiDevice를 만들어 네이티브 코드에 전달해야 합니다.

  1. 자바 MidiManager 클래스로 MIDI 하드웨어를 검색합니다.
  2. MIDI 하드웨어에 해당하는 자바 MidiDevice 객체를 가져옵니다.
  3. JNI로 자바 MidiDevice를 네이티브 코드에 전달합니다.

하드웨어 및 포트 탐색

입력 및 출력 포트 객체는 앱에 속하지 않으며 midi 기기에 있는 포트를 나타냅니다. 앱에서는 MIDI 데이터를 기기에 보내기 위해 MIDIInputPort를 열고 데이터를 작성합니다. 반대로 데이터를 받으려면 앱에서 MIDIOutputPort를 엽니다. 제대로 작동하도록 앱에서 여는 포트가 올바른 유형인지 확인해야 합니다. 기기와 포트 검색은 자바 측에서 수행됩니다.

다음은 각 MIDI 기기를 검색하고 포트를 확인하는 메서드입니다. 이 메서드는 데이터 수신을 위한 출력 포트가 있는 기기의 목록 또는 데이터 전송을 위한 입력 포트가 있는 기기의 목록을 반환합니다. MIDI 기기는 입력 포트와 출력 포트를 모두 가질 수 있습니다.

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

C/C++ 코드에 AMidi 함수를 사용하려면 AMidi/AMidi.h를 포함하고 amidi 라이브러리에 연결해야 합니다. 이 둘은 모두 Android NDK에서 찾을 수 있습니다.

자바 측에서는 JNI 호출을 통해 하나 이상의 MidiDevice 객체와 포트 번호를 네이티브 레이어에 전달해야 합니다. 그런 다음 네이티브 레이어에서 다음 단계를 따라야 합니다.

  1. 각 자바 MidiDeviceAMidiDevice_fromJava()를 사용하여 AMidiDevice를 가져옵니다.
  2. AMidiDevice에서 AMidiInputPort_open() 또는 AMidiOutputPort_open()를 사용하여 AMidiInputPort 또는 AMidiOutputPort를 가져옵니다.
  3. 가져온 포트를 사용하여 MIDI 데이터를 보내거나 받습니다.

AMidi 중지

자바 앱은 MIDI 기기를 더 이상 사용하지 않을 때 리소스를 해제하도록 네이티브 레이어에 신호를 보냅니다. MIDI 기기가 분리되었거나 앱이 종료되었기 때문일 수 있습니다.

MIDI 리소스를 해제하려면 코드에서 다음 작업을 실행해야 합니다.

  1. MIDI 포트의 읽기 또는 쓰기를 중지합니다. 읽기 스레드를 사용하여 입력을 폴링하고 있었다면(아래의 폴링 루프 구현 참고) 스레드를 중지합니다.
  2. AMidiInputPort_close() 또는 AMidiOutputPort_close() 함수를 사용하여 열려 있는 모든 AMidiInputPort 또는 AMidiOutputPort 객체를 닫습니다.
  3. AMidiDevice_release()를 사용하여 AMidiDevice를 해제합니다.

MIDI 데이터 수신

MIDI를 받는 MIDI 앱의 일반적인 예로는 오디오 합성을 제어하기 위해 MIDI 성능 데이터를 수신하는 '가상 신시사이저'가 있습니다.

수신하는 MIDI 데이터는 비동기로 수신되므로, 하나 이상의 MIDI 출력 포트를 계속 폴링하는 백그라운드 스레드에서 MIDI를 읽는 것이 가장 좋습니다. 이는 백그라운드 스레드 또는 오디오 스레드일 수 있습니다. AMidi는 포트에서 읽을 때 차단되지 않으므로 오디오 콜백 내에서 사용하는 것이 안전합니다.

MidiDevice 및 그 출력 포트 설정

앱은 기기의 출력 포트에서 수신되는 MIDI 데이터를 읽습니다. 앱의 자바 측에서는 사용할 기기와 포트를 결정해야 합니다.

이 스니펫은 Android의 MIDI 서비스에서 MidiManager를 만들고, 발견된 첫 번째 기기의 MidiDevice를 엽니다. MidiDevice가 열렸으면 MidiManager.OnDeviceOpenedListener()의 인스턴스로 콜백이 수신됩니다. 이 리스너의 onDeviceOpened 메서드가 호출되고 이 메서드는 startReadingMidi()를 호출하여 기기에서 출력 포트 0을 엽니다. 이는 AppMidiManager.cpp에 정의된 JNI 함수입니다. 함수는 다음 스니펫에서 설명됩니다.

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

네이티브 코드는 자바 측 MIDI 기기와 포트를 AMidi 함수에서 사용하는 참조로 변환합니다.

다음은 AMidiDevice_fromJava()를 호출하여 AMidiDevice를 만든 후 AMidiOutputPort_open()을 호출하여 기기의 출력 포트를 여는 JNI 함수입니다.

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

}

폴링 루프 구현

MIDI 데이터를 수신하는 앱은 출력 포트를 폴링하고 AMidiOutputPort_receive()에서 0보다 큰 수를 반환하면 응답해야 합니다.

MIDI 범위와 같이 대역폭이 낮은 앱의 경우 우선순위가 낮은 백그라운드 스레드에서 적절한 절전 모드로 폴링할 수 있습니다.

오디오를 생성하고 더 엄격한 실시간 성능 요구사항이 있는 앱은 기본 오디오 생성 콜백(OpenSL ES의 경우 BufferQueue 콜백, AAudio의 경우 AudioStream 데이터 콜백)에서 폴링할 수 있습니다. AMidiOutputPort_receive()는 비차단이므로 성능에는 거의 영향을 미치지 않습니다.

위의 readThreadRoutine() 함수에서 호출된 startReadingMidi() 함수는 다음과 같을 수 있습니다.

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

네이티브 오디오 API(예: OpenSL ES 또는 AAudio)를 사용하는 앱은 다음과 같이 MIDI 수신 코드를 오디오 생성 콜백에 추가할 수 있습니다.

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

다음 다이어그램은 MIDI 읽기용 앱의 흐름을 보여줍니다.

MIDI 데이터 보내기

MIDI 쓰기용 앱의 일반적인 예로는 MIDI 컨트롤러 또는 시퀀서가 있습니다.

MidiDevice 및 그 입력 포트 설정

앱은 발신 MIDI 데이터를 MIDI 기기의 입력 포트에 씁니다. 앱의 자바 측에서는 사용할 MIDI 기기와 포트를 결정해야 합니다.

아래의 설정 코드는 위 수신 예의 변형으로, Android의 MIDI 서비스에서 MidiManager를 생성합니다. 그런 다음 발견하는 첫 번째 MidiDevice를 열고 startWritingMidi()를 호출하여 기기의 첫 번째 입력 포트를 엽니다. 이는 AppMidiManager.cpp에 정의된 JNI 함수입니다. 함수는 다음 스니펫에서 설명됩니다.

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

다음은 AMidiDevice_fromJava()를 호출하여 AMidiDevice를 만든 후 AMidiInputPort_open()을 호출하여 기기의 입력 포트를 여는 JNI 함수입니다.

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

MIDI 데이터 보내기

나가는 MIDI 데이터의 타이밍은 앱에서 잘 파악되고 제어되므로 MIDI 앱의 주 스레드에서 데이터 전송을 실행할 수 있습니다. 하지만 시퀀서의 경우처럼 성능상의 이유로 MIDI 생성과 전송을 별도의 스레드에서 실행할 수 있습니다.

앱은 언제든지 필요할 때 MIDI 데이터를 전송할 수 있습니다. 데이터를 쓸 때 AMidi는 차단됩니다.

다음은 MIDI 명령어의 버퍼를 수신하고 쓰는 JNI 메서드의 예입니다.

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

다음 다이어그램은 MIDI 쓰기용 앱의 흐름을 보여줍니다.

콜백

엄격히 AMidi 기능은 아니지만 네이티브 코드는 자바 측에 데이터를 다시 전달해야 할 수 있습니다(예: UI 업데이트 목적). 그렇게 하려면 다음과 같이 자바 측과 네이티브 레이어에 코드를 작성해야 합니다.

  • 자바 측에 콜백 메소드를 작성합니다.
  • 콜백을 호출하는 데 필요한 정보를 저장하는 JNI 함수를 작성합니다.

콜백할 때가 되면 네이티브 코드가 생성될 수 있습니다.

다음은 자바 측 콜백 메서드 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);
            }
        });
}

다음은 MainActivity.onNativeMessageReceive()의 콜백을 설정하는 JNI 함수의 C 코드입니다. 시작 시 자바 MainActivityinitNative()를 호출합니다.

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

데이터를 다시 자바에 보낼 때가 되면 네이티브 코드에서 콜백 포인터를 검색하고 콜백을 생성합니다.

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

추가 리소스