Rust здесь будет лишь для иллюстрации. Я уже не хочу акцентироваться на нём, но игра GMO Apple требует звукового оформления, а рассматриваемый принцип генерации звука универсален и поэтому можно сказать, что он платформенно-независим.
Некоторое время назад я уже писал о том, как генерируется звук.
Рассмотрим способ работы со звуковым буфером, который заполняется программно, а воспроизводится аппаратно.
К счастью, для Rust был готовый пример, который скомпилировался и заработал с первого раза. Так что его и буду комментировать.
Звуковой буфер
Звуковой буфер – область памяти, куда записаны звуковые данные, которые автоматически проигрываются системой. Ссылка на буфер придёт к нам уже готовая, поэтому наша задача – просто заполнить его.
В данном примере будет генерироваться звуковая волна с прямоугольным профилем, она же меандр:
Формат данных
Каждый элемент буфера это один звуковой сэмпл. Сэмпл это значение амплитуды волны в один момент времени. Значение можно хранить в разных форматах: как целое число 8 или 16 бит, как вещественное число от -1 до 1 и т.п. В нашем примере используются вещественные числа от -1 до 1.
Диапазон от -1 до 1 задаёт максимальную амплитуду (громкость), но можно использовать и более "тихие" значения, например от -0.25 до 0.25.
Высота тона
Итак, чтобы сгенерировать меандр, мы должны в звуковой буфер последовательно записать значения громкости (амплитуды), назовём её volume: сначала -volume, затем volume, затем -volume, затем volume и т.д. Других значений нет, потому что профиль волны прямоугольный и все её значения находятся либо внизу, либо вверху.
Допустим, что звуковая система работает на стандартной частоте 44100 Гц. Это 44100 сэмплов в секунду. Тогда, записав в буфер последовательность -1, 1, -1, 1..., мы получим волну с частотой 22050 Гц, которую просто не услышим (старость не радость).
Нормальный звуковой тон это например нота "ля" (440 Гц). Если нам нужно воспроизвести звук с частотой 440 Герц на опорной частоте 44100 Гц, это значит, что знак сэмпла volume должен переключаться с -1 на 1 каждый 44100/440-й раз. Это одно колебание частоты. Но чтобы постоянно переключаться с -1 на 1, нужно ещё переключаться обратно на -1, т.е. одно колебание это два переключения. Соответственно, вообще переключения происходят каждый 44100/880-й раз. В остальное время знак остаётся там же, где и был, формируя горизонтальную часть волны.
Надо отметить, что диапазон -1..1 выбран лишь потому, что таков стандарт звуковых данных в данном примере. Можно измерять амплитуду в диапазоне 0..65535, -128..127 и т.п. Всё это приводится одно к другому путём несложных сдвигов и пропорций.
Напишем структуру SquareWave для параметров генерации звука:
Атрибут volume задаёт амплитуду (-1..1), атрибут phase накапливает текущее значение частоты, а атрибут phase_inc прибавляется к phase на каждом следующем сэмпле.
Заполнение звукового буфера
Система работает следующим образом. При инициализации звукового устройства мы передаём туда свою структуру, которая должна поддерживать трейт AudioCallback. У него есть метод callback(), который и будет вызываться системой, и в него будет передаваться указатель на звуковой буфер. Так что в методе callback() нам нужно просто заполнить буфер по описанному ранее принципу.
Параметр out является ссылкой на мутабельный массив 32-битных вещественных чисел. Это и есть наш звуковой буфер.
Чтобы знать, в какой момент переключаться с -1 на 1, используется атрибут phase. В нём накапливается некоторое значение. Пока оно меньше 0.5, генерируем -1, иначе генерируем 1.
К phase прибавляется значение phase_inc, равное 440/44100 для воспроизводимой частоты 440 Гц и опорной частоты 44100 Гц. Когда phase становится больше 1, оставляем там только дробный остаток.
Инициализация
Осталось только рассмотреть, как запустить всё это хозяйство.
Сначала мы как обычно получаем SDL2-контекст, а из этого контекста получаем аудиоподсистему:
Затем открываем устройство воспроизведения звука:
AudioSpecDesired это структура, описывающая, какой формат звука мы хотим: с разрешением 44100 Гц, одноканальный, и с длиной буфера по умолчанию.
Судя по всему, запросить мы можем желаемые для нас параметры, но нет никакой гарантии, что система выполнит наши требования. В этом случае, предполагаю, должна присутствовать какая-то расширенная диагностика. Но обычный звук типа 44100 Гц, 2 канала, 16 бит должен поддерживаться любой современной системой, так что думаю, проблем не будет.
Для получения доступа к устройству вопроизведения вызывается метод audio_subsystem.open_playback() с параметрами:
- имя запрашиваемого устройства (None) – видимо, по умолчанию
- & desired_spec – ссылка на структуру AudioSpecDesired с параметрами воспроизведения, которые мы хотим
- |spec| { SquareWave... } – наша структура SquareWave, с реализованным трейтом AudioCallback. Данная странная запись это замыкание, но как я уже говорил, лезть в Rust здесь не будем. Мы просто устанавливаем обратный вызов функции (callback) с параметрами из нашей структуры.
Итак: в метод аудиоподсистемы open_playback() передаётся желаемый формат звука, с этим же форматом (только уже не с желаемым, а с реальным, который вернула система) будет проинициализирована структура типа SquareWave, реализующая трейт AudioCallback.
Далее устройство запускается на воспроизведение:
audio_device.resume();
И наш метод callback() начинает регулярно вызываться. Теперь всё, что мы помещаем в буфер, будет звучать.
Что дальше
Буфер имеет ограниченную длину и закольцован. То есть когда драйвер прочитывает из него данные до конца, то начинает читать с начала (и где-то между этими событиями вызывает callback() для заполнения буфера).
Это значит, что генерация делается по частям. Длина буфера по умолчанию в моём случае получилась 2048 сэмплов. Следовательно, в буфер помещается примерно 0.046 секунды звука с указанными настройками.
Если бы мы воспроизводили, скажем, WAV-файл, то копировали бы данные из него в буфер по кусочкам, запоминая текущую позицию чтения между вызовами callback().
Но в данном случае всего лишь программно генерируется бесконечная нота "ля", поэтому запоминать позицию не нужно. Вместо этого в структуре SquareWave запоминается текущая фаза волны phase.
Мы можем дополнить этот функционал следующими способами:
- Играть не одну ноту, а последовательность разных нот
- Играть несколько нот одновременно (смешивая разные частоты)
- Играть не квадратный меандр, а другой профиль волны (синусоиду, треугольник или пилу)
- Совмещать в одном буфере программную генерацию и готовые данные из WAV-файла.
Всё это мне понадобится сделать дальше для воспроизведения музыки в игре GMO Apple.
Продолжение: