Всем привет!
Вы читаете вторую часть статьи про создание VST-синтезатора на С#. В первой части был рассмотрен SDK и библиотеки для создания VST плагинов, рассмотрено программирование осциллятора.
В этой части я расскажу про огибающие сигнала, их разновидности, применение в обработке звука. В статье будет рассмотрено программирование ADSR-огибающей для управления амплитудой сигнала, генерируемого осциллятором.
Огибающие есть в любом синтезаторе, применяются не только в синтезе, а повсеместно обработке звука.


Исходный код написанного мною синтезатора доступен на GitHub'е.




Цикл статей


  1. Понимаем и пишем VSTi синтезатор на C# WPF
  2. ADSR-огибающая сигнала
  3. Продолжение следует...

Оглавление


  1. Огибающая
  2. Различные виды огибающих в существующих плагинах
  3. MIDI-сообщения нажатия клавиш
  4. Более объектно-ориентированный подход в коде осциллятора
  5. Щелчек при переключении ноты
  6. Программирование ADSR-огибающей
  7. Примеры звучаний с использованием ADSR-огибающей
  8. Список литературы


Огибающая


С точки зрения математики, огибающая кривая (или функция) — это такая кривая (функция), которая в каждой своей точке касается некоторого множества. Это может быть множество кривых, точек, фигур, элементов. Она огибает заданное множество. Можно представить, что огибающая есть некоторая граница, которая ограничивает элементы множества.


Представим себе множество одинаковых окружностей, центры которых расположены на одной линии. Их огибающие — это две параллельные прямые. Эти огибающие, по сути, являются границами для множеств точек окружностей.



Огибающие для окружностей. Скриншот взят с сайта dic.academic.ru


В плане синтезирования и обработки сигналов, огибающая — это функция, описывающая изменения какого-либо параметра во времени.



Красная кривая — огибающая амплитуды волны. Скриншот взят с сайта www.kit-e.ru


Огибающие в основном используются для описания изменения амплитуды сигнала. Но никто не запрещает вам использовать огибающую для описания изменений частоты среза фильтра (cutoff), высоты тона (pitch), панорамы (pan) и некоторых других существующих параметров синтезатора.


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


ADSR-огибающая позволяет описывает изменения громкости звука как 4 последовательные фазы с параметрами:


  1. Attack (Атака) — фаза возрастания сигнала от полной тишины до максимального уровня громкости сигнала. Время атаки (говорят просто "атака") — время, нужное для того, чтобы громкость сигнала достигла своего максимального уровня. У любого сигнала есть атака, так как амплитуда (в реальной жизни) изменяется непрерывно, без скачков.
  2. Decay (Спад) — фаза спада сигнала до уровня, определенном в фазе Sustain. Аналогично, параметром является время спада.
  3. Sustain (Удержание) — фаза "стабильного звучания" с постоянной заданной амплитудой. Если описывать нажатие клавиши на пианино, то фаза Sustain длится до того момента, пока игрок держит клавишу нажатой. Конечно, в настоящем пианино, если долго держать клавишу нажатой, звук постепенно стихнет. Если же рассмотреть синтезатор, то он может продолжать генерировать ноту, и в этой фазе мы будем слышать постоянный, не меняющийся по амплитуде сигнал.
  4. Release (Затухание) — фаза затухания сигнала. Определяет время (говорят "релиз") нужное для окончательного спада амплитуды сигнала до нуля. На пианино это фаза начинается сразу, как клавиша отпущена.

Рассмотрим график в начале статьи, обозначения:


  • As — значение амплитуды сигнала в состоянии Sustain.
  • Ta — время состояния Attack
  • Td — время состояния Decay
  • Tr — время состояния Release
  • Tkey — время удержания клавиши/ноты (от начала нажатия до отпускания)

Представим, что мы генерируем осциллятором определенную гармонику с амплитудой c максимальным уровнем k (значения синуса будут от -k до k).
Рассмотрим применение ADSR-огибающей для этого сигнала.
Так как в момент перехода с фазы Attack на фазу Decay амплитуда сигнала максимальная (на графике это 1), то применение огибающей мы можем рассматривать как умножение текущего уровня сигнала на текущее значение огибающей. В таком случае "максимальная амплитуда" означает что сигнал не ограничивается (не изменяется) огибающей и его амплитуда будет равной k.


В момент начала генерирования сигнала начинается фаза атаки. За время Ta сигнал возрастает до уровня 1 * k. После начинается фаза Decay — за время Td значение уровня снижается до As * k.
Огибающая переходит в фазу Sustain — она не ограничена по времени. После перехода в фазу Release (отжали клавишу) амплитуда сигнала снижается до нуля за время Tr.


Что если мы отпустим ноту до момента наступления фазы Decay или Sustain? В любом случае, мы должны перейти в фазу Release, которая начнется с текущего уровня сигнала.


Важно понять что параметр Sustain — это уровень сигнала, в отличие от параметров Attack, Delay, Release — которые представляют собой время (если глядеть на графики ADSR в интернете можно поймать некоторое непонимание).



Различные виды огибающих в существующих плагинах


В интернете очень много информации, статей, видеоуроков, посвященным огибающим — они повсеместно используются, а уж особенно в синтезаторах для модулирования параметров.
Никакой текст не передаст картинку, а уж тем более звук. Поэтому я рекомендую вам посмотреть пару видео на ютубе по запросу "adsr envelope": хотя бы это, по первой ссылке, годное объяснение с интерактивом.


В VST-синтезаторах огибающие применяются как:


  1. ADSR-огибающая для управления громкости сигнала после осциллятора
  2. Более сложные огибающие для модулирования параметров


ADSR-огибающая обрабатывает амплитуду звука с двух осцилляторов. Скриншот из синтезатора Sylenth1


Часто в ADSR-огибающую добавляют фазу Hold — фаза с конечным временем и максимальной амплитудой между фазами Attack и Decay.



AHDSR-огибающая (добавлена фаза Hold) в синтезаторе Serum, в блоке огибающих для модулирования параметров


Существуют и более сложные огибающие — ADBSSR-огибающая.



MIDI-сообщения нажатия клавиш


Переходим к программированию и обзору кода написанного мною синта. (Ссылка на GitHub)


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


Прикрутим Midi-сообщения к осциллятору, чтобы сигнал генерировался, только если зажата клавиша.


Чтобы получить Midi-сообщения из хоста, класс плагина (наследник SyntagePlugin) должен перегрузить метод CreateMidiProcessor, возвращающий IVstMidiProcessor.
В Syntage.Framework есть готовый класс MidiListener, реализующий IVstMidiProcessor.
У MidiListener есть события OnNoteOn и OnNoteOff, которые мы будем использовать в осцилляторе.


Теперь у осциллятора не будет параметра частоты (Frequency), так как частота будет определяться зажатой клавишей.
Подпишемся в конструкторе на события OnNoteOn и OnNoteOff и реализуем их.


private int _note = -1; // -1 значит что нота не генерируется

...

public Oscillator(AudioProcessor audioProcessor) :
    base(audioProcessor)
{
    _stream = Processor.CreateAudioStream();

    audioProcessor.PluginController.MidiListener.OnNoteOn += MidiListenerOnNoteOn;
    audioProcessor.PluginController.MidiListener.OnNoteOff += MidiListenerOnNoteOff;
}

private void MidiListenerOnNoteOn(object sender, MidiListener.NoteEventArgs e)
{
    _time = 0; // сбрасываем время
    _note = e.NoteAbsolute;
}

private void MidiListenerOnNoteOff(object sender, MidiListener.NoteEventArgs e)
{
    // останавливаем генерацию, только если отпустили соответствующую клавишу
    if (_note == e.NoteAbsolute)
        _note = -1;
}

В обработчики событий приходит аргумент NoteEventArgs, который имеет информацию о ноте, октаве, силе нажатия (note velocity).


Минимальное расстояние между нотами — полутон, соответственно целочисленную абсолютную величину ноты (её номер) мы будем мерить как количество полутонов от некоторой басовой ноты (какой- поймем далее). Почему? Это удобно для формулы получения частоты по номеру ноты.


Немножко теории — сейчас в музыке правит равномерно темперированный строй.
Октава — диапазон из 12 полутонов, из 12 различных нот (в мажорных и минорных тональностях нот 7, так как между некоторыми нотами интервалы тон, а между некоторыми полутон).
Весь звукоряд делится на октавы. Соответственно, весь слышимый спектр частот так же делится на отрезки-октавы.
Нота на октаву выше будет иметь вдвое большую частоту, но называться так же.


Чтобы посчитать частоту ноты, которая отстоит на i полутонов от эталонной ноты с частотой f0, используют следующую формулу:



Напишем соответствующую статическую функцию в вспомогательном классе DSPFunctions:


public static double GetNoteFrequency(double note)
{
    var r = 440 * Math.Pow(2, (note - 69) / 12.0);
    return r;
}

Здесь за эталон берется нота Ля первой октавы (440 Гц). Из формулы видно, что нота с "номером" 0 стоит ниже Ля первой октавы на 69 полутонов, это нота До с частотой 8.1758 Гц (еще ниже на октаву, чем в субконтроктаве), у Ля первой октавы же "номер" будет 69. Почему эта нота? Видимо, так договорились люди, когда делали клавиатуры для синтезаторов, чтобы уж "с запасом".


Теперь, нужно генерировать сигнал, пока выполняется условие _note != -1.


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



Более объектно-ориентированный подход в коде осциллятора


Обработчики OnNoteOn и OnNoteOff говорят нам о том, что была нажата либо снята определенная клавиша. Внутри осциллятора же, мы определяем текущую клавишу целочисленной переменной _note, при чем отсутствие нажатой клавиши мы трактуем как значение -1.


Пора переходить на более высокий уровень: предлагаю написать класс для представления генерируемого тона (тем более в дальнейшем нам это упростит жизнь).


Тон характеризуется номером и пройденным временем со старта — пока ничего нового, просто добавили ясность в код:


private class Tone
{
    public int Note;
    public double Time;
}

Соответственно, теперь осциллятор имеет поле Tone _tone, которое будет null при отсутствии зажатой клавиши.
Тем более, с таким классом легче будет программировать многоголосный осциллятор.



Щелчек при переключении ноты


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


Чтобы сгенерировать волну по таблице, мы находили "относительную фазу" как значение от 0 до 1, где 1 понималось как полный период волны. Если мы сменим частоту волны, но оставим такой же "относительную фазу" то значения волны совпадут и щелчка не будет.


Нужно определить начальное время для новой ноты, если в текущий момент генерировалась нота с другой частотой.
Формула из прошлой статьи для "относительной фазы":



Соответственно, зная новую частоту, получим нужное время:



В коде это выглядит так:


private void MidiListenerOnNoteOn(object sender, MidiListener.NoteEventArgs e)
{
    var newNote = e.NoteAbsolute;

    // если уже была нажата нота, нужно скопировать ее фазу, чтобы не было щелчка
    double time = 0;
    if (_tone != null)
    {
        // фаза от 0 до 1
        var tonePhase = DSPFunctions.Frac(_tone.Time * DSPFunctions.GetNoteFrequency(_tone.Note));

        // фаза второй ноты должна быть такой же, определим время по частоте
        time = tonePhase / DSPFunctions.GetNoteFrequency(newNote);
    }

    _tone = new Tone
    {
        Time = time,
        Note = e.NoteAbsolute
    };
}

Полный код осциллятора для ленивых
public class Oscillator : SyntageAudioProcessorComponentWithParameters<AudioProcessor>, IGenerator
{
    private class Tone
    {
        public int Note;
        public double Time;
    }

    private readonly IAudioStream _stream;
    private Tone _tone;

    public VolumeParameter Volume { get; private set; }
    public EnumParameter<WaveGenerator.EOscillatorType> OscillatorType { get; private set; }
    public RealParameter Fine { get; private set; }
    public RealParameter Panning { get; private set; }

    public Oscillator(AudioProcessor audioProcessor) :
        base(audioProcessor)
    {
        _stream = Processor.CreateAudioStream();

        audioProcessor.PluginController.MidiListener.OnNoteOn += MidiListenerOnNoteOn;
        audioProcessor.PluginController.MidiListener.OnNoteOff += MidiListenerOnNoteOff;
    }

    private void MidiListenerOnNoteOn(object sender, MidiListener.NoteEventArgs e)
    {
        var newNote = e.NoteAbsolute;

        // если уже была нажата нота, нужно скопировать ее фазу, чтобы не было щелчка
        double time = 0;
        if (_tone != null)
        {
            // фаза от 0 до 1
            var tonePhase = DSPFunctions.Frac(_tone.Time * DSPFunctions.GetNoteFrequency(_tone.Note));

            // фаза второй ноты должна быть такой же, определим время по частоте
            time = tonePhase / DSPFunctions.GetNoteFrequency(newNote);
        }

        _tone = new Tone
        {
            Time = time,
            Note = e.NoteAbsolute
        };
    }

    private void MidiListenerOnNoteOff(object sender, MidiListener.NoteEventArgs e)
    {
        // останавливаем генерацию, только если отпустили соответствующую клавишу
        if (_tone != null
            && _tone.Note == e.NoteAbsolute)
            _tone = null;
    }

    public override IEnumerable<Parameter> CreateParameters(string parameterPrefix)
    {
        Volume = new VolumeParameter(parameterPrefix + "Vol", "Oscillator Volume");
        OscillatorType = new EnumParameter<WaveGenerator.EOscillatorType>(parameterPrefix + "Osc", "Oscillator Type", "Osc", false);

        Fine = new RealParameter(parameterPrefix + "Fine", "Oscillator pitch", "Fine", -2, 2, 0.01);
        Fine.SetDefaultValue(0);

        Panning = new RealParameter(parameterPrefix + "Pan", "Oscillator Panorama", "", 0, 1, 0.01);
        Panning.SetDefaultValue(0.5);

        return new List<Parameter> {Volume, OscillatorType, Fine, Panning};
    }

    public IAudioStream Generate()
    {
        _stream.Clear();

        if (_tone != null)
            GenerateToneToStream(_tone);

        return _stream;
    }

    private void GenerateToneToStream(Tone tone)
    {
        var leftChannel = _stream.Channels[0];
        var rightChannel = _stream.Channels[1];

        double timeDelta = 1.0 / Processor.SampleRate;
        var count = Processor.CurrentStreamLenght;
        for (int i = 0; i < count; ++i)
        {
            var frequency = DSPFunctions.GetNoteFrequency(tone.Note);
            var sample = WaveGenerator.GenerateNextSample(OscillatorType.Value, frequency, tone.Time);

            sample *= Volume.Value;

            var panR = Panning.Value;
            var panL = 1 - panR;

            leftChannel.Samples[i] += sample * panL;
            rightChannel.Samples[i] += sample * panR;

            tone.Time += timeDelta;
        }
    }
}


Программирование ADSR-огибающей


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


Осциллятор перестает генерировать сигнал при отжимании клавиши — огибающая не сможет корректно обработать фазу Release. В фазе Release сигнал должен продолжать генерироваться осциллятором, а огибающая сделает свое дело для затухания громкости.
Для этого нужно убрать обработчик OnNoteOff из кода осциллятора — теперь этим займется огибающая.


Как было описано в главе "Огибающая" — мы используем значение огибающей как множитель для семпла сигнала. Значение огибающей меняется во времени от 0 до 1, значение 1 огибающая принимает лишь при переходе между фазами Attack и Decay (при значении огибающей, равной 1, амплитуда сигнала не меняется).


Чтобы запрограммировать переходы из фаз A-D-S-R, нужно закодить стейт-машину, поэтому далее я будут говорить состояния, а не фазы огибающей.


Огибающая имеет 4 параметра: время Attack, Delay, Release и множитель амплитуды Sustain. Обычно Attack, Delay — это миллисекунды, Release уже может быть сравним с секундой.


Все 4 состояния сменяют друг друга всегда последовательно: нельзя перескочить из одного состояния в другое. Если у состояний Attack, Delay или Release время будет нулевым — можно получить клип. Поэтому минимум у этих параметров нужно сделать не нулевым. Я сделал параметры Attack, Delay или Release от 0.01 до 1 секунды.


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


Логика огибающей будет скрыта в классе NoteEnvelope, от которого, по сути, требуется только метод — для получения текущего значения огибающей. Этот метод нужно вызывать последовательно для всех семплов потока и производить умножение.


Важно договориться, как обрабатывать нажатия клавиш. Например, как обработать нажатие клавиши, если уже нажата другая клавиша? Методом пристального взгляда я изучил, как сделано в синтезаторе Sylenth1, если не использовать в нем полифонию. Решил выбрать такую стратегию:


  • Если изменилось состояние клавиш и сейчас зажаты клавиши, а раньше не были — регистрируем нажатие (переходим в стейт Attack).
  • Если изменилось состояние клавиш и сейчас нет зажатых клавиш, а раньше были — регистрируем снятие клавиши (переходим в стейт Release).

Простыми словами — мы обращаем внимание только на изменение состояния клавиш "все отпущены — хоть одна зажата".


Таким образом, при нажатии новой клавиши и зажатой текущей, в осцилляторе просто переключится частота, а огибающая никак на это не отреагирует. При программировании многоголосия все становится сложнее.


Запишем все вышесказанное в классе-каркасе ADSR: параметры, обработчик нажатия клавиш, обработка семплов.


public class ADSR : SyntageAudioProcessorComponentWithParameters<AudioProcessor>, IProcessor
{
    private readonly NoteEnvelope _noteEnvelope; // класс с логикой огибающей
    private int _lastPressedNotesCount; // нужно, чтобы знать, были ли зажаты клавиши

    public RealParameter Attack { get; private set; }
    public RealParameter Decay { get; private set; }
    public RealParameter Sustain { get; private set; }
    public RealParameter Release { get; private set; }

    public ADSR(AudioProcessor audioProcessor) : base(audioProcessor)
    {
        _noteEnvelope = new NoteEnvelope(this);

        Processor.Input.OnPressedNotesChanged += OnPressedNotesChanged;
    }

    public override IEnumerable<Parameter> CreateParameters(string parameterPrefix)
    {
        Attack = new RealParameter(parameterPrefix + "Atk", "Envelope Attack", "", 0.01, 1, 0.01);
        Decay = new RealParameter(parameterPrefix + "Dec", "Envelope Decay", "", 0.01, 1, 0.01);
        Sustain = new RealParameter(parameterPrefix + "Stn", "Envelope Sustain", "", 0, 1, 0.01);
        Release = new RealParameter(parameterPrefix + "Rel", "Envelope Release", "", 0.01, 1, 0.01);

        return new List<Parameter> {Attack, Decay, Sustain, Release};
    }

    private void OnPressedNotesChanged(object sender, EventArgs e)
    {
        // если сейчас нажаты какие-то клавиши, а раньше не были - регистрируем нажатие
        // если сейчас нет нажатых клавиш, а раньше были нажаты - значит отжали последнюю клавишу,
        // регистрируем релиз

        var currentPressedNotesCount = Processor.Input.PressedNotesCount;
        if (currentPressedNotesCount > 0
            && _lastPressedNotesCount == 0)
        {
            _noteEnvelope.Press();
        }
        else if (currentPressedNotesCount == 0
            && _lastPressedNotesCount > 0)
        {
            _noteEnvelope.Release();
        }

        _lastPressedNotesCount = currentPressedNotesCount;
    }

    public void Process(IAudioStream stream)
    {
        var lc = stream.Channels[0];
        var rc = stream.Channels[1];

        var count = Processor.CurrentStreamLenght;
        for (int i = 0; i < count; ++i)
        {
            var multiplier = _noteEnvelope.GetNextMultiplier();
            lc.Samples[i] *= multiplier;
            rc.Samples[i] *= multiplier;
        }
    }
}

Приступаем к программированию логики огибающей — класс NoteEnvelope. Как можно видеть из приведенного кода класса ADSR, от класса NoteEnvelope нужны следующие публичные методы:


  1. Press() — обработать нажатие клавиши (перейти в стейт Attack)
  2. Release() — обработать отжимание клавиши (перейти в стейт Release)
  3. GetNextMultiplier() — получить следующее значение огибающей

Определим перечисление для состояний:


private enum EState
{
    None,
    Attack,
    Decay,
    Sustain,
    Release
}

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


Начальное значение и время для состояния определяется в момент переключения стейта:


private void SetState(EState newState)
{
    // запомним текущее значение огибающей - это будет стартовое значение для новой фазы
    _startMultiplier = (_time > 0) ? _multiplier : GetCurrentStateFinishValue();

    _state = newState;

    // получим время новой фазы
    _time = GetCurrentStateMultiplier();
}

private double GetCurrentStateMultiplier()
{
    switch (_state)
    {
        case EState.None:
            return 1;

        case EState.Attack:
            return _ownerEnvelope.Attack.Value;

        case EState.Decay:
            return _ownerEnvelope.Decay.Value;

        case EState.Sustain:
            return _ownerEnvelope.Sustain.Value;

        case EState.Release:
            return _ownerEnvelope.Release.Value;

        default:
            throw new ArgumentOutOfRangeException();
    }
}

Каждый вызов функции GetNextMultiplier() нужно уменьшать _time на время между двумя семплами. Если время стало меньше либо равно нуля, то нужно переходить в другой стейт.
Переход в стейт Release возможен только из функции Release().


public double GetNextMultiplier()
{
    var startMultiplier = GetCurrentStateStartValue();
    var finishMultiplier = GetCurrentStateFinishValue();

    // время уменьшается, поэтому используем обратную величину
    var stateTime = 1 - _time / GetCurrentStateMultiplier();

    // логика интерполяция между startMultiplier и finishMultiplier скрыта в CalculateLevel
    _multiplier = CalculateLevel(startMultiplier, finishMultiplier, stateTime);

    switch (_state)
    {
        case EState.None:
            break;

        case EState.Attack:
            if (_time < 0)
                SetState(EState.Decay);
            break;

        case EState.Decay:
            if (_time < 0)
                SetState(EState.Sustain);
            break;

        case EState.Sustain:
            // сустейн не ограничен по времени
            break;

        case EState.Release:
            if (_time < 0)
                SetState(EState.None);
            break;

        default:
            throw new ArgumentOutOfRangeException();
    }

    // вычитаем время одного семпла
    var timeDelta = 1.0 / _ownerEnvelope.Processor.SampleRate;
    _time -= timeDelta;

    return _multiplier;
}

Конечное время для стейтов очевидно: для Attack — 1, для Decay и Sustain — параметр амплитуды Sustain, для Release — 0.


Полный код ADSR-огибающей
public class ADSR : SyntageAudioProcessorComponentWithParameters<AudioProcessor>, IProcessor
{
    private class NoteEnvelope
    {
        private enum EState
        {
            None,
            Attack,
            Decay,
            Sustain,
            Release
        }

        private readonly ADSR _ownerEnvelope;
        private double _time;
        private double _multiplier;
        private double _startMultiplier;
        private EState _state;

        public NoteEnvelope(ADSR owner)
        {
            _ownerEnvelope = owner;
        }

        public double GetNextMultiplier()
        {
            var startMultiplier = GetCurrentStateStartValue();
            var finishMultiplier = GetCurrentStateFinishValue();

            // время уменьшается, поэтому используем обратную величину
            var stateTime = 1 - _time / GetCurrentStateMultiplier();

            // логика интерполяция между startMultiplier и finishMultiplier скрыта в CalculateLevel
            _multiplier = CalculateLevel(startMultiplier, finishMultiplier, stateTime);

            switch (_state)
            {
                case EState.None:
                    break;

                case EState.Attack:
                    if (_time < 0)
                        SetState(EState.Decay);
                    break;

                case EState.Decay:
                    if (_time < 0)
                        SetState(EState.Sustain);
                    break;

                case EState.Sustain:
                    // сустейн не ограничен по времени
                    break;

                case EState.Release:
                    if (_time < 0)
                        SetState(EState.None);
                    break;

                default:
                    throw new ArgumentOutOfRangeException();
            }

            // вычитаем время одного семпла
            var timeDelta = 1.0 / _ownerEnvelope.Processor.SampleRate;
            _time -= timeDelta;

            return _multiplier;
        }

        public void Press()
        {
            SetState(EState.Attack);
        }

        public void Release()
        {
            SetState(EState.Release);
        }

        private double CalculateLevel(double a, double b, double t)
        {
            return DSPFunctions.Lerp(a, b, t);
        }

        private void SetState(EState newState)
        {
            // запомним текущее значение огибающей - это будет стартовое значение для новой фазы
            _startMultiplier = (_time > 0) ? _multiplier : GetCurrentStateFinishValue();

            _state = newState;

            // получим время новой фазы
            _time = GetCurrentStateMultiplier();
        }

        private double GetCurrentStateStartValue()
        {
            return _startMultiplier;
        }

        private double GetCurrentStateFinishValue()
        {
            switch (_state)
            {
                case EState.None:
                    return 0;

                case EState.Attack:
                    return 1;

                case EState.Decay:
                case EState.Sustain:
                    return _ownerEnvelope.Sustain.Value;

                case EState.Release:
                    return 0;

                default:
                    throw new ArgumentOutOfRangeException();
            }
        }

        private double GetCurrentStateMultiplier()
        {
            switch (_state)
            {
                case EState.None:
                    return 1;

                case EState.Attack:
                    return _ownerEnvelope.Attack.Value;

                case EState.Decay:
                    return _ownerEnvelope.Decay.Value;

                case EState.Sustain:
                    return _ownerEnvelope.Sustain.Value;

                case EState.Release:
                    return _ownerEnvelope.Release.Value;

                default:
                    throw new ArgumentOutOfRangeException();
            }
        }
    }

    private readonly NoteEnvelope _noteEnvelope;
    private int _lastPressedNotesCount;

    public RealParameter Attack { get; private set; }
    public RealParameter Decay { get; private set; }
    public RealParameter Sustain { get; private set; }
    public RealParameter Release { get; private set; }

    public ADSR(AudioProcessor audioProcessor) : base(audioProcessor)
    {
        _noteEnvelope = new NoteEnvelope(this);

        Processor.Input.OnPressedNotesChanged += OnPressedNotesChanged;
    }

    public override IEnumerable<Parameter> CreateParameters(string parameterPrefix)
    {
        Attack = new RealParameter(parameterPrefix + "Atk", "Envelope Attack", "", 0.01, 1, 0.01);
        Decay = new RealParameter(parameterPrefix + "Dec", "Envelope Decay", "", 0.01, 1, 0.01);
        Sustain = new RealParameter(parameterPrefix + "Stn", "Envelope Sustain", "", 0, 1, 0.01);
        Release = new RealParameter(parameterPrefix + "Rel", "Envelope Release", "", 0.01, 1, 0.01);

        return new List<Parameter> {Attack, Decay, Sustain, Release};
    }

    private void OnPressedNotesChanged(object sender, EventArgs e)
    {
        // если сейчас нажаты какие-то клавиши, а раньше не были - регистрируем нажатие
        // если сейчас нет нажатых клавиш, а раньше были нажаты - значит отжали последнюю клвишу,
        // регистрируем релиз

        var currentPressedNotesCount = Processor.Input.PressedNotesCount;
        if (currentPressedNotesCount > 0
            && _lastPressedNotesCount == 0)
        {
            _noteEnvelope.Press();
        }
        else if (currentPressedNotesCount == 0
            && _lastPressedNotesCount > 0)
        {
            _noteEnvelope.Release();
        }

        _lastPressedNotesCount = currentPressedNotesCount;
    }

    public void Process(IAudioStream stream)
    {
        var lc = stream.Channels[0];
        var rc = stream.Channels[1];

        var count = Processor.CurrentStreamLenght;
        for (int i = 0; i < count; ++i)
        {
            var multiplier = _noteEnvelope.GetNextMultiplier();
            lc.Samples[i] *= multiplier;
            rc.Samples[i] *= multiplier;
        }
    }
}

Если сгенерировать простой синус с применением написанной огибающей, получим следующую волну (аудиофайл генерировался в FL Studio а затем исследовался в опен сорсном редакторе Audacity)



В фазах Attack, Decay и Release огибающая меняется по линейному закону (Скриншот из Audacity)


Видно, что в фазах Attack, Decay и Release огибающая меняется по линейному закону.


В реальном мире колебания не могут длится вечно, из-за траты энергии на силы сопротивления колебания затухают. Амплитуда затухающих колебаний меняется по экспоненциальнму закону.



Применение огибающей в синтезаторе Sylenth1 (Скриншот из Audacity)



Применение огибающей в синтезаторе 3x Osc из FL Studio (Скриншот из Audacity)


По примеру огибающих в других синтезаторах в стейте Decay и Release используем экспоненциальную функцию, а в стейте Attack используем обратную функцию — логарифм.


Для каждого стейта мы знаем относительное время (от 0 до 1) и крайние значения огибающей a и b.



Возьмем функцию E^x на промежутке [0, 1]. Она имеет значения в отрезке [1, E]. Нужно перевести этот отрезок в [a, b]. Логарифм найдем как обратную для экспоненты функцию:



F1 — Функция для состояния Decay и Release. Так как она вогнута в другую сторону, нужно отобразить ее симметрично оси Y (заменяем аргумент на 1-x).
Функция F2 является обратной для F1, нужно выразить x через y.


Оформим в коде:


private double CalculateLevel(double a, double b, double t)
{
    //return DSPFunctions.Lerp(a, b, t);

    switch (_state)
    {
        case EState.None:
        case EState.Sustain:
            return a;

        case EState.Attack:
            return Math.Log(1 + t * (Math.E - 1)) * Math.Abs(b - a) + a;

        case EState.Decay:
        case EState.Release:
            return (Math.Exp(1 - t) - 1) / (Math.E - 1) * (a - b) + b;

        default:
            throw new ArgumentOutOfRangeException();
    }
}


Результат трудов (Скриншот из Audacity)


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



Огибающая в синтезаторе Serum



Примеры звучаний с использованием ADSR-огибающей


Рассмотрим пару идей звучаний, которые можно получить из простого сигнала, используя ADSR-огибающую.


  1. A и S на нуле, D и R ~1/4 ручки. Сигнал будет резко затухать, если генерировать шум то звук будет похож на хай-хэт.
  2. A — большое значение (~1/2 ручки), D и R — 0, S — 1: эффект, как будто ноты звучат "в обратной форме".
  3. A, D, R — небольшие, S на нуле. Если генерировать шум то звук будет похож на тряску шейкера.
  4. D небольшое, остальное на нуле. Если генерировать квадратный или пилообразный сигнал, звук будет напоминать щелчки метронома.
  5. A, D — небольшие, S и R на нуле. Если генерировать шум то звук будет отдаленно похож на нажатие клавиш печатающей машинки.

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


В следующей статье я расскажу про частотный фильтр Баттервота.


Всем добра!
Удачи в программировании!



Список литературы


Основные статьи и книги по цифровому звуку указаны в предыдущей статье.


  1. The Human Ear And Sound Perception
  2. Sound Envelopes маленькая статья с аудио-примерами
  3. en.wikiaudio.org/ADSR_envelope ссылки на годные видео
  4. Графики огибающих инструментов
  5. Nine Components of Sound
Поделиться с друзьями
-->

Комментарии (10)


  1. prostofilya
    05.10.2016 05:04
    +3

    Очень люблю крутить синты, особенно обожаю стек Ableton, но до программной составляющей всё как-то дело не доходило, интересно, надо бы попробовать и самому написать хоть какую-нибудь vst, спасибо за статью!
    P.S. Эта статья несёт гораздо больше пользы и гораздо интереснее чем все вместе взятые статьи аудиомании, побольше бы такого.


    1. lis355
      05.10.2016 05:34

      спасибо)

      >статьи аудиомании
      я не знаю про аудиоманию, можете дать ссылки?


      1. prostofilya
        05.10.2016 06:08

        они много пишут про музыку, но там всегда какие-то высокие материи, даже для гиктаймса через чур гуманитарно


  1. armature_current
    05.10.2016 07:42

    Однозначно в закладки. Спасибо, особенно за MIDI!


  1. Refridgerator
    05.10.2016 08:00

    Если мы сменим частоту волны, но оставим такой же «относительную фазу» то значения волны совпадут и щелчка не будет.
    Щелчок от такого решения никуда не денется, а станет лишь менее слышимым.

    Логарифмическая функция на атаку — это ваше личное изобретение? Поскольку её применение также будет приводить к щелчку перед звучанием ноты.

    И экспоненциальную форму затухания используют вовсе не из-за особенностей слуха. А потому что она достаточно близко аппроксимирует затухающие колебания струны.


    1. lis355
      05.10.2016 14:44

      Щелчок от такого решения никуда не денется
      также будет приводить к щелчку

      можете объяснить, почему, если "разрыва" нет?
      в статье я объяснял это тем, что в реальном мире амплитуда сигнала — непрерывная функция, а в месте щелчка — разрыв, т.е. амплитуда очень резко меняется. Если представить волну как сумму гармоник, то в этом месте их будет много, амплитуда будет "скакать" в месте разрыва. на слух слышится как короткий щелчек (как короткий импульс). это правильно?


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


      А потому что она достаточно близко аппроксимирует затухающие колебания струны.

      Спасибо, поправил статью.


      1. Refridgerator
        05.10.2016 19:20
        +2

        Разрыва нет только в значениях, а в первой и далее производных — уже есть. Математически «щелчок» — это всплеск в широком диапазоне частот; он может быть вызван как резким изменением амплитуды, так и резким изменением частоты. Увидеть это можно в частности на вейвлет-скалограмме:


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


      1. Refridgerator
        05.10.2016 20:40

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

        Совершенно верно. Я заострил на этом внимание только потому, что чуть ниже на скриншоте из Serum`а атака нарастет плавно. В этом смысле ваш вариант даже ещё более «жёсткий», чем обычная линейная атака:


        Если уж и заморачиваться на более сложные формулы, то логичнее делать что-то типа такого:

        Формула, если интересно


  1. UA3MQJ
    05.10.2016 12:55
    +1

    Есть еще варианты огибающей

    Roland alpha juno 2

    image

    ADBSSR — Korg EX800

    image


    1. prostofilya
      05.10.2016 15:41

      Ну так можно на много промежутков разбивать. В массиве (да много где), например, есть классный степ секвенсор, в котором можно плавно менять велосити параметра. Уж правильнее его использовать, чем такое сейчас изобретать.