Native MIDI API

‫AMidi API זמין ב-Android NDK r20b ואילך. הוא מאפשר למפתחי אפליקציות לשלוח ולקבל נתוני MIDI באמצעות קוד C/C++‎.

אפליקציות MIDI ל-Android בדרך כלל משתמשות ב-API של midi כדי לתקשר עם שירות ה-MIDI של Android. אפליקציות MIDI מסתמכות בעיקר על MidiManager כדי לגלות, לפתוח ולסגור אובייקט אחד או יותר של MidiDevice, ולהעביר נתונים אל כל מכשיר וממנו דרך יציאות הקלט והפלט של MIDI במכשיר:

כשמשתמשים ב-AMidi, מעבירים את הכתובת של MidiDevice לשכבת קוד ה-Native באמצעות קריאת JNI. משם, AMidi יוצר הפניה אל AMidiDevice שכולל את רוב הפונקציונליות של MidiDevice. קוד ה-Native משתמש בפונקציות AMidi שמתקשרות ישירות עם AMidiDevice. ה-AMidiDevice מתחבר ישירות לשירות ה-MIDI:

באמצעות קריאות ל-AMidi, אתם יכולים לשלב את לוגיקת האודיו/הבקרה של האפליקציה ב-C/C++‎ עם שידור MIDI. יש פחות צורך בקריאות JNI או בקריאות חוזרות (callbacks) לצד של Java באפליקציה. לדוגמה, סינתיסייזר דיגיטלי שמוטמע בקוד C יכול לקבל אירועי מפתח ישירות מ-AMidiDevice, במקום להמתין לקריאת JNI כדי לשלוח את האירועים מהצד של Java. או שתהליך הלחנה אלגוריתמי יכול לשלוח ביצוע MIDI ישירות אל AMidiDevice בלי לבצע קריאה חוזרת לצד של Java כדי להעביר את אירועי המקשים.

למרות ש-AMidi משפר את החיבור הישיר למכשירי MIDI, האפליקציות עדיין צריכות להשתמש ב-MidiManager כדי לגלות ולפתוח אובייקטים של MidiDevice. מכאן, AMidi יכול להמשיך.

לפעמים צריך להעביר מידע משכבת ממשק המשתמש אל קוד ה-Native. לדוגמה, כשאירועי MIDI נשלחים בתגובה ללחיצה על כפתורים במסך. כדי לעשות את זה, צריך ליצור קריאות JNI בהתאמה אישית ללוגיקה המקורית. אם אתם צריכים לשלוח נתונים בחזרה כדי לעדכן את ממשק המשתמש, אתם יכולים להפעיל קריאה חוזרת (callback) מהשכבה המקורית כרגיל.

במסמך הזה מוסבר איך להגדיר אפליקציית קוד Native של AMidi, ומוצגות דוגמאות לשליחה ולקבלה של פקודות MIDI. אפשר לראות דוגמה מלאה שעובדת באפליקציה לדוגמה NativeMidi.

שימוש ב-AMidi

כל האפליקציות שמשתמשות ב-AMidi כוללות את אותם שלבי הגדרה וסגירה, בין אם הן שולחות או מקבלות MIDI, או גם וגם.

הפעלת AMidi

בצד של Java, האפליקציה צריכה לזהות חומרת MIDI מחוברת, ליצור MidiDevice תואם ולהעביר אותו לקוד ה-Native.

  1. מגלים חומרת MIDI באמצעות המחלקה MidiManager של Java.
  2. מקבלים אובייקט MidiDevice של Java שתואם לחומרת ה-MIDI.
  3. מעבירים את ה-MidiDevice של Java לקוד Native באמצעות JNI.

זיהוי חומרה ויציאות

אובייקטי היציאות של הקלט והפלט לא שייכים לאפליקציה. הם מייצגים יציאות במכשיר ה-MIDI. כדי לשלוח נתוני MIDI למכשיר, האפליקציה פותחת MIDIInputPort ואז כותבת בו נתונים. לעומת זאת, כדי לקבל נתונים, אפליקציה פותחת MIDIOutputPort. כדי שהאפליקציה תפעל כמו שצריך, היא צריכה לוודא שהיציאות שהיא פותחת הן מהסוג הנכון. גילוי המכשיר והיציאה מתבצע בצד של Java.

הנה שיטה שמגלה כל מכשיר 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;
}

כדי להשתמש בפונקציות AMidi בקוד C/C++‎, צריך לכלול את AMidi/AMidi.h ולקשר לספרייה amidi. שניהם נמצאים ב-Android NDK.

צד ה-Java צריך להעביר אובייקט MidiDevice אחד או יותר ומספרי יציאות לשכבה המקורית באמצעות קריאת JNI. לאחר מכן, השכבה המקורית צריכה לבצע את השלבים הבאים:

  1. לכל MidiDevice של Java, מקבלים AMidiDevice באמצעות AMidiDevice_fromJava().
  2. צריך לקבל AMidiInputPort ו/או AMidiOutputPort מ-AMidiDevice עם AMidiInputPort_open() ו/או AMidiOutputPort_open().
  3. משתמשים ביציאות שהתקבלו כדי לשלוח או לקבל נתוני MIDI.

הפסקת AMidi

אפליקציית Java צריכה לאותת לשכבה המקורית לשחרר משאבים כשהיא לא משתמשת יותר במכשיר ה-MIDI. יכול להיות שהסיבה לכך היא שמכשיר ה-MIDI נותק או שהאפליקציה נסגרת.

כדי לשחרר משאבי MIDI, הקוד צריך לבצע את הפעולות הבאות:

  1. להפסיק לקרוא או לכתוב ליציאות MIDI. אם השתמשתם בשרשור קריאה כדי לדגום קלט (ראו הטמעת לולאת דגימה בהמשך), עצרו את השרשור.
  2. סוגרים את כל האובייקטים הפתוחים מסוג AMidiInputPort או AMidiOutputPort באמצעות הפונקציות AMidiInputPort_close() או AMidiOutputPort_close().
  3. משחררים את AMidiDevice עם AMidiDevice_release().

קבלת נתוני MIDI

דוגמה אופיינית לאפליקציית MIDI שמקבלת MIDI היא "סינתיסייזר וירטואלי" שמקבל נתוני ביצועים של MIDI כדי לשלוט בסינתזת אודיו.

נתוני MIDI נכנסים מתקבלים באופן אסינכרוני. לכן, מומלץ לקרוא MIDI בשרשור נפרד שדוגם באופן רציף יציאת פלט אחת או יותר של MIDI. זה יכול להיות שרשור ברקע או שרשור אודיו. ‫AMidi לא חוסם את הקריאה מיציאה, ולכן אפשר להשתמש בו בבטחה בתוך קריאה חוזרת (callback) של אודיו.

הגדרת MidiDevice ויציאות הפלט שלו

אפליקציה קוראת נתוני MIDI נכנסים מיציאות הפלט של המכשיר. הצד של Java באפליקציה צריך לקבוע באילו מכשיר ויציאות להשתמש.

הקטע הזה יוצר את MidiManager משירות ה-MIDI של Android ופותח את MidiDevice למכשיר הראשון שהוא מוצא. כשהמודעה מסוג MidiDevice נפתחת, מתקבלת קריאה חוזרת (callback) למופע של MidiManager.OnDeviceOpenedListener(). מתבצעת קריאה ל-method‏ onDeviceOpened של המאזין הזה, ואז מתבצעת קריאה ל-startReadingMidi() כדי לפתוח את יציאת הפלט 0 במכשיר. זו פונקציית JNI שמוגדרת ב-AppMidiManager.cpp. הפונקציה הזו מוסברת בקטע הקוד הבא.

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

קוד ה-Native מתרגם את מכשיר ה-MIDI בצד של Java ואת היציאות שלו להפניות שמשמשות את פונקציות AMidi.

זו פונקציית JNI שיוצרת AMidiDevice באמצעות קריאה ל-AMidiDevice_fromJava(), ואז קוראת ל-AMidiOutputPort_open() כדי לפתוח יציאת פלט במכשיר:

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() מחזירה מספר גדול מאפס.

באפליקציות עם רוחב פס נמוך, כמו היקף MIDI, אפשר לבצע דגימה בשרשור רקע בעדיפות נמוכה (עם זמני השהיה מתאימים).

באפליקציות שיוצרות אודיו ויש להן דרישות מחמירות יותר לגבי ביצועים בזמן אמת, אפשר לבצע דגימה בקריאה החוזרת (callback) הראשית של יצירת האודיו (הקריאה החוזרת BufferQueue ל-OpenSL ES, הקריאה החוזרת של נתוני AudioStream ב-AAudio). מכיוון ש-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 לקריאה החוזרת (callback) ליצירת אודיו, באופן הבא:

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. בצד של Java באפליקציה, צריך לקבוע באילו מכשיר ויציאות MIDI להשתמש.

קוד ההגדרה שבהמשך הוא וריאציה של הדוגמה לקבלת נתונים שלמעלה. הוא יוצר את MidiManager משירות ה-MIDI של Android. לאחר מכן היא פותחת את ה-MidiDevice הראשון שהיא מוצאת וקוראת ל-startWritingMidi() כדי לפתוח את יציאת הקלט הראשונה במכשיר. זו קריאת JNI שמוגדרת ב-AppMidiManager.cpp. הפונקציה מוסברת בקטע הקוד הבא.

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

זוהי פונקציית JNI שיוצרת AMidiDevice על ידי קריאה ל-AMidiDevice_fromJava(), ואז קוראת ל-AMidiInputPort_open() כדי לפתוח יציאת קלט במכשיר:

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 חוסם כשכותבים נתונים.

דוגמה לשיטת JNI שמקבלת מאגר של פקודות MIDI וכותבת אותו:

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, יכול להיות שקוד ה-Native יצטרך להעביר נתונים בחזרה לצד של Java (למשל, כדי לעדכן את ממשק המשתמש). כדי לעשות את זה, צריך לכתוב קוד בצד של Java ובשכבה המקורית:

  • יוצרים שיטת קריאה חוזרת בצד של Java.
  • כותבים פונקציית JNI שמאחסנת את המידע שנדרש כדי להפעיל את פונקציית ה-callback.

כשיגיע הזמן להפעיל את פונקציית ה-callback, קוד ה-Native יוכל ליצור

זו שיטת הקריאה החוזרת בצד של 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);
            }
        });
}

זהו קוד ה-C של פונקציית ה-JNI שמגדירה את הקריאה החוזרת (callback) ל-MainActivity.onNativeMessageReceive(). ‫Java MainActivity קורא ל-initNative() בזמן ההפעלה:

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

כשהגיע הזמן לשלוח נתונים בחזרה ל-Java, קוד ה-Native מאחזר את המצביעים של הקריאה החוזרת ויוצר את הקריאה החוזרת:

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

מקורות מידע נוספים