Програмування&Музика: ADSR-обвідна сигналу. Частина 2

Всім привіт!
Ви читаєте другу частину статті про створення 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 EState enum
{
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 EState enum
{
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
Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.