Музыкальный D: синтезатор в 100 строк

Библиотека dlib предоставляет базовые инструменты для работы с аудиоданными, которые позволяют написать синтезатор с сохранением полученных звуков в WAV. В этой статье я покажу, как с их помощью сгенерировать знаменитую мелодию «Popcorn» Гершона Кингсли, используя для этого всего три функции, умещающиеся в 100 строк кода.

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

Цифровой звук, как известно, представляет собой ряд амплитуд звуковой волны – или, вернее, амплитуд напряжения переменного тока, из которого получается звук в динамиках – закодированных через равные промежутки времени (импульсно-кодовая модуляция, PCM). При помощи dlib.audio вы можете создать в памяти звук (объект Sound) и модифицировать его амплитуды, а затем сохранить в файл WAV (или воспроизвести каким-нибудь звуковым API, но эта задача выходит за рамки возможностей dlib). При этом используются три важнейших параметра:

  • Частота дискретизации (Sound.sampleRate) – количество закодированных амплитуд (сэмплов) в секунду. Чем выше это значение, тем больший диапазон звуковых частот можно закодировать. Обычно используется частота 44100 Гц.
  • Битовая глубина (Sound.bitDepth) – количество бит на сэмпл. Чем выше это значение, тем точнее звуковые данные передают градации громкости. dlib поддерживает глубины 8 и 16 бит. 8-битная глубина достаточна для представления некоторых простых синтезированных звуков, но для голоса и музыки обычно используются 16 бит. Обе глубины могут быть со знаком и беззнаковыми – для лучшей совместимости с другими библиотеками. Принципиальной разницы между этими форматами нет, но сэмпл со знаком будет обрабатываться быстрее за счет отсутствия нормализации.
  • Количество каналов (Sound.channels) – dlib может работать с любым количеством каналов, однако в WAV можно сохранить только одноканальные и двухканальные звуки. Многоканальные сэмплы хранятся в памяти последовательно, подобно пикселям изображения – таким образом, любой звук представляет собой всего один массив данных.

Чтобы можно было абстрагировать алгоритмы синтеза и обработки звука от битовой глубины, в dlib.audio применяется обработка сэмплов в числах с плавающей запятой. Иными словами, сэмплы считываются и записываются не в 8- и 16-битных int’ах, а во float’ах. Помимо удобства, это повышает точность вычислений благодаря отсутствию потерь информации в промежуточных данных – в целочисленное представление кодируется только результат вычисления. Диапазон информации во float-сэмплах составляет от -1.0 до 1.0. При конвертировании в беззнаковое целочисленное представление производится нормализация этого значения, поэтому лучше хранить сэмплы в формате со знаком.

API dlib.audio очень близок по духу к dlib.image. Чтение и запись сэмплов осуществляется при помощи оператора квадратных скобок с двумя индексами – первый индекс обозначает номер канала (начиная с 0), второй – индекс сэмпла. Это абстрактный параметр, зависящий от частоты дискретизации – можно его назвать X-координатой сэмпла. Чтобы получить индекс из значения времени, нужно умножить время в секундах на частоту дискретизации, а затем округлить до ближайшего целого. Но обычно эффективнее работать не во времени, а сразу в шкале частот, предварительно вычислив интервал дискретизации (1.0 / sound.sampleRate) – таким образом, для произвольного индекса сэмпла можно легко получить соответствующее ему время, умножив на интервал. Имея время, вы можете вычислить амплитуду при помощи функции-осциллятора. Самыми популярными осцилляторами являются синусоида (sine wave), прямоугольная волна (square wave) и пилообразная волна (sawtooth wave).

Функцию, заполняющую объект Sound синусоидальным сигналом, вы можете найти в модуле dlib.audio.synth. Выглядит она следующим образом:

void sineWave(Sound sound, uint channel, float freq)
{
    float samplePeriod = 1.0f / cast(float)sound.sampleRate;
    foreach(i; 0..sound.size)
    {
        sound[channel, i] = sin(freq * i * samplePeriod * 2.0f * PI);
    }
}

Пользователь может контролировать частоту полученного сигнала. Чтобы сгенерировать амплитуду, нужно получить число колебаний (частота, помноженная на время – freq * i * samplePeriod) и перевести его в фазу (одно колебание – приращение фазы – соответствует 2 π радиан).

Пример использования:

auto snd = new GenericSound(1.0f, 44100, 1, SampleFormat.S16);
sineWave(snd, 0, 425);
saveWAV(snd, "output.wav");

Этот код создаст одноканальный звук длительностью в 1 секунду с частотой дискретизации 44100 Гц и 16 битами со знаком на сэмпл, заполнив его синусоидой частоты 425 Гц.

График:

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

void fmSynth(Sound snd, uint ch, float carrierFreq, float modulatorFreq, 
  float startTime, float duration, float volume)
{
    float samplePeriod = 1.0f / cast(float)snd.sampleRate;
    uint startSample = cast(uint)(startTime * snd.sampleRate);
    uint numSamples = cast(uint)(duration * snd.sampleRate);

    foreach(i; startSample..(startSample + numSamples))
    {
        float time = samplePeriod * (i - startSample);
        float modulator = sin(modulatorFreq * time * 2.0f * PI);
        float carrier = sin((carrierFreq + modulator) * time * 2.0f * PI);

        float src = snd[ch, i];
        snd[ch, i] = src + carrier * volume;
    }
}

Функция fmSynth добавляет в заданный объект Sound синусоидальный сигнал частоты carrierFreq, модулированной прямоугольной волной частоты modulatorFreq. Время начала и длительность сигнала задаются параметрами startTime и duration, пиковая громкость – параметром volume.

Наиболее гармоничные сочетания звуков получаются, если отношение модулирующей частоты к несущей равно целому числу:

fmSynth(snd, 0, 50, 50 * 10, 0.0, 1.0, 0.5);

График:

Увеличенный фрагмент:

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

float adsr(float attackTime, float decayTime, 
  float sustainTime, float sustain, float releaseTime, float t)
{
    if (t < attackTime)
        return lerp(0.0f, 1.0f, t / attackTime);
    else if (t < decayTime)
        return lerp(1.0f, sustain, (t - attackTime) / (decayTime - attackTime));
    else if (t < sustainTime)
        return sustain;
    else if (t < releaseTime)
        return lerp(sustain, 0.0f, (t - sustainTime) / (releaseTime - sustainTime));
    else
        return 0.0f;
}

Смысл этой функции понятен из кода – она позволяет управлять изменением громкости в четыре стадии: атака (attack), спад (decay), поддержка (sustain), затухание (release). Атака – время, за которое громкость повышается от нуля до пикового значения. Спад – время, за которое громкость падает от пикового до значения поддержки. Затухание – время, за которое громкость падает от значения поддержки до нуля. Все значения времени задаются в абстрактных единицах в диапазоне 0..1 (по сути, в «пространстве длительности» ноты).

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

void fmSynth(Sound snd, uint ch, float carrierFreq, float modulatorFreq, 
  float startTime, float duration, float volume)
{
    float samplePeriod = 1.0f / cast(float)snd.sampleRate;
    uint startSample = cast(uint)(startTime * snd.sampleRate);
    uint numSamples = cast(uint)(duration * snd.sampleRate);

    float envelopePeriod = 1.0f / cast(float)numSamples;

    float at = 0.15f;
    float dt = 0.25f;
    float st = 0.5f;
    float rt = 0.75f;
    float s = 0.66f;

    foreach(i; startSample..(startSample + numSamples))
    {
        float time = samplePeriod * (i - startSample);
        float envelopeTime = envelopePeriod * (i - startSample);
        float envelope = adsr(at, dt, st, s, rt, envelopeTime);

        float modulator = sin(modulatorFreq * time * 2.0f * PI);
        float carrier = sin((carrierFreq + modulator) * time * 2.0f * PI);

        float src = snd[ch, i];
        snd[ch, i] = src + carrier * envelope * volume;
    }
}

Параметры ADSR вшиты в функцию, но в реальном синтезаторе их лучше сделать настраиваемыми.

График:

Теперь мы можем воспроизводить звук любой частоты в любом месте дорожки – осталось реализовать способ записи нот и их интерпретацию, и получится простейшая музыкальная машина, или секвенсор. Я решил использовать последовательности обозначений вида C/4/3/2, где буква означает ноту, первое число – номер октавы, второе – позицию ноты, третье – длительность ноты. Позиция и длительность задаются в 1/16 ноты – то есть, в примере выше позиция 3 соответствует времени, равному трем шестнадцатым нотам от начала дорожки, а длительность 2 – восьмой ноте.

Функция, считывающая ноты из строки и записывающая их в объект звука, выглядит следующим образом:

void recordScore(Sound snd, string score, float bpm, float volume)
{
    const float[9][string] noteTable = [
        "C":  [16, 33,  65, 131, 262, 523, 1047, 2093, 4186],
        "C#": [17, 35,  69, 139, 278, 554, 1109, 2218, 4435],
        "D":  [18, 37,  73, 147, 294, 587, 1175, 2349, 4699],
        "D#": [20, 39,  78, 156, 311, 622, 1245, 2489, 4978],
        "E":  [21, 41,  82, 165, 330, 659, 1319, 2637, 5274],
        "F":  [22, 44,  87, 175, 349, 699, 1397, 2794, 5588],
        "F#": [23, 46,  93, 185, 370, 740, 1475, 2960, 5920],
        "G":  [25, 49,  98, 196, 392, 784, 1568, 3136, 6272],
        "G#": [26, 52, 104, 208, 415, 831, 1661, 3322, 6645],
        "A":  [28, 55, 110, 220, 440, 880, 1760, 3520, 7040],
        "A#": [29, 58, 117, 233, 466, 932, 1865, 3729, 7459],
        "B":  [31, 62, 124, 247, 494, 988, 1976, 3951, 7902]
    ];

    float quarterNote = 60.0f / bpm;
    float sixteenthNote = quarterNote / 4.0f;

    foreach(t, note; score.split)
    {
        string pitch;
        uint octave, position, duration;
        formattedRead(note, "%s/%s/%s/%s", 
          &pitch, &octave, &position, &duration);
        float freq = noteTable[pitch][octave];
        fmSynth(snd, 0, freq, freq * 10, 
          sixteenthNote * position, 
          sixteenthNote * duration, volume);
    }
}

Параметр bpm определяет скорость композиции (количество бит, или четвертных нот, в минуту). Для «Popcorn» (см. ниже) я использовал скорость 120. Параметр volume определяет громкость композиции.

Стандартные частоты, соответствующие нотам (таблица noteTable), я взял отсюда: http://peabody.sapp.org/class/st2/lab/notehz.

Теперь записываем ноты мелодии и воспроизводим их:

auto snd = new GenericSound(8.0f, 44100, 1, SampleFormat.S16);
string popcorn = 
"C/4/0/1 A#/3/2/1 C/4/4/1 G/3/6/1 D#/3/8/1 G/3/10/1 C/3/12/1
 C/4/16/1 A#/3/18/1 C/4/20/1 G/3/22/1 D#/3/24/1 G/3/26/1 C/3/28/1 
 C/4/32/1 D/4/34/1 D#/4/36/1 D/4/38/1 D#/4/40/1 C/4/42/1 D/4/44/1 
 C/4/46/1 D/4/48/1 A#/3/50/1 C/4/52/1 A#/3/54/1 C/4/56/1 G#/3/58/1 C/4/60/1";
recordScore(snd, popcorn, 120, 0.8);
saveWAV(snd, "popcorn.wav");

Кстати, поскольку позиции задаются явно, можно записать несколько нот на одной позиции и получить аккорд.

Полный исходник программы:

module audiotest;

import std.math;
import std.string;
import std.format;
import dlib.audio;
import dlib.math;

float adsr(float attackTime, float decayTime, float sustainTime, float sustain, float releaseTime, float t)
{
    if (t < attackTime)
        return lerp(0.0f, 1.0f, t / attackTime);
    else if (t < decayTime)
        return lerp(1.0f, sustain, (t - attackTime) / (decayTime - attackTime));
    else if (t < sustainTime)
        return sustain;
    else if (t < releaseTime)
        return lerp(sustain, 0.0f, (t - sustainTime) / (releaseTime - sustainTime));
    else
        return 0.0f;
}

void fmSynth(Sound snd, uint ch, float carrierFreq, float modulatorFreq, float startTime, float duration, float volume)
{
    float samplePeriod = 1.0f / cast(float)snd.sampleRate;
    uint startSample = cast(uint)(startTime * snd.sampleRate);
    uint numSamples = cast(uint)(duration * snd.sampleRate);

    float envelopePeriod = 1.0f / cast(float)numSamples;

    float at = 0.15f;
    float dt = 0.25f;
    float st = 0.5f;
    float rt = 0.75f;
    float s = 0.66f;

    foreach(i; startSample..(startSample + numSamples))
    {
        float time = samplePeriod * (i - startSample);
        float envelopeTime = envelopePeriod * (i - startSample);
        float envelope = adsr(at, dt, st, s, rt, envelopeTime);

        float modulator = sin(modulatorFreq * time * 2.0f * PI);
        float carrier = sin((carrierFreq + modulator) * time * 2.0f * PI);

        float src = snd[ch, i];
        snd[ch, i] = src + carrier * envelope * volume;
    }
}

void recordScore(Sound snd, string score, float bpm, float volume)
{
    const float[9][string] noteTable = [
        "C":  [16, 33,  65, 131, 262, 523, 1047, 2093, 4186],
        "C#": [17, 35,  69, 139, 278, 554, 1109, 2218, 4435],
        "D":  [18, 37,  73, 147, 294, 587, 1175, 2349, 4699],
        "D#": [20, 39,  78, 156, 311, 622, 1245, 2489, 4978],
        "E":  [21, 41,  82, 165, 330, 659, 1319, 2637, 5274],
        "F":  [22, 44,  87, 175, 349, 699, 1397, 2794, 5588],
        "F#": [23, 46,  93, 185, 370, 740, 1475, 2960, 5920],
        "G":  [25, 49,  98, 196, 392, 784, 1568, 3136, 6272],
        "G#": [26, 52, 104, 208, 415, 831, 1661, 3322, 6645],
        "A":  [28, 55, 110, 220, 440, 880, 1760, 3520, 7040],
        "A#": [29, 58, 117, 233, 466, 932, 1865, 3729, 7459],
        "B":  [31, 62, 124, 247, 494, 988, 1976, 3951, 7902]
    ];

    float quarterNote = 60.0f / bpm;
    float sixteenthNote = quarterNote / 4.0f;

    foreach(t, note; score.split)
    {
        string pitch;
        uint octave, position, duration;
        formattedRead(note, "%s/%s/%s/%s", &pitch, &octave, &position, &duration);
        float freq = noteTable[pitch][octave];
        fmSynth(snd, 0, freq, freq * 10, sixteenthNote * position, sixteenthNote * duration, volume);
    }
}

void main(string[] args)
{
   auto snd = new GenericSound(8.0f, 44100, 1, SampleFormat.S16);

    string popcorn = 
"C/4/0/1 A#/3/2/1 C/4/4/1 G/3/6/1 D#/3/8/1 G/3/10/1 C/3/12/1
 C/4/16/1 A#/3/18/1 C/4/20/1 G/3/22/1 D#/3/24/1 G/3/26/1 C/3/28/1 
 C/4/32/1 D/4/34/1 D#/4/36/1 D/4/38/1 D#/4/40/1 C/4/42/1 D/4/44/1 
 C/4/46/1 D/4/48/1 A#/3/50/1 C/4/52/1 A#/3/54/1 C/4/56/1 G#/3/58/1 C/4/60/1";

    recordScore(snd, popcorn, 120, 0.5);
    saveWAV(snd, "popcorn.wav"); 
}

Добавить комментарий