Ableton не нужон: подключаем Ableton Push 2 к VCV Rack

в 15:52, , рубрики: midi-контроллеры, open source, звук, звук и музыка, модульные синтезаторы
Ableton не нужон: подключаем Ableton Push 2 к VCV Rack - 1

Создание музыки, в последнее время, проходит примерно тот же путь что фотография 10 лет назад: у каждого появляется своя зеркалка и аккаунт в инстаграме. Музыкальная индустрия этому очень рада, ведь такой интерес приносит много денег. Каждый день появляются новые VST плагины и аналоговые девайсы, стремительно растёт количество тематических курсов, блоги, посвящённые музыкальному продакшену выходят в топ youtube. На этом фоне очень интересно выглядят open-source проекты, позволяющие любому желающему попробовать себя в роли продюсера, не тратя на это кучу средств. Одним из таких проектов является VCV Rack, ставящий целью сделать самый дорогой класс музыкальных инструментов — аналоговые модульные синтезаторы — доступными каждому. Недавно, когда у меня возникла идея трека, а под рукой оказался только ноутбук на linux я захотел сделать трек целиком в VCV. А по ходу дела появилось желание управлять синтезаторами с помощью midi контроллера так, как с использованием доступных модулей не получалось. В итоге я решил написать плагин для подключения Ableton Push 2 к VCV Rack. О том что из этого получилось, и как писать свои модули для VCV поговорим в этой статье.

Краткая справка

VCV Rack — open source среда, эмулирующая модульные синтезаторы. Про VCV можно посмотреть вот тут, а про модульные синтезаторы можно почитать хорошую статью на Хабре.

Ableton Push 2 — это второе поколение midi-контроллера от Ableton, заточенного на работу с одноимённой DAW, но также имеющего API для использования сторонними разработчиками.

API VCV Rack

Каждый модуль в VCV состоит из двух частей — звуковой и графической. Звуковая часть наследует от класса Module метод process, вызывающийся для каждого семпла, то есть с частотой дискретизации. Кстати, частота дискретизации в VCV может варьироваться от стандартных 44.1 кГц до аж 768 кГц, что позволяет более точно эмулировать модульные синтезаторы при наличии достаточной вычислительной мощности.

За графику отвечают объекты типа ModuleWidget, которые наследуют от базовой структуры метод draw. В VCV используется библиотека векторной графики nanovg. Отрисовка внутри метода draw может происходить как внутри границ модуля (лишнее отрезается движком), так и например во фреймбуфер, чем мы ещё воспользуемся.

Итак, что же нужно, чтобы написать свой модуль для VCV?

Настройка окружения и установка Rack SDK

Первый шаг хорошо описан в документации и совершенно не вызывает трудностей (по-крайней мере под linux), так что не будем на нём останавливаться.

Генерируем шаблон плагина

Для этого в Rack SDK есть скрипт helper.py. Ему нужно сказать createplugin, после чего указать название плагина и, опционально, информацию о нём.

<Rack SDK folder>/helper.py createplugin MyPlugin

Когда шаблон плагина создан, его можно скомпилировать и установить командой

RACK_DIR=<Rack SDK folder> make install

Рисуем фронтенд модуля

Каждый плагин может содержать несколько модулей, и для каждого из модулей нужно нарисовать главную панель. Для этого документация VCV предлагает нам использовать Inkscape или любой другой векторный редактор. Так как модули в VCV крепятся на виртуальный Eurorack-стенд, их высота всегда равна 128,5мм, а ширина должна быть кратна 5,08мм.
Основные элементы интерфейса, такие как CV/Gate гнёзда, кнопки и лампочки можно разметить в векторном виде. Для этого нужно нарисовать на их месте круги соответствующих цветов (подробнее тут), чтобы потом helper.py сгенерил по этой разметке код. Лично мне показалось что это не очень удобная фича и проще размещать элементы напрямую из кода. Когда картинка и разметка готовы, нужно снова запустить helper.py чтобы создать шаблон модуля и связать его с передней панелью.

helper.py createmodule MyModule res/MyModule.svg src/MyModule.cpp

Подключаем кнопки и крутилки

Ableton не нужон: подключаем Ableton Push 2 к VCV Rack - 2

Не считая дисплея, Ableton Push 2 видится компьютером как обычный USB-MIDI девайс, что позволяет легко наладить связь между ним и VCV Rack. Для этого создадим внутри класса модуля входную midi очередь и выходной midi порт.

Код инициализации

struct AbletonPush2 : Module {
    midi::Output midiOutput;
    midi::InputQueue midiInput;

    bool inputConnected;
    bool outputConnected;
}

Попробуем найти Ableton Push среди подключенных midi девайсов и подключиться к нему. Так как модуль предназначен для работы исключительно с Ableton Push, нам не нужно обременять юзера выбором девайса и можно просто найти его по названию.

Код подключения к контроллеру

void connectPush() {
    auto in_devs = midiInput.getDeviceIds();
    for (int i = 0; i < in_devs.size(); i++){
        if (midiInput.getDeviceName(in_devs[i]).find("Ableton") != std::string::npos) {
            midiInput.setDeviceId(in_devs[i]);
            inputConnected = true;
            break;
        }
    }
		
    auto out_devs = midiOutput.getDeviceIds();
    for (int i = 0; i < out_devs.size(); i++){
        if (midiOutput.getDeviceName(out_devs[i]).find("Ableton") != std::string::npos) {
            midiOutput.setDeviceId(out_devs[i]);
            outputConnected = true;
            break;
        }
    }
}

Теперь в методе process можно периодически проверять, подключен ли контроллер и спрашивать midi очередь, не пришло ли нам каких-нибудь сообщений. Тут стоит сказать о том что и как вообще кодируется в стандарте midi. По сути есть два главных типа сообщений, это Note ON/OFF, передающие номер ноты и силу нажатия и CC — Command Control, передающие численное значение некоторого изменяемого параметра. Подробнее про midi можно посмотреть здесь.

Код опроса MIDI очереди

void process(const ProcessArgs &args) override {
    if (sampleCounter > args.sampleRate / updateFrequency) {

        if ((!inputConnected) && (!outputConnected)) {
            connectPush();
        }

	midi::Message msg;
	while (midiInput.shift(&msg)) {
	    processMidi(msg);
	}
    }
}

Теперь хочется научить пэды контроллера светиться разными цветами, чтобы было удобнее в них ориентироваться. Для этого нам надо будет посылать ему соответствующие midi команды. Рассмотрим, например, пэд с номером 36 (это самый нижний левый пэд). Если на него нажать, контроллер пошлёт команду 0x90 (note on), следом за ней номер ноты (36) и число от 0 до 127, означающее силу нажатия. А когда, наоборот, компьютер посылает контроллеру ту же самую команду 0x90 и тот же самый номер ноты 36, то третье число будет означать цвет, которым должен светиться нижний левый пэд. Номера кнопок и пэдов представлены на рисунке выше. У Push довольно много возможностей для работы с цветом, палитрами, анимацией и другими параметрами подсветки. Я не стал вникать в подробности и просто вывел все возможные цвета дефолтной палитры на пэды и выбрал понравившиеся. Но в общем случае, преобразование midi команды в значения ШИМ на светодиодах, согласно документации, выглядит так:

Ableton не нужон: подключаем Ableton Push 2 к VCV Rack - 3

Код управления подсветкой

void lightOn(int note, int color){
    midi::Message msg;
    msg.setNote(note);
    msg.setValue(color);
    msg.setChannel(1);
    msg.setStatus(0x9);
    midiOutput.sendMessage(msg);
}

void lightOff(int note){
    midi::Message msg;
    msg.setNote(note);
    msg.setValue(0);
    msg.setChannel(1);
    msg.setStatus(0x8);
    midiOutput.sendMessage(msg);
}

Подключаем дисплей

Ableton не нужон: подключаем Ableton Push 2 к VCV Rack - 4

Для подключения дисплея придётся поработать с USB. Сам по себе контроллер не умеет ничего рисовать и не знает ничего о графике. Всё что он хочет — это чтобы не реже чем раз в 2 секунды ему присылали кадр размером 160x960 пикселей. Весь рендеринг происходит на стороне компьютера. Для начала подключим и откроем USB девайс, как это описано в документации:

Код подключения дисплея

#ifdef _MSC_VER
#define _CRT_SECURE_NO_WARNINGS
#endif

#include <stdio.h>

#ifdef _WIN32

// see following link for a discussion of the
// warning suppression:
// http://sourceforge.net/mailarchive/forum.php?
// thread_name=50F6011C.2020000%40akeo.ie&forum_name=libusbx-devel

// Disable: warning C4200: nonstandard extension used:
// zero-sized array in struct/union
#pragma warning(disable:4200)

#include <windows.h>
#endif

#ifdef __linux__
#include <libusb-1.0/libusb.h>
#else
#include "libusb.h"
#endif

#define ABLETON_VENDOR_ID 0x2982
#define PUSH2_PRODUCT_ID  0x1967

static libusb_device_handle* open_push2_device()
{
  int result;

  if ((result = libusb_init(NULL)) < 0)
  {
    printf("error: [%d] could not initilialize usblibn", result);
    return NULL;
  }

  libusb_set_debug(NULL, LIBUSB_LOG_LEVEL_ERROR);

  libusb_device** devices;
  ssize_t count;
  count = libusb_get_device_list(NULL, &devices);
  if (count < 0)
  {
    printf("error: [%ld] could not get usb device listn", count);
    return NULL;
  }

  libusb_device* device;
  libusb_device_handle* device_handle = NULL;

  char ErrorMsg[128];

  // set message in case we get to the end of the list w/o finding a device
  sprintf(ErrorMsg, "error: Ableton Push 2 device not foundn");

  for (int i = 0; (device = devices[i]) != NULL; i++)
  {
    struct libusb_device_descriptor descriptor;
    if ((result = libusb_get_device_descriptor(device, &descriptor)) < 0)
    {
      sprintf(ErrorMsg,
        "error: [%d] could not get usb device descriptorn", result);
      continue;
    }

    if (descriptor.bDeviceClass == LIBUSB_CLASS_PER_INTERFACE
      && descriptor.idVendor == ABLETON_VENDOR_ID
      && descriptor.idProduct == PUSH2_PRODUCT_ID)
    {
      if ((result = libusb_open(device, &device_handle)) < 0)
      {
        sprintf(ErrorMsg,
          "error: [%d] could not open Ableton Push 2 devicen", result);
      }
      else if ((result = libusb_claim_interface(device_handle, 0)) < 0)
      {
        sprintf(ErrorMsg,
          "error: [%d] could not claim interface 0 of Push 2 devicen", result);
        libusb_close(device_handle);
        device_handle = NULL;
      }
      else
      {
        break; // successfully opened
      }
    }
  }

  if (device_handle == NULL)
  {
    printf(ErrorMsg);
  }

  libusb_free_device_list(devices, 1);
  return device_handle;
}

static void close_push2_device(libusb_device_handle* device_handle)
{
  libusb_release_interface(device_handle, 0);
  libusb_close(device_handle);
}

Чтобы передать кадр, нужно сначала послать 16-байтовый заголовок, а потом 160 строк из 960 16-битных чисел каждая. При этом, согласно документации, строки следует передавать не по 1920 байт, а пакетами по 2048 байт, дополняя нулями.

Код передачи кадра

struct Push2Display : FramebufferWidget {

    unsigned char frame_header[16] = {
          0xFF, 0xCC, 0xAA, 0x88,
          0x00, 0x00, 0x00, 0x00,
          0x00, 0x00, 0x00, 0x00,
          0x00, 0x00, 0x00, 0x00 
    };

    libusb_device_handle* device_handle;

    unsigned char* image;

    void sendDisplay(unsigned char * image) {
        int actual_length;
     
        libusb_bulk_transfer(device_handle, PUSH2_BULK_EP_OUT, frame_header, sizeof(frame_header), &actual_length, TRANSFER_TIMEOUT);
        for (int i = 0; i < 160; i++)
            libusb_bulk_transfer(device_handle, PUSH2_BULK_EP_OUT, &image[(159 - i)*1920], 2048, &actual_length, TRANSFER_TIMEOUT);
    }
}

Теперь осталось только отрисовать и записать в буфер кадр. Для этого используем класс FramebufferWidget, реализующий метод drawFramebuffer. VCV Rack из коробки использует библиотеку nanovg, так что графику здесь писать довольно легко. Узнаем текущий графический контекст из глобальной переменной APP и сохраним его состояние. Дальше создадим пустой кадр размером 160x960 и отрисуем его методом draw. После этого скопируем фреймбуфер в массив, который будет отправлен по USB, и вернём состояние контекста. В конце выставим флаг dirty, чтобы на следующей итерации отрисовки движок VCV не забыл обновить наш виджет.

Код отрисовки кадра

void drawFramebuffer() override {

    NVGcontext* vg = APP->window->vg;

    if (display_connected) {
        nvgSave(vg);
	glViewport(0, 0, 960, 160);
        glClearColor(0, 0, 0, 0);
        glClear(GL_COLOR_BUFFER_BIT|GL_STENCIL_BUFFER_BIT);

	nvgBeginFrame(vg, 960,  160, 1);
        draw(vg);
        nvgEndFrame(vg);

        glReadPixels(0, 0, 960, 160, GL_RGB, GL_UNSIGNED_SHORT_5_6_5, image); 

        // Побайтовый XOR с кодирующей последовательностью, согласно документации
        for (int i = 0; i < 80*960; i ++){
            image[i * 4] ^= 0xE7;
	    image[i * 4 + 1] ^= 0xF3;
            image[i * 4 + 2] ^= 0xE7;
	    image[i * 4 + 3] ^= 0xFF;
        }
	    	
	sendDisplay(image);

	nvgRestore(vg);
    }

    dirty = true;
}

Логика взаимодействия и маппинг параметров

Для моей задачи мне хотелось уметь переключать паттерны, записанные в секвенсеры, и при этом контролировать определённый набор параметров, свой для каждой группы паттернов. Под маппингом я понимаю связывание параметра одного модуля с параметром другого модуля или midi контроллера. Я нашёл крайне мало информации о том как маппинг сделать красиво, поэтому большую часть кода, реализующего его взял из встроенного в VCV модуля MIDI Map. Вкратце, для каждой связки параметров там создаётся специальный объект ParamHandle, который через костыли сообщает движку что с чем связано.

Заключение

Вот такой модуль у меня получился в итоге. Помимо стандартного преобразования midi в CV, он позволяет группировать пэды, присваивать им цвета и связывать произвольные параметры модулей с энкодерами Push, отображая их названия и значения на дисплее контроллера. На видео ниже можно посмотреть его обзор, а на этом видео увидеть в действии.

Полный код доступен вот здесь.

Протестировать его удалось только под Linux (Ubuntu 18.04), на MacOS не подключился дисплей из-за специфики libusb и возникли странные задержки в midi интерфейсе. Не смотря на это, VCV Rack оставил очень хорошее впечатление и как фреймворк и как modular DAW, я надеюсь что VCV будет развиваться и дальше, а эта статья поможет ещё кому-то написать для него свои модули.

Автор: ferluht

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js