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

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

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


Звук интересен тогда, когда в нем происходят какие-то изменения. Давайте сделаем генератор огибающей (envelope), которая будет менять громкость звука.

Огибающие

Основные принципы

Если вам незнакома аббревиатура ADSR (Attack, Decay, Sustain, Release), прочитайте эту статейку на Википедии прежде чем продолжить.
По сути, генератор будет представлять собой конечный автомат с состояниями Off, Attack, Decay, Sustain и Release. Это такой заумный способ сказать, что в любой момент времени генератор будет находиться только в одном из этих пяти возможных состояний. В терминологии огибающих эти состояния называются стадиями (stages). Переход от одной стадии к другой будет осуществляться вызовом функции члена enterStage.
Пара ключевых моментов, касающихся стадий:

  • Выход их стадий ATTACK, DECAY и RELEASE будет осуществляться генератором самостоятельно: после заданного параметрами времени он будет вызывать enterStage для перехода
  • В состояниях OFF и SUSTAIN он может оставаться неограниченно долго, пока не будет осуществлен вызов enterStage извне
  • ATTACK, DECAY и RELEASE, как вы уже поняли, это переменные времени, в то время как SUSTAIN это переменная уровня сигнала
  • Генератор может перейти в стадию RELEASE из ATTACK, DECAY и SUSTAIN
  • Переходя в стадию RELEASE, амплитуда сигнала начинает затухать с текущего уровня до нуля

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

Вообще ADSR – это первое приближение к более продвинутому моделированию звука. На многих современных софтовых и железных синтезаторах можно найти огибающие с большим количеством стадий. Все они, по сути, являются просто расширением той модели, которую мы сейчас создадим. Количество состояний таких конечных автоматов просто немного больше пяти.

Класс EnvelopeGenerator

Создайте новый класс EnvelopeGenerator и добавьте во все таргеты (на Mac) и проекты (на Windows). Между #define и #endif в EnvelopeGenerator.h вставьте объявление класса:

#include <cmath>

class EnvelopeGenerator {
public:
    enum EnvelopeStage {
        ENVELOPE_STAGE_OFF = 0,
        ENVELOPE_STAGE_ATTACK,
        ENVELOPE_STAGE_DECAY,
        ENVELOPE_STAGE_SUSTAIN,
        ENVELOPE_STAGE_RELEASE,
        kNumEnvelopeStages
    };
    void enterStage(EnvelopeStage newStage);
    double nextSample();
    void setSampleRate(double newSampleRate);
    inline EnvelopeStage getCurrentStage() const { return currentStage; };
    const double minimumLevel;

    EnvelopeGenerator() :
    minimumLevel(0.0001),
    currentStage(ENVELOPE_STAGE_OFF),
    currentLevel(minimumLevel),
    multiplier(1.0),
    sampleRate(44100.0),
    currentSampleIndex(0),
    nextStageSampleIndex(0) {
        stageValue[ENVELOPE_STAGE_OFF] = 0.0;
        stageValue[ENVELOPE_STAGE_ATTACK] = 0.01;
        stageValue[ENVELOPE_STAGE_DECAY] = 0.5;
        stageValue[ENVELOPE_STAGE_SUSTAIN] = 0.1;
        stageValue[ENVELOPE_STAGE_RELEASE] = 1.0;
    };
private:
    EnvelopeStage currentStage;
    double currentLevel;
    double multiplier;
    double sampleRate;
    double stageValue[kNumEnvelopeStages];
    void calculateMultiplier(double startLevel, double endLevel, unsigned long long lengthInSamples);
    unsigned long long currentSampleIndex;
    unsigned long long nextStageSampleIndex;
};

Для начала мы создаем enum со всеми стадиями. kNumEnvelopeStages в конце говорит нам, сколько всего стадий имеется. Обратите внимание, что этот enum находится в границах пространства имен класса огибающей, и не будет доступен извне, в глобальном пространстве имен.
Мы рассмотрим функции члены подробнее, когда будем их имплементировать. Минимальный уровень minimumLevel необходим, т. к. вычисления уровня сигнала не будут работать с нулем. Мы инициализируем эту переменную с очень маленьким значением 0.001.
В списке инициализации огибающая по умолчанию находится на стадии OFF. Массив stageValue тоже инициализируется с предустановленными значениями: сотая секунды атаки, полсекунды спада, тихий уровень и секунда на полное затухание.
В секции private currentStage обозначает, в какой стадии генератор сейчас находится. сurrentLevel это значение громкости огибающей в данный семпл времени. multiplyer обеспечивает экспоненциальное затухание, как будет видно дальше.
На стадиях ATTACK, DECAY и RELEASE генератор должен отслеживать свое положение во времени, чтобы в нужный момент перейти в следующую стадию. Для этого будем использовать переменную currentSampleIndex. В EnvelopeGenerator.cpp добавьте вот такую функцию:

double EnvelopeGenerator::nextSample() {
    if (currentStage != ENVELOPE_STAGE_OFF &&
        currentStage != ENVELOPE_STAGE_SUSTAIN) {
        if (currentSampleIndex == nextStageSampleIndex) {
            EnvelopeStage newStage = static_cast<EnvelopeStage>(
                (currentStage + 1) % kNumEnvelopeStages
            );
            enterStage(newStage);
        }
        currentLevel *= multiplier;
        currentSampleIndex++;
    }
    return currentLevel;
}

Если генератор на стадиях ATTACK, DECAY или RELEASE и currentSampleIndex достигает значения nextStageSampleIndex, мы переходим к следующему элементу в enum EnvelopeStage. Благодаря делению с остатком (%) после ENVELOPE_STAGE_RELEASE генератор сразу перейдет в ENVELOPE_STAGE_OFF, что нам и нужно. Этот переход осуществляется вызовом enterStage.
Затем мы вычисляем новое значение уровня currentLevel и обновляем currentSampleIndex, чтобы следить за положением генератора во времени. Эти вычисления не выполняются на стадиях OFF и SUSTAIN, т. к. на них уровень не должен меняться. То же самое и с currentSampleIndex: эти две стадии не ограничены по времени.

Переходы во времени

В ATTACK, DECAY и RELEASE генератор переходит между двумя значениями за заданное время. Человеческое ухо воспринимает громкость в логарифмическом масштабе. Следовательно, чтобы изменение громкости воспринималось на слух линейным, оно должно происходить экспоненциально.
Есть разные способы построить экспоненциальную кривую между двумя точками. Первое, что приходит на ум — каждый семпл вызывать относительно тяжелую функцию exp из библиотеки <cmath>. Однако, можно поступить умнее: имея два значения уровня и значение времени, можно вычислить некоторый множитель, на который мы будем умножать текущее значение огибающей.
Вот как выглядит такая функция (она основана на быстром алгоритме экспоненциальной огибающей Кристиана Шонебека):

void EnvelopeGenerator::calculateMultiplier(double startLevel,
                                            double endLevel,
                                            unsigned long long lengthInSamples) {
    multiplier = 1.0 + (log(endLevel) - log(startLevel)) / (lengthInSamples);
}

На данный момент не обязательно вникать в детали, если что. Главное понимать, что функция принимает уровни сигнала startLevel и endLevel и время перехода lengthInSamples для вычисления значения multiplier, которое будет немного больше или меньше единицы. Это значение мы перемножаем с currentLevel. Функция log() вычисляет натуральный логарифм.

Изменения стадий огибающей

Пора приступить к enterStage:

void EnvelopeGenerator::enterStage(EnvelopeStage newStage) {
    currentStage = newStage;
    currentSampleIndex = 0;
    if (currentStage == ENVELOPE_STAGE_OFF ||
        currentStage == ENVELOPE_STAGE_SUSTAIN) {
        nextStageSampleIndex = 0;
    } else {
        nextStageSampleIndex = stageValue[currentStage] * sampleRate;
    }
    switch (newStage) {
        case ENVELOPE_STAGE_OFF:
            currentLevel = 0.0;
            multiplier = 1.0;
            break;
        case ENVELOPE_STAGE_ATTACK:
            currentLevel = minimumLevel;
            calculateMultiplier(currentLevel,
                                1.0,
                                nextStageSampleIndex);
            break;
        case ENVELOPE_STAGE_DECAY:
            currentLevel = 1.0;
            calculateMultiplier(currentLevel,
                                fmax(stageValue[ENVELOPE_STAGE_SUSTAIN], minimumLevel),
                                nextStageSampleIndex);
            break;
        case ENVELOPE_STAGE_SUSTAIN:
            currentLevel = stageValue[ENVELOPE_STAGE_SUSTAIN];
            multiplier = 1.0;
            break;
        case ENVELOPE_STAGE_RELEASE:
            // We could go from ATTACK/DECAY to RELEASE,
            // so we're not changing currentLevel here.
            calculateMultiplier(currentLevel,
                                minimumLevel,
                                nextStageSampleIndex);
            break;
        default:
            break;
    }
}

После обновления значения currentStage мы обнуляем счетчик времени currentSampleIndex. Затем мы вычисляем продолжительность новой стадии в семплах. Как мы знаем, вычисления параметров нужны только на стадиях атаки, спада и затухания. stageValue[currentStage] имеет тип double (длительность стадии в секундах), соответственно, это значение надо умножить на частоту семплирования, чтобы получить длительность в семплах. Switch разветвляет алгоритм на несколько вариантов. На OFF мы устанавливаем уровень равным нулю и множитель единице (последнее не обязательно, но так выглядит логичнее). ATTACK мы начинаем с minimumLevel, вычисляем multiplier для наращивания currentLevel до 1.0. На стадии DECAY уровень падает с текущего значения до stageValue[ENVELOPE_STAGE_SUSTAIN], но fmax гарантирует, что уровень не упадет до нуля. RELEASE опускает currentLevel, каким бы он ни был, до minimumLevel. Как написано в комменте, на входе в RELEASE мы не меняем currentLevel, т. к. в эту стадию генератор может перейти не только из SUSTAIN, но и из ATTACK или DECAY. Как мы уже поняли, SUSTAIN отличается от остальных стадий тем, что stageValue[ENVELOPE_STAGE_SUSTAIN] содержит не длительность стадии, а уровень сигнала. Так что мы просто задаем этот уровень равным currentLevel.

Первые испытания

Давайте напишем простую функцию для вычисления частоты семплирования:

void EnvelopeGenerator::setSampleRate(double newSampleRate) {
    sampleRate = newSampleRate;
}

В секцию private класса Synthesis (в файле заголовка) добавим новый член:

EnvelopeGenerator mEnvelopeGenerator;

Не забудьте #include "EnvelopeGenerator.h" перед объявлением класса.
Теперь, в функции ProcessDoubleReplacing Synthesis.cpp замените вычисление leftOutput[i] следующим кодом:

// leftOutput[i] = rightOutput[i] = mOscillator.nextSample() * velocity / 127.0;
if (mEnvelopeGenerator.getCurrentStage() == EnvelopeGenerator::ENVELOPE_STAGE_OFF) {
    mEnvelopeGenerator.enterStage(EnvelopeGenerator::ENVELOPE_STAGE_ATTACK);
}
if (mEnvelopeGenerator.getCurrentStage() == EnvelopeGenerator::ENVELOPE_STAGE_SUSTAIN) {
    mEnvelopeGenerator.enterStage(EnvelopeGenerator::ENVELOPE_STAGE_RELEASE);
}
leftOutput[i] = rightOutput[i] = mOscillator.nextSample() * mEnvelopeGenerator.nextSample() * velocity / 127.0;

Это исключительно для тестирования. Два условных оператора запустят неограниченный по времени луп: Из OFF в ATTACK, из SUSTAIN в RELEASE. Каждый семпл осциллятора умножается на соответственное значение огибающей.
При установке частоты семплирования mEnvelopeGenerator должен быть в курсе происходящего. Добавьте в Synthesis::Reset():

mEnvelopeGenerator.setSampleRate(GetSampleRate());

И вновь мы готовы протестировать наш синтюк. Запускайте плагин и зажмите что-нибудь на виртуальной клавиатуре — генератор будет бегать по кругу через все стадии! Отпад.

Запуск генератора посредством Note On/Off

Зацикленные стадии это интересно, может быть это даже пригодится позже (например, при создании степпера — более сложной огибающей, состоящей из одинаковых частей), но пока что мы хотим придерживаться классического подхода: нажимаем клавишу — генератор входит в ATTACK, отпускаем — в RELEASE. mMIDIReceiver умеет обращаться с сообщениями note on/off, а значит, надо как-то подключить его к генератору.

Самым простым способом сделать это было бы добавить #include EnvelopeGenerator.h в MIDIReceiver.h. Затем, из Synthesis.h можно было бы передать ссылку на объект класса генератор mEnvelopeGenerator объекту mMIDIReceiver, чтобы он мог достучаться до генератора. Потом MIDIReceiver вызывал бы enterStage при получении note on/off.
Идея плохая, т. к. MIDIReceiver становится зависимым от EnvelopeGenerator. Мы же стремимся к разделению элементов дизайна, ведь возможно мы захотим использовать MIDIReceiver в ином контексте, где будет отсутствовать EnvelopeGenerator.

Лучше использовать сигналы и слоты. Этот подход унаследован из фреймворка Qt. Его можно использовать чтобы, например, подключить друг к другу кнопку и текстовое поле без того, чтобы они знали друг о друге. Когда на кнопку жмут, она посылает сигнал. Этот сигнал может приниматься слотом текстового поля, например setText(), чтобы при нажатии на кнопку менялся текст поля. Ключевой момент в том, что кнопке не важно, подключены ли к ней текстовые поля, и сколько их. Все, кто подключен, получают сигнал. Мы можем использовать этот подход для компонентов нашего плагина (Oscillator, EnvelopeGenerator, MIDIReceiver). Соединения будут осуществляться вне компонентов, т. е. в классе Synthesis.

Но мы не будем использовать целый Qt для одной этой функциональности. Вместо этого воспользуемся библиотекой Signals Патрика Хогана. Скачайте и разархивируйте. Переименуйте Signal.h в GallantSignal.h, чтобы избежать конфликта имен. Перетягивайте Delegate.h и GallantSignal.h в проект (только так, чтобы они скопировались, используя “Copy items into destination group's folder”) и добавьте их во все таргеты в Xcode. В VisualStudio нужно скопировать эти два файла в папку проекта, и из нее перетянуть во все проекты в Solution Explorer.

Мы хотим, чтобы MIDIReceiver посылал сигнал, когда жмут на клавишу и когда ее отпускают. Перед определением класса в MIDIReceiver.h добавьте следующее:

#include "GallantSignal.h"
using Gallant::Signal2;

Signal2 это сигнал, передающий два параметра. Есть несколько — от Signal0 до Signal8, так что можно выбрать подходящий в зависимости от задачи.
В секцию public допишите:

Signal2< int, int > noteOn;
Signal2< int, int > noteOff;

Как видите, оба сигнала будут передавать две переменной типа int. Теперь в MIDIReceiver.cpp надо отредактировать функцию advance:

// A key pressed later overrides any previously pressed key:
if (noteNumber != mLastNoteNumber) {
    mLastNoteNumber = noteNumber;
    mLastFrequency = noteNumberToFrequency(mLastNoteNumber);
    mLastVelocity = velocity;
    // Emit a "note on" signal:
    noteOn(noteNumber, velocity);
}
// If the last note was released, nothing should play:
if (noteNumber == mLastNoteNumber) {
    mLastNoteNumber = -1;
    noteOff(noteNumber, mLastVelocity);
}

Первый аргумент — номер ноты, второй — громкость. Теперь мы не устанавливаем mLastFrequency равным -1, когда отпускатся клавиша: значение частоты пригодится на стадии RELEASE. То же с mLastVelocity: если ее обнулить, громкость упадет мнговенно.
Обратите внимание, что код гладко работает, не смотря на то, что мы еще не соединяли слоты и сигналы. Это наглядная демонстрация практичности и элегантности такого подхода. Компоненты независимы друг от друга.

Теперь надо подключить mEnvelopeGenerator к двум сигналам. Можно было бы добавить функции члены onNoteOn и onNoteOff в класс EnvelopeGenerator и подключить именно их. Это вариант, но тогда мы сваливаем в кучу EnvelopeGenerator и концепт ноты. На мой взгляд, генератору огибающих не нужно знать о нотах. Также мы не можем подключить сигналы к enterStage из-за нестыковки в аргументах. Давайте лучше добавим функции члены в private секцию класса Synthesis:

inline void onNoteOn(const int noteNumber, const int velocity) 
    { mEnvelopeGenerator.enterStage(EnvelopeGenerator::ENVELOPE_STAGE_ATTACK); };
inline void onNoteOff(const int noteNumber, const int velocity) 
    { mEnvelopeGenerator.enterStage(EnvelopeGenerator::ENVELOPE_STAGE_RELEASE); };

Передаваемые аргументы соответствуют именам функций.
Чтобы подключить сигналы, допишите в конструктор Synthesis.cpp пару строк:

mMIDIReceiver.noteOn.Connect(this, &Synthesis::onNoteOn);
mMIDIReceiver.noteOff.Connect(this, &Synthesis::onNoteOff);

Первый аргумент — это указатель на объект класса, второй — на функцию член.
Теперь можно удалить зацикленность генератора в ProcessDoubleReplacing. Удалите два условных оператора if, которые мы добавили чуть раньше, но оставьте строку с генерацией семплов.

Готово!

Запускайте. Теперь каждый раз при нажатии новой клавиши огибающая перезапускается и удерживает громкость сигнала пока клавиша держится нажатой. Если отпустить клавишу на стадии DECAY, то генератор должен перейти сразу в RELEASE, плавно заглушая сигнал с текущего значения до нуля. Поиграйтесь с разными stageValues и послушайте звук.
Было бы удобно, если эти параметры можно было менять ручками интерфейса, прямо в процессе игры. Для этого нам нужно допилить GUI до чего-то подобного:

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

Этим и займемся в следующий раз!

Код проекта на данном этапе можно скачать тут.

Автор: 1eqinfinity

Источник

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


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