Руководство программиста для звука

0090036

Издание содержит подробную информацию о хранении, обработке и компрессии звуковых файлов. Разработчики, которые хотят использовать в своих приложениях возможность обработки звука, найдут в этой книге ответы на все интересующие их вопросы: применение низкоуровневых средств работы со звуком для Win32, Mac OS и UNIX; декомпрессия данных в форматах MPEG, IMA ADPCM; чтение файлов WAVE, VOC, AIFF и AU; воспроизведение файлов MIDI и MOD.

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

Для каждого раздела приведены полные исходные тексты программ, иллюстрирующие все обсуждаемые принципы.

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

Введение

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

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

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

Исходные тексты программ

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

За редким исключением, вы можете использовать исходные тексты моих программ в разрабатываемых вами программных продуктах. Я всячески приветствую сотрудничество такого рода. Тем не менее, пожалуйста, внимательно изучите условия, приведенные в начале каждого файла с исходными текстами. Если у вас возникнут вопросы, не стесняйтесь и свяжитесь со мной через издателя. Даже если вопросов не возникнет, я бы хотел узнать о том, как вы использовали мой код и каковы были ваши впечатления. Если вы проявите достаточную заинтересованность,
то при согласии издателя эту книгу можно будет переработать так, чтобы она в большей степени соответствовала вашим потребностям.

Издание содержит подробную информацию о хранении, обработке и компрессии звуковых файлов. Разработчики, которые хотят использовать в своих приложениях возможность обработки звука, найдут в этой книге ответы на все интересующие их вопросы: применение низкоуровневых средств работы со звуком для Win32, Mac OS и UNIX; декомпрессия данных в форматах MPEG, IMA ADPCM; чтение файлов WAVE, VOC, AIFF и AU; воспроизведение файлов MIDI и MOD.

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

Для каждого раздела приведены полные исходные тексты программ, иллюстрирующие все обсуждаемые принципы.

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

Вашей игре нужен звук! Наверно, вы уже использовали OpenGL для рисования на экране. Вы разобрались с его API, и поэтому обратились к OpenAL, потому что название кажется знакомым.

Что же, хорошие новости — OpenAL тоже имеет очень знакомый API. Он изначально задумывался для имитации API спецификации OpenGL. Именно поэтому я выбрал его среди множества звуковых систем для игр; к тому же он кроссплатформенный.

В этой статье я подробно расскажу о том, какой код нужен для использования OpenAL в игре, написанной на C++. Мы обсудим звуки, музыку и позиционирование звука в 3D-пространстве с примерами кода.

История OpenAL

Постараюсь быть кратким. Как говорилось выше, он намеренно разрабатывался как имитация OpenGL API, и на то есть причина. Это удобный API, который многим известен, и если графика — одна сторона игрового движка, то звук должен быть другой. Изначально OpenAL должен был стать open-source, но потом кое-что произошло…

Людей не так сильно интересует звук, как графика, поэтому в конечном итоге Creative сделала OpenAL своей собственностью, а эталонная реализация теперь проприетарна и небесплатна. Но! Спецификация OpenAL по-прежнему является «открытым» стандартом, то есть она публикуется.

Время от времени в спецификацию вносятся изменения, но их не так много. Звук меняется не так быстро, как графика, ведь особой нужды к этому нет.

Открытая спецификация позволила другим людям создать open-source-реализацию спецификации. Одной из таких реализаций является OpenAL Soft, и, честно говоря, нет никакого смысла искать любые другие. Это та реализация, которую буду использовать я, и рекомендую вам использовать её же.

Она кроссплатформенная. Реализована она достаточно любопытно — по сути, внутри библиотека использует другие звуковые API, присутствующие в вашей системе. В Windows она использует DirectSound, в Unix — OSS. Благодаря этому она и смогла стать кроссплатформенной; в сущности, это громкое название для обёртки API.

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

Но хватит истории, давайте перейдём к технологиям.

Что нужно, чтобы писать код на OpenAL?

Нужно собрать OpenAL Soft в выбранном вами тулчейне. Это очень простой процесс, который можно выполнить в соответствии с инструкциями в разделе Source Install. У меня никогда не возникало с этим проблем, но если появятся затруднения, то напишите комментарий под оригиналом статьи или напишите в список рассылки OpenAL Soft.

Далее вам понадобится несколько звуковых файлов и способ их загрузки. Загрузка аудиоданных в буферы и тонкие подробности различных аудиоформатов находятся за пределами тематики этой статьи, но вы можете почитать о загрузке и потоковом воспроизведении файлов Ogg/Vorbis. Загрузка файлов WAV очень проста, об этом уже есть сотни статей в Интернете.

Задачу поиска аудиофайлов вам придётся решать самим. В Интернете есть множество шумов и взрывов, которые можно скачать. Если у вас есть слух, то можете попробовать написать собственную чиптюн-музыку [перевод на Хабре].

Кроме того, держите под рукой Programmers Guide from OpenALSoft. Эта документация гораздо лучше pdf с «официальной» специализацией.

Вот, собственно, и всё. Будем считать, что вы уже знаете, как писать код, использовать IDE и тулчейн.

Обзор OpenAL API

Как я уже несколько раз говорил, он похож на OpenGL API. Схожесть заключается в том, что он основан на состояниях и вы взаимодействуете с дескрипторами/идентификаторами, а не с самими объектами напрямую.

Существуют расхождения между условными обозначениями API в OpenGL и OpenAL, но они незначительны. В OpenGL для генерации контекста рендеринга нужно выполнять специальные вызовы ОС. Эти вызовы для разных ОС различны и на самом деле не являются частью спецификации OpenGL. В OpenAL всё иначе — функции создания контекста являются частью спецификации и одинаковы вне зависимости от операционной системы.

При взаимодействии с API существуют три основных типа объектов, с которыми вы взаимодействуете. Listeners («слушатели») — это местонахождение «ушей», расположенных в 3D-пространстве (всегда существует только один listener). Sources («источники») — это «динамики», издающие звук, опять-таки в 3D-пространстве. Listener и sources можно перемещать в пространстве и в зависимости от этого изменяется то, что вы слышите через динамики в игре.

Последние объекты — это buffers («буферы»). В них хранятся сэмплы звуков, которые sources будут воспроизводить для listeners.

Существуют также modes («режимы»), которые игра использует для изменения способа обработки звука через OpenAL.

Sources

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

Listener

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

Buffers

В OpenGL их аналогом является Texture2D. По сути, это аудиоданные, которые воспроизводит source.

Типы данных

Чтобы иметь возможность поддержки кроссплатформенного кода, OpenAL выполняет определённую последовательность действий и задаёт некоторые типы данных. На самом деле, он так точно следует OpenGL, что мы даже можем напрямую преобразовывать типы OpenAL в типы OpenGL. В таблице ниже перечислены они и их эквиваленты.

Распознавание ошибок OpenAL

Есть статья о том, как упростить распознавание ошибок OpenAL, но ради полноты руководства я повторю её здесь. Существует два типа вызовов OpenAL API: обычные и контекстные.

Контекстные вызовы, начинающиеся с alc, похожи на win32-вызовы OpenGL для получения контекста рендеринга или их аналогов в Linux. Звук — достаточно простая вещь, чтобы у всех операционных систем были одинаковые вызовы. Обычные вызовы начинаются с al. Для получения ошибок в контекстных вызовах мы вызываем alcGetError; в случае обычных вызовов мы вызываем alGetError. Они возвращают или значение ALCenum, или значение ALenum, в которых просто перечисляются возможные ошибки.

Сейчас мы рассмотрим только один случай, но во всём остальном они практически одинаковы. Давайте возьмём обычные вызовы al. Сначала создадим макрос препроцессора для выполнения скучной работы по передаче подробностей:

#define alCall(function, ...) alCallImpl(__FILE__, __LINE__, function, __VA_ARGS__)

Теоретически, ваш компилятор может не поддерживать __FILE__ или __LINE__, но, честно говоря, я был бы удивлён, если бы это оказалось так. __VA_ARGS__ обозначает переменное количество аргументов, которые могут передаваться этому макросу.

Далее мы реализуем функцию, которая вручную получает последнюю сообщённую ошибку и выводит в стандартный поток ошибок понятное значение.

bool check_al_errors(const std::string& filename, const std::uint_fast32_t line)
{
    ALenum error = alGetError();
    if(error != AL_NO_ERROR)
    {
        std::cerr << "***ERROR*** (" << filename << ": " << line << ")n" ;
        switch(error)
        {
        case AL_INVALID_NAME:
            std::cerr << "AL_INVALID_NAME: a bad name (ID) was passed to an OpenAL function";
            break;
        case AL_INVALID_ENUM:
            std::cerr << "AL_INVALID_ENUM: an invalid enum value was passed to an OpenAL function";
            break;
        case AL_INVALID_VALUE:
            std::cerr << "AL_INVALID_VALUE: an invalid value was passed to an OpenAL function";
            break;
        case AL_INVALID_OPERATION:
            std::cerr << "AL_INVALID_OPERATION: the requested operation is not valid";
            break;
        case AL_OUT_OF_MEMORY:
            std::cerr << "AL_OUT_OF_MEMORY: the requested operation resulted in OpenAL running out of memory";
            break;
        default:
            std::cerr << "UNKNOWN AL ERROR: " << error;
        }
        std::cerr << std::endl;
        return false;
    }
    return true;
}

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

Затем мы реализуем две разные шаблонные функции, которые будут «оборачивать» все наши вызовы OpenGL.

template<typename alFunction, typename... Params>
auto alCallImpl(const char* filename, 
                const std::uint_fast32_t line, 
                alFunction function, 
                Params... params)
->typename std::enable_if_t<!std::is_same_v<void,decltype(function(params...))>,decltype(function(params...))>
{
    auto ret = function(std::forward<Params>(params)...);
    check_al_errors(filename,line);
    return ret;
}

template<typename alcFunction, typename... Params>
auto alcCallImpl(const char* filename, 
                 const std::uint_fast32_t line, 
                 alcFunction function, 
                 ALCdevice* device, 
                 Params... params)
->typename std::enable_if_t<std::is_same_v<void,decltype(function(params...))>,bool>
{
    function(std::forward<Params>(params)...);
    return check_alc_errors(filename,line,device);
}

Их две, потому что первая используется для функций OpenAL, возвращающих void, а вторая используется, когда функция возвращает непустое значение. Если вы не очень знакомы с метапрограммированием шаблонов в C++, то взгляните на части кода с std::enable_if. Они определяют то, какие из этих шаблонных функций реализуются компилятором для каждого вызова функции.

А теперь то же самое для вызовов alc:

#define alcCall(function, device, ...) alcCallImpl(__FILE__, __LINE__, function, device, __VA_ARGS__)

bool check_alc_errors(const std::string& filename, const std::uint_fast32_t line, ALCdevice* device)
{
    ALCenum error = alcGetError(device);
    if(error != ALC_NO_ERROR)
    {
        std::cerr << "***ERROR*** (" << filename << ": " << line << ")n" ;
        switch(error)
        {
        case ALC_INVALID_VALUE:
            std::cerr << "ALC_INVALID_VALUE: an invalid value was passed to an OpenAL function";
            break;
        case ALC_INVALID_DEVICE:
            std::cerr << "ALC_INVALID_DEVICE: a bad device was passed to an OpenAL function";
            break;
        case ALC_INVALID_CONTEXT:
            std::cerr << "ALC_INVALID_CONTEXT: a bad context was passed to an OpenAL function";
            break;
        case ALC_INVALID_ENUM:
            std::cerr << "ALC_INVALID_ENUM: an unknown enum value was passed to an OpenAL function";
            break;
        case ALC_OUT_OF_MEMORY:
            std::cerr << "ALC_OUT_OF_MEMORY: an unknown enum value was passed to an OpenAL function";
            break;
        default:
            std::cerr << "UNKNOWN ALC ERROR: " << error;
        }
        std::cerr << std::endl;
        return false;
    }
    return true;
}

template<typename alcFunction, typename... Params>
auto alcCallImpl(const char* filename, 
                 const std::uint_fast32_t line, 
                 alcFunction function, 
                 ALCdevice* device, 
                 Params... params)
->typename std::enable_if_t<std::is_same_v<void,decltype(function(params...))>,bool>
{
    function(std::forward<Params>(params)...);
    return check_alc_errors(filename,line,device);
}

template<typename alcFunction, typename ReturnType, typename... Params>
auto alcCallImpl(const char* filename,
                 const std::uint_fast32_t line,
                 alcFunction function,
                 ReturnType& returnValue,
                 ALCdevice* device, 
                 Params... params)
->typename std::enable_if_t<!std::is_same_v<void,decltype(function(params...))>,bool>
{
    returnValue = function(std::forward<Params>(params)...);
    return check_alc_errors(filename,line,device);
}

Самое большое изменение — это включение device, которое используют все вызовы alc, а также соответствующее использование ошибок стиля ALCenum и ALC_. Они выглядят очень похожими, и очень долго небольшие изменения с al на alc сильно вредили моему коду и пониманию, поэтому я просто продолжал чтение прямо поверх этого c.

Вот и всё. Обычно вызов OpenAL на C++ выглядит как один из следующих вариантов:

/* example #1 */
alGenSources(1, &source);
ALenum error = alGetError();
if(error != AL_NO_ERROR)
{
    /* handle different possibilities */
}

/* example #2 */
alcCaptureStart(&device);
ALCenum error = alcGetError();
if(error != ALC_NO_ERROR)
{
    /* handle different possibilities */
}

/* example #3 */
const ALchar* sz = alGetString(param);
ALenum error = alGetError();
if(error != AL_NO_ERROR)
{
    /* handle different possibilities */
}

/* example #4 */
const ALCchar* sz = alcGetString(&device, param);
ALCenum error = alcGetError();
if(error != ALC_NO_ERROR)
{
    /* handle different possibilities */
}

Но теперь мы можем делать это вот так:

/* example #1 */
if(!alCall(alGenSources, 1, &source))
{
    /* error occurred */
}

/* example #2 */
if(!alcCall(alcCaptureStart, &device))
{
    /* error occurred */
}

/* example #3 */
const ALchar* sz;
if(!alCall(alGetString, sz, param))
{
    /* error occurred */
}

/* example #4 */
const ALCchar* sz;
if(!alcCall(alcGetString, sz, &device, param))
{
    /* error occurred */
}

Возможно, это выглядит для вас странно, но мне так удобнее. Разумеется, вы можете выбрать другую структуру.

Загрузка файлов .wav

Вы можете или загружать их самостоятельно, или использовать библиотеку. Вот open-source-реализация загрузки файлов .wav. Я сумасшедший, поэтому делаю это сам:

std::int32_t convert_to_int(char* buffer, std::size_t len)
{
    std::int32_t a = 0;
    if(std::endian::native == std::endian::little)
        std::memcpy(&a, buffer, len);
    else
        for(std::size_t i = 0; i < len; ++i)
            reinterpret_cast<char*>(&a)[3 - i] = buffer[i];
    return a;
}

bool load_wav_file_header(std::ifstream& file,
                          std::uint8_t& channels,
                          std::int32_t& sampleRate,
                          std::uint8_t& bitsPerSample,
                          ALsizei& size)
{
    char buffer[4];
    if(!file.is_open())
        return false;

    // the RIFF
    if(!file.read(buffer, 4))
    {
        std::cerr << "ERROR: could not read RIFF" << std::endl;
        return false;
    }
    if(std::strncmp(buffer, "RIFF", 4) != 0)
    {
        std::cerr << "ERROR: file is not a valid WAVE file (header doesn't begin with RIFF)" << std::endl;
        return false;
    }

    // the size of the file
    if(!file.read(buffer, 4))
    {
        std::cerr << "ERROR: could not read size of file" << std::endl;
        return false;
    }

    // the WAVE
    if(!file.read(buffer, 4))
    {
        std::cerr << "ERROR: could not read WAVE" << std::endl;
        return false;
    }
    if(std::strncmp(buffer, "WAVE", 4) != 0)
    {
        std::cerr << "ERROR: file is not a valid WAVE file (header doesn't contain WAVE)" << std::endl;
        return false;
    }

    // "fmt/0"
    if(!file.read(buffer, 4))
    {
        std::cerr << "ERROR: could not read fmt/0" << std::endl;
        return false;
    }

    // this is always 16, the size of the fmt data chunk
    if(!file.read(buffer, 4))
    {
        std::cerr << "ERROR: could not read the 16" << std::endl;
        return false;
    }

    // PCM should be 1?
    if(!file.read(buffer, 2))
    {
        std::cerr << "ERROR: could not read PCM" << std::endl;
        return false;
    }

    // the number of channels
    if(!file.read(buffer, 2))
    {
        std::cerr << "ERROR: could not read number of channels" << std::endl;
        return false;
    }
    channels = convert_to_int(buffer, 2);

    // sample rate
    if(!file.read(buffer, 4))
    {
        std::cerr << "ERROR: could not read sample rate" << std::endl;
        return false;
    }
    sampleRate = convert_to_int(buffer, 4);

    // (sampleRate * bitsPerSample * channels) / 8
    if(!file.read(buffer, 4))
    {
        std::cerr << "ERROR: could not read (sampleRate * bitsPerSample * channels) / 8" << std::endl;
        return false;
    }

    // ?? dafaq
    if(!file.read(buffer, 2))
    {
        std::cerr << "ERROR: could not read dafaq" << std::endl;
        return false;
    }

    // bitsPerSample
    if(!file.read(buffer, 2))
    {
        std::cerr << "ERROR: could not read bits per sample" << std::endl;
        return false;
    }
    bitsPerSample = convert_to_int(buffer, 2);

    // data chunk header "data"
    if(!file.read(buffer, 4))
    {
        std::cerr << "ERROR: could not read data chunk header" << std::endl;
        return false;
    }
    if(std::strncmp(buffer, "data", 4) != 0)
    {
        std::cerr << "ERROR: file is not a valid WAVE file (doesn't have 'data' tag)" << std::endl;
        return false;
    }

    // size of data
    if(!file.read(buffer, 4))
    {
        std::cerr << "ERROR: could not read data size" << std::endl;
        return false;
    }
    size = convert_to_int(buffer, 4);

    /* cannot be at the end of file */
    if(file.eof())
    {
        std::cerr << "ERROR: reached EOF on the file" << std::endl;
        return false;
    }
    if(file.fail())
    {
        std::cerr << "ERROR: fail state set on the file" << std::endl;
        return false;
    }

    return true;
}

char* load_wav(const std::string& filename,
               std::uint8_t& channels,
               std::int32_t& sampleRate,
               std::uint8_t& bitsPerSample,
               ALsizei& size)
{
    std::ifstream in(filename, std::ios::binary);
    if(!in.is_open())
    {
        std::cerr << "ERROR: Could not open "" << filename << """ << std::endl;
        return nullptr;
    }
    if(!load_wav_file_header(in, channels, sampleRate, bitsPerSample, size))
    {
        std::cerr << "ERROR: Could not load wav header of "" << filename << """ << std::endl;
        return nullptr;
    }

    char* data = new char[size];

    in.read(data, size);

    return data;
}

Я не буду объяснять код, потому что это не совсем в тематике нашей статьи; но он очень очевиден, если читать его параллельно со спецификацией файла WAV.

Инициализация и уничтожение

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

ALCdevice аналогично выбору графической карты. на которой ваша OpenGL-игра будет выполнять рендеринг. ALCcontext аналогичен контексту рендеринга, который нужно создать (уникальным для операционной системы образом) для OpenGL.

ALCdevice

OpenAL Device — это то, через что выполняется вывод звука, будь то звуковая карта или чип, но теоретически это может быть и множеством различных вещей. Аналогично тому, как стандартный вывод iostream может быть вместо экрана принтером, устройство может быть файлом или даже потоком данных.

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

Для получения списка доступных в системе устройств можно запросить их такой функцией:

bool get_available_devices(std::vector<std::string>& devicesVec, ALCdevice* device)
{
    const ALCchar* devices;
    if(!alcCall(alcGetString, devices, device, nullptr, ALC_DEVICE_SPECIFIER))
        return false;

    const char* ptr = devices;

    devicesVec.clear();

    do
    {
        devicesVec.push_back(std::string(ptr));
        ptr += devicesVec.back().size() + 1;
    }
    while(*(ptr + 1) != '');

    return true;
}

На самом деле это просто обёртка вокруг обёртки вокруг вызова alcGetString. Возвращаемое значение — это указатель на список строк, разделённых значением null и заканчивающийся двумя значениями null. Здесь обёртка просто превращает его в удобный для нас вектор.

К счастью, нам не нужно этого делать! В общем случае, как я подозреваю, большинство игр может просто выводить звук на устройство по умолчанию, каким бы оно ни было. Я нечасто вижу опции изменения аудиоустройства, через которое нужно выводить звук. Поэтому для инициализации OpenAL Device мы используем вызов alcOpenDevice. Этот вызов немного отличается от всего остального, поскольку он не задаёт состояния ошибки, которое можно получить через alcGetError, поэтому мы вызываем его, как обычную функцию:

ALCdevice* openALDevice = alcOpenDevice(nullptr);
if(!openALDevice)
{
    /* fail */
}

Если вы перечислили устройства показанным выше образом, и хотите, чтобы пользователь выбрал одно из них, то нужно передать его название в alcOpenDevice вместо nullptr. Отправка nullptr приказывает открыть устройство по умолчанию. Возвращаемое значение — это или соответствующее устройство, или nullptr, если произошла ошибка.

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

Последнее, что мы делаем при закрытии программы — правильно её завершаем.

ALCboolean closed;
if(!alcCall(alcCloseDevice, closed, openALDevice, openALDevice))
{
    /* do we care? */
}

На этом этапе, если выполнить завершение не удалось, то нам это уже не важно. Перед закрытием устройства мы должны закрыть все созданные контексты, однако по моему опыту, этот вызов тоже завершает контекст. Но мы сделаем это правильно. Если вы всё завершаете перед совершением вызова alcCloseDevice, то ошибок быть не должно, и если они по каким-то причинам возникли, то вы ничего не сможете с этим сделать.

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

Теоретически, я могу улучшить шаблонную функцию так, чтобы она передавала первый параметр на проверку ошибок и всё равно пересылала его функции; но мне лениво это делать. Оставлю это вам в качестве домашнего задания.

Наш ALCcontext

Вторая часть инициализации — это контекст. Как и ранее, он аналогичен контексту рендеринга из OpenGL. В одной программе может быть несколько контекстов и мы можем переключаться между ними, но нам это не понадобится. Каждый контекст имеет собственные listener и sources, и их нельзя передавать между контекстами.

Возможно, это полезно в ПО обработки звука. Однако для игр в 99.9% случаев достаточно только одного контекста.

Создать новый контекст очень просто:

ALCcontext* openALContext;
if(!alcCall(alcCreateContext, openALContext, openALDevice, openALDevice, nullptr) || !openALContext)
{
    std::cerr << "ERROR: Could not create audio context" << std::endl;
    /* probably exit program */
}

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

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

Если вы получаете ошибки, то скорее всего это из-за того, что желаемые вами атрибуты невозможны или вы не можете создать ещё один контекст для поддерживаемого устройства; при этом будет получена ошибка ALC_INVALID_VALUE. Если вы передадите недопустимое устройство, то получите ошибку ALC_INVALID_DEVICE, но, разумеется, эту ошибку мы уже проверяем.

Создания контекста недостаточно. Нам ещё нужно сделать его текущим — выглядит похоже на Windows OpenGL Rendering Context, правда? Это то же самое.

ALCboolean contextMadeCurrent = false;
if(!alcCall(alcMakeContextCurrent, contextMadeCurrent, openALDevice, openALContext)
   || contextMadeCurrent != ALC_TRUE)
{
    std::cerr << "ERROR: Could not make audio context current" << std::endl;
    /* probably exit or give up on having sound */
}

Делать контекст текущим необходимо для совершения любых дальнейших операций с контекстом (или с sources и listeners в нём). Операция вернёт true или false, единственное возможное значение ошибки, передаваемое alcGetError — это ALC_INVALID_CONTEXT, которое понятно из названия.

Завершив с контекстом, т.е. при выходе из программы, нужно, чтобы контекст больше не был текущим, а затем уничтожить его.

if(!alcCall(alcMakeContextCurrent, contextMadeCurrent, openALDevice, nullptr))
{
    /* what can you do? */
}

if(!alcCall(alcDestroyContext, openALDevice, openALContext))
{
    /* not much you can do */
}

Единственная возможная ошибка от alcDestroyContext такая же, как и у alcMakeContextCurrentALC_INVALID_CONTEXT; если вы всё делаете правильно, то не получите её, а если получаете, то с этим ничего нельзя поделать.

Зачем проверять наличие ошибок, с которыми ничего нельзя сделать?

Потому что мне хочется, чтобы сообщения о них хотя бы появлялись в потоке ошибок, что для нас делает alcCall.Допустим, он никогда не выдаёт нам ошибки, но будет полезно знать, что подобная ошибка возникает на чьём-то чужом компьютере. Благодаря этому мы можем изучить проблему, а возможно и сообщить о баге разработчикам OpenAL Soft.

Воспроизводим наш первый звук

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

I am the protector of this system!

Итак, откроем IDE и используем следующий код. Не забудьте подключить OpenAL Soft и добавить показанный выше код загрузки файла и код проверки ошибок.

int main()
{
    ALCdevice* openALDevice = alcOpenDevice(nullptr);
    if(!openALDevice)
        return 0;

    ALCcontext* openALContext;
    if(!alcCall(alcCreateContext, openALContext, openALDevice, openALDevice, nullptr) || !openALContext)
    {
        std::cerr << "ERROR: Could not create audio context" << std::endl;
        return 0;
    }
    ALCboolean contextMadeCurrent = false;
    if(!alcCall(alcMakeContextCurrent, contextMadeCurrent, openALDevice, openALContext)
       || contextMadeCurrent != ALC_TRUE)
    {
        std::cerr << "ERROR: Could not make audio context current" << std::endl;
        return 0;
    }

    std::uint8_t channels;
    std::int32_t sampleRate;
    std::uint8_t bitsPerSample;
    std::vector<char> soundData;
    if(!load_wav("iamtheprotectorofthissystem.wav", channels, sampleRate, bitsPerSample, soundData))
    {
        std::cerr << "ERROR: Could not load wav" << std::endl;
        return 0;
    }

    ALuint buffer;
    alCall(alGenBuffers, 1, &buffer);

    ALenum format;
    if(channels == 1 && bitsPerSample == 8)
        format = AL_FORMAT_MONO8;
    else if(channels == 1 && bitsPerSample == 16)
        format = AL_FORMAT_MONO16;
    else if(channels == 2 && bitsPerSample == 8)
        format = AL_FORMAT_STEREO8;
    else if(channels == 2 && bitsPerSample == 16)
        format = AL_FORMAT_STEREO16;
    else
    {
        std::cerr
            << "ERROR: unrecognised wave format: "
            << channels << " channels, "
            << bitsPerSample << " bps" << std::endl;
        return 0;
    }

    alCall(alBufferData, buffer, format, soundData.data(), soundData.size(), sampleRate);
    soundData.clear(); // erase the sound in RAM

    ALuint source;
    alCall(alGenSources, 1, &source);
    alCall(alSourcef, source, AL_PITCH, 1);
    alCall(alSourcef, source, AL_GAIN, 1.0f);
    alCall(alSource3f, source, AL_POSITION, 0, 0, 0);
    alCall(alSource3f, source, AL_VELOCITY, 0, 0, 0);
    alCall(alSourcei, source, AL_LOOPING, AL_FALSE);
    alCall(alSourcei, source, AL_BUFFER, buffer);

    alCall(alSourcePlay, source);

    ALint state = AL_PLAYING;

    while(state == AL_PLAYING)
    {
        alCall(alGetSourcei, source, AL_SOURCE_STATE, &state);
    }

    alCall(alDeleteSources, 1, &source);
    alCall(alDeleteBuffers, 1, &buffer);

    alcCall(alcMakeContextCurrent, contextMadeCurrent, openALDevice, nullptr);
    alcCall(alcDestroyContext, openALDevice, openALContext);

    ALCboolean closed;
    alcCall(alcCloseDevice, closed, openALDevice, openALDevice);

    return 0;
}

Компилируем! Компонуем! Запускаем! I am the prrrootector of this system. Если вы не слышите звука, то снова всё проверьте. Если в окне консоли что-то написано, то это должен быть стандартный вывод потока ошибок, и он важен. Наши функции сообщений об ошибках должны подсказать нам строку исходного кода, сгенерировавшую ошибку.

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

Загрузка данных RIFF WAVE

std::uint8_t channels;
std::int32_t sampleRate;
std::uint8_t bitsPerSample;
std::vector<char> soundData;
if(!load_wav("iamtheprotectorofthissystem.wav", channels, sampleRate, bitsPerSample, soundData))
{
    std::cerr << "ERROR: Could not load wav" << std::endl;
    return 0;
}

Это относится к коду загрузки wave. Важно то, что мы получаем данные, или как указатель, или собранные в вектор: количество каналов, частота дискретизации и количество битов на сэмпл.

Генерация буфера

ALuint buffer;
alCall(alGenBuffers, 1, &buffer);

Вероятно, это выглядит для вас знакомым, если вы когда-нибудь генерировали буферы текстурных данных в OpenGL. По сути, мы генерируем буфер и притворяемся, что он будет существовать только в звуковой карте. На самом же деле он скорее всего будет храниться в обычной ОЗУ, но спецификация OpenAL абстрагирует все эти операции.

Итак, значение ALuint является дескриптором нашего буфера. Помните, что buffer в сущности является звуковыми данными в памяти звуковой карты. У нас больше нет прямого доступа к этим данным, поскольку мы забрали их из программы (из обычной ОЗУ) и переместили в звуковую карту/чип и т.п. Аналогичным образом работает OpenGL, перемещая текстурные данные из ОЗУ во VRAM.

Этот дескриптор генерирует alGenBuffers. Он имеет пару возможных значений ошибок, самым важным из которых является AL_OUT_OF_MEMORY, которое означает, что мы больше не может добавлять звуковые данные в звуковую карту. Вы не получите этой ошибки, если, например, используете единственный буфер, однако это нужно учитывать, если вы создаёте движок.

Определяем формат звуковых данных

ALenum format;

if(channels == 1 && bitsPerSample == 8)
    format = AL_FORMAT_MONO8;
else if(channels == 1 && bitsPerSample == 16)
    format = AL_FORMAT_MONO16;
else if(channels == 2 && bitsPerSample == 8)
    format = AL_FORMAT_STEREO8;
else if(channels == 2 && bitsPerSample == 16)
    format = AL_FORMAT_STEREO16;
else
{
    std::cerr
        << "ERROR: unrecognised wave format: "
        << channels << " channels, "
        << bitsPerSample << " bps" << std::endl;
    return 0;
}

Звуковые данные работают так: существует несколько каналов и есть величина битов на сэмпл. Данные состоят из множества сэмплов.

Для определения количества сэмплов в аудиоданных мы делаем следующее:

std::int_fast32_t numberOfSamples = dataSize / (numberOfChannels * (bitsPerSample / 8));

Что удобным образом можно преобразовать в вычисление длительности звуковых данных:

std::size_t duration = numberOfSamples / sampleRate;

Но пока нам не нужно знать ни numberOfSamples, ни duration, однако важно знать, как используются все эти фрагменты информации.

Вернёмся к format — нам нужно сообщить OpenAL формат звуковых данных. Это кажется очевидным, правда? Аналогично тому, как мы заполняет буфер текстур OpenGL, сообщая, что данные находятся в последовательности BGRA и составлены из 8-битных значений, нам нужно сделать подобное и в OpenAL.

Чтобы сообщить OpenAL о том, как интерпретировать данные, на которые указывает тот указатель, который мы передадим позже, нам нужно определить формат данных. Под форматом подразумевается то, как его понимает OpenAL. Существует всего четыре возможных значения. Есть два возможных значения для количества каналов: один для моно, два для стерео.

Кроме количества каналов, у нас есть количество битов на сэмпл. Оно равно или 8, или 16, и по сути является качеством звука.

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

Заполнение буфера

alCall(alBufferData, buffer, format, soundData.data(), soundData.size(), sampleRate);
soundData.clear(); // erase the sound in RAM

С этим всё должно быть просто. Мы загрузим в OpenAL Buffer, на который указывает дескриптор buffer; данные, на которые указывает ptr soundData.data(), в размере size с указанной sampleRate. Также мы сообщим OpenAL формат этих данных через параметр format.

В конце мы просто удаляем данные, которые получил загрузчик wave. Зачем же? Потому что мы уже скопировали их в звуковую карту. Нам нет необходимости хранить их в двух местах и расходовать драгоценные ресурсы. Если звуковая карта потеряет данные, то мы просто снова загрузим их с диска и нам не нужно будет копировать их для ЦП или ещё кого-то.

Настройка Source

Вспомним, что OpenAL по сути является listener, слушающим звуки, издаваемые одним или несколькими sources. Ну, теперь настало время создать источник звука.

ALuint source;
alCall(alGenSources, 1, &source);
alCall(alSourcef, source, AL_PITCH, 1);
alCall(alSourcef, source, AL_GAIN, 1.0f);
alCall(alSource3f, source, AL_POSITION, 0, 0, 0);
alCall(alSource3f, source, AL_VELOCITY, 0, 0, 0);
alCall(alSourcei, source, AL_LOOPING, AL_FALSE);
alCall(alSourcei, source, AL_BUFFER, buffer);

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

Сначала мы генерируем source — помните, это снова дескриптор чего-то внутри OpenAL API. Мы задаём pitch (тон) так, чтобы он не изменился, gain (громкость) делаем равным исходному значению звуковых данных, позицию и скорость обнуляем; мы не зацикливаем звук, потому что в противном случае наша программа никогда не завершится, и указываем буфер.

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

Воспроизведение звука

alCall(alSourcePlay, source);

ALint state = AL_PLAYING;

while(state == AL_PLAYING)
{
    alCall(alGetSourcei, source, AL_SOURCE_STATE, &state);
}

Сначала нам нужно запустить воспроизведение source. Достаточно просто вызвать alSourcePlay.

Затем мы создаём значение для хранения текущего состояния AL_SOURCE_STATE источника и бесконечно его обновляем. Когда оно больше не равно AL_PLAYING мы можем продолжить. Можно изменить состояние на AL_STOPPED, когда он завершит издавать звук из буфера (или когда возникнет ошибка). Если задать для looping значение true, то звук будет воспроизводиться вечно.

Затем мы можем изменить буфер источника и воспроизвести другой звук. Или заново проиграть тот же звук, и т.д. Просто задаём буфер, используем alSourcePlay и, может быть, alSourceStop, если нужно. В следующих статьях мы рассмотрим это более подробно.

Очистка

alCall(alDeleteSources, 1, &source);
alCall(alDeleteBuffers, 1, &buffer);

Так как мы просто воспроизводим звуковые данные один раз и выполняем выход, то удалим ранее созданные source и buffer.

Остальная часть кода понятна без объяснений.

Куда двигаться дальше?

Зная всё описанное в данной статье, уже можно создать небольшую игру! Попробуйте создать Pong или какую-нибудь другую классическую игру, для них большего и не требуется.

Но помните! Эти буферы подходят только для коротких звуков, скорее всего, длительностью несколько секунд. Если вам нужна музыка или озвучка, то потребуется потоковая передача аудиоданных в OpenAL. Об этом мы расскажем в одной из следующих частей серии туториалов.

Занимаясь музыкальным творчеством, я часто делаю аранжировки и записи на компьютере — используя кучу всяких VST плагинов и инструментов. Стыдно признаться — я никогда не понимал, как «накручивают» звуки в синтезаторах. Программирование позволило мне написать свой синтезатор, «пропустить через себя» процесс создания звука.

Я планирую несколько статей, в которых будет пошагово рассказано, как написать свой VST плагин/инструмент: программирование осциллятора, частотного фильтра, различных эффектов и модуляции параметров. Упор будет сделан на практику, объяснение программисту простым языком, как же все это работает. Теорию (суровые выводы и доказательства) обойдем стороной (естественно, будут ссылки на статьи и книги).

Обычно плагины пишутся на C++ (кроссплатформенность, возможность эффективно реализовать алгоритмы), но я решил выбрать более подходящий для меня язык — C#; сфокусироваться на изучении самого синтезатора, алгоритмов, а не технических деталей программирования. Для создания красивого интерфейса я использовал WPF. Возможность использования архитектуры .NET дала возможность библиотека-обертка VST. NET.

Ниже представлен обзорный ролик моего простого синтезатора, полученных интересных звучаний.

Предстоит нелегкий путь, если вы готовы — добро пожаловать под кат.

Цикл статей

  1. Понимаем и пишем VSTi синтезатор на C# WPF
  2. ADSR-огибающая сигнала
  3. Частотный фильтр Баттервота
  4. Delay, Distortion и модуляция параметров

Оглавление

  1. Загадочный мир синтеза звука
  2. Звук в цифровом виде
  3. VST SDK
  4. WDL-OL и JUCE
  5. VST .NET
  6. Моя надстройка над VST .NET
  7. WPF UI
  8. UI-поток
  9. Обзор архитектуры синтезатора Syntage
  10. Настраиваем проект для создания плагина/инструмента
  11. Отладка кода
  12. Пишем простой осциллятор
  13. Список литературы

Загадочный мир синтеза звука

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

Перебирая пресеты одного синтезатора можно встретить как «ожидаемый» звук электронного синтезатора из детства (музыка из мультика Летучий Корабль) так и имитацию ударных, звуков, шума, даже голоса! И все это делает один синтезатор, с одними и теми же ручками параметров. Это меня всегда удивляло, хотя я понимал: каждый звук — суть конкретная настройка всех ручек.

Недавно я решил наконец-таки разобраться, каким же образом создаётся (или, правильнее сказать, синтезируется) звук, как и почему нужно крутить ручки, как видоизменяется от эффектов сигнал (визуально и на слух). И конечно же, научиться (хотя бы понять основы) самому «накручивать» звук, копировать понравившиеся мне стили. Я решил последовать одной цитате:

«Скажи мне — и я забуду, покажи мне — и я запомню, дай мне сделать — и я пойму.»
Конфуций

Конечно, все подряд делать не надо (куда столько велосипедов?), но сейчас я хочу получить знания и самое главное — поделиться ими с вами.

Цель: не углубляясь в теорию, создать простой синтезатор, сделав упор на объяснение процессов с точки зрения программирования, на практике.

В синтезаторе будут:

  • генератор волны (осциллятор)
  • ADSR огибающая сигнала
  • фильтр частот
  • эхо/дилей
  • модуляция параметров

Все составляющие я планирую рассмотреть в нескольких статьях. В данной будет рассмотрено программирование осциллятора.

Программировать будем на C#; UI можно писать либо на WPF, либо на Windows Forms, либо вообще обойтись без графической оболочки. Плюс выбора WPF — красивая графика, которую достаточно быстро кодить, минус — только на Windows. Владельцы других ОС — не расстраивайтесь, всё-таки цель — понять работу синтезатора (а не запилить красивый UI), тем более, код, который я буду демонстрировать, можно быстро перенести, скажем, на С++.

В главах VST SDK и WDL-OL и JUCE я расскажу про концепцию VST, ее внутреннюю реализацию; про библиотеки-надстройки, которые хорошо подойдут для разработки серьезных плагинов. В главе VST .NET я расскажу про данную библиотеку, ее минусы, мою надстройку, программирование UI.

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

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

Звук в цифровом виде

По-сути, конечная наша цель — создание звука на компьютере. Обязательно прочитайте (хотя бы, бегло) статью на хабре «Теория звука» — в ней изложены базовые знания о представлении звука на компьютере, понятия и термины.

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

Хотя, конечно, есть задачи, которым не важно знать, что вы обрабатываете — они рассматривают конкретный семпл. Например, задача — поднять уровень громкости в 2 раза. Мы можем работать с каждым семплом по-отдельности, и нам не нужно знать про остальные.

Мы будем работать с семплом как с float-числом от -1 до 1. Обычно, чтобы не говорить «значение семпла», можно сказать «амплитуда». Если амплитуда каких-то семплов будет больше 1 или меньше -1, произойдет клиппинг, этого нужно избегать.

VST SDK

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

Чтобы создавать VST плагины, компания Steinberg (некоторые ее знают по программе Cubase) выпустила VST SDK, написанный на C++. Помимо технологии (или, как еще говорят, «формата плагинов») VST, есть и другие — RTAS, AAX, тысячи их. Я выбрал VST, из-за большей известности, большого количества плагинов и инструментов (хотя, большинство известных плагинов поставляется в разных форматах).

На данный момент актуальная версия VST SDK 3.6.6, хотя многие продолжают использовать версию 2.4. Исторически складывается, что сложно найти DAW без поддержки версии 2.4, и не все поддерживают версию 3.0 и выше.

VST SDK можно скачать с официального сайта.
В дальнейшем мы будем работать с библиотекой VST.NET, которая является оберткой для VST 2.4.

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

Сейчас я кратко изложу принципы VST SDK 2.4, для общего понимания работы плагина и его взаимодействия с DAW.

В Windows VST плагин версии 2.4 представляется как динамическая DLL библиотека.
Хостом мы будем называть программу, которая загружает нашу DLL. Обычно это либо программа редактирования музыки (DAW), либо простая оболочка, чтобы запускать плагин независимо от других программ (например, очень часто в виртуальных инструментах с .dll плагином поставляется .exe файл, чтобы загружать плагин как отдельную программу — пианино, синтезатор).

Дальнейшие функции, перечисления и структуры вы можете найти в скачанном VST SDK в исходниках из папки «VST3 SDKpluginterfacesvst2.x».

Библиотека должна экспортировать функцию со следующей сигнатурой:

EXPORT void* VSTPluginMain(audioMasterCallback hostCallback)

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

VstIntPtr (VSTCALLBACK *audioMasterCallback) (AEffect* effect, VstInt32 opcode, VstInt32 index, VstIntPtr value, void* ptr, float opt)

Все делается на достаточно «низком» уровне — чтобы хост понял, что от него хотят, нужно передавать номер команды через параметр opcode. Перечисление всех опкодов хардкорные C-кодеры могут найти в перечислении AudioMasterOpcodesX. Остальные параметры используются аналогичным образом.

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

Основные поля структуры AEffect:

  • Информация о плагине. Название, версия, число параметров, число программ и пресетов (читай далее), тип плагина и прочее.

  • Фунции для запроса и установки значений параметров.

  • Функции смены пресетов/программ.

  • Фунция обработки массива семплов

    void (VSTCALLBACK *AEffectProcessProc) (AEffect* effect, float** inputs, float** outputs, VstInt32 sampleFrames)

    float** — это массив каналов, каждый канал содержит одинаковое количество семплов (количество семплов в массиве зависит от звукового драйвера и его настроек). В основном встречаются плагины, обрабатывающие моно и стерео.

  • Супер-функция, подобна audioMasterCallback.

    VstIntPtr (VSTCALLBACK *AEffectDispatcherProc) (AEffect* effect, VstInt32 opcode, VstInt32 index, VstIntPtr value, void* ptr, float opt)

    Вызывается хостом, по параметру opcode определяется необходимое действие (список AEffectOpcodes). Используется, чтобы узнать дополнительную информацию о параметрах, сообщать плагину об изменениях в хосте (изменение частоты дискредитации), для взаимодействия с UI плагина.

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

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

Хосту все равно, какая логика у параметров в плагине. Задача хоста — сохранять, загружать, автоматизировать параметры. Хосту очень удобно воспринимать параметр, как float-число от 0 до 1 — а уж плагин пусть как хочет, так его и толкует (так и сделали большинство DAW, неофициально).

Пресеты (в терминах VST SDK — programs/программы) это коллекция конкретных значений всех параметров плагина. Хост может менять/переключать/выбирать номера пресетов, узнавать их названия, аналогично с параметрами. Банки — коллекция пресетов. Банки логически существуют только в DAW, в VST SDK есть только пресеты и программы.

Поняв идею структуры AEffect можно набросать и скомпилировать простой DLL-плагинчик.
А мы пойдем дальше, на уровень выше.

WDL-OL и JUCE

Чем плоха разработка на голом VST SDK?

  • Писать всю рутину с нуля самому?.. По-любому, кто-то уже это сделал!
  • Структуры, коллбэки… а хочется чего-то более высокоуровневого
  • Хочется кроссплатформенность, чтобы код был один
  • А что насчет UI, которое легко разрабатывать!?

На сцену выходит WDL-OL. Это C++ библиотека для создания кроссплатформенных плагинов. Поддерживаются форматы VST, VST3, Audiounit, RTAS, AAX. Удобство библиотеки состоит в том, что (при правильной настройке проекта) вы пишете один код, а при компилировании получаете свой плагин в разных форматах.

Как работать с WDL-OL хорошо описано в Martin Finke’s Blog «Music & Programming», даже есть хабр статьи-переводы на русский.

WDL-OL решает, по крайней мере, первые три пункта минусов разработки на VST SDK. Все, что вам нужно — корректно настроить проект (первая статья из блога), и отнаследоваться от класса IPlug.

class MySuperPuperPlugin : public IPlug
{
public:

    explicit MyFirstPlugin(IPlugItanceInfo instanceInfo);
    virtual ~MyFirstPlugin() override;

    void ProcessDoubleReplacing(double** inputs, double** outputs, int nFrames) override;
};

Теперь с чистой совестью можно реализовать функцию ProcessDoubleReplacing, которая, по сути и является «ядром» плагина. Все заботы взял на себя класс IPlug. Если его изучать, можно быстро понять, что (в формате VST) он является оберткой структуры AEffect. Коллбэки от хоста и функции для хоста превратились в удобные виртуальные функции, с понятными названиями и адекватными списками параметров.

В WDL-OL уже есть средства для создания UI. Но как по мне, все это делается с большой болью: UI собирается в коде, все ресурсы нужно описывать в .rc файле и так далее.

Помимо WDL-OL я так же узнал про библиотеку JUCE. JUCE похожа на WDL-OL, решает все заявленные минусы разработки на VST SDK. Помимо всего прочего, она уже имеет в своем составе и UI-редактор, и кучу классов для работы с аудио данными. Я лично ее не использовал, поэтому советую прочитать о ней, хотя бы, на вики.

Если вы хотите писать серьезный плагин, тут я бы уже всерьез задумался над использованием библиотек WDL-OL или JUCE. Всю рутину они сделают за вас, а у вас же остается вся мощь языка C++ для реализации эффективных алгоритмов и кроссплатформенность — что не маловажно в мире большого количества DAW.

VST .NET

Чем же мне не угодили WDL-OL и JUCE?

  1. Моя задача — понять как программируется синтезатор, обработка аудио, эффекты, а не как собрать плагин под максимальное количество форматов и платформ. «Техническое программирование» здесь отходит на второй план (конечно, это не повод писать плохой код и не использовать ООП).
  2. Я разбалован языком C#. Опять же, этот язык, в отличие от того же C++, позволяет не думать о некоторых технических моментах.
  3. Мне нравится технология WPF в плане ее визуальных возможностей.

Страничка библиотеки — vstnet.codeplex.com, там есть исходники, бинарники, документация. Как я понял, библиотека находится в стадии почти доделал и забил заморозки (не реализованы некоторые редко используемые функции, пару лет нет изменений репозитория).

Библиотека состоит из трех ключевых сборок:

  1. Jacobi.Vst.Core.dll — содержит интерфейсы, определяющие поведения хоста и плагина, вспомогательные классы аудио, событий, MIDI. Большая часть является оберткой нативных структур, дефайнов и перечислений из VST SDK.
  2. Jacobi.Vst.Framework.dll — содержит базовые классы плагинов, реализующие интерфейсы из Jacobi.Vst.Core, позволяющие ускорить разработку плагинов и не писать все с нуля; классы для более высокоуровневого взаимодействия «хост-плагин», различные менеджеры параметров и программ, MIDI-сообщений, работы с UI.
  3. Jacobi.Vst.Interop.dll — Managed C++ обертка над VST SDK, которая позволяет соединить хост с загруженной .NET сборкой (вашим плагином).

Как можно делать .NET сборки, если хост ожидает простую динамическую DLL? А вот как: на самом деле хост грузит не вашу сборку, а скомпилированную DLL Jacobi.Vst.Interop, которая уже в свою очередь грузит ваш плагин в рамках .NET.

Используется следующая хитрость: допустим, вы разрабатываете свой плагин, и на выходе получаете .NET-сборку MyPlugin.dll. Нужно сделать так, чтобы хост вместо вашей MyPlugin.dll загрузил Jacobi.Vst.Interop.dll, а она загрузила ваш плагин. Вопрос, а как Jacobi.Vst.Interop.dll узнает откуда грузить вашу либу? Вариантов решения много. Разработчик выбрал вариант называть либу-обертку одинаковым именем с вашей либой, а затем искать .NET-сборку как «мое_имя.vstdll».

Работает все это следующим образом

  1. Вы скомпилировали и получили MyPlugin.dll
  2. Переименовываем MyPlugin.dll в MyPlugin.vstdll
  3. Копируем рядом Jacobi.Vst.Interop.dll
  4. Переименовываем Jacobi.Vst.Interop.dll на MyPlugin.dll
  5. Теперь хост будет грузить MyPlugin.dll (т.е. Jacobi.Vst.Interop обертку) а она, зная что ее имя «MyPlugin», загрузит вашу сборку MyPlugin.vstdll.

При загрузке вашей либы необходимо, чтобы в ней был класс, реализующий интерфейс IVstPluginCommandStub:

public interface IVstPluginCommandStub : IVstPluginCommands24
{
    VstPluginInfo GetPluginInfo(IVstHostCommandStub hostCmdStub);
    Configuration PluginConfiguration { get; set; }
}

VstPluginInfo содержит базовую о плагине — версия, уникальный ID плагина, число параметров и программ, число обрабатываемых каналов. PluginConfiguration нужна для вызывающей либы-обертки Jacobi.Vst.Interop.

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

Jacobi.Vst.Framework содержит готовый удобный класс StdPluginCommandStub, реализующий IVstPluginCommandStub. Все что нужно сделать — отнаследоваться от StdPluginCommandStub и реализовать метод CreatePluginInstance(), который будет возвращать объект (instance) вашего класса-плагина, реализующего IVstPlugin.

public class PluginCommandStub : StdPluginCommandStub
{
    protected override IVstPlugin CreatePluginInstance()
    {
        return new MyPluginController();
    }
}

Опять же, есть готовый удобный класс VstPluginWithInterfaceManagerBase:

public abstract class VstPluginWithInterfaceManagerBase : PluginInterfaceManagerBase, IVstPlugin, IExtensible, IDisposable
{
    protected VstPluginWithInterfaceManagerBase(string name, VstProductInfo productInfo, VstPluginCategory category,
        VstPluginCapabilities capabilities, int initialDelay, int pluginID);

    public VstPluginCapabilities Capabilities { get; }
    public VstPluginCategory Category { get; }
    public IVstHost Host { get; }
    public int InitialDelay { get; }
    public string Name { get; }
    public int PluginID { get; }
    public VstProductInfo ProductInfo { get; }

    public event EventHandler Opened;

    public virtual void Open(IVstHost host);
    public virtual void Resume();
    public virtual void Suspend();
    protected override void Dispose(bool disposing);
    protected virtual void OnOpened();
}

Если смотреть исходный код библиотеки, можно увидеть интерфейсы, описывающие компоненты плагина, для работы с аудио, параметрами, MIDI и т.д. :

IVstPluginAudioProcessor
IVstPluginParameters
IVstPluginPrograms
IVstHostAutomation
IVstMidiProcessor

Класс VstPluginWithInterfaceManagerBase содержит виртуальные методы, возвращающие эти интерфейсы:

protected virtual IVstPluginAudioPrecisionProcessor CreateAudioPrecisionProcessor(IVstPluginAudioPrecisionProcessor instance);
protected virtual IVstPluginAudioProcessor CreateAudioProcessor(IVstPluginAudioProcessor instance);
protected virtual IVstPluginBypass CreateBypass(IVstPluginBypass instance);
protected virtual IVstPluginConnections CreateConnections(IVstPluginConnections instance);
protected virtual IVstPluginEditor CreateEditor(IVstPluginEditor instance);
protected virtual IVstMidiProcessor CreateMidiProcessor(IVstMidiProcessor instance);
protected virtual IVstPluginMidiPrograms CreateMidiPrograms(IVstPluginMidiPrograms instance);
protected virtual IVstPluginMidiSource CreateMidiSource(IVstPluginMidiSource instance);
protected virtual IVstPluginParameters CreateParameters(IVstPluginParameters instance);
protected virtual IVstPluginPersistence CreatePersistence(IVstPluginPersistence instance);
protected virtual IVstPluginProcess CreateProcess(IVstPluginProcess instance);
protected virtual IVstPluginPrograms CreatePrograms(IVstPluginPrograms instance);

Эти методы и нужно перегружать, чтобы реализовывать свою логику в кастомных классах-компонентах. Например, вы хотите обрабатывать семплы, тогда вам нужно написать класс, реализующий IVstPluginAudioProcessor, и вернуть его в методе CreateAudioProcessor.

public class MyPlugin : VstPluginWithInterfaceManagerBase
{
    ...
    protected override IVstPluginAudioProcessor CreateAudioProcessor(IVstPluginAudioProcessor instance)
    {
        return new MyAudioProcessor();
    }
    ...
}
...
public class MyAudioProcessor : VstPluginAudioProcessorBase // используем готовый класс из либы
{
    public override void Process(VstAudioBuffer[] inChannels, VstAudioBuffer[] outChannels)
    {
        // обработка семплов
    }
}

Используя различные готовые классы-компоненты можно сосредоточиться на программировании логики плагина. Хотя, вам никто не мешает реализовывать все самому, как хочется, основываясь только на интерфейсах из Jacobi.Vst.Core.

Для тех, кто уже кодит — предлагаю вам пример просто плагина, который понижает громкость на 6 дБ (для этого нужно умножить семпл на 0.5, почему — читай в статье про звук).

Пример просто плагина

using Jacobi.Vst.Core;
using Jacobi.Vst.Framework;
using Jacobi.Vst.Framework.Plugin;

namespace Plugin
{
    public class PluginCommandStub : StdPluginCommandStub
    {
        protected override IVstPlugin CreatePluginInstance()
        {
            return new MyPlugin();
        }
    }

    public class MyPlugin : VstPluginWithInterfaceManagerBase
    {
        public MyPlugin() : base(
            "MyPlugin",
            new VstProductInfo("MyPlugin", "My Company", 1000),
            VstPluginCategory.Effect,
            VstPluginCapabilities.None,
            0,
            new FourCharacterCode("TEST").ToInt32())
        {
        }

        protected override IVstPluginAudioProcessor CreateAudioProcessor(IVstPluginAudioProcessor instance)
        {
            return new AudioProcessor();
        }
    }

    public class AudioProcessor : VstPluginAudioProcessorBase
    {
        public AudioProcessor() : base(2, 2, 0) // плагин будет обрабатывать стерео
        {
        }

        public override void Process(VstAudioBuffer[] inChannels, VstAudioBuffer[] outChannels)
        {
            for (int i = 0; i < inChannels.Length; ++i)
            {
                var inChannel = inChannels[i];
                var outChannel = outChannels[i];
                for (int j = 0; j < inChannel.SampleCount; ++j)
                {
                    outChannel[j] = 0.5f * inChannel[j];
                }
            }
        }
    }
}

Моя надстройка над VST .NET

При программировании синта я столкнулся с некоторыми проблемами при использовании классов из Jacobi.Vst.Framework. Основная проблема заключалась в использовании параметров и их автоматизации.

Во первых, мне не понравилась реализация событий изменения значения; во вторых, обнаружились баги при тестировании плагина в FL Studio и Cubase. FL Studio воспринимает все параметры как float-числа от 0 до 1, даже не используя специальную функцию из VST SDK с опкодом effGetParameterProperties (функция вызывается у плагина чтобы получить дополнительную информацию о параметре). В WDL-OL реализация закомментирована с пометкой:

could implement effGetParameterProperties to group parameters, but can’t find a host that supports it

Хотя, конечно же, в Cubase эта функция вызывается (Cubase — продукт компании Steinberg, которая и выпустила VST SDK).

В VST .NET этот коллбэк реализован в виде функции GetParameterProperties, возвращающей объект класса VstParameterProperties. Все равно, Cubase некорректно воспринимал и автоматизировал мои параметры.

В начале я внес правки саму библиотеку, написал автору, чтобы он дал разрешение выложить исходники в репозиторий, либо сам создал репозиторий на GitHub’е. Но внятного ответа я так и не получил, поэтому решил сделать надстройку над либой — Syntage.Framework.dll.

Помимо этого, в надстройке реализованы удобные классы для работы с UI, если вы хотите использовать WPF.

Самое время скачать исходный код моего синтезатора и скомпилировать его.

Компилирование кода

  1. Склонировать/скачать репозиторий.
  2. Собрать солюшн в Visual Studio в Debug.
  3. Чтобы запустить синт из студии, нужно использовать проект SimplyHost.
  4. Файлы плагина и зависимые библиотеки будут в папке «outvst»:

Правила использования моей надстройки просты: вместо StdPluginCommandStub юзаем SyntagePluginCommandStub, а свой плагин наследуем от SyntagePlugin.

WPF UI

В VST плагине не обязательно должен быть графический интерфейс. Я видел много плагинов без UI (одни из них — mda). Большинство DAW (по крайней мере, Cubase и FL Studio) предоставят вам возможность управлять параметрами из сгенерированного ими UI.

Автосгенерированный UI для моего синтезатора в FL Studio

Чтобы ваш плагин был с UI, во-первых, у вас должен быть класс, реализующий IVstPluginEditor; во-вторых, нужно вернуть его инстанс в перегруженной функции CreateEditor вашего класса плагина (наследник SyntagePlugin).

Я написал класс PluginWpfUI<T>, который непосредственно владеет WPF-окном. Здесь T — это тип вашего UserControl, являющийся «главной формой» UI. PluginWpfUI<T> имеет 3 виртуальных метода, которые вы можете перегружать для реализации своей логики:

  • public virtual void Open(IntPtr hWnd) — вызывается при каждом открытии UI плагина
  • public virtual void Close() — вызывается при каждом закрытии UI плагина
  • public virtual void ProcessIdle() — вызывается несколько раз в секунду из UI-потока, для обработки кастомной логики (базовая реализация пустая)

В своем синтезаторе Syntage я написал пару контролов — слайдер, крутилка (knob), клавиатура пианино — если вы хотите, можете их скопировать и использовать.

UI-поток (thread)

Я тестировал синтезатор в FL Studio и Cubase 5 и уверен, что, в других DAW будет тоже самое: UI плагина обрабатывается отдельным потоком. А это значит, что логики аудио и UI обрабатывается в независимых потоках. Это влечет все проблемы, или, последствия такого подхода: доступ к данным из разных потоков, критические данные, доступ к UI из другого потока…

Для облегчения решения проблем я написал класс UIThread, который, по сути, является очередью команд. Если вы в какой-то момент хотите что-то сообщить/поменять/сделать в UI, а текущий код работает не в UI-потоке, то вы можете поставить на выполнение в очередь необходимую функцию:

UIThread.Instance.InvokeUIAction(() => Control.Oscilloscope.Update());

Здесь в очередь команд помещается анонимный метод, обновляющий нужные данные. При вызове ProcessIdle все накопившиеся в очереди команды будут выполнены.

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

Обзор архитектуры синтезатора Syntage

При написании синтезатора активно использовалось ООП; предлагаю вам познакомиться с получившейся архитектурой и использовать мой код. Вы можете сделать все по-своему, но в этих статьях придется терпеть мое видение)

Класс PluginCommandStub нужен только чтобы создать и вернуть объект класса PluginController. PluginController предоставляет информацию о плагине, так же создает и владеет следующими компонентами:

  • AudioProcessor — класс со всей логикой обработки аудио данных
  • MidiListener — класс для обработки MIDI-сообщений (из Syntage.Framework)
  • PluginUI (наследник PluginWpfUI<View>) — класс, управляющий графическим интерфейсом синтезатора, главная форма — это UserControl «View».

Чтобы обрабатывать аудиоданные есть интерфейсы IAudioChannel и IAudioStream. IAudioChannel предоставляет прямой доступ к массиву/буферу семплов (double[] Samples). IAudioStream содержит массив каналов.

Представленные интерфейсы содержат удобные методы обработки всех семплов и каналов «скопом»: микширование каналов и потоков, применение метода к каждому семплу в отдельности и так далее.

Для интерфейсов IAudioChannel и IAudioStream написаны реализации AudioChannel и AudioStream. Здесь важно запомнить следующую вещь: нельзя хранить ссылки на AudioStream и AudioChannel, если они являются внешними данными в функции. Суть в том, что размеры буферов могут меняться по ходу работы плагина, буферы постоянно переиспользуются — не выгодно постоянно перевыделять и копировать память. Если вам необходимо сохранить буфер для дальнейшего использования (уж не знаю, зачем) — копируйте его в свой буфер.

IAudioStreamProvider является владельцем аудиопотоков, можно попросить создать поток функцией CreateAudioStream и вернуть поток для его удаления функцией ReleaseAudioStream.

В каждый момент времени длина (длина массива семплов) всех аудиопотоков и каналов одинакова, технически она определяется хостом. В коде ее можно получить либо у самого IAudioChannel или IAudioStream (свойство Length), так же у «хозяина» IAudioStreamProvider (свойство CurrentStreamLenght).

Класс AudioProcessor является «ядром» синтезатора — в нем-то и происходит синтез звука. Класс является наследником SyntageAudioProcessor, который, в свою очередь, реализует следующие интерфейсы:

  • VstPluginAudioProcessorBase — чтобы обрабатывать буфер семплов (метод Process)
  • IVstPluginBypass — чтобы отключать логику синтезатора, если плагин находится в режиме Bypass
  • IAudioStreamProvider — чтобы предоставлять аудиопотоки для генераторов

Синтез звука проходит длинную цепочку обработки: создание простой волны в осцилляторах, микширование звука с разных осцилляторов, последовательная обработка в эффектах. Логика создания и обработки звука была разделена на классы-компоненты для AudioProcessor. Каждый компонент является наследником класса SyntageAudioProcessorComponentWithParameters<T> — содержит ссылку на AudioProcessor и возможность создавать параметры.

В синтезаторе представлены следующие компоненты:

  • Input — обрабатывает сообщения о нажатии нот (MIDI-сообщения и нажатия из UI)
  • Oscillator — осциллятор
  • ADSR — огибающая сигнала
  • ButterworthFilter — фильтр частот
  • Distortion — эффект дисторшн
  • Delay — эффект эхо/дилей
  • Clip — ограничивает значение всех семплов от -1 до 1.
  • LFO — модулирование параметров (обычно в синтезаторах модуляция осуществляется с использованием Low Frequency Oscillator — генератора низких частот)
  • Master — мастер-обработка (финальная обработка) сигнала. В данном случае содержит ручку главной громкости.
  • Oscillograph — осциллограф
  • Routing — содержит в себе цепочку логики обработки звука

Все этапы создания звука вы можете найти в функции Routing.Process и на следующей схеме:

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

Далее будет рассмотрено программирование логики класса Oscillator, а в следующих статьях будут рассмотрены другие классы-компоненты.

Чтобы использовать параметры, можно использовать абстрактный класс Parameter<T>, либо готовые реализации: EnumParameter, IntegerParameter, RealParameter и другие. Здесь важно понимать, что у параметра есть текущее значение Value типа T, и float-значение RealValue — отображающее обычное значение в отрезок [0,1] (нужно для работы с UI и хостом).

Настраиваем проект для создания плагина/инструмента

Наконец-то! Сейчас мы будем создавать плагин. Кодим мы на C#, и работаем в Visual Studio.
Создаем обычную .NET Class Library, и импортируем ссылки на Jacobi.Vst.Core.dll и Jacobi.Vst.Framework.dll, Syntage.Framework.dll.

Настроим копирование и переименование файлов при успешной компиляции проекта (зачем это нужно было написано в главе VST .NET).

Предлагаю вам использовать следующий скрипт (его нужно прописать в Project → Properties → Build Events → Post-build event command line, выполнение скрипта поставьте на On successful build):

if not exist "$(TargetDir)vst" mkdir "$(TargetDir)vst"
copy "$(TargetDir)$(TargetFileName)" "$(TargetDir)vst$(TargetName).net.vstdll"
copy "$(TargetDir)Syntage.Framework.dll" "$(TargetDir)vstSyntage.Framework.dll"
copy "$(TargetDir)Jacobi.Vst.Interop.dll" "$(TargetDir)vst$(TargetName).dll"
copy "$(TargetDir)Jacobi.Vst.Core.dll" "$(TargetDir)vstJacobi.Vst.Core.dll"
copy "$(TargetDir)Jacobi.Vst.Framework.dll" "$(TargetDir)vstJacobi.Vst.Framework.dll"

Отладка кода

В файле моего проекта Syntage вы найдете сборку SimplyHost. Это простой хост, который на старте загружает плагин с расширением «.vstdll» (файл ищется рядом с .exe или в дочерних папках). Рекомендую вам скопировать его к себе в проект — тогда вы без проблем сразу сможете отлаживать свой плагин.

Вы так же можете использовать другие хосты для отладки, но сделать это будет уже сложнее. Когда я тестировал синтезатор, я использовал две DAW: FL Studio 12 и Cubase 5. Если в FL Studio загрузить плагин, можно из Visual Studio приконнектиться к процессу FL Studio (Debug → Attach To Process). Это не всегда работает, нужно быть очень внимательным: загружаемая .dll должна соответствовать вашему коду в студии (пересоберите проект перед отладкой); коннектиться к процессу можно только после загрузки вашего плагина в DAW.

Пишем простой осциллятор

Я надеюсь, что вы прочитали главу «Обзор архитектуры синтезатора Syntage» — я буду объяснять все в терминах своей архитектуры.

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

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

Какие выбрать «простые» сигналы? Очевидно, сигналы, спектр которых известен и хорошо изучен, которые легко обрабатывать. Возьмем четыре знаменитые типа сигналов:

Периоды четырех типов сигналов: синус, треугольник, импульс/квадрат, пила.

Чтобы синтезировать звуки, вы должны четко представлять себе исходное звучание этих простых сигналов.

Синус имеет глухое и тихое звучание, остальные же — «острое» и громкое. Это связано с тем, что, в отличие от синуса, другие сигналы содержат большое количество других тонов (гармоник) в спектре.

Наш генерируемый сигнал будет характеризоваться двумя параметрами: типом волны и частотой.
На графике изображены периоды нужных нам волн. Заметьте, что все волны представлены в интервале от 0 до 1. Это очень удобно, так как позволяет одинаково запрограммировать расчет значений. Такой подход позволяет задать произвольную форму сигнала, я даже видел синтезаторы, где можно вручную его нарисовать.

По представленным картинкам напишем вспомогательный класс WaveGenerator, с методом GetTableSample, который будет возвращать значение амплитуды сигнала в зависимости от типа волны и времени (время должно быть в пределах от 0 до 1).

Добавим так же в тип волны белый шум — он полезен в синтезе нестандартных звуков. Белый шум характеризуется тем, что спектральные составляющие равномерно распределены по всему диапазону частот. Функция NextDouble стандартного класса Random имеет равномерное распределение — таким образом, мы можем считать, что каждый сгенерированный семпл относится к некоторой гармонике. Соответственно, мы будем выбирать гармоники равномерно, получая белый шум. Нужно лишь сделать отображение результата функции из интервала [0,1] в интервал минимального и максимального значения амплитуды [-1,1].

public static class WaveGenerator
{
    public enum EOscillatorType
    {
        Sine,
        Triangle,
        Square,
        Saw,
        Noise
    }

    private static readonly Random _random = new Random();

    public static double GetTableSample(EOscillatorType oscillatorType, double t)
    {
        switch (oscillatorType)
        {
            case EOscillatorType.Sine:
                return Math.Sin(DSPFunctions.Pi2 * t);

            case EOscillatorType.Triangle:
                if (t < 0.25) return 4 * t;
                if (t < 0.75) return 2 - 4 * t;
                return 4 * (t - 1);

            case EOscillatorType.Square:
                return (t < 0.5f) ? 1 : -1;

            case EOscillatorType.Saw:
                return 2 * t - 1;

            case EOscillatorType.Noise:
                return _random.NextDouble() * 2 - 1;

            default:
                throw new ArgumentOutOfRangeException();
        }
    }
}

Теперь, пишем класс Oscillator, который будет наследником SyntageAudioProcessorComponentWithParameters<AudioProcessor>. В осцилляторе рождается звук, поэтому класс будет реализовывать интерфейс IGenerator, а именно функцию

IAudioStream Generate();

Необходимо запросить у IAudioStreamProvider (для нас это будет родительский AudioProcessor) аудиопоток, и в каждом вызове функции Generate заполнять его сгенерированными семплами.

Пока что у нашего осциллятора будет два параметра:

  • Тип волны — WaveGenerator.EOscillatorType, используем класс EnumParameter из Syntage.Framework
  • Частота сигнала — слышимый диапазон от 20 до 20000 Гц, используем класс FrequencyParameter из Syntage.Framework

Оформим все вышесказанное:

public class Oscillator : SyntageAudioProcessorComponentWithParameters<AudioProcessor>, IGenerator
{
    private readonly IAudioStream _stream; // поток, куда будем генерировать семплы
    private double _time;

    public EnumParameter<WaveGenerator.EOscillatorType> OscillatorType { get; private set; }
    public RealParameter Frequency { get; private set; }

    public Oscillator(AudioProcessor audioProcessor) :
        base(audioProcessor)
    {
        _stream = Processor.CreateAudioStream(); // запрашиваем поток
    }

    public override IEnumerable<Parameter> CreateParameters(string parameterPrefix)
    {
        OscillatorType = new EnumParameter<WaveGenerator.EOscillatorType>(parameterPrefix + "Osc", "Oscillator Type", "Osc", false);
        Frequency = new FrequencyParameter(parameterPrefix + "Frq", "Oscillator Frequency", "Hz");

        return new List<Parameter> { OscillatorType, Frequency };
    }

    public IAudioStream Generate()
    {
        _stream.Clear(); // очищаем все, что было раньше

        GenerateToneToStream(); // самое интересное

        return _stream;
    }
}

Осталось написать функцию GenerateToneToStream.

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

  • длина текущего буфера
  • частота дискретизации

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

Семплы генерируются в цикле от 0 до [длина текущего буфера].

Частота дискретизации — число семплов в секунду. Время, которое проходит от начала одного семпла до другого равно timeDelta = 1/SampleRate. При частоте дискретизации 44100 Гц это очень маленькое время — 0.00002267573 секунды.

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

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

Нужное относительное время мы можем получить как дробную часть деления прошедшего времени на период волны.

Пример: мы генерируем синус со знаменитой частотой 440 Гц. Из частоты находим период синуса: 1/440 = 0.00227272727 секунды.
Частота дискретизации 44100 Гц.
Рассчитаем 44150-й семпл, если на нулевом семпле время равнялось нулю.
На 44150-м семпле прошло 44150/44100 = 1.00113378685 секунд.
Смотрим, сколько это в периодах — 1.00113378685/0.00227272727 = 440.498866743.
Отбрасываем целую часть — 0.498866743. Именно это значение и нужно передать в функцию WaveGenerator.GetTableSample.

Если записать все символьно, получим:

Оформим выкладки в виде отдельной функции WaveGenerator.GenerateNextSample и запишем итоговую функцию GenerateToneToStream.

public static double GenerateNextSample(EOscillatorType oscillatorType, double frequency, double time)
{
    var ph = time * frequency;
    ph -= (int)ph; // реализация frac вычитанием целой части

    return GetTableSample(oscillatorType, ph);
}
...
private void GenerateToneToStream()
{
    var count = Processor.CurrentStreamLenght; // сколько семплов нужо сгенерировать
    double timeDelta = 1.0 / Processor.SampleRate; // столько времени разделяет два соседних семпла

    // кешируем ссылки на каналы, чтобы было меньше обращений в цикле
    var leftChannel = _stream.Channels[0];
    var rightChannel = _stream.Channels[1];

    for (int i = 0; i < count; ++i)
    {
        // Frequency и OscillatorType лучше не кешировать - это параметры плагина и
        // они могут меняться
        var frequency = DSPFunctions.GetNoteFrequency(Frequency.Value);
        var sample = WaveGenerator.GenerateNextSample(OscillatorType.Value, frequency, _time);

        leftChannel.Samples[i] = sample;
        rightChannel.Samples[i] = sample;

        _time += timeDelta;
    }
}

Обычно, в параметры осциллятора добавляют следующие:

  • Громкость
  • Подстройка (Fine) — изменение частоты генерируемой волны в большую или меньшую сторону. Можно получить эффект, похожий на wah-wah если модулировать этот параметр. Если генераторов много и они смешиваются, можно делать расстройку генераторов друг относительно друга.
  • Панировка/Стерео (Pan/Panning/Stereo) отношение громкостей сигнала в левом и правом ухе.

Данные параметры есть в реализованном мною синтезаторе — вы можете самостоятельно их реализовать.

Осталось реализовать классы AudioProcessor (будет создавать осциллятор и вызывать у него метод Generate) и PluginController (создает AudioProcessor).
Посмотрите реализацию данных классов в моем коде Syntage. На текущем этапе AudioProcessor нужен, чтобы:

  • Создать осциллятор
  • Заполнить параметры (вызвать функцию CreateParameters)
  • В функции обработки буфера семплов вызывать метод Generte у осциллятора

Простая реализация перечисленных классов, для ленивых

public class PluginCommandStub : SyntagePluginCommandStub<PluginController>
{
    protected override IVstPlugin CreatePluginInstance()
    {
        return new PluginController();
    }
}
...
public class PluginController : SyntagePlugin
{
    public AudioProcessor AudioProcessor { get; }

    public PluginController() : base(
        "MyPlugin",
        new VstProductInfo("MyPlugin", "TestCompany", 1000),
        VstPluginCategory.Synth,
        VstPluginCapabilities.None,
        0,
        new FourCharacterCode("TEST").ToInt32())
    {
        AudioProcessor = new AudioProcessor(this);

        ParametersManager.SetParameters(AudioProcessor.CreateParameters());
        ParametersManager.CreateAndSetDefaultProgram();
    }

    protected override IVstPluginAudioProcessor CreateAudioProcessor(IVstPluginAudioProcessor instance)
    {
        return AudioProcessor;
    }
}
...
public class AudioProcessor : SyntageAudioProcessor
{
    private readonly AudioStream _mainStream;

    public readonly PluginController PluginController;

    public Oscillator Oscillator { get; }

    public AudioProcessor(PluginController pluginController) :
        base(0, 2, 0) // у нас синт, на вход он не принимает данные, а только генерирует стерео-сигнал
    {
        _mainStream = (AudioStream)CreateAudioStream();

        PluginController = pluginController;

        Oscillator = new Oscillator(this);
    }

    public override IEnumerable<Parameter> CreateParameters()
    {
        var parameters = new List<Parameter>();

        parameters.AddRange(Oscillator.CreateParameters("O"));

        return parameters;
    }

    public override void Process(VstAudioBuffer[] inChannels, VstAudioBuffer[] outChannels)
    {
        base.Process(inChannels, outChannels);

        // генерируем семплы
        var stream = Oscillator.Generate();

        // копируем полученный stream в _mainStream
        _mainStream.Mix(stream, 1, _mainStream, 0);

        // отправляем результат
        _mainStream.WriteToVstOut(outChannels);
    }
}

В следующей статье я расскажу как написать ADSR-огибающую.

Удачи в программировании!

P.S. В заголовке я писал что занимаюсь музыкой — если кому то интересно, можете послушать мою музыку, и в частности записанный diy-альбом.

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

  1. Теория звука. Что нужно знать о звуке, чтобы с ним работать. Опыт Яндекс.Музыки.
  2. Марпл-мл. С. Л. Цифровой спектральный анализ и его приложения.
  3. Айфичер Э., Джервис Б. — Цифровая обработка сигналов. Практический подход.
  4. Martin Finke’s Blog «Music & Programming» цикл статей по созданию синта от и до на C++, используя библиотеку WDL-OL.
  5. Хабр-переводы Martin Finke’s Blog
  6. Модульные аналоговые синтезаторы (большая хабр-статья затрагивающая вопросы синтеза звука, обзора аналоговых синтезаторов и их составляющих).

О книге «Руководство программиста по работе со звуком»

Издание содержит подробную информацию о хранении, обработке и компрессии звуковых файлов. Разработчики, которые хотят использовать в своих приложениях возможность обработки звука, найдут в этой книге ответы на все интересующие их вопросы: применение низкоуровневых средств работы со звуком для Win32, Mac OS и UNIX; декомпрессия данных в форматах MPEG, IMA ADPCM; чтение файлов WAVE, VOC, AIFF и AU; воспроизведение файлов MIDI и MOD. В этой книге вы также найдете обзор смежных тем, в частности синтеза звучания музыкальных инструментов, восприятия звуков человеком, цифровой фильтрации и преобразований Фурье. Для каждого раздела приведены полные исходные тексты программ, иллюстрирующие все обсуждаемые принципы. Книга предназначена для звукоинженеров, желающих пополнить свои знания о программировании звука, а также для программистов, которым необходима информация о работе с аудиоматериалами. Некоторые разделы книги могут быть полезны специалистам, разрабатывающим программные средства обработки сигналов для геофизики, телеметрии, связи, систем автоматического управления и сбора данных.

Произведение относится к жанру Руководства. Книга входит в серию «Для программистов (ДМК Пресс)». На нашем сайте можно скачать книгу «Руководство программиста по работе со звуком» в формате fb2, rtf, epub, pdf, txt или читать онлайн. Здесь так же можно перед прочтением обратиться к отзывам читателей, уже знакомых с книгой, и узнать их мнение. В интернет-магазине нашего партнера вы можете купить и прочитать книгу в бумажном варианте.

Понравилась статья? Поделить с друзьями:
  • Высшее руководство ссср это
  • Вакцина от пастереллеза свиней инструкция по применению в ветеринарии для
  • Как правильно пить розувастатин инструкция по применению
  • Декта форте от ушного клеща инструкция
  • Жавель абсолют 100 инструкция по применению