ה-API של AMidi זמין ב-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 בהתאמה אישית ללוגיקת הקוד המקורי. אם צריך לשלוח נתונים חזרה כדי לעדכן את ממשק המשתמש, אפשר לחזור כרגיל משכבת ה-Native.
במסמך הזה מוסבר איך להגדיר אפליקציה בקוד ילידי של AMidi, עם דוגמאות לשליחה ולקבלה של פקודות MIDI. דוגמה מלאה שפועלת מופיעה באפליקציית הדוגמה NativeMidi.
שימוש ב-AMidi
כל האפליקציות שמשתמשות ב-AMidi כוללות את אותם שלבים להגדרה ולסגירה, בין שהן שולחות או מקבלות MIDI ובין שהן עושות את שניהם.
התחלת AMidi
בצד Java, האפליקציה צריכה לזהות חומרת MIDI מחוברת, ליצור MidiDevice
תואם ולהעביר אותו לקוד המקורי.
- מידע על חומרה של MIDI באמצעות הכיתה
MidiManager
ב-Java. - מקבלים אובייקט Java
MidiDevice
שתואם לחומרת ה-MIDI. - מעבירים את
MidiDevice
ב-Java לקוד מקומי באמצעות 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 ListgetMidiDevices(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. לאחר מכן, השכבה המקורית צריכה לבצע את השלבים הבאים:
- לכל
MidiDevice
ב-Java, מקבליםAMidiDevice
באמצעותAMidiDevice_fromJava()
. - מקבלים
AMidiInputPort
ו/אוAMidiOutputPort
מה-AMidiDevice
באמצעותAMidiInputPort_open()
ו/אוAMidiOutputPort_open()
. - משתמשים בשקעים שהתקבלו כדי לשלוח או לקבל נתוני MIDI.
הפסקת AMidi
אפליקציית Java צריכה להאותת לשכבה המקורית לשחרר משאבים כשהיא כבר לא משתמשת במכשיר ה-MIDI. יכול להיות שהסיבה לכך היא שהתקן ה-MIDI התנתק או שהאפליקציה יוצאת.
כדי לשחרר משאבי MIDI, הקוד צריך לבצע את המשימות הבאות:
- הפסקת הקריאה ו/או הכתיבה ביציאות MIDI. אם השתמשתם בשרשור קריאה כדי לבצע סקרים לצורך קליטת קלט (ראו הטמעת לולאת סקרים בהמשך), עליכם להפסיק את השרשור.
- סוגרים את כל האובייקטים הפתוחים מסוג
AMidiInputPort
ו/אוAMidiOutputPort
באמצעות הפונקציותAMidiInputPort_close()
ו/אוAMidiOutputPort_close()
. - משחררים את
AMidiDevice
באמצעותAMidiDevice_release()
.
קבלת נתוני MIDI
דוגמה אופיינית לאפליקציית MIDI שמקבלת MIDI היא 'סינתיסייזר וירטואלי', שמקבלת נתוני ביצועים של MIDI כדי לשלוט בסינתזת האודיו.
נתוני MIDI נכנסים מתקבלים באופן אסינכרוני. לכן מומלץ לקרוא MIDI בשרשור נפרד שמבצע סקרים רצופיים ביציאה אחת או יותר של MIDI. זה יכול להיות שיחת רקע או שיחת אודיו. AMidi לא נחסם בזמן קריאה מיציאה, ולכן אפשר להשתמש בו בתוך קריאה חוזרת (callback) של אודיו.
הגדרת MidiDevice ויציאות הפלט שלו
אפליקציה קוראת נתוני MIDI נכנסים משקעי הפלט של המכשיר. הצד של Java באפליקציה צריך לקבוע באיזה מכשיר ובאילו יציאות להשתמש.
קטע הקוד הזה יוצר את MidiManager
משירות ה-MIDI של Android ופותח MidiDevice
למכשיר הראשון שהוא מוצא. כשה-MidiDevice
נפתח, מתקבלת קריאה חוזרת למופע של 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); ListmidiDevices = 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, אפשר לבצע סקרים בשרשור רקע בעדיפות נמוכה (עם השהיה מתאימה).
באפליקציות שיוצרות אודיו ויש להן דרישות ביצועים מחמירות יותר בזמן אמת, אפשר לבצע סקירה חוזרת ב-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), ×tamp);
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), ×tamp);
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); ListmidiDevices = 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 היוצאים מובן ונשלט על ידי האפליקציה עצמה, ולכן אפשר להעביר את הנתונים ב-thread הראשי של אפליקציית ה-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 ובשכבת ה-Native:
- יוצרים שיטה לקריאה חוזרת בצד 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()
. MainActivity
של Java קורא ל-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, הקוד המקורי מאחזר את הפונקציות החוזרות (callbacks) ומרכיב את הפונקציה החוזרת:
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);
}
מקורות מידע נוספים
- מידע נוסף על AMidi
- אפשר לראות את האפליקציה המלאה לדוגמה של Native MIDI ב-github.