Создание аудиоплагинов, часть 7

в 20:19, , рубрики: c++, dsp, Martin Finke, Reaper, Visual Studio, VST, xcode, перевод, Работа со звуком, метки: , , , , , , , ,

Все посты серии:
Часть 1. Введение и настройка
Часть 2. Изучение кода
Часть 3. VST и AU
Часть 4. Цифровой дисторшн
Часть 5. Пресеты и GUI
Часть 6. Синтез сигналов
Часть 7. Получение MIDI сообщений


Пока что мы генерировали только постоянную звуковую волну, которая просто звучала на заданной частоте. Давайте посмотрим, как можно реагировать на MIDI сообщения, включать и выключать генерацию волны на нужной частоте в зависимости от получаемой ноты.

Получение MIDI сообщений

Основы обработки MIDI

Когда плагин загружен в хост, он получает все MIDI сообщения с того трека, к которому он подцеплен. Когда начинается и заканчивается воспроизведение ноты, в плагине вызывается функция ProcessMidiMsg. Помимо ноты в MIDI сообщениях может передаваться информация о портаменто (Pitch Bend) и управляющие команды (Control Changes, сокращенно CC), которые могут использоваться для автоматизации параметров плагина. Функции ProcessMidiMsg передается сообщение IMidiMsg, которое описывает MIDI событие в своем, независимом от формата виде. В этом описании присутствуют параметры NoteNumber и Velocity, которые содержат информацию о высоте громкости звука нашего осциллятора.

Каждый раз, когда приходит MIDI сообщение, система уже воспроизводит аудиобуфер, заполненный ранее. Не существует способа впихнуть новое аудио точно в момент получения MIDI сообщения. Эти события надо запомнить до следующего вызова функции ProcessDoubleReplacing. Также необходимо запомнить время получения сообщения, так что эту информацию мы оставим нетронутой для следующего заполнения буфера.

Инструментом для выполнения этих задач послужит IMidiQueue.

Получатель MIDI

Будем использовать наш проект Synthesis. Если вы используете систему контроля версий, самое время закомитить проект. Создайте новый класс MIDIReceiver и проверьте, чтобы .cpp компилировался в каждом таргете. В MIDIReceiver.h вставьте интерфейс между #define и #endif:

#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Wextra-tokens"
#include "IPlug_include_in_plug_hdr.h"
#pragma clang diagnostic pop

#include "IMidiQueue.h"

class MIDIReceiver {
private:
    IMidiQueue mMidiQueue;
    static const int keyCount = 128;
    int mNumKeys; // how many keys are being played at the moment (via midi)
    bool mKeyStatus[keyCount]; // array of on/off for each key (index is note number)
    int mLastNoteNumber;
    double mLastFrequency;
    int mLastVelocity;
    int mOffset;
    inline double noteNumberToFrequency(int noteNumber) { return 440.0 * pow(2.0, (noteNumber - 69.0) / 12.0); }

public:
    MIDIReceiver() :
    mNumKeys(0),
    mLastNoteNumber(-1),
    mLastFrequency(-1.0),
    mLastVelocity(0),
    mOffset(0) {
        for (int i = 0; i < keyCount; i++) {
            mKeyStatus[i] = false;
        }
    };

    // Returns true if the key with a given index is currently pressed
    inline bool getKeyStatus(int keyIndex) const { return mKeyStatus[keyIndex]; }
    // Returns the number of keys currently pressed
    inline int getNumKeys() const { return mNumKeys; }
    // Returns the last pressed note number
    inline int getLastNoteNumber() const { return mLastNoteNumber; }
    inline double getLastFrequency() const { return mLastFrequency; }
    inline int getLastVelocity() const { return mLastVelocity; }
    void advance();
    void onMessageReceived(IMidiMsg* midiMessage);
    inline void Flush(int nFrames) { mMidiQueue.Flush(nFrames); mOffset = 0; }
    inline void Resize(int blockSize) { mMidiQueue.Resize(blockSize); }
};

Здесь нам нужно включить IPlug_include_in_plug_hdr.h, потому что иначе IMidiQueue.h будет создавать ошибки.
Как видите, у нас есть private объект IMidiQueue для хранения очереди MIDI сообщений. Еще мы храним информацию о том, какие ноты сейчас играются и сколько их всего играется. Три параметра mLast... нужны, т. к. наш плагин будет монофоническим: каждая следующая нота будет заглушать предыдущие (т. н. приоритет последней ноты). Функция noteNumberToFrequency конвертирует номер MIDI ноты в частоту в герцах. Мы используем ее, потому что класс Oscillator работает с частотой, а не с номером ноты.
Секция public содержит ряд встроенных (inline) геттеров и передает Flush и Resize в очередь mMidiQueue.
В теле Flush мы устанавливаем mOffset равным нулю. Вызов mMidiQueue.Flush(nFrames) означает, что мы убираем из начала очереди ее часть размером nFrames, так как мы уже обработали события этой части в предыдущем вызове функции advance. Обнуление mOffset гарантирует то, что в следующий раз в процессе выполнения advance мы тоже будем обрабатывать начало очереди. Слова const, стоящие после скобок, означают, что функция не будет изменять неизменяемые члены своего класса.

Давайте добавим имплементацию onMessageReceived в MIDIReceiver.cpp:

void MIDIReceiver::onMessageReceived(IMidiMsg* midiMessage) {
    IMidiMsg::EStatusMsg status = midiMessage->StatusMsg();
    // We're only interested in Note On/Off messages (not CC, pitch, etc.)
    if(status == IMidiMsg::kNoteOn || status == IMidiMsg::kNoteOff) {
        mMidiQueue.Add(midiMessage);
    }
}

Эта функция вызывается каждый раз при получении MIDI сообщения. Нас на данный момент интересуют только сообщения note on и note off (начать/перестать играть ноту), и мы добавляем их в очередь mMidiQueue.
Следующая интересная функция — advance:

void MIDIReceiver::advance() {
    while (!mMidiQueue.Empty()) {
        IMidiMsg* midiMessage = mMidiQueue.Peek();
        if (midiMessage->mOffset > mOffset) break;

        IMidiMsg::EStatusMsg status = midiMessage->StatusMsg();
        int noteNumber = midiMessage->NoteNumber();
        int velocity = midiMessage->Velocity();
        // There are only note on/off messages in the queue, see ::OnMessageReceived
        if (status == IMidiMsg::kNoteOn && velocity) {
            if(mKeyStatus[noteNumber] == false) {
                mKeyStatus[noteNumber] = true;
                mNumKeys += 1;
            }
            // A key pressed later overrides any previously pressed key:
            if (noteNumber != mLastNoteNumber) {
                mLastNoteNumber = noteNumber;
                mLastFrequency = noteNumberToFrequency(mLastNoteNumber);
                mLastVelocity = velocity;
            }
        } else {
            if(mKeyStatus[noteNumber] == true) {
                mKeyStatus[noteNumber] = false;
                mNumKeys -= 1;
            }
            // If the last note was released, nothing should play:
            if (noteNumber == mLastNoteNumber) {
                mLastNoteNumber = -1;
                mLastFrequency = -1;
                mLastVelocity = 0;
            }
        }
        mMidiQueue.Remove();
    }
    mOffset++;
}

Эта функция вызывается каждый семпл, пока заполняется аудиобуфер. Пока в очереди есть сообщения, мы обрабатываем их и удаляем из начала (используя Peek и Remove). Но мы делаем это только для тех MIDI сообщений, чье смещение (mOffset) не больше, чем смещение буфера. Это означает, что мы обрабатываем каждое сообщение в соответствующем семпле, оставляя относительные сдвиги по времени нетронутыми.
После считывания значений noteNumber и Velocity условный оператор if разделяет сообщения note on и note off (отсутствие значения velocity интерпретируется как note off). В обоих случаях мы отслеживаем, какие ноты играются и сколько их всего в данный момент. Также обновляются значения mLast... для выполнения приоритета последней ноты. Далее, логично, что именно здесь должна обновляться частота звука, что мы и делаем. В самом конце mOffset обновляется, чтобы получатель знал, насколько далеко в буфере это сообщение находится в данный момент. Сообщить это получателю можно и другим способом — передав смещение как аргумент.
Итак, у нас есть класс, который получает все входящие MIDI сообщения note on/off. Он отслеживает, какие ноты сейчас играются, какая последняя нота и какая у нее частота. Давайте его используем.

Использование получателя MIDI

Для начала аккуратно внесите эти изменения в resource.h:

// #define PLUG_CHANNEL_IO "1-1 2-2"
#if (defined(AAX_API) || defined(RTAS_API)) 
#define PLUG_CHANNEL_IO "1-1 2-2"
#else
// no audio input. mono or stereo output
#define PLUG_CHANNEL_IO "0-1 0-2"
#endif

// ...
#define PLUG_IS_INST 1

// ...
#define EFFECT_TYPE_VST3 "Instrument|Synth"

// ...
#define PLUG_DOES_MIDI 1

Эти строки сообщают хосту, что наш плагин «умеет миди». 0-1 и 0-2 обозначают, что у плагина нет аудиовхода и есть один выход, т.е. моно (0-1), либо у него нет аудиовходов и есть стереовыход (0-2).
Теперь добавьте #include "MIDIReceiver.h" после Oscillator.h в Synthesis.h. Там же, в секции public, добавьте объявление функции-члена:

// to receive MIDI messages:
void ProcessMidiMsg(IMidiMsg* pMsg);

Добавьте объект MIDIReceiver в секции private:

private:
    // ...
    MIDIReceiver mMIDIReceiver;

В Synthesis.cpp напишите такую простую функцию:

void Synthesis::ProcessMidiMsg(IMidiMsg* pMsg) {
    mMIDIReceiver.onMessageReceived(pMsg);
}

Она вызывается каждый раз при получении MIDI сообщения, и мы передаем сообщение нашему получателю.
Давайте теперь немного наведем порядок. Отредактируйте оба enums наверху:

enum EParams
{
    kNumParams
};

enum ELayout
{
    kWidth = GUI_WIDTH,
    kHeight = GUI_HEIGHT
};

И создайте только один пресет по умолчанию:

void Synthesis::CreatePresets() {
    MakeDefaultPreset((char *) "-", kNumPrograms);
}

При изменении параметров плагина ничего делать не надо:

void Synthesis::OnParamChange(int paramIdx)
{
    IMutexLock lock(this);
}

Ручка в интерфейсе нам уже не пригодится. Давайте сократим конструктор до минимально необходимого:

Synthesis::Synthesis(IPlugInstanceInfo instanceInfo)
    :   IPLUG_CTOR(kNumParams, kNumPrograms, instanceInfo) {
    TRACE;

    IGraphics* pGraphics = MakeGraphics(this, kWidth, kHeight);
    pGraphics->AttachPanelBackground(&COLOR_RED);
    AttachGraphics(pGraphics);
    CreatePresets();
}

Когда меняются настройки звука в системе, нам надо сообщить осциллятору новую частоту семплирования:

void Synthesis::Reset()
{
    TRACE;
    IMutexLock lock(this);
    mOscillator.setSampleRate(GetSampleRate());
}

У нас еще осталась функция ProcessDoubleReplacing. Задумайтесь: mMIDIReceiver.advance() нужно выполнять каждый семпл. После этого мы узнаем частоту и громкость при помощи getLastVelocity и getLastFrequency у получателя MIDI. Затем вызываем mOscillator.setFrequency() и mOscillator.generate() для заполнения аудиобуфера звуком нужной частоты.
Функция generate создавалась для обработки целого буфера; получатель MIDI же работает на уровне отдельного семпла: сообщения могут иметь любое смещение в пределах буфера, а значит mLastFrequency может измениться на любом семпле. Придется доработать класс Oscillator, чтобы он тоже работал на уровне семплов.

Сначала вынесем twoPI из generate и переместим в private секцию Oscillator.h. Пока мы тут, давайте сразу добавим переменную липа bool для обозначения того, заглушен ли осциллятор (т.е. ни одна нота не играется):

const double twoPI;
bool isMuted;

Инициализируем их путем добавления в список инициализации конструктора. Так это выглядит:

Oscillator() :
    mOscillatorMode(OSCILLATOR_MODE_SINE),
    mPI(2*acos(0.0)),
    twoPI(2 * mPI), // This line is new
    isMuted(true),  // And this line
    mFrequency(440.0),
    mPhase(0.0),
    mSampleRate(44100.0) { updateIncrement(); };

Добавим сеттер inline в секцию public:

inline void setMuted(bool muted) { isMuted = muted; }

И сразу под этой вставьте следующую строку:

double nextSample();

Будем вызывать эту функцию каждый семпл и получать от осцилятора аудиоданные.
Добавьте следующий код в Oscilator.cpp:

double Oscillator::nextSample() {
    double value = 0.0;
    if(isMuted) return value;

    switch (mOscillatorMode) {
        case OSCILLATOR_MODE_SINE:
            value = sin(mPhase);
            break;
        case OSCILLATOR_MODE_SAW:
            value = 1.0 - (2.0 * mPhase / twoPI);
            break;
        case OSCILLATOR_MODE_SQUARE:
            if (mPhase <= mPI) {
                value = 1.0;
            } else {
                value = -1.0;
            }
            break;
        case OSCILLATOR_MODE_TRIANGLE:
            value = -1.0 + (2.0 * mPhase / twoPI);
            value = 2.0 * (fabs(value) - 0.5);
            break;
    }
    mPhase += mPhaseIncrement;
    while (mPhase >= twoPI) {
        mPhase -= twoPI;
    }
    return value;
}

Как видим, тут используется twoPI. Было бы избыточным вычислять это значение каждый семпл, поэтому мы и добавили два пи как константу в класс.
Когда осциллятор ничего не генерирует, мы возвращаем ноль. Конструкция switch вам уже знакома, хоть тут мы и не используем цикл for. Здесь мы просто генерируем одно значение для буфера, вместо того, чтобы заполнять его целиком. Также подобная структура позволяет нам вынести в конец приращение фазы, избегая повторений.
Это был неплохой пример рефакторинга, причиной которого послужила недостаточная гибкость кода. Конечно, мы могли бы подумать часок-другой прежде чем начинать писать функцию generate с «буферным» подходом. Но эта реализация заняла у нас меньше часа целиком. В простых приложениях (типа этого) иногда эффективней реализовать какой-нибудь подход и посмотреть, как код справляется с задачей на практике. Чаще всего, как мы только что увидели, оказывается, что в целом идея была правильной (принцип вычисления разных звуковых волн), но какой-то аспект проблемы был упущен. С другой стороны, если вы разрабатываете публичный API, то менять что-то позже, мягко говоря, неудобно, так что тут надо все продумать заранее. В общем, это зависит от ситуации.

Функция setFrequency тоже будет вызываться каждый семпл. Значит, updateIncrement тоже будет вызываться очень часто. Но она пока что не оптимизирована:

void Oscillator::updateIncrement() {
    mPhaseIncrement = mFrequency * 2 * mPI / mSampleRate;
}

2 * mPI * mSampleRate меняется только при изменении частоты дискретизации. Так что результат этого вычисления лучше запомнить и пересчитывать его только внутри Oscillator::setSampleRate. Стоить помнить, что запредельная оптимизация может сделать код нечитабельным и даже уродливым. В конкретном случае у нас не возникнет проблем с производительностью, т. к. мы пишем элементарный монофонический синт. Когда доберемся до полифонии, это будет другое дело, и тогда уже обязательно оптимизируем.
Теперь мы можем переписать ProcessDoubleReplacing в Synthesis.cpp:

void Synthesis::ProcessDoubleReplacing(
    double** inputs,
    double** outputs,
    int nFrames)
{
    // Mutex is already locked for us.

    double *leftOutput = outputs[0];
    double *rightOutput = outputs[1];

    for (int i = 0; i < nFrames; ++i) {
        mMIDIReceiver.advance();
        int velocity = mMIDIReceiver.getLastVelocity();
        if (velocity > 0) {
            mOscillator.setFrequency(mMIDIReceiver.getLastFrequency());
            mOscillator.setMuted(false);
        } else {
            mOscillator.setMuted(true);
        }
        leftOutput[i] = rightOutput[i] = mOscillator.nextSample() * velocity / 127.0;
    }

    mMIDIReceiver.Flush(nFrames);
}

В цикле for получатель MIDI cначала обновляет значения (вызывается advance). Если звучит нота (velocity > 0), мы обновляем частоту осциллятора и даем ему звучать. В противном случае мы заглушаем его (тогда nextSample будет возвращать нули).
Далее все сводится просто к вызову nextSample для получения значения, изменения громкости (velocity это целое число между 0 и 127) и записи результата в выходные буферы. В конце вызывается Flush для удаления начала очереди.

Испытания

Запускайте VST или AU. Если AU не появляется в хосте, то, возможно, придется изменить PLUG_UNIQUE_ID в resource.h. Если у двух плагинов одинаковый ID, хост будет игнорировать все, кроме какого-то одного.
Плагину надо подать на вход какие-нибудь MIDI данные. Проще всего использовать виртуальную клавиатуру REAPERа (меню View → Virtual MIDI Keyboard). На треке с плагином слева есть круглая красная кнопка. Зайдите в конфигурацию MIDI правым кликом по ней и выберите получение сообщений от виртуальной клавиатуры:

Создание аудиоплагинов, часть 7

В том же меню включите Monitor Input. Теперь, когда фокус на окне виртуальной клавиатуры, можно играть на синтезаторе обычной клавиатурой. Наберите ваше имя или пароль от менеджера пароля и послушайте, как это звучит.
Если же у вас есть MIDI клавиатура, то, подключив ее, можно потестировать и самостоятельное приложение. Главное выбрать правильный MIDI вход. Если не слышно никакого звука, попробуйте удалить ~/Library/Application Support/Synthesis/settings.ini.

Весь проект на данной стадии можно скачать отсюда.

В следующий раз мы добавим симпатичную клавиатуру в интерфейс :)

Автор: 1eqinfinity

Источник

Поделиться

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