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