واجهة برمجة تطبيقات MIDI الأصلية

تتوفّر واجهة برمجة التطبيقات AMidi في الإصدار r20b من Android NDK والإصدارات الأحدث. وتمنح هذه الواجهة مبرمجِي التطبيقات إمكانية إرسال بيانات MIDI وتلقّيها باستخدام رمز C/C++.

تستخدم تطبيقات MIDI على Android عادةً واجهة برمجة التطبيقات midi للتواصل مع خدمة MIDI على Android. تعتمد تطبيقات MIDI بشكل أساسي على برمجة MidiManager لاكتشاف عنصر أو أكثر من MidiDevice وفتحه وإغلاقه، ونقْل البيانات من كل جهاز وإليه من خلال منفذَي إدخال إخراج MIDI للجهاز:

عند استخدام AMidi، يمكنك تمرير عنوان MidiDevice إلى رمز برمجي أصلي الطبقة باستخدام طلب JNI. ومن هناك، ينشئ AMidi إشارة إلى AMidiDevice التي تمتلك معظم وظائف MidiDevice. تستخدم التعليمات البرمجية الأصلية وظائف AMidi التي تتواصل بشكل مباشر مع AMidiDevice. يتصل AMidiDevice مباشرةً بخدمات MIDI:

باستخدام طلبات AMidi، يمكنك دمج منطق الصوت/التحكّم في C/C++ الخاص بتطبيقك بشكل وثيق مع نقل MIDI. لا حاجة إلى استدعاءات JNI أو عمليات استدعاء للوظائف من جانب Java في تطبيقك. على سبيل المثال، يمكن لمُركّب رقمي تم تنفيذه في رمز C تلقّي الأحداث الرئيسية مباشرةً من AMidiDevice، بدلاً من انتظار استدعاء JNI لإرسال الأحداث من جانب Java. أو يمكن أن تُرسِل عملية الإنشاء algorithmic أداءً 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. نقْل 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 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. ويمكن العثور على كلٍّ منهما في حزمة تطوير البرامج (NDK) لنظام التشغيل Android.

يجب أن يُرسِل جانب 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 عند القراءة من منفذ، وبالتالي يمكن استخدامه بأمان في طلب معاودة الاتصال الصوتي.

إعداد MidiDevice ومنافذ الإخراج

يقرأ التطبيق بيانات MIDI الواردة من منافذ الإخراج في الجهاز. يجب أن يحدِّد جانب Java في تطبيقك الجهاز والمنافذ المطلوب استخدامها.

ينشئ هذا المقتطف MidiManager من خدمة MIDI في Android ويفتحMidiDevice لأول جهاز يعثر عليه. عند فتح MidiDevice، يتم تلقّي طلب إعادة اتصال في مثيل MidiManager.OnDeviceOpenedListener(). يتم استدعاء الطريقة 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);
    }
  }
}

يترجم الرمز الأصلي جهاز 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، يمكنك إجراء الاستطلاع في سلسلة مهام خلفيّة ذات أولوية منخفضة (مع فترات الانتظار المناسبة).

بالنسبة إلى التطبيقات التي تُنشئ صوتًا وتتطلب متطلبات أداء أكثر صرامة في الوقت الفعلي، يمكنك إجراء طلب بحث في دالة الاستدعاء الرئيسية لإنشاء الصوت (دالة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;
        }
  }
}

يمكن للتطبيق الذي يستخدم واجهة برمجة تطبيقات أصلية للصوت (مثل 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 أو أداة تسلسل 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، يسترجع الرمز البرمجي الأصلي مؤشرات callback وينشئ دالة الاستدعاء:

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

مصادر إضافية