Вашей игре нужен звук! Наверно, вы уже использовали 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 | Тип OpenALC | Тип OpenGL | C++ Typedef | Описание |
---|---|---|---|---|
ALboolean |
ALCboolean |
GLboolean |
std::int8_t |
8-битное булево значение |
ALbyte |
ALCbyte |
GLbyte |
std::int8_t |
8-битное целочисленное значение дополнительного кода со знаком |
ALubyte |
ALCubyte |
GLubyte |
std::uint8_t |
8-битное целочисленное значение без знака |
ALchar |
ALCchar |
GLchar |
char |
символ |
ALshort |
ALCshort |
GLshort |
std::int16_t |
16-битное целочисленное значение дополнительного кода со знаком |
ALushort |
ALCushort |
GLushort |
std::uint16_t |
16-битное целочисленное значение без знака |
ALint |
ALCint |
GLint |
std::int32_t |
32-битное целочисленное значение дополнительного кода со знаком |
ALuint |
ALCuint |
GLuint |
std::uint32_t |
32-битное целочисленное значение без знака |
ALsizei |
ALCsizei |
GLsizei |
std::int32_t |
неотрицательное 32-битное двоичное целочисленное значение |
ALenum |
ALCenum |
GLenum |
std::uint32_t |
перечислимое 32-битное значение |
ALfloat |
ALCfloat |
GLfloat |
float |
32-битное значение с плавающей запятой IEEE 754 |
ALdouble |
ALCdouble |
GLdouble |
double |
64-битное значение с плавающей запятой IEEE 754 |
ALvoid |
ALCvoid |
GLvoid |
void |
пустое значение |
Распознавание ошибок 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_FREQUENCY |
Частота микширования в буфер вывода, измеряемая в Гц |
ALC_REFRESH |
Интервалы обновления, измеряемые в Гц |
ALC_SYNC |
0 или 1 обозначают, должен ли это быть синхронный или асинхронный контекст |
ALC_MONO_SOURCES |
Значение, помогающее сообщить, сколько источников вы будете использовать, которым потребуется возможность обработки монофонических звуковых данных. Оно не ограничивает максимально допустимое количество, просто позволяет быть более эффективным, когда знаешь это заранее. |
ALC_STEREO_SOURCES |
То же самое, но для стереоданных. |
Если вы получаете ошибки, то скорее всего это из-за того, что желаемые вами атрибуты невозможны или вы не можете создать ещё один контекст для поддерживаемого устройства; при этом будет получена ошибка 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
такая же, как и у alcMakeContextCurrent
— ALC_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. Об этом мы расскажем в одной из следующих частей серии туториалов.
Автор: PatientZero