Pull to refresh

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

Reading time11 min
Views5.8K
Все посты серии:
Часть 1. Введение и настройка
Часть 2. Изучение кода
Часть 3. VST и AU
Часть 4. Цифровой дисторшн
Часть 5. Пресеты и GUI
Часть 6. Синтез сигналов
Часть 7. Получение MIDI сообщений
Часть 8. Виртуальная клавиатура
Часть 9. Огибающие
Часть 10. Доработка GUI
Часть 11. Фильтр
Часть 12. Низкочастотный осциллятор
Часть 13. Редизайн
Часть 14. Полифония 1
Часть 15. Полифония 2
Часть 16. Антиалиасинг



Приступим к созданию полифонического синтезатора из тех компонентов, которые у нас имеются!

В прошлый раз мы работали над параметрами и пользовательским интерфейсом, сегодня мы начнем работу над лежащей в основе плагина полифонической обработкой аудио. В нашем случае мы сможем играть до 64-х нот одновременно. Это требует основательных изменений в структуре плагина, но мы сможем использовать уже написанные нами классы Oscillator, EnvelopeGenerator, MIDIReceiver и Filter.

В этом посте мы напишем класс Voice (голос), представляющий одну звучащую ноту. Затем создадим класс VoiceManager, следящий за тем, чтобы все ноты звучали и заглушались вовремя.
В следующем посте мы почистим код от ненужных устаревших частей, добавим модуляцию тона и приведем элементы управления интерфейса в рабочее состояние. На первый взгляд, работы полно. Но во-первых, у нас уже есть практически все нужные компоненты, а во-вторых, в конце у нас будет настоящий-полифонический-субстрактивный-блин-синтезатор!



Что куда?



Задумаемся на минутку над тем, какие части архитектуры плагина глобальны, а какие существуют обособленно для каждой отдельной ноты. Представьте, вот вы играете несколько нот на клавишах. При каждом нажатии на клавишу появляется тон, который затухает и, возможно, тембр которого меняется фильтром по некоторой огибающей. Когда нажимаете вторую клавишу, первая все еще звучит, и появляется второй тон со своими огибающими амплитуды и фильтра. Второе нажатие никак не влияет на первый тон, он звучит и меняется сам по себе. Так что каждый голос независим и имеет свои огибающие амплитуды и фильтра.
LFO является глобальным и единственным, он просто работает и не перезапускается при нажатии на клавиши.
Что касается фильтра, понятно, что частота среза и резонанс глобальны, потому что все голоса смотрят на одни и те же ручки среза и резонанса в GUI. Но частота среза фильтра модулируется огибающей, так что в каждый момент времени вычисленная частота среза для каждого голоса разная. Взгляните на Filter::cutoff — в ней вызывается getCalculatedCutoff. Так что для каждого голоса нужен свой фильтр.
Можем ли мы обойтись двумя осцилляторами для всех голосов? Каждый Voice играет свою ноту, т.е. у него своя частота, а значит, и свой независимый Oscillator.

Вкратце, структура такая:

  • В плагине есть один MIDIReceiver и один VoiceManager
  • У VoiceManager есть один LFO и много голосов Voice
  • У Voice есть два Oscillator, два геренатора огибающих EnvelopeGenerators (для амплитуды и фильтра) и один Filter


Класс Voice



Как обычно, создайте новый класс, назовите его Voice. И, как обычно, не забудьте добавить его во все таргеты XCode и все проекты VS. В Voice.h добавьте:

#include "Oscillator.h"
#include "EnvelopeGenerator.h"
#include "Filter.h"


В теле класса начнем с секции private:

private:
    Oscillator mOscillatorOne;
    Oscillator mOscillatorTwo;
    EnvelopeGenerator mVolumeEnvelope;
    EnvelopeGenerator mFilterEnvelope;
    Filter mFilter;


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

    int mNoteNumber;
    int mVelocity;


Каждая из следующих переменных задает величину модуляции параметров:

    double mFilterEnvelopeAmount;
    double mOscillatorMix;
    double mFilterLFOAmount;
    double mOscillatorOnePitchAmount;
    double mOscillatorTwoPitchAmount;
    double mLFOValue;


Все они, кроме mLFOValue, связаны со значениями ручек интерфейса. На самом деле эти величины одинаковы для всех голосов, но мы не будем делать их глобальными и закидывать в класс плагина. Каждому голосу нужен доступ к этим параметрам каждый семпл, а класс Voice даже не знает о существовании класса плагина (отсутствует #include "SpaceBass.h"). Настроить такой доступ было бы трудоемкой задачей.
И есть еще один параметр. Вы помните, мы добавили флаг isMuted в класс Oscillator? Переместим его в Voice, чтобы когда голос молчит, не вычислялись значения осциллятора, огибающих и фильтра:

    bool isActive;


Теперь перед private добавим public. Начнем с конструктора:

public:
    Voice()
    : mNoteNumber(-1),
    mVelocity(0),
    mFilterEnvelopeAmount(0.0),
    mFilterLFOAmount(0.0),
    mOscillatorOnePitchAmount(0.0),
    mOscillatorTwoPitchAmount(0.0),
    mOscillatorMix(0.5),
    mLFOValue(0.0),
    isActive(false) {
        // Set myself free everytime my volume envelope has fully faded out of RELEASE stage:
        mVolumeEnvelope.finishedEnvelopeCycle.Connect(this, &Voice::setFree);
    };


Эти строки инициализируют переменные с разумными значениями. По умолчанию Voice не активен. Также, используя сигналы и слоты EnvelopeGenerator, мы «освобождаем» голос как только огибающая амплитуды выходит из стадии release.
Добавим сеттеры в public:

 inline void setFilterEnvelopeAmount(double amount) { mFilterEnvelopeAmount = amount; }
    inline void setFilterLFOAmount(double amount) { mFilterLFOAmount = amount; }
    inline void setOscillatorOnePitchAmount(double amount) { mOscillatorOnePitchAmount = amount; }
    inline void setOscillatorTwoPitchAmount(double amount) { mOscillatorTwoPitchAmount = amount; }
    inline void setOscillatorMix(double mix) { mOscillatorMix = mix; }
    inline void setLFOValue(double value) { mLFOValue = value; }

    inline void setNoteNumber(int noteNumber) {
        mNoteNumber = noteNumber;
        double frequency = 440.0 * pow(2.0, (mNoteNumber - 69.0) / 12.0);
        mOscillatorOne.setFrequency(frequency);
        mOscillatorTwo.setFrequency(frequency);
    }


Единственный интересный момент здесь — это setNoteNumber. Она вычисляет частоту для данной ноты по уже известной нам формуле и передает ее обоим осцилляторам. После нее добавьте:

    double nextSample();
    void setFree();


Как Oscillator::nextSample дает нам выход Oscillator, так и Voice::nextSample выдает результирующее значение голоса после огибающей амплитуды и фильтра. Напишем имплементацию в Voice.cpp:

double Voice::nextSample() {
    if (!isActive) return 0.0;

    double oscillatorOneOutput = mOscillatorOne.nextSample();
    double oscillatorTwoOutput = mOscillatorTwo.nextSample();
    double oscillatorSum = ((1 - mOscillatorMix) * oscillatorOneOutput) + (mOscillatorMix * oscillatorTwoOutput);

    double volumeEnvelopeValue = mVolumeEnvelope.nextSample();
    double filterEnvelopeValue = mFilterEnvelope.nextSample();

    mFilter.setCutoffMod(filterEnvelopeValue * mFilterEnvelopeAmount + mLFOValue * mFilterLFOAmount);

    return mFilter.process(oscillatorSum * volumeEnvelopeValue * mVelocity / 127.0);
}


Первая сточка гарантирует, что когда голос неактивен ничего не вычисляется и возвращается ноль. Следующие три строки вычисляют nextSample для обоих осцилляторов и смешивают их в соответствии с mOscillatorMix. Когда mOscillatorMix равен нулю, слышен только oscillatorOneOutput. При 0.5 оба осциллятора имеют равную амплитуду.
Затем вычисляется следующий семпл обеих огибающих. Мы применяем filterEnvelopeValue к частоте среза фильтра и берем в расчет значение LFO. Общая модуляция среза это сумма огибающей фильтра и LFO.
Модуляция тона обоих осцилляторов это просто выход LFO помноженный на величину модуляции. Мы напишем это через минутку.
Интересна последняя строка. Сначала содержание скобок: берем сумму двух осцилляторов, применяем огибающую громкости и значение громкости ноты. Затем пропускаем результат через mFilter.process, в результате получаем отфильтрованный выход, который и возвращаем.

Имплементация setFree предельно простая:

void Voice::setFree() {
    isActive = false;
}


Как уже говорилось, вызов этой функции осуществляется каждый раз, когда mVolumeEnvelope полностью затухает.

VoiceManager



Пора написать класс для управления голосами. Создайте класс с именем VoiceManager. В хедере начните с этих строк:

#include "Voice.h"

class VoiceManager {
};


И продолжите private членами класса:

static const int NumberOfVoices = 64;
Voice voices[NumberOfVoices];
Oscillator mLFO;
Voice* findFreeVoice();


Константа NumberOfVoices обозначает максимальное количество одновременно звучащих голосов. В следующей строке создается массив из голосов. Эта структура использует место под 64 голоса, так что лучше задуматься о динамическом распределении памяти. Впрочем, класс плагина и так распределен динамически (поищите "new PLUG_CLASS_NAME" в Iplug_include_in_plug_src.h), так что все члены класса плагина тоже в куче.

mLFO — это глобальный LFO для плагина. Он никогда не перезапускается, просто независимо осциллирует. Можно поспорить, что он должен быть внутри класса плагина (VoiceManager не нужно знать об LFO). Но это внесет еще один слой разграничения между голосами Voice и LFO, а значит, нам понадобится больше склеивающего кода.
findFreeVoice это вспомогательная функция для поиска голосов, которые не звучат в данный момент. Добавьте ее имплементацию в VoiceManager.cpp:

Voice* VoiceManager::findFreeVoice() {
    Voice* freeVoice = NULL;
    for (int i = 0; i < NumberOfVoices; i++) {
        if (!voices[i].isActive) {
            freeVoice = &(voices[i]);
            break;
        }
    }
    return freeVoice;
}


Она просто итерирует над всеми голосами и находит первый молчащий. Мы возвращаем указатель (вместо ссылки &), потому что в таком случае, в отличие от ссылки, можно вернуть NULL. Это будет означать, что все голоса звучат.

Теперь добавим в public такие заголовки функций:

void onNoteOn(int noteNumber, int velocity);
void onNoteOff(int noteNumber, int velocity);
double nextSample();


Как понятно из имени, onNoteOn вызывается при получении MIDI сообщения Note On. onNoteOff, Соответственно, вызывается при Note Off сообщении. Напишем код этих функций в .cpp файле класса:

void VoiceManager::onNoteOn(int noteNumber, int velocity) {
    Voice* voice = findFreeVoice();
    if (!voice) {
        return;
    }
    voice->reset();
    voice->setNoteNumber(noteNumber);
    voice->mVelocity = velocity;
    voice->isActive = true;
    voice->mVolumeEnvelope.enterStage(EnvelopeGenerator::ENVELOPE_STAGE_ATTACK);
    voice->mFilterEnvelope.enterStage(EnvelopeGenerator::ENVELOPE_STAGE_ATTACK);
}


Сначала находим свободный голос при помощи findFreeVoice. Если ничего не нашлось, мы ничего не возвращаем. Это значит, что когда все голоса звучат, нажатие еще одной клавиши не будет иметь никакого результата. Реализация подхода voice stealing будет одной из тем следующего поста. Если находится свободный голос, нам нужно его обновить до начального состояния (reset, мы сделаем это очень скоро). После этого мы задаем правильные значения setNoteNumber и mVelocity. Помечаем голос как активный и переводим обе огибающие в стадию attack.
Если запустить сборку прямо сейчас, выскочит ошибка о том, что мы пытаемся получить доступ к private членам Voice извне. На мой взгляд, лучшим решением в этой ситуации будет использовать ключевое слово friend. Добавьте соответствующую строчку перед public в Voice.h:

friend class VoiceManager;


Благодаря этой строчке Voice дает VoiceManager доступ к своим private членам. Я не сторонник обширного использования этого подхода, но если у вас есть класс Foo и класс FooManager, это хороший способ избежать написания множества сеттеров.

onNoteOff выглядит так:

void VoiceManager::onNoteOff(int noteNumber, int velocity) {
    // Find the voice(s) with the given noteNumber:
    for (int i = 0; i < NumberOfVoices; i++) {
        Voice& voice = voices[i];
        if (voice.isActive && voice.mNoteNumber == noteNumber) {
            voice.mVolumeEnvelope.enterStage(EnvelopeGenerator::ENVELOPE_STAGE_RELEASE);
            voice.mFilterEnvelope.enterStage(EnvelopeGenerator::ENVELOPE_STAGE_RELEASE);
        }
    }
}


Мы находим все голоса с номером отпущенной ноты и переводим их огибающие в стадию release. Почему голоса, а не голос? Представьте себе, что у вас очень длительная стадия затухания в огибающей амплитуды. Вы нажимаете на клавишу и отпускаете ее, и, пока хвост ноты все еще звучит, быстро жмете эту клавишу еще раз. Естественно, вы не хотите обрубить предыдущую звучащую ноту. Это было бы очень некрасиво. Нужно и чтобы дозвучала предыдущая нота, и чтобы новая начала параллельно звучать. Таким образом, вам понадобится больше одного голоса на ноту. Если долбить по клавишам очень быстро, то понадобится много голосов.
Так что происходит, если, например, у нас пять активных голосов для До третьей октавы и мы отпускаем эту клавишу? Вызывается onNoteOff и переводит огибающие всех пяти голосов в стадию release. Четыре из них и так уже в этой стадии, так что давайте посмотрим на первую строку EnvelopeGenerator::enterStage:

if (currentStage == newStage) return;


Как видите, для этих четырех нот ничего не произойдет, никаких загвоздок тут не будет.

Давайте теперь напишем функцию-член nextSample для VoiceManager. Она должна выводить суммарное значение для всех активных голосов:

double VoiceManager::nextSample() {
    double output = 0.0;
    double lfoValue = mLFO.nextSample();
    for (int i = 0; i < NumberOfVoices; i++) {
        Voice& voice = voices[i];
        voice.setLFOValue(lfoValue);
        output += voice.nextSample();
    }
    return output;
}


Мы начинаем с тишины (0.0), итерируем над всеми голосами, устанавливаем текущее значение LFO и добавляем выход голоса к суммарному выходу. Как мы помним, если голос неактивен, его функция Voice::nextSample не будет ничего вычислять и сразу завершится.

Многоразовые компоненты



До текущего момента мы создавали объекты Oscillator и Filter и использовали их на протяжении всего времени работы плагина. Но VoiceManager повторно использует свободные голоса, так что надо придумать, как полностью перевести голос в начальное состояние. Начнем с добавления функции в public хедера Voice:

void reset();


Тело функции напишем в .cpp:

void Voice::reset() {
    mNoteNumber = -1;
    mVelocity = 0;
    mOscillatorOne.reset();
    mOscillatorTwo.reset();
    mVolumeEnvelope.reset();
    mFilterEnvelope.reset();
    mFilter.reset();
}


Как видно, здесь сбрасывются mNoteNumber и mVelocity, затем сбрасываются осцилляторы, огибающие и фильтр. Давайте это напишем!

В public секции Oscillator.h добавьте:

void reset() { mPhase = 0.0; }


Это позволяет запускать форму волны сначала каждый раз, когда начинает звучать голос.

Заодно, пока мы там, удалите флаг isMuted из секции private. Не забудьте удалить его также из списка инициализации конструктора и удалить функцию-член setMuted. Мы теперь отслеживаем состояние активности на уровне Voice, так что осциллятору это все больше не нужно. Удалите эту строчку из функции Oscillator::nextSample:

// remove this line:
if(isMuted) return value;


Функция reset в EnvelopeGenerator немного длиннее. В секции public хедера EnvelopeGenerator напишите следующее:

void reset() {
    currentStage = ENVELOPE_STAGE_OFF;
    currentLevel = minimumLevel;
    multiplier = 1.0;
    currentSampleIndex = 0;
    nextStageSampleIndex = 0;
}


Тут просто нужно сбросить больше значений, все линейно. Осталось добавить reset для класса Filter (тоже в public):

void reset() {
    buf0 = buf1 = buf2 = buf3 = 0.0;
}


Как вы наверное помните, эти буферы содержат в себе предыдущие выходные семплы фильтра. Когда мы повторно используем голос, эти буферы должны быть пустыми.

Подводя итог: каждый раз, когда VoiceManager использует Voice, он вызывает функцию reset для сброса голоса до начального состояния. Эта функция, в свою очередь, сбрасывает осцилляторы голоса, его генераторы огибающих и фильтр.

static или не static?



Переменные-члены для всех голосов одни и те же:

  • Oscillator: mOscillatorMode
  • Filter: cutoff, resonance, mode
  • EnvelopeGenerator: stageValue


Сначала я думал, что подобная избыточность это зло, и все эти штуки должны быть статическими членами. Давайте представим, что mOscillatorMode — статический. Тогда у LFO была бы та же форма волны, что и у остальных осцилляторов, а этого мы не хотим. Далее, если бы значения stageValue генератора огибающих EnvelopeGenerator были статическими, огибающие амплитуды и фильтра были бы одинаковыми.

Это можно было бы исправить путем наследования: создав классы VolumeEnvelope и FilterEnvelope, которые наследовали бы от класса EnvelopeGenerator. Параметр stageValue мог бы быть статическим и VolumeEnvelope и FilterEnvelope могли бы его менять. Это четко разделило бы огибающие и все голоса могли бы иметь доступ к статическим членам. Но в данном случае речь не идет о больших объемах памяти. Все, что приходится делать при той структуре, которую создали мы, это синхронизировать эти переменные между огибающими амплитуд и фильтров всех голосов.

Однако одна вещь может быть статической: sampleRate. Нет смысла в том, чтобы компоненты синтезатора работали на разных частотах дискретизации. Давайте подправим это в Oscillator.h:

static double mSampleRate;


А значит, мы не должны инициализировать эту переменную через список инициализации. Удалите mSampleRate(44100.0). В Oscillator.cpp после #include добавьте:

double Oscillator::mSampleRate = 44100.0;


Частота дискретизации теперь статическая и все осцилляторы используют одно ее значение.
Давайте сделаем то же самое для EnvelopeGenerator. Сделайте sampleRate статическим, удалите из списка инициализации конструктора и допишите в EnvelopeGenerator.cpp:

double EnvelopeGenerator::sampleRate = 44100.0;


В EnvelopeGenerator.h сделайте статическим сеттер:

static void setSampleRate(double newSampleRate);


Мы добавили много нового! В следующий раз мы почистим лишнее и приведем GUI в рабочее состояние.

Код можно скачать отсюда.
Оригинал поста.
Tags:
Hubs:
Total votes 32: ↑31 and ↓1+30
Comments17

Articles