В своей прошлой статье я рассматривал вопрос создания NuGet-пакета для .NET-библиотеки с платформозависимым API. После публикации пришло время перейти от слов к делу. А именно начать реализовывать нативный API для работы с MIDI-устройствами в macOS.
Взаимодействие с MIDI в macOS производится с помощью фреймворка CoreMIDI. К сожалению, документация к данному фреймворку крайне скудная, многие вещи приходится выяснять либо экспериментальным путём, либо подглядывать в других проектах (при этом всё равно проверяя, насколько алгоритмы там верны). В данной статье я расскажу обо всём, что узнал касаемо CoreMIDI в процессе реализации нативной прослойки для своей библиотеки. Все примеры кода будут приведены на языке C.
Стоит заметить, что сейчас в CoreMIDI есть ряд устаревших API. Новые функции введены в macOS 11.0 Big Sur и призваны обеспечить работу с MIDI 2.0. Но я по большей части буду рассматривать именно старый API, так как он:
На момент написания статьи у меня в библиотеке существует 360 юнит-тестов на проверку взаимодействия с MIDI-устройствами. Отладку я производил в версии macOS 10.14 Mojave, но также прогонял весь набор в 11.3 Big Sur и в 12.0 Monterey (Beta 3).
Устройство "глазами" CoreMIDI
То есть, устройство состоит из компонентов (entities), а каждый компонент имеет произвольное количество точек ввода/вывода (endpoints), которые мы будем называть источниками (source) и приёмниками (destination). Приложение, задействующее CoreMIDI, использует источник, чтобы получать данные от устройства, а приёмник – чтобы отправлять данные на него.
Количество устройств в системе можно получить с помощью функции MIDIGetNumberOfDevices:
ItemCount devicesCount = MIDIGetNumberOfDevices();
а ссылку на конкретное устройство по индексу (от 0 до devicesCount - 1) с помощью функции MIDIGetDevice. Например, чтобы получить ссылку на первое устройство:
MIDIDeviceRef deviceRef = MIDIGetDevice(0);
После получения ссылки на устройство можно получить его компоненты, используя аналогичные функции MIDIDeviceGetNumberOfEntities и MIDIDeviceGetEntity:
ItemCount entitiesCount = MIDIDeviceGetNumberOfEntities(deviceRef);
MIDIEntityRef firstEntityRef = MIDIDeviceGetEntity(deviceRef, 0);
И наконец, дабы получить источники и приёмники компонентов, используются функции MIDIEntityGetNumberOfSources, MIDIEntityGetSource, MIDIEntityGetNumberOfDestinations и MIDIEntityGetDestination:
ItemCount entitySourcesCount = MIDIEntityGetNumberOfSources(firstEntityRef);
MIDIEndpointRef firstEntitySourceRef = MIDIEntityGetSource(firstEntityRef, 0);
ItemCount entityDestinationsCount = MIDIEntityGetNumberOfDestinations(firstEntityRef);
MIDIEndpointRef firstEntityDetinationRef = MIDIEntityGetDestination(firstEntityRef, 0);
Можно также получать источники и приёмники безотносительно конкретного устройства и его компонента, используя функции MIDIGetNumberOfSources, MIDIGetSource, MIDIGetNumberOfDestinations и MIDIGetDestination:
ItemCount sourcesCount = MIDIGetNumberOfSources();
MIDIEndpointRef firstSourceRef = MIDIGetSource(0);
ItemCount destinationsCount = MIDIGetNumberOfDestinations();
MIDIEndpointRef firstDetinationRef = MIDIGetDestination(0);
Например, именно такой подход я и использую в DryWetMIDI, ибо мне нужно предоставлять унифицированный API для Windows и macOS. В Windows нет понятий, используемых в CoreMIDI. Там используются термины входное устройство и выходное устройство. Таких терминов я придерживаюсь и в библиотеке, а потому источники в CoreMIDI я считаю входными устройствами, а приёмники – выходными.
Мы теперь знаем, как получать ссылки на устройства, его компоненты и точки ввода/вывода. Но от этого мало толка, если мы не умеем получать информацию о каждом из объектов. Например, мы можем захотеть показать в нашем приложении список имеющихся в системе устройств с их именами. CoreMIDI предоставляет несколько функций для получения свойств MIDI-объектов (и установки некоторых из них). Перечень этих функций приведён на странице MIDI Object Properties в секции Property Accessors. Все доступные свойства приведены на той же странице и логически сгруппированы.
Например, чтобы найти источник с именем MIDI A, можно написать такой код:
ItemCount sourcesCount = MIDIGetNumberOfSources();
MIDIEndpointRef sourceRef;
for (int i = 0; i < sourcesCount; i++)
{
sourceRef = MIDIGetSource(i);
CFStringRef nameRef;
MIDIObjectGetStringProperty(sourceRef, kMIDIPropertyDisplayName, &nameRef);
if (CFStringCompare(nameRef, CFSTR("MIDI A"), 0) == kCFCompareEqualTo)
{
break;
}
}
Здесь мы не стали проверять результат вызова функции MIDIObjectGetStringProperty, а по-хорошему нужно. Вот только функция возвращает OSStatus, что по сути просто 32-битное целое число со знаком. В CoreMIDI много функций возвращают OSStatus, но в документации ни к одной из них не сказано, какие именно константы допустимы для конкретной функции. Максимум, что мы можем узнать, это весь список возможных ошибок в CoreMIDI, после чего с помощью хрустального шара прикинуть, какие из них к какой функции подходят. Я не буду далее показывать в каждом месте, что та или иная функция возвращает OSStatus, это можно увидеть в документации.
В Windows для того, чтобы поиметь виртуальные устройства в системе, нужно устанавливать сторонние решения вроде virtualMIDI. Центральным их элементом является драйвер режима ядра. Звучит не очень просто, а потому продукты являются либо платными, либо только для некоммерческого использования, либо без возможности распространять их, либо всё вместе.
К счастью, в macOS есть встроенная возможность создавать виртуальные источники и приёмники. Источник создаётся с помощью функции MIDISourceCreate (обратите внимание, что в CoreMIDI для создания пользовательских сущностей необходимо создавать клиент (MIDIClientCreate) и передавать его в соответствующие функции):
MIDIClientRef clientRef;
MIDIClientCreate(CFSTR("CLIENT"), NULL, NULL, &clientRef);
MIDIEndpointRef sourceRef;
MIDISourceCreate(clientRef, CFSTR("SRC"), &sourceRef);
Функция объявлена устаревшей, новая же – MIDISourceCreateWithProtocol – отличается лишь указанием протокола:
MIDISourceCreateWithProtocol(clientRef, CFSTR("SRC"), kMIDIProtocol_1_0, &sourceRef);
Приёмник создаётся функцией MIDIDestinationCreate:
void ReadProc(const MIDIPacketList *pktlist, void *readProcRefCon, void *srcConnRefCon)
{
// executed on new packet received
}
// ...
MIDIEndpointRef destinationRef;
MIDIDestinationCreate(clientRef, nameRef, ReadProc, NULL, &destinationRef);
Функция ReadProc будет вызвана всякий раз, как в приёмник поступит новый пакет данных. Разумеется, есть и новый способ создания приёмника – MIDIDestinationCreateWithProtocol:
MIDIDestinationCreateWithProtocol(clientRef, nameRef, kMIDIProtocol_1_0, &destinationRef, ^(const MIDIEventList *evtlist, void *srcConnRefCon)
{
// executed on new event packet received
});
Видя, что в C нынче есть лямбда-функции (они же блоки), понимаю, что сильно отстал от жизни.
На практике полезнее создавать не просто источники и приёмники в вакууме, а связывать их в пары, образуя loopback-устройства. Такие устройства принимают данные через приёмник и отправляют их обратно через источник безо всякой обработки. Таким образом, появляется возможность проверить, а достиг ли на самом деле пакет данных устройства или нет. Если да, мы получим его обратно. Вот код простейшей программы, создающей loopback-устройства с заданными через аргументы именами:
#include <CoreFoundation/CoreFoundation.h>
#include <CoreMIDI/CoreMIDI.h>
#include <stdio.h>
typedef int LPBCREATE_RESULT;
#define LPBCREATE_OK 0
#define LPBCREATE_FAILEDCREATECLIENT 1
#define LPBCREATE_FAILEDCREATESOURCE 2
#define LPBCREATE_FAILEDCREATEDESTINATION 3
typedef struct
{
MIDIEndpointRef destRef;
MIDIEndpointRef srcRef;
char *portName;
} PortInfo;
void ReadProc(const MIDIPacketList *pktlist, void *readProcRefCon, void *srcConnRefCon)
{
PortInfo* portInfo = (PortInfo*)readProcRefCon;
printf("Data arrived on '%s'; notifying ports...", portInfo->portName);
OSStatus status = MIDIReceived(portInfo->srcRef, pktlist);
printf("%d\n", status);
}
int main(int argc, char *argv[])
{
printf("Creating client...\n");
MIDIClientRef clientRef;
OSStatus result = MIDIClientCreate(CFSTR("LoopbackClient"), NULL, NULL, &clientRef);
if (result != noErr)
return LPBCREATE_FAILEDCREATECLIENT;
for (int i = 1; i < argc; i++)
{
printf("Creating port '%s'...", argv);
PortInfo *portInfo = malloc(sizeof(PortInfo));
portInfo->portName = argv;
CFStringRef nameRef = CFStringCreateWithCString(NULL, argv, kCFStringEncodingUTF8);
result = MIDISourceCreate(clientRef, nameRef, &portInfo->srcRef);
if (result != noErr)
return LPBCREATE_FAILEDCREATESOURCE;
result = MIDIDestinationCreate(clientRef, nameRef, ReadProc, portInfo, &portInfo->destRef);
if (result != noErr)
return LPBCREATE_FAILEDCREATEDESTINATION;
printf("OK\n");
}
printf("Waiting for data...\n");
getchar();
return LPBCREATE_OK;
}
Всякий раз, как какое-либо приложение отправит данные на приёмник нашего виртуального loopback-устройства, будет вызвана функция ReadProc, внутри которой функция MIDIReceived оповестит все подключенные к источнику порты о том, что получен пакет данных.
Обмен данными в CoreMIDI
Входной порт призван получать данные от источника. Создать его можно с помощью функции MIDIInputPortCreate (функция нового API – MIDIInputPortCreateWithProtocol):
MIDIPortRef inPortRef;
MIDIInputPortCreate(clientRef, CFSTR("PORT_NAME"), ReadProc, NULL, &inPortRef);
Вместо NULL можно передать указатель на объект, который будет приходить через параметр readProcRefCon в ReadProc. Пример функции ReadProc можно посмотреть в предыдущем разделе в коде виртуального loopback-устройства.
Однако просто создать входной порт недостаточно, его нужно соединить с источником (или даже несколькими). На картинке выше такие соединения показаны жирной сплошной линией, в противоположность штриховым между выходными портами и приёмниками, где связь не требуется, но об этом позже. Чтобы соединить входной порт с источником, нужно вызвать функцию MIDIPortConnectSource:
MIDIPortConnectSource(inPortRef, sourceRef, NULL);
Вместо NULL опять же можно передать указатель на произвольный объект, который будет передан в ReadProc через параметр srcConnRefCon. Связать можно любое количество входных портов с любым количеством источников, не в пример Windows API, где владение устройством эксклюзивное (повторный вызов функции midiInOpen без предварительного вызова midiInClose приведёт к ошибке).
Теперь всякий раз, как от источника будут идти данные, они будут получены функцией ReadProc. Мы также можем вызвать MIDIReceived без фактической отправки данных источником. При этом на всех связанных с источником входных портах будет вызвана функция обратного вызова с данными, переданными в MIDIReceived.
Разумеется, связь можно и разорвать, для чего служит функция MIDIPortDisconnectSource:
MIDIPortConnectSource(inPortRef, sourceRef);
Для отправки же данных от приложения к устройству необходимо создать выходной порт, используя функцию MIDIOutputPortCreate:
MIDIPortRef outPortRef;
MIDIOutputPortCreate(clientRef, CFSTR("PORT_NAME"), &outPortRef);
после чего с помощью функции MIDISend (или MIDISendEventList), собственно, отправить данные:
MIDISend(outPortRef, destinationRef, packetList);
Здесь мы отправляем пакет данных packetList через выходной порт приложения outPortRef на приёмник destinationRef. Единственный неясный момент – а как же сформировать пакет для отправки? Как я уже упоминал выше, я в основном описываю старый API. И отправка пакета данных через MIDISend – это устаревший подход. Я опишу подробно его, и скажу пару слов про новый.
Если мы изучим документацию по структуре MIDIPacketList (которая по неясным причинам не помечена устаревшей), то резюме будет таким:
Теперь что касается формирования списка пакетов. Да, конечно, можно сделать по-простому:
MIDIPacket packet;
packet.timeStamp = 0;
packet.length = 3;
packet.data[0] = 0x90; // note on, channel 0
packet.data[1] = 0xA6; // note number
packet.data[2] = 0xB7; // velocity
MIDIPacketList packetList;
packetList.numPackets = 1;
packetList.packet[0] = packet;
Тут есть нюансы:
ByteCount dataSize = 9999;
Byte buffer[dataSize + sizeof(MIDIPacketList)];
MIDIPacketList* packetList = (MIDIPacketList*)buffer;
MIDIPacket* packet = MIDIPacketListInit(packetList);
for (int i = 0; i < dataSize; i += 3)
{
data = 0x90;
data[i+1] = 0xA6;
data[i+2] = 0xB7;
packet = MIDIPacketListAdd(packetList, sizeof(buffer), packet, 0, 3, &data);
}
Здесь используется магия преобразования массива байтов (которые unsigned char) в указатель на MIDIPacketList. После чего функцией MIDIPacketListInit получаем указатель на первый пакет в списке. Этот указатель затем нужно передавать в последовательные вызовы MIDIPacketListAdd, дабы CoreMIDI сам разбил всё по пакетам, как считает нужным. Стоит понимать, что данная функция не всегда создаёт новый пакет, название её стоит читать как “добавить данные в список пакетов”.
В документации сказано, что максимальный размер списка пакетов 65536. Однако, отослав данные, подготовленные кодом выше (9999 байт), и заглянув в отладчике в функцию ReadProc (функция обратного вызова входного порта), заметим интересные моменты:
И, раз уж мы затронули получение данных, в общем случае код для работы с ними будет примерно таким:
void ReadProc(const MIDIPacketList *pktlist, void *readProcRefCon, void *srcConnRefCon)
{
MIDIPacket* packet = &pktlist->packet[0];
for (int i = 0; i < pktlist->numPackets; i++)
{
// do something with packet's data
packet = MIDIPacketNext(packet);
}
}
Функция MIDIPacketNext возвращает указатель на следующий пакет в списке. По моим наблюдениям вызов функции в отсутствие следующего пакета вернёт какую-то ерунду, не NULL. Стоит иметь это в виду и не ориентироваться на возвращаемое значение при принятии решения о завершении итерирования по данным.
Немного о том, как отправляются и получаются данные в новом API. По сути всё абсолютно то же самое. Например, простейший способ отправить данные такой:
MIDIEventPacket packet;
packet.timeStamp = 0;
packet.wordCount = 1;
packet.words[0] = (2 << 28) | (0x90 << 16) | (0x40 << 8) | 0x65;
MIDIEventList eventList;
eventList.protocol = kMIDIProtocol_1_0;
eventList.numPackets = 1;
eventList.packet[0] = packet;
MIDISendEventList(outPortRef, destinationRef, &eventList);
Фактически, отличия только в названии функций и указании протокола. Разумеется, тут нужно знать формат Universal MIDI Packet (UMP, спецификацию можно скачать с официального сайта). Вместо байтов нужно оперировать 32-битными числами (словами), вместо MIDIPacket используется MIDIEventPacket, вместо MIDIPacketList – MIDIEventList, вместо MIDISend – MIDISendEventList. Логика понятна – активно используется слово event.
Я сильно не вникал в работу с новым API, поэтому не могу сказать, можно ли создавать MIDIEventList динамически тем же способом, что и MIDIPacketList, через приведение массива байтов (или же слов) к указателю на необходимую структуру.
Как я уже говорил, я сосредоточен на старом API. Но нужно быть уверенным, что он работает и в новых версиях macOS. Я проверял на 11.3 Big Sur и 12 Monterey (Beta 3). На Big Sur в целом всё здорово, хотя тот наш пример с 3333 событиями в списке пакетов не работает. Выдержка из отчёта о падении:
...
Exception Type: EXC_CRASH (SIGABRT)
Exception Codes: 0x0000000000000000, 0x0000000000000000
Exception Note: EXC_CORPSE_NOTIFY
...
5 libc++abi.dylib 0x00007fff205fe307 std::__terminate(void (*)()) + 8
6 libc++abi.dylib 0x00007fff205fe2b8 std::terminate() + 56
7 com.apple.audio.midi.CoreMIDI 0x00007fff350177de void MIDI::EventList::traverse<MIDIIORingBufferWriter::copyPacketList(PacketHeader&, MIDIEventList const&)::'lambda'(auto const&)>(MIDIEventList const&, auto&&) + 212
8 com.apple.audio.midi.CoreMIDI 0x00007fff35021ac9 caulk::inplace_function_detail::vtable<void, MIDI::EventList const&>::vtable<MIDIIORingBufferWriter::copyPacketList(PacketHeader&, MIDIPacketList const&)::'lambda'(MIDI::EventList const&)>(caulk::inplace_function_detail::wrapper<MIDIIORingBufferWriter::copyPacketList(PacketHeader&, MIDIPacketList const&)::'lambda'(MIDI::EventList const&)>)::'lambda'(void*, MIDI::EventList const&)::__invoke(void*, MIDI::EventList const&) + 29
9 com.apple.audio.midi.CoreMIDI 0x00007fff34ff9b72 MIDI:acketizerBase<MIDI::EventList>::begin_new_packet(unsigned long long, gsl::span<unsigned int const, -1l>) + 52
10 com.apple.audio.midi.CoreMIDI 0x00007fff34ffc7d8 MIDI:acketizer::add(unsigned long long, MIDI::UniversalPacket const&) + 296
11 com.apple.audio.midi.CoreMIDI 0x00007fff34ffd03f void MIDI::LegacyPacketList::traverse<MIDI::MIDIPacketList_to_MIDIEventList(MIDIPacketList const&, caulk::inplace_function<void (MIDI::EventList const&), 48ul, 16ul>)::'lambda'(MIDIPacket const&)>(MIDIPacketList const&, MIDI::MIDIPacketList_to_MIDIEventList(MIDIPacketList const&, caulk::inplace_function<void (MIDI::EventList const&), 48ul, 16ul>)::'lambda'(MIDIPacket const&)&&) + 131
12 com.apple.audio.midi.CoreMIDI 0x00007fff35021960 void MIDIProcess::WriteOutput<MIDI::LegacyPacketList>(MIDIIOHeader&, MIDIProtocolID, MIDI::LegacyPacketList const&) + 166
13 com.apple.audio.midi.CoreMIDI 0x00007fff35021864 MIDISend + 313
Из стека вызовов можно узнать любопытный факт – вызов старого API транслируется в вызовы нового (MIDIPacketList_to_MIDIEventList). Т.е. в новых версиях macOS CoreMIDI полностью использует новые функции для фактического общения с устройствами.
Сама ошибка мне не критична, ибо я в своей библиотеке придерживаюсь правила “одно событие – один пакет данных”. Поэтому ситуации с несколькими событиями в пакете у меня нет.
Что касается macOS Monterey, то тут обнаружился баг. Если попытаться отправить событие нажатия ноты (note on) с нулевой скоростью нажатия (velocity), то CoreMIDI преобразует событие в note off с velocity = 64. Так как мы уже знаем, что внутри используется новый API, ошибка присутствует при отправке события как через MIDISend, так и через MIDISendEventList. Я сообщил об ошибке в Apple, на момент написания этих строк обращению выставлен статус Potential fix identified - In macOS 12.
В моих экспериментах мне сильно помог проект RtMidi, особенно в части понимания, как создавать список пакетов данных.
Нативные API (Windows/macOS) для библиотеки DryWetMIDI можно посмотреть в папке Resources/Native в репозитории проекта на GitHub.
Взаимодействие с MIDI в macOS производится с помощью фреймворка CoreMIDI. К сожалению, документация к данному фреймворку крайне скудная, многие вещи приходится выяснять либо экспериментальным путём, либо подглядывать в других проектах (при этом всё равно проверяя, насколько алгоритмы там верны). В данной статье я расскажу обо всём, что узнал касаемо CoreMIDI в процессе реализации нативной прослойки для своей библиотеки. Все примеры кода будут приведены на языке C.
Стоит заметить, что сейчас в CoreMIDI есть ряд устаревших API. Новые функции введены в macOS 11.0 Big Sur и призваны обеспечить работу с MIDI 2.0. Но я по большей части буду рассматривать именно старый API, так как он:
- существует давно, следовательно, есть огромная масса приложений, написанных с его использованием (кроме того, согласно статистике на сайте statcounter, доля пользователей Big Sur ничтожно мала (CSV-датасет с сырыми данными за июнь 2020 – июнь 2021 тут));
- вопреки заявлениям Apple в документации (например, по функции MIDIInputPortCreate, колонка Availability справа), работает и в последних версиях macOS;
- принципиально не отличается от нового, все современные API просто принимают аргументом тип протокола (MIDI 1.0/2.0) и/или функцию обратного вызова в виде блока.
На момент написания статьи у меня в библиотеке существует 360 юнит-тестов на проверку взаимодействия с MIDI-устройствами. Отладку я производил в версии macOS 10.14 Mojave, но также прогонял весь набор в 11.3 Big Sur и в 12.0 Monterey (Beta 3).
Устройства
Очень кратко структура MIDI-устройства в CoreMIDI описана на странице MIDI Services официальной документации, а визуально её можно представить так:То есть, устройство состоит из компонентов (entities), а каждый компонент имеет произвольное количество точек ввода/вывода (endpoints), которые мы будем называть источниками (source) и приёмниками (destination). Приложение, задействующее CoreMIDI, использует источник, чтобы получать данные от устройства, а приёмник – чтобы отправлять данные на него.
Количество устройств в системе можно получить с помощью функции MIDIGetNumberOfDevices:
ItemCount devicesCount = MIDIGetNumberOfDevices();
а ссылку на конкретное устройство по индексу (от 0 до devicesCount - 1) с помощью функции MIDIGetDevice. Например, чтобы получить ссылку на первое устройство:
MIDIDeviceRef deviceRef = MIDIGetDevice(0);
После получения ссылки на устройство можно получить его компоненты, используя аналогичные функции MIDIDeviceGetNumberOfEntities и MIDIDeviceGetEntity:
ItemCount entitiesCount = MIDIDeviceGetNumberOfEntities(deviceRef);
MIDIEntityRef firstEntityRef = MIDIDeviceGetEntity(deviceRef, 0);
И наконец, дабы получить источники и приёмники компонентов, используются функции MIDIEntityGetNumberOfSources, MIDIEntityGetSource, MIDIEntityGetNumberOfDestinations и MIDIEntityGetDestination:
ItemCount entitySourcesCount = MIDIEntityGetNumberOfSources(firstEntityRef);
MIDIEndpointRef firstEntitySourceRef = MIDIEntityGetSource(firstEntityRef, 0);
ItemCount entityDestinationsCount = MIDIEntityGetNumberOfDestinations(firstEntityRef);
MIDIEndpointRef firstEntityDetinationRef = MIDIEntityGetDestination(firstEntityRef, 0);
Можно также получать источники и приёмники безотносительно конкретного устройства и его компонента, используя функции MIDIGetNumberOfSources, MIDIGetSource, MIDIGetNumberOfDestinations и MIDIGetDestination:
ItemCount sourcesCount = MIDIGetNumberOfSources();
MIDIEndpointRef firstSourceRef = MIDIGetSource(0);
ItemCount destinationsCount = MIDIGetNumberOfDestinations();
MIDIEndpointRef firstDetinationRef = MIDIGetDestination(0);
Например, именно такой подход я и использую в DryWetMIDI, ибо мне нужно предоставлять унифицированный API для Windows и macOS. В Windows нет понятий, используемых в CoreMIDI. Там используются термины входное устройство и выходное устройство. Таких терминов я придерживаюсь и в библиотеке, а потому источники в CoreMIDI я считаю входными устройствами, а приёмники – выходными.
Мы теперь знаем, как получать ссылки на устройства, его компоненты и точки ввода/вывода. Но от этого мало толка, если мы не умеем получать информацию о каждом из объектов. Например, мы можем захотеть показать в нашем приложении список имеющихся в системе устройств с их именами. CoreMIDI предоставляет несколько функций для получения свойств MIDI-объектов (и установки некоторых из них). Перечень этих функций приведён на странице MIDI Object Properties в секции Property Accessors. Все доступные свойства приведены на той же странице и логически сгруппированы.
Например, чтобы найти источник с именем MIDI A, можно написать такой код:
ItemCount sourcesCount = MIDIGetNumberOfSources();
MIDIEndpointRef sourceRef;
for (int i = 0; i < sourcesCount; i++)
{
sourceRef = MIDIGetSource(i);
CFStringRef nameRef;
MIDIObjectGetStringProperty(sourceRef, kMIDIPropertyDisplayName, &nameRef);
if (CFStringCompare(nameRef, CFSTR("MIDI A"), 0) == kCFCompareEqualTo)
{
break;
}
}
Здесь мы не стали проверять результат вызова функции MIDIObjectGetStringProperty, а по-хорошему нужно. Вот только функция возвращает OSStatus, что по сути просто 32-битное целое число со знаком. В CoreMIDI много функций возвращают OSStatus, но в документации ни к одной из них не сказано, какие именно константы допустимы для конкретной функции. Максимум, что мы можем узнать, это весь список возможных ошибок в CoreMIDI, после чего с помощью хрустального шара прикинуть, какие из них к какой функции подходят. Я не буду далее показывать в каждом месте, что та или иная функция возвращает OSStatus, это можно увидеть в документации.
Виртуальные устройства
Если вы хотите автоматизированно тестировать сценарии работы с MIDI-устройствами, вряд ли вы посчитаете хорошей затеей создавать робота, который будет при запуске тестов подключать физическую аппаратуру. А потому нужно уметь программно настраивать MIDI-окружение.В Windows для того, чтобы поиметь виртуальные устройства в системе, нужно устанавливать сторонние решения вроде virtualMIDI. Центральным их элементом является драйвер режима ядра. Звучит не очень просто, а потому продукты являются либо платными, либо только для некоммерческого использования, либо без возможности распространять их, либо всё вместе.
К счастью, в macOS есть встроенная возможность создавать виртуальные источники и приёмники. Источник создаётся с помощью функции MIDISourceCreate (обратите внимание, что в CoreMIDI для создания пользовательских сущностей необходимо создавать клиент (MIDIClientCreate) и передавать его в соответствующие функции):
MIDIClientRef clientRef;
MIDIClientCreate(CFSTR("CLIENT"), NULL, NULL, &clientRef);
MIDIEndpointRef sourceRef;
MIDISourceCreate(clientRef, CFSTR("SRC"), &sourceRef);
Функция объявлена устаревшей, новая же – MIDISourceCreateWithProtocol – отличается лишь указанием протокола:
MIDISourceCreateWithProtocol(clientRef, CFSTR("SRC"), kMIDIProtocol_1_0, &sourceRef);
Приёмник создаётся функцией MIDIDestinationCreate:
void ReadProc(const MIDIPacketList *pktlist, void *readProcRefCon, void *srcConnRefCon)
{
// executed on new packet received
}
// ...
MIDIEndpointRef destinationRef;
MIDIDestinationCreate(clientRef, nameRef, ReadProc, NULL, &destinationRef);
Функция ReadProc будет вызвана всякий раз, как в приёмник поступит новый пакет данных. Разумеется, есть и новый способ создания приёмника – MIDIDestinationCreateWithProtocol:
MIDIDestinationCreateWithProtocol(clientRef, nameRef, kMIDIProtocol_1_0, &destinationRef, ^(const MIDIEventList *evtlist, void *srcConnRefCon)
{
// executed on new event packet received
});
Видя, что в C нынче есть лямбда-функции (они же блоки), понимаю, что сильно отстал от жизни.
На практике полезнее создавать не просто источники и приёмники в вакууме, а связывать их в пары, образуя loopback-устройства. Такие устройства принимают данные через приёмник и отправляют их обратно через источник безо всякой обработки. Таким образом, появляется возможность проверить, а достиг ли на самом деле пакет данных устройства или нет. Если да, мы получим его обратно. Вот код простейшей программы, создающей loopback-устройства с заданными через аргументы именами:
#include <CoreFoundation/CoreFoundation.h>
#include <CoreMIDI/CoreMIDI.h>
#include <stdio.h>
typedef int LPBCREATE_RESULT;
#define LPBCREATE_OK 0
#define LPBCREATE_FAILEDCREATECLIENT 1
#define LPBCREATE_FAILEDCREATESOURCE 2
#define LPBCREATE_FAILEDCREATEDESTINATION 3
typedef struct
{
MIDIEndpointRef destRef;
MIDIEndpointRef srcRef;
char *portName;
} PortInfo;
void ReadProc(const MIDIPacketList *pktlist, void *readProcRefCon, void *srcConnRefCon)
{
PortInfo* portInfo = (PortInfo*)readProcRefCon;
printf("Data arrived on '%s'; notifying ports...", portInfo->portName);
OSStatus status = MIDIReceived(portInfo->srcRef, pktlist);
printf("%d\n", status);
}
int main(int argc, char *argv[])
{
printf("Creating client...\n");
MIDIClientRef clientRef;
OSStatus result = MIDIClientCreate(CFSTR("LoopbackClient"), NULL, NULL, &clientRef);
if (result != noErr)
return LPBCREATE_FAILEDCREATECLIENT;
for (int i = 1; i < argc; i++)
{
printf("Creating port '%s'...", argv);
PortInfo *portInfo = malloc(sizeof(PortInfo));
portInfo->portName = argv;
CFStringRef nameRef = CFStringCreateWithCString(NULL, argv, kCFStringEncodingUTF8);
result = MIDISourceCreate(clientRef, nameRef, &portInfo->srcRef);
if (result != noErr)
return LPBCREATE_FAILEDCREATESOURCE;
result = MIDIDestinationCreate(clientRef, nameRef, ReadProc, portInfo, &portInfo->destRef);
if (result != noErr)
return LPBCREATE_FAILEDCREATEDESTINATION;
printf("OK\n");
}
printf("Waiting for data...\n");
getchar();
return LPBCREATE_OK;
}
Всякий раз, как какое-либо приложение отправит данные на приёмник нашего виртуального loopback-устройства, будет вызвана функция ReadProc, внутри которой функция MIDIReceived оповестит все подключенные к источнику порты о том, что получен пакет данных.
Отправка и получение данных
Пришло время узнать, как же обмениваться данными с MIDI-устройствами. Отправка и получение данных происходят между портами (создаваемыми приложением) и точками ввода/вывода устройств (источники/приёмники):Входной порт призван получать данные от источника. Создать его можно с помощью функции MIDIInputPortCreate (функция нового API – MIDIInputPortCreateWithProtocol):
MIDIPortRef inPortRef;
MIDIInputPortCreate(clientRef, CFSTR("PORT_NAME"), ReadProc, NULL, &inPortRef);
Вместо NULL можно передать указатель на объект, который будет приходить через параметр readProcRefCon в ReadProc. Пример функции ReadProc можно посмотреть в предыдущем разделе в коде виртуального loopback-устройства.
Однако просто создать входной порт недостаточно, его нужно соединить с источником (или даже несколькими). На картинке выше такие соединения показаны жирной сплошной линией, в противоположность штриховым между выходными портами и приёмниками, где связь не требуется, но об этом позже. Чтобы соединить входной порт с источником, нужно вызвать функцию MIDIPortConnectSource:
MIDIPortConnectSource(inPortRef, sourceRef, NULL);
Вместо NULL опять же можно передать указатель на произвольный объект, который будет передан в ReadProc через параметр srcConnRefCon. Связать можно любое количество входных портов с любым количеством источников, не в пример Windows API, где владение устройством эксклюзивное (повторный вызов функции midiInOpen без предварительного вызова midiInClose приведёт к ошибке).
Теперь всякий раз, как от источника будут идти данные, они будут получены функцией ReadProc. Мы также можем вызвать MIDIReceived без фактической отправки данных источником. При этом на всех связанных с источником входных портах будет вызвана функция обратного вызова с данными, переданными в MIDIReceived.
Разумеется, связь можно и разорвать, для чего служит функция MIDIPortDisconnectSource:
MIDIPortConnectSource(inPortRef, sourceRef);
Для отправки же данных от приложения к устройству необходимо создать выходной порт, используя функцию MIDIOutputPortCreate:
MIDIPortRef outPortRef;
MIDIOutputPortCreate(clientRef, CFSTR("PORT_NAME"), &outPortRef);
после чего с помощью функции MIDISend (или MIDISendEventList), собственно, отправить данные:
MIDISend(outPortRef, destinationRef, packetList);
Здесь мы отправляем пакет данных packetList через выходной порт приложения outPortRef на приёмник destinationRef. Единственный неясный момент – а как же сформировать пакет для отправки? Как я уже упоминал выше, я в основном описываю старый API. И отправка пакета данных через MIDISend – это устаревший подход. Я опишу подробно его, и скажу пару слов про новый.
Если мы изучим документацию по структуре MIDIPacketList (которая по неясным причинам не помечена устаревшей), то резюме будет таким:
- есть поле numPackets, содержащее количество пакетов в списке;
- поле packet объявлено как массив пакетов длины 1, а пакет представляется структурой MIDIPacket, которая имеет:
Т.е. при отправке можно запланировать передачу данных на устройство, указав время в будущем. А можно указать 0, и тогда данные будут переданы сразу. Но, указав 0, мы этот 0 и увидим в пакете при получении его от источника. И выходит, что метка времени будет не совсем the time at which the events occurred. По мне, здесь перемудрили, и в Windows сделано проще – время события проставляется системой, и нет планирования отправки.If receiving MIDI data, this property represents the time at which the events occurred. If sending MIDI data, it represents the time at which to play the events. A value of 0 means “now.”
Теперь что касается формирования списка пакетов. Да, конечно, можно сделать по-простому:
MIDIPacket packet;
packet.timeStamp = 0;
packet.length = 3;
packet.data[0] = 0x90; // note on, channel 0
packet.data[1] = 0xA6; // note number
packet.data[2] = 0xB7; // velocity
MIDIPacketList packetList;
packetList.numPackets = 1;
packetList.packet[0] = packet;
Тут есть нюансы:
- мы используем 256 байт (так объявлено поле data) для передачи всего лишь трёх;
- не выйдет передать одним пакетом больше данных (например, несколько сообщений нажатия ноты или же system exclusive данные).
ByteCount dataSize = 9999;
Byte buffer[dataSize + sizeof(MIDIPacketList)];
MIDIPacketList* packetList = (MIDIPacketList*)buffer;
MIDIPacket* packet = MIDIPacketListInit(packetList);
for (int i = 0; i < dataSize; i += 3)
{
data = 0x90;
data[i+1] = 0xA6;
data[i+2] = 0xB7;
packet = MIDIPacketListAdd(packetList, sizeof(buffer), packet, 0, 3, &data);
}
Здесь используется магия преобразования массива байтов (которые unsigned char) в указатель на MIDIPacketList. После чего функцией MIDIPacketListInit получаем указатель на первый пакет в списке. Этот указатель затем нужно передавать в последовательные вызовы MIDIPacketListAdd, дабы CoreMIDI сам разбил всё по пакетам, как считает нужным. Стоит понимать, что данная функция не всегда создаёт новый пакет, название её стоит читать как “добавить данные в список пакетов”.
В документации сказано, что максимальный размер списка пакетов 65536. Однако, отослав данные, подготовленные кодом выше (9999 байт), и заглянув в отладчике в функцию ReadProc (функция обратного вызова входного порта), заметим интересные моменты:
- ReadProc будет вызвана 3 раза;
- numPackets в переданном в функцию списке пакетов всегда будет 1;
- длина пакета при первом и втором вызове будет 4082, а в третьем 1835.
И, раз уж мы затронули получение данных, в общем случае код для работы с ними будет примерно таким:
void ReadProc(const MIDIPacketList *pktlist, void *readProcRefCon, void *srcConnRefCon)
{
MIDIPacket* packet = &pktlist->packet[0];
for (int i = 0; i < pktlist->numPackets; i++)
{
// do something with packet's data
packet = MIDIPacketNext(packet);
}
}
Функция MIDIPacketNext возвращает указатель на следующий пакет в списке. По моим наблюдениям вызов функции в отсутствие следующего пакета вернёт какую-то ерунду, не NULL. Стоит иметь это в виду и не ориентироваться на возвращаемое значение при принятии решения о завершении итерирования по данным.
Немного о том, как отправляются и получаются данные в новом API. По сути всё абсолютно то же самое. Например, простейший способ отправить данные такой:
MIDIEventPacket packet;
packet.timeStamp = 0;
packet.wordCount = 1;
packet.words[0] = (2 << 28) | (0x90 << 16) | (0x40 << 8) | 0x65;
MIDIEventList eventList;
eventList.protocol = kMIDIProtocol_1_0;
eventList.numPackets = 1;
eventList.packet[0] = packet;
MIDISendEventList(outPortRef, destinationRef, &eventList);
Фактически, отличия только в названии функций и указании протокола. Разумеется, тут нужно знать формат Universal MIDI Packet (UMP, спецификацию можно скачать с официального сайта). Вместо байтов нужно оперировать 32-битными числами (словами), вместо MIDIPacket используется MIDIEventPacket, вместо MIDIPacketList – MIDIEventList, вместо MIDISend – MIDISendEventList. Логика понятна – активно используется слово event.
Я сильно не вникал в работу с новым API, поэтому не могу сказать, можно ли создавать MIDIEventList динамически тем же способом, что и MIDIPacketList, через приведение массива байтов (или же слов) к указателю на необходимую структуру.
Как я уже говорил, я сосредоточен на старом API. Но нужно быть уверенным, что он работает и в новых версиях macOS. Я проверял на 11.3 Big Sur и 12 Monterey (Beta 3). На Big Sur в целом всё здорово, хотя тот наш пример с 3333 событиями в списке пакетов не работает. Выдержка из отчёта о падении:
...
Exception Type: EXC_CRASH (SIGABRT)
Exception Codes: 0x0000000000000000, 0x0000000000000000
Exception Note: EXC_CORPSE_NOTIFY
...
5 libc++abi.dylib 0x00007fff205fe307 std::__terminate(void (*)()) + 8
6 libc++abi.dylib 0x00007fff205fe2b8 std::terminate() + 56
7 com.apple.audio.midi.CoreMIDI 0x00007fff350177de void MIDI::EventList::traverse<MIDIIORingBufferWriter::copyPacketList(PacketHeader&, MIDIEventList const&)::'lambda'(auto const&)>(MIDIEventList const&, auto&&) + 212
8 com.apple.audio.midi.CoreMIDI 0x00007fff35021ac9 caulk::inplace_function_detail::vtable<void, MIDI::EventList const&>::vtable<MIDIIORingBufferWriter::copyPacketList(PacketHeader&, MIDIPacketList const&)::'lambda'(MIDI::EventList const&)>(caulk::inplace_function_detail::wrapper<MIDIIORingBufferWriter::copyPacketList(PacketHeader&, MIDIPacketList const&)::'lambda'(MIDI::EventList const&)>)::'lambda'(void*, MIDI::EventList const&)::__invoke(void*, MIDI::EventList const&) + 29
9 com.apple.audio.midi.CoreMIDI 0x00007fff34ff9b72 MIDI:acketizerBase<MIDI::EventList>::begin_new_packet(unsigned long long, gsl::span<unsigned int const, -1l>) + 52
10 com.apple.audio.midi.CoreMIDI 0x00007fff34ffc7d8 MIDI:acketizer::add(unsigned long long, MIDI::UniversalPacket const&) + 296
11 com.apple.audio.midi.CoreMIDI 0x00007fff34ffd03f void MIDI::LegacyPacketList::traverse<MIDI::MIDIPacketList_to_MIDIEventList(MIDIPacketList const&, caulk::inplace_function<void (MIDI::EventList const&), 48ul, 16ul>)::'lambda'(MIDIPacket const&)>(MIDIPacketList const&, MIDI::MIDIPacketList_to_MIDIEventList(MIDIPacketList const&, caulk::inplace_function<void (MIDI::EventList const&), 48ul, 16ul>)::'lambda'(MIDIPacket const&)&&) + 131
12 com.apple.audio.midi.CoreMIDI 0x00007fff35021960 void MIDIProcess::WriteOutput<MIDI::LegacyPacketList>(MIDIIOHeader&, MIDIProtocolID, MIDI::LegacyPacketList const&) + 166
13 com.apple.audio.midi.CoreMIDI 0x00007fff35021864 MIDISend + 313
Из стека вызовов можно узнать любопытный факт – вызов старого API транслируется в вызовы нового (MIDIPacketList_to_MIDIEventList). Т.е. в новых версиях macOS CoreMIDI полностью использует новые функции для фактического общения с устройствами.
Сама ошибка мне не критична, ибо я в своей библиотеке придерживаюсь правила “одно событие – один пакет данных”. Поэтому ситуации с несколькими событиями в пакете у меня нет.
Что касается macOS Monterey, то тут обнаружился баг. Если попытаться отправить событие нажатия ноты (note on) с нулевой скоростью нажатия (velocity), то CoreMIDI преобразует событие в note off с velocity = 64. Так как мы уже знаем, что внутри используется новый API, ошибка присутствует при отправке события как через MIDISend, так и через MIDISendEventList. Я сообщил об ошибке в Apple, на момент написания этих строк обращению выставлен статус Potential fix identified - In macOS 12.
Заключение
В статье мы рассмотрели основные сценарии работы с CoreMIDI. Конечно, возможности фреймворка гораздо шире, и многое осталось “за кадром”. Однако статья даёт быстрый старт и может использоваться в качестве шпаргалки при разработке своего приложения, взаимодействующего с MIDI-устройствами.В моих экспериментах мне сильно помог проект RtMidi, особенно в части понимания, как создавать список пакетов данных.
Нативные API (Windows/macOS) для библиотеки DryWetMIDI можно посмотреть в папке Resources/Native в репозитории проекта на GitHub.
Введение в CoreMIDI
В своей прошлой статье я рассматривал вопрос создания NuGet-пакета для .NET-библиотеки с платформозависимым API. После публикации пришло время перейти от слов к делу. А именно начать реализовывать...
habr.com