Native MIDI API

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

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

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

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

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

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

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

שימוש ב-AMidi

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

הפעלת AMidi

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

  1. אפשר לגלות חומרת MIDI באמצעות מחלקת Java MidiManager.
  2. מקבלים אובייקט Java‏ MidiDevice שמתאים לחומרת ה-MIDI.
  3. מעבירים את Java MidiDevice לקוד מקורי באמצעות 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. לכל Java MidiDevice מקבלים 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, מתקבלת קריאה חוזרת למופע של MidiManager.OnDeviceOpenedListener(). ה-method‏ onDeviceOpened של ה-listener הזה נקרא, ואז נקרא 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);
    }
  }
}

הקוד המקורי מתרגם את מכשיר ה-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 scope, אפשר לבצע סקר בשרשור רקע בעדיפות נמוכה (עם השהיות מתאימות).

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

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

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

כשהגיע הזמן להתקשר חזרה, הקוד המקורי יכול ליצור

זוהי שיטת הקריאה החוזרת בצד 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 שמגדירה קריאה חוזרת ל-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, הקוד המקורי מאחזר את מצביעי הקריאה החוזרת ובונה את הקריאה החוזרת:

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

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