Pull to refresh

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

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



Давайте добавим несколько элементов управления, чтобы можно было менять параметры огибающей и форму волны. Вот результат, который мы хотим получить (отсюда можно скачать слоеный TIFF):



Скачайте и закиньте в проект следующие файлы:
bg.png
knob.png (автор файла — Bootsie)
waveform.png

Как всегда, дописываем ссылки и ID в resource.h:

// Unique IDs for each image resource.
#define BG_ID         101
#define WHITE_KEY_ID  102
#define BLACK_KEY_ID  103
#define WAVEFORM_ID   104
#define KNOB_ID       105

// Image resource locations for this plug.
#define BG_FN         "resources/img/bg.png"
#define WHITE_KEY_FN  "resources/img/whitekey.png"
#define BLACK_KEY_FN  "resources/img/blackkey.png"
#define WAVEFORM_FN   "resources/img/waveform.png"
#define KNOB_FN       "resources/img/knob.png"


И меняем высоту окна, чтобы она соответствовала размеру фоновой картинки:

#define GUI_HEIGHT 296


Вносим изменения в шапку Synthesis.rc:

#include "resource.h"

BG_ID       PNG BG_FN
WHITE_KEY_ID       PNG WHITE_KEY_FN
BLACK_KEY_ID       PNG BLACK_KEY_FN
WAVEFORM_ID       PNG WAVEFORM_FN
KNOB_ID       PNG KNOB_FN


Теперь нужно добавить параметры для формы волны и стадий генератора огибающей. Допишите в EParams Synthesis.cpp:

enum EParams
{
    mWaveform = 0,
    mAttack,
    mDecay,
    mSustain,
    mRelease,
    kNumParams
};


Виртуальную клавиатуру нужно переместить вниз:

enum ELayout
{
    kWidth = GUI_WIDTH,
    kHeight = GUI_HEIGHT,
    kKeybX = 1,
    kKeybY = 230
};


В Oscillator.h нужно дополнить OscillatorMode суммарным количеством режимов:

enum OscillatorMode {
    OSCILLATOR_MODE_SINE = 0,
    OSCILLATOR_MODE_SAW,
    OSCILLATOR_MODE_SQUARE,
    OSCILLATOR_MODE_TRIANGLE,
    kNumOscillatorModes
};


В списке инициализации укажем синус как форму волны по умолчанию:

Oscillator() :
    mOscillatorMode(OSCILLATOR_MODE_SINE),
    // ...


Сборка GUI осуществляется в конструкторе. Добавьте непосредственно перед AttachGraphics(pGraphics) эти строки:

// Waveform switch
GetParam(mWaveform)->InitEnum("Waveform", OSCILLATOR_MODE_SINE, kNumOscillatorModes);
GetParam(mWaveform)->SetDisplayText(0, "Sine"); // Needed for VST3, thanks plunntic
IBitmap waveformBitmap = pGraphics->LoadIBitmap(WAVEFORM_ID, WAVEFORM_FN, 4);
pGraphics->AttachControl(new ISwitchControl(this, 24, 53, mWaveform, &waveformBitmap));

// Knob bitmap for ADSR
IBitmap knobBitmap = pGraphics->LoadIBitmap(KNOB_ID, KNOB_FN, 64);
// Attack knob:
GetParam(mAttack)->InitDouble("Attack", 0.01, 0.01, 10.0, 0.001);
GetParam(mAttack)->SetShape(3);
pGraphics->AttachControl(new IKnobMultiControl(this, 95, 34, mAttack, &knobBitmap));
// Decay knob:
GetParam(mDecay)->InitDouble("Decay", 0.5, 0.01, 15.0, 0.001);
GetParam(mDecay)->SetShape(3);
pGraphics->AttachControl(new IKnobMultiControl(this, 177, 34, mDecay, &knobBitmap));
// Sustain knob:
GetParam(mSustain)->InitDouble("Sustain", 0.1, 0.001, 1.0, 0.001);
GetParam(mSustain)->SetShape(2);
pGraphics->AttachControl(new IKnobMultiControl(this, 259, 34, mSustain, &knobBitmap));
// Release knob:
GetParam(mRelease)->InitDouble("Release", 1.0, 0.001, 15.0, 0.001);
GetParam(mRelease)->SetShape(3);
pGraphics->AttachControl(new IKnobMultiControl(this, 341, 34, mRelease, &knobBitmap));


Сначала мы создаем параметр mWaveform типа Enum. По умолчанию его значение равно OSCILLATOR_MODE_SINE, и он может иметь всего kNumOscillatorModes значений. Затем, подгружаем waveform.png. Здесь 4 обозначает количество кадров, как мы знаем. Можно было бы использовать kNumOscillatorModes, который сейчас тоже равен четырем. Но если мы добавим новые формы волны и не поменяем waveform.png, то все поползет. Впрочем, это могло бы послужить напоминанием о том, что надо обновить изображение.
Затем мы создаем ISwitchControl, передаем координаты и привязываем к параметру mWaveform.
Мы подгружаем один файл knob.png и используем его для всех четырех IKnobMultiControls.
Настраиваем SetShape так, чтобы ручки были более чувствительны на маленьких значениях и более грубы на больших. Значения по умолчанию те же, что и в конструкторе EnvelopeGenerator. Но можно выбрать и какие-нибудь другие минимальные и максимальные значения.

Обработка изменений значений



Как вы помните, реакция на изменение пользователем параметров прописывается в функции OnParamChange в основном .cpp файле проекта:

void Synthesis::OnParamChange(int paramIdx)
{
    IMutexLock lock(this);
    switch(paramIdx) {
        case mWaveform:
            mOscillator.setMode(static_cast<OscillatorMode>(GetParam(mWaveform)->Int()));
            break;
        case mAttack:
        case mDecay:
        case mSustain:
        case mRelease:
            mEnvelopeGenerator.setStageValue(static_cast<EnvelopeGenerator::EnvelopeStage>(paramIdx), GetParam(paramIdx)->Value());
            break;
    }
}


При изменении mWaveform значение типа int преобразуется в тип OscillatorMode.
Как видите, на все параметры огибающей приходится одна строка. Если сравнить EParams и EnvelopeStage enums, видно, что и там, и там стадиям Attack, Decay, Sustain и Release соответствуют значения 1, 2, 3 и 4. Следовательно, static_cast<EnvelopeGenerator::EnvelopeStage>(paramIdx) дает изменяемую стадию огибающей EnvelopeStage, а GetParam(paramIdx)->Value() дает значение изменяемой стадии. Поэтому мы можем просто вызвать setStageValue с этими двумя аргументами. Только эта функция еще не написана. Добавьте в public класса EnvelopeGenerator:

void setStageValue(EnvelopeStage stage, double value);


Представим на минуту, что эта функция была бы простым сеттером:

// This won't be enough:
void EnvelopeGenerator::setStageValue(EnvelopeStage stage,
                                      double value) {
    stageValue[stage] = value;
}


Что если изменить stageValue[ENVELOPE_STAGE_ATTACK] на стадии атаки? Подобная имплементация не вызывает calculateMultiplier и не пересчитывает nextStageSampleIndex. Генератор будет использовать новые значения только в следующий раз, когда окажется на этой стадии. То же самое с SUSTAIN: хотелось бы иметь возможность держать ноту и параллельно искать нужный уровень.
Такая реализация неудобна, и такой плагин выглядел бы абсолютно непрофессионально.
Генератор должен сразу обновлять параметры текущей стадии, когда крутится соответствующая ручка. Значит, нужно вызывать calculateMultiplier с новым аргументом времени и вычислять новое значение nextStageSampleIndex:

void EnvelopeGenerator::setStageValue(EnvelopeStage stage,
                                      double value) {
    stageValue[stage] = value;
    if (stage == currentStage) {
        // Re-calculate the multiplier and nextStageSampleIndex
        if(currentStage == ENVELOPE_STAGE_ATTACK ||
                currentStage == ENVELOPE_STAGE_DECAY ||
                currentStage == ENVELOPE_STAGE_RELEASE) {
            double nextLevelValue;
            switch (currentStage) {
                case ENVELOPE_STAGE_ATTACK:
                    nextLevelValue = 1.0;
                    break;
                case ENVELOPE_STAGE_DECAY:
                    nextLevelValue = fmax(stageValue[ENVELOPE_STAGE_SUSTAIN], minimumLevel);
                    break;
                case ENVELOPE_STAGE_RELEASE:
                    nextLevelValue = minimumLevel;
                    break;
                default:
                    break;
            }
            // How far the generator is into the current stage:
            double currentStageProcess = (currentSampleIndex + 0.0) / nextStageSampleIndex;
            // How much of the current stage is left:
            double remainingStageProcess = 1.0 - currentStageProcess;
            unsigned long long samplesUntilNextStage = remainingStageProcess * value * sampleRate;
            nextStageSampleIndex = currentSampleIndex + samplesUntilNextStage;
            calculateMultiplier(currentLevel, nextLevelValue, samplesUntilNextStage);
        } else if(currentStage == ENVELOPE_STAGE_SUSTAIN) {
            currentLevel = value;
        }
    }
}


Вложенный if проверяет, находится ли генератор на стадии, ограниченной по времени параметром nextStageSampleIndex (ATTACK, DECAY или RELEASE). nextLevelValue это уровень сигнала на следующей стадии, к которому стремится огибающая. Его значение устанавливается так же, как в функции enterStage. Самое интересное после switch: в любой текущей стадии генератор должен работать в соответствии с новыми значениями всю оставшуюся часть этой стадии. Для этого текущая стадия разделяется на прошедшую и оставшуюся части. Сначала вычисляется, насколько далеко по времени генератор уже находится внутри стадии. Например, 0.1 означает, что 10% пройдено. RemainingStageProcess отражает, соответственно, сколько осталось. Теперь нужно вычислить samplesUntilNextStage и обновить nextStageSampleIndex. И самое важное — вызов calculateMultiplier, чтобы перейти с уровня currentLevel до nextLevelValue за samplesUntilNextStage семплов.
C SUSTAIN все просто: обновляем currentLevel.

Такая имплементация покрывает почти все возможные случаи. Осталось разобраться с тем, когда генератор в DECAY, а меняется значение SUSTAIN. Сейчас сделано так, что уровень спадет до старого значения, а когда стадия спада закончится, уровень подпрыгнет на новое. Чтобы этого избежать, добавьте в конец setStageValue:

if (currentStage == ENVELOPE_STAGE_DECAY &&
    stage == ENVELOPE_STAGE_SUSTAIN) {
    // We have to decay to a different sustain value than before.
    // Re-calculate multiplier:
    unsigned long long samplesUntilNextStage = nextStageSampleIndex - currentSampleIndex;
    calculateMultiplier(currentLevel,
                        fmax(stageValue[ENVELOPE_STAGE_SUSTAIN], minimumLevel),
                        samplesUntilNextStage);
}


Теперь будет плавный переход до нового уровня. Тут мы не меняем nextStageSampleIndex, т. к. он не зависит от Sustain.
Запустите плагин, пощелкайте по формам волны и покрутите ручки — все изменения должны сразу отражаться на звуке.

Улучшение производительности



Взгляните на эту часть ProcessDoubleReplacing:

int velocity = mMIDIReceiver.getLastVelocity();
if (velocity > 0) {
    mOscillator.setFrequency(mMIDIReceiver.getLastFrequency());
    mOscillator.setMuted(false);
} else {
    mOscillator.setMuted(true);
}


Помните мы решили, что не будем сбрасывать mLastVelocity получателя MIDI? Это значит, что после первой ноты mOscillator будет генерировать волну даже когда ни одна нота не звучит. Измените цикл for следующим образом:

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


Логично, что осциллятор должен генерировать волну, когда mEnvelopeGenerator.currentStage не равна ENVELOPE_STAGE_OFF. Значит, включать отключать генерацию надо где-то в mEnvelopeGenerator.enterStage. По причинам, которые мы обсуждали в предыдущем посте, мы не будем ничего вызывать непосредственно отсюда, а снова воспользуемся сигналами и слотами. Перед определением класса в EnvelopeGenerator.h добавьте пару строк:

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


Затем добавьте пару сигналов в public:

Signal0<> beganEnvelopeCycle;
Signal0<> finishedEnvelopeCycle;


В самом начале enterStage в EnvelopeGenerator.cpp добавьте:

if (currentStage == newStage) return;
if (currentStage == ENVELOPE_STAGE_OFF) {
    beganEnvelopeCycle();
}
if (newStage == ENVELOPE_STAGE_OFF) {
    finishedEnvelopeCycle();
}


Первый if для того, чтобы генератор не зацикливался на той же самой стадии. Смысл двух других следующий:
  • Выход из стадии OFF означает начало нового цикла
  • Вход в OFF означает конец цикла


Теперь давайте напишем реакцию на Signal. Добавьте следующие private функции в Synthesis.h:

inline void onBeganEnvelopeCycle() { mOscillator.setMuted(false); }
inline void onFinishedEnvelopeCycle() { mOscillator.setMuted(true); }


Когда начинается цикл огибающей, мы даем осциллятору генерировать волну. Когда заканчивается — заглушаем его.
В конце конструктора в Synthesis.cpp соединим сигналы со слотами:

mEnvelopeGenerator.beganEnvelopeCycle.Connect(this, &Synthesis::onBeganEnvelopeCycle);
mEnvelopeGenerator.finishedEnvelopeCycle.Connect(this, &Synthesis::onFinishedEnvelopeCycle);


Вот и все! При запуске все должно работать. В REAPER при нажатии Cmd+Alt+P (на Mac) или Ctrl+Alt+P (на Windows) появится монитор производительности:



Красным выделена суммарная нагрузка трека на процессор. Когда нота начинает звучать, это значение должно вырасти, а когда она окончательно затихнет — упасть, т. к. осцилятор больше не вычисляет семплы впустую.

Теперь у нас есть вполне приемлемый генератор огибающей.
Отсюда можно скачать код.

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

Оригинал статьи:
martin-finke.de/blog/articles/audio-plugins-012-envelopes-gui
Tags:
Hubs:
Total votes 28: ↑26 and ↓2+24
Comments2

Articles