Найти в Дзене
ZDG

Программная генерация звука

Оглавление

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-й раз. В остальное время знак остаётся там же, где и был, формируя горизонтальную часть волны.

-2

Надо отметить, что диапазон -1..1 выбран лишь потому, что таков стандарт звуковых данных в данном примере. Можно измерять амплитуду в диапазоне 0..65535, -128..127 и т.п. Всё это приводится одно к другому путём несложных сдвигов и пропорций.

Напишем структуру SquareWave для параметров генерации звука:

-3

Атрибут volume задаёт амплитуду (-1..1), атрибут phase накапливает текущее значение частоты, а атрибут phase_inc прибавляется к phase на каждом следующем сэмпле.

Заполнение звукового буфера

Система работает следующим образом. При инициализации звукового устройства мы передаём туда свою структуру, которая должна поддерживать трейт AudioCallback. У него есть метод callback(), который и будет вызываться системой, и в него будет передаваться указатель на звуковой буфер. Так что в методе callback() нам нужно просто заполнить буфер по описанному ранее принципу.

-4

Параметр out является ссылкой на мутабельный массив 32-битных вещественных чисел. Это и есть наш звуковой буфер.

Чтобы знать, в какой момент переключаться с -1 на 1, используется атрибут phase. В нём накапливается некоторое значение. Пока оно меньше 0.5, генерируем -1, иначе генерируем 1.

К phase прибавляется значение phase_inc, равное 440/44100 для воспроизводимой частоты 440 Гц и опорной частоты 44100 Гц. Когда phase становится больше 1, оставляем там только дробный остаток.

Инициализация

Осталось только рассмотреть, как запустить всё это хозяйство.

Сначала мы как обычно получаем SDL2-контекст, а из этого контекста получаем аудиоподсистему:

-5

Затем открываем устройство воспроизведения звука:

-6

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.

Продолжение: