Все посты серии:
Часть 1. Введение и настройка
Часть 2. Изучение кода
Часть 3. VST и AU
Часть 4. Цифровой дисторшн
Часть 5. Пресеты и GUI
Часть 6. Синтез сигналов
Часть 7. Получение MIDI сообщений
Часть 8. Виртуальная клавиатура
Часть 9. Огибающие
Часть 10. Доработка GUI
Давайте добавим несколько элементов управления, чтобы можно было менять параметры огибающей и форму волны. Вот результат, который мы хотим получить (отсюда можно скачать слоеный 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) появится монитор производительности:
Красным выделена суммарная нагрузка трека на процессор. Когда нота начинает звучать, это значение должно вырасти, а когда она окончательно затихнет — упасть, т. к. осцилятор больше не вычисляет семплы впустую.
Теперь у нас есть вполне приемлемый генератор огибающей.
Отсюда можно скачать код.
В следующий раз будем создавать не менее важный компонент синтезатора: фильтр!
Автор: 1eqinfinity