API MIDI gốc

API AMidi có trong Android NDK r20b trở lên. API này cho phép các nhà phát triển ứng dụng gửi và nhận dữ liệu MIDI bằng mã C/C++.

Các ứng dụng MIDI trên Android thường sử dụng API midi để giao tiếp với dịch vụ Android MIDI. Các ứng dụng MIDI chủ yếu dựa vào MidiManager để khám phá, mở và đóng một hoặc nhiều đối tượng MidiDevice rồi truyền dữ liệu đến và đi từ mỗi thiết bị qua các cổng đầu vàođầu ra MIDI của thiết bị:

Khi sử dụng AMidi, bạn chuyển địa chỉ của MidiDevice tới lớp mã gốc bằng lệnh gọi JNI. Từ đó, AMidi tạo tham chiếu đến một AMidiDevice có hầu hết chức năng của MidiDevice. Mã gốc của bạn sử dụng các hàm AMidi giao tiếp trực tiếp với một AMidiDevice. AMidiDevice kết nối trực tiếp với dịch vụ MIDI:

Khi sử dụng các lệnh gọi AMidi, bạn có thể tích hợp chặt chẽ logic kiểm soát/âm thanh C/C++ của ứng dụng với tính năng truyền MIDI. Lệnh gọi JNI hoặc lệnh gọi lại không thực sự cần thiết đối với phía Java của ứng dụng. Ví dụ: một trình tổng hợp kỹ thuật số được triển khai trong mã C có thể nhận các sự kiện chính trực tiếp qua một AMidiDevice, thay vì chờ lệnh gọi JNI để gửi các sự kiện từ phía Java. Hoặc một quy trình soạn thảo thuật toán có thể gửi trực tiếp hiệu năng MIDI đến AMidiDevice mà không cần gọi lại phía Java để truyền các sự kiện chính.

Mặc dù AMidi cải thiện khả năng kết nối trực tiếp đến các thiết bị MIDI, nhưng các ứng dụng vẫn phải sử dụng MidiManager để khám phá và mở các đối tượng MidiDevice. AMidi có thể tận dụng được điều này.

Đôi khi, có thể bạn cần truyền thông tin từ lớp giao diện người dùng tới mã gốc. Ví dụ: khi các sự kiện MIDI được gửi để phản hồi các nút trên màn hình. Để làm việc này, hãy tạo các lệnh gọi JNI tuỳ chỉnh cho logic gốc của bạn. Nếu cần gửi lại dữ liệu để cập nhật giao diện người dùng, bạn có thể gọi lại qua lớp gốc như bình thường.

Tài liệu này trình bày cách thiết lập ứng dụng mã gốc AMidi, đồng thời đưa ra các ví dụ về việc gửi và nhận lệnh MIDI. Để xem ví dụ toàn bộ cách thức hoạt động, bạn hãy xem ứng dụng mẫu NativeMidi.

Sử dụng AMidi

Tất cả ứng dụng sử dụng AMidi đều có cùng các bước thiết lập và đóng, cho dù các ứng dụng đó gửi hay nhận MIDI hay cả hai.

Khởi động AMidi

Ở phía Java, ứng dụng phải khám phá một cấu phần của phần cứng MIDI được đính kèm, tạo và truyền một MidiDevice tương ứng vào mã gốc.

  1. Khám phá phần cứng MIDI với lớp MidiManager của Java.
  2. Nhận một đối tượng MidiDevice của Java tương ứng với phần cứng MIDI.
  3. Chuyển MidiDevice của Java sang mã gốc bằng JNI.

Khám phá phần cứng và cổng

Các đối tượng cổng đầu vào và đầu ra không thuộc về ứng dụng. Các đối tượng này đại diện cho các cổng trên thiết bị midi. Để gửi dữ liệu MIDI đến một thiết bị, ứng dụng sẽ mở một MIDIInputPort rồi ghi dữ liệu vào đó. Ngược lại, để nhận dữ liệu, ứng dụng sẽ mở một MIDIOutputPort. Để hoạt động đúng cách, ứng dụng phải đảm bảo các cổng mà ứng dụng mở ra thuộc đúng loại. Việc khám phá thiết bị và cổng được thực hiện xong ở phía Java.

Sau đây là một phương thức để khám phá từng thiết bị MIDI và xem các cổng của thiết bị đó. Phương thức này trả về một danh sách thiết bị có các cổng đầu ra để nhận dữ liệu hoặc danh sách thiết bị có các cổng đầu vào để gửi dữ liệu. Một thiết bị MIDI có thể có cả cổng đầu vào và cổng đầu ra.

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

Để sử dụng các hàm AMidi trong mã C/C++, bạn phải thêm AMidi/AMidi.h và liên kết đến thư viện amidi. Bạn có thể tìm thấy cả hai thành phần này trong Android NDK.

Phía Java sẽ truyền một hoặc nhiều đối tượng MidiDevice và truyền các số đến lớp gốc thông qua lệnh gọi JNI. Sau đó, lớp gốc sẽ thực hiện các bước sau đây:

  1. Đối với mỗi MidiDevice của Java, hãy lấy AMidiDevice bằng cách dùng AMidiDevice_fromJava().
  2. Nhận một AMidiInputPort và/hoặc AMidiOutputPort qua AMidiDevice bằng AMidiInputPort_open() và/hoặc AMidiOutputPort_open().
  3. Sử dụng các cổng thu được để gửi và/hoặc nhận dữ liệu MIDI.

Dừng AMidi

Ứng dụng Java sẽ phát tín hiệu cho lớp gốc để giải phóng tài nguyên khi không còn sử dụng thiết bị MIDI nữa. Lý do có thể là thiết bị MIDI đã bị ngắt kết nối hoặc ứng dụng đã thoát.

Để giải phóng tài nguyên MIDI, mã của bạn phải thực hiện những việc sau:

  1. Dừng đọc và/hoặc ghi vào cổng MIDI. Nếu bạn đang sử dụng một luồng đọc để thăm dò đầu vào (xem nội dung Triển khai vòng lặp thăm dò dưới đây), hãy dừng luồng đó.
  2. Đóng mọi đối tượng AMidiInputPort và/hoặc AMidiOutputPort đang mở bằng các hàm AMidiInputPort_close() và/hoặc AMidiOutputPort_close().
  3. Giải phóng AMidiDevice bằng AMidiDevice_release().

Nhận dữ liệu MIDI

Trình tổng hợp ảo (virtual synthesizer) là một ví dụ điển hình về trường hợp ứng dụng MIDI nhận được MIDI. Trình tổng hợp này nhận dữ liệu hiệu suất MIDI để kiểm soát hoạt động tổng hợp âm thanh.

Quá trình nhận dữ liệu MIDI đến được thực hiện không đồng bộ. Do đó, tốt nhất là bạn nên đọc MIDI trong một luồng riêng. Luồng này sẽ liên tục thăm dò một hoặc nhiều cổng đầu ra MIDI. Đây có thể là một luồng chạy ở chế độ nền hoặc một luồng xử lý âm thanh. AMidi không chặn khi đọc qua một cổng, nên có thể sử dụng bên trong một lệnh gọi lại âm thanh.

Thiết lập MidiDevice và các cổng đầu ra của thiết bị

Ứng dụng đọc dữ liệu MIDI đến qua các cổng đầu ra của thiết bị. Phía Java của ứng dụng phải xác định thiết bị và cổng sẽ sử dụng.

Đoạn mã này sẽ tạo MidiManager qua dịch vụ MIDI của Android và mở MidiDevice cho thiết bị đầu tiên mà đoạn mã tìm thấy. Khi MidiDevice đã được mở, một thực thể của MidiManager.OnDeviceOpenedListener() sẽ nhận được một lệnh gọi lại. Phương thức onDeviceOpened của trình nghe này được gọi và sau đó gọi startReadingMidi() để mở cổng đầu ra 0 trên thiết bị. Đây là hàm JNI được xác định trong AppMidiManager.cpp. Đoạn mã tiếp theo sẽ giải thích hàm này.

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

Mã gốc sẽ chuyển thiết bị MIDI phía Java và các cổng của thiết bị thành các lượt tham chiếu do các hàm AMidi sử dụng.

Sau đây là hàm JNI tạo ra một AMidiDevice bằng cách gọi AMidiDevice_fromJava(), sau đó gọi AMidiOutputPort_open() để mở một cổng đầu ra trên thiết bị:

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

}

Triển khai vòng lặp thăm dò

Các ứng dụng nhận dữ liệu MIDI phải thăm dò cổng đầu ra và phản hồi khi AMidiOutputPort_receive() trả về một số lớn hơn 0.

Đối với các ứng dụng băng thông thấp, chẳng hạn như phạm vi MIDI (MIDI scope), bạn có thể thăm dò trong một luồng trong nền có mức độ ưu tiên thấp (với các khoảng ngủ thích hợp).

Đối với các ứng dụng tạo ra âm thanh và có yêu cầu khắt khe hơn về hiệu suất theo thời gian thực, bạn có thể thăm dò trong lệnh gọi lại phương thức tạo âm thanh chính (lệnh gọi lại BufferQueue cho OpenSL ES, lệnh gọi lại dữ liệu AudioStream trong AAudio). Vì AMidiOutputPort_receive() không chặn, nên sẽ có rất ít tác động về mặt hiệu năng.

Hàm readThreadRoutine() được gọi qua hàm startReadingMidi() ở trên có thể có dạng như sau:

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

Một ứng dụng dùng API âm thanh gốc (chẳng hạn như OpenSL ES hoặc AAudio) có thể thêm mã nhận dữ liệu MIDI vào lệnh gọi lại phương thức tạo âm thanh như sau:

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

Sơ đồ sau đây minh hoạ quy trình của một ứng dụng đọc MIDI:

Gửi dữ liệu MIDI

Trình điều khiển MIDI (MIDI controller) hoặc bộ tuần tự MIDI (MIDI sequencer) là ví dụ điển hình về ứng dụng viết MIDI.

Thiết lập MidiDevice và các cổng đầu vào của thiết bị

Ứng dụng ghi dữ liệu MIDI gửi đi vào cổng đầu vào của thiết bị MIDI. Phía Java của ứng dụng phải xác định thiết bị và cổng MIDI sẽ sử dụng.

Mã nguồn thiết lập dưới đây là một biến thể trong ví dụ về quá trình nhận dữ liệu ở trên. Ứng dụng này tạo MidiManager qua dịch vụ MIDI của Android. Sau đó, mã này sẽ mở MidiDevice tìm thấy đầu tiên rồi gọi startWritingMidi() để mở cổng đầu vào trên thiết bị. Đây là lệnh gọi JNI được xác định trong AppMidiManager.cpp. Đoạn mã tiếp theo sẽ giải thích hàm này.

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

Sau đây là hàm JNI tạo ra một AMidiDevice bằng cách gọi AMidiDevice_fromJava(), sau đó gọi AMidiInputPort_open() để mở một cổng đầu vào trên thiết bị:

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

Gửi dữ liệu MIDI

Do ứng dụng tự hiểu rõ và kiểm soát thời gian của dữ liệu MIDI gửi đi, nên dữ liệu có thể được truyền trong luồng chính của ứng dụng MIDI. Tuy nhiên, vì lý do hiệu năng (như trong bộ tuần tự), việc tạo và truyền MIDI có thể được thực hiện trong một luồng riêng biệt.

Các ứng dụng có thể gửi dữ liệu MIDI bất cứ khi nào cần thiết. Xin lưu ý rằng AMidi chặn trong khi ghi dữ liệu.

Sau đây là ví dụ về một phương thức JNI tiếp nhận vùng đệm của các lệnh MIDI và ghi dữ liệu đó:

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

Sơ đồ sau minh hoạ quy trình của một ứng dụng viết MIDI:

Lệnh gọi lại

Mặc dù không hoàn toàn là tính năng của AMidi, nhưng có thể mã gốc của bạn cần truyền dữ liệu về phía Java (chẳng hạn như để cập nhật giao diện người dùng). Để làm được điều đó, bạn phải ghi mã ở phía Java và lớp gốc:

  • Tạo một phương thức gọi lại ở phía Java.
  • Viết một hàm JNI lưu trữ thông tin cần thiết để kích hoạt lệnh gọi lại.

Mã gốc của bạn có thể tạo lệnh gọi lại khi đến lúc sử dụng.

Sau đây là một phương thức gọi lại phía 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);
            }
        });
}

Đây là mã C của hàm JNI thiết lập lệnh gọi lại đến MainActivity.onNativeMessageReceive(). MainActivity của Java gọi initNative() khi khởi động:

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

Khi đến lúc gửi dữ liệu trở lại Java, mã gốc sẽ truy xuất các con trỏ gọi lại và xây dựng lệnh gọi lại:

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

Tài nguyên khác