Создание синтезатора звуковых эффектов из ретро-игр

Создание синтезатора звуковых эффектов из ретро-игр

Из этой статьи вы узнаете, как создать аудиодвижок на основе синтезатора, способный генерировать звуки для игр в ретро-стиле. Звуковой движок будет генерировать все звуки во время выполнения и ему не требуются никакие внешние зависимости, например, файлы MP3 или WAV. В конечном результате у нас получится рабочая библиотека, которую можно удобно встраивать в игры.

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

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

Волны

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

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

Пульс

Пульсовая волна создаёт резкий и гармоничный звук.

Для генерирования представляющих пульсовую волну массива значений (в интервале от -1.0 до 1.0), можно использовать следующий код, в котором n — количество значений, необходимых для заполнения массива, a — массив, p — нормализованное положение внутри волны:

Пилообразная волна создаёт резкий и жёсткий звук.

Чтобы сгенерировать представляющий пилообразную волну массив значений (в интервале от -1.0 до 1.0), где n — количество значений, необходимых для заполнения массива, a — массив, p — нормализованное положение внутри волны:

Синусоида

Синусоидальная волна создаёт плавный и чистый звук.

Чтобы сгенерировать представляющий синусоидальную волну массив значений (в интервале от -1.0 до 1.0), можно использовать следующий код, где n — количество значений, необходимых для заполнения массива, a — массив, p — нормализованное положение внутри волны:

Треугольник

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

Чтобы сгенерировать представляющий треугольную волну массив значений (в интервале от -1.0 до 1.0), можно использовать следующий код, где n — количество значений, необходимых для заполнения массива, a — массив, p — нормализованное положение внутри волны:

Вот развёрнутая версия строки 6:

Амплитуда и частота волны

Звуковая волна имеет два важных свойства — амплитуду и частоту волны: от них зависят, соответственно, громкость и высота звука. Амплитуда — это абсолютное пиковое значение волны, а частота — количество раз, которое волна повторяется за секунду. Обычно частота измеряется в герцах (Гц, Hz).

На рисунке ниже показан 200-миллисекундный снимок состояния пилообразной волны с амплитудой 0,5 и частотой 20 Гц:

Приведу пример того, как частота волны непосредственно влияет на высоту звука: волна с частотой 440 Гц имеет ту же высоту, что и стандартная нота ля первой октавы (A4) современного концертного пианино. С учётом этой частоты мы можем вычислить частоту любой другой ноты с помощью следующего кода:

Переменная n в этом коде — это количество нот от A4 до интересующей нас ноты. Например, чтобы найти частоту ля второй октавы (A5), на одну октаву выше A4, нам нужно присвоить n значение 12 , потому что A5 на 12 нот выше A4. Чтобы найти частоту ми большой октавы (E2), нам нужно присвоить n значение -5 , потому что E2 на 5 нот ниже A4. Можно также сделать обратную операцию и найти ноту (относительно A4) по заданной частоте:

Эти вычисления работают, потому что частоты нот являются логарифмическими — умножение частоты на два смещает ноту вверх на одну октаву, а деление частоты на два опускает ноту на одну октаву.

Цифровые звуковые волны

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

На рисунке ниже показан способ сэмплирования звуковой волны:

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

Кроме частоты сэмплирования цифровые звуки имеют и скорость передачи битов, измеряемую в битах в секунду. От скорости передачи битов (битрейта) зависит количество двоичных битов, используемых для хранения каждого сэмпла волны. Это похоже на количество битов, используемых для хранения информации ARGB каждого пикселя битового изображения. Например, звук с частотой сэмплирования 44100 и скоростью передачи битов 705600 будет сохранять каждый из сэмплов волн как 16-битное значение, и мы можем довольно просто вычислить его с помощью следующего кода:

Вот практический пример, в котором используются приведённые выше значения:

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

Модуляторы

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

Для примера возьмём вибрато. Вибрато — это периодическое пульсирующее изменение высоты. Для создания такого эффекта с помощью модулятора можно задать волну модулятора для синусоидальной волны и установить частоту модулятора равной, например, 8 Гц. Если затем подсоединить этот модулятор к частоте звуковой волны, то в результате получится эффект вибрато — модулятор будет плавно увеличивать и снижать частоту (высоту) звуковой волны восемь раз в секунду.

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

Демо аудиодвижка

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

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

Класс AudioWaveform

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

Начнём с создания нового пакета класса под названием noise , а затем добавим в этот пакет следующей класс:

Также мы добавим к классу статичный общий метод, который можно будет использовать для проверки значения волны. Метод будет возвращать true или false в зависимости от правильности значения волны.

Наконец, нам нужно защитить класс от создания его экземпляров, потому что нет никаких причин для их создания. Это можно сделать внутри конструктора класса:

На этом мы завершили создание класса.

Защита классов в стиле enum, полностью статичных классов и классов-синглтонов от непосредственного создания экземпляров является хорошей практикой, потому что экземпляры таких классов не должны создаваться, для этого нет никаких причин. В некоторых языках программирования, например, в Java для большинства таких типов классов это делается автоматически, но в ActionScript 3.0 необходимо принудительно обеспечивать такое поведение внутри конструктора класса.

Класс Audio

Следующий в списке — класс Audio . По своей природе этот класс схож с нативным классом ActionScript 3.0 Sound , каждый аудиодвижок будет представлен как экземпляр класса Audio .

Добавим в пакет noise следующий скелет класса:

Первое, что нужно добавить в класс — это свойства, сообщающие аудиодвижку, как генерировать звуковую волну при воспроизведении звука. Этими свойствами являются тип волны, использованный в звуке, частота и амплитуда волны, длительность звука и время затухания. Все эти свойства будут приватными, а доступ к ним будет осуществляться через геттеры/сеттеры:

Как вы видите, мы задали разумные значения по умолчанию для каждого свойства. amplitude — это значение в интервале от 0.0 до 1.0 , frequency указывается в Гц, а duration и release — в секундах.

Также нам нужно добавить ещё два приватных свойства для модуляторов, подсоединяемых к звуку. Доступ к этим свойствам тоже будет осуществляться через геттеры/сеттеры:

Наконец, класс Audio должен содержать несколько внутренних свойств, к которым будет иметь доступ только класс AudioEngine (его мы вскоре напишем). Эти свойства не нужно прятать за геттерами/сеттерами:

position задаётся в секундах и позволяет классу AudioEngine отслеживать положение звука при его воспроизведении. Это необходимо для вычисления звуковых сэмплов волны. Свойства playing и releasing сообщают классу AudioEngine , в каком состоянии находится звук, а свойство samples является ссылкой на кэшированные сэмплы волн, используемые звуком. Как используются эти свойства, мы поймём, когда напишем класс AudioEngine .

Чтобы закончить класс Audio , нужно добавить геттеры/сеттеры:

Audio.waveform

Audio.frequency

Audio.amplitude

Audio.duration

Audio.release

Audio.frequencyModulator

Audio.amplitudeModulator

Вы конечно заметили метку метаданных [Inline] , связанную с некоторыми из функций геттеров. Эта метка метаданных — особенность ActionScript 3.0 Compiler, и делает она именно то, что следует из её названия: она встраивает (расширяет) содержимое функции. При разумном использовании эта особенность невероятно полезна при оптимизации, а задача генерирования динамического аудиосигнала во время выполнения программы оптимизации точно требует.

Класс AudioModulator

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

Начнём с начала — создадим в пакете noise следующий скелет класса:

Теперь добавим приватные свойства:

Если вы думаете, что это очень похоже на класс Audio , то вы не ошибаетесь: здесь всё то же самое, за исключением свойства shift .

Чтобы понять, что делает свойство shift , вспомните одну из базовых волн, используемых аудиодвижком (пульсовую, пилообразную, синусоидальную или треугольную) и представьте вертикальную линию, проходящую через волну в любом месте. Горизонтальное положение этой вертикальной линии будет значением shift ; это значение в интервале от 0.0 до 1.0 , сообщающее модулятору, откуда нужно начинать считывать волну. В свою очередь, она имеет абсолютное влияние на модификации, вносимые модулятором в амплитуду или частоту звука.

Например, если модулятор использует синусоидальную волну для модулирования частоты звука, а shift имеет значение 0.0 , то частота звука сначала увеличится, а потом опустится в соответствии с кривизной синусоиды. Однако если shift задать значение 0.5 , то частота звука сначала уменьшится, а потому увеличится.

Ну, вернёмся к коду. AudioModulator содержит один внутренний метод, используемый только AudioEngine . Метод имеет следующий вид:

Эта функция встроена, потому что часто используется, и под «часто» я имею в виду «44100 раз в секунду» для каждого воспроизводимого звука, к которому подсоединён модулятор (именно здесь встраивание оказывается невероятно полезным). Функция просто получает звуковой сэмпл из используемой модулятором формы волны, изменяет амплитуду сэмпла, а затем возвращает результат.

Чтобы завершить класс AudioModulator , нужно добавить геттеры/сеттеры:

AudioModulator.waveform

AudioModulator.frequency

AudioModulator.amplitude

AudioModulator.shift

И на этом класс AudioModulator можно считать завершённым.

Класс AudioEngine

А теперь серьёзная задача: класс AudioEngine . Это полностью статичный класс. Он управляет почти всем, что связано с экземлярами Audio и генерированием звука.

Давайте как обычно начнём со скелета класса в noise :

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

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

Используются следующие константы:

Также классом используются две приватные константы:

BUFFER_SIZE — это количество звуковых сэмплов, передаваемых звуковому API ActionScript 3.0 при совершении запроса звуковых сэмплов. Это наименьшее допустимое количество сэмплов, которое обеспечивает наименьшую возможную латентность звука. Количество сэмплов можно увеличить, чтобы снизить уровень нагрузки на ЦП, но это увеличит латентность звука. SAMPLE_TIME — это длительность одного звукового сэмпла в секундах.

А теперь приватные переменные:

  • m_position используется для отслеживания потокового времени звука в секундах.
  • m_amplitude — это глобальная вторичная амплитуда для всех воспроизводимых экземпляров Audio .
  • m_soundStream и m_soundChannel не требуют объяснений.
  • m_audioList содержит ссылки на все воспроизводимые экземпляры Audio .
  • m_sampleList — это временный буфер, используемый для хранения звуковых сэмплов, когда они запрашиваются звуковым API ActionScript 3.0.

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

Класс AudioEngine имеет три общих метода, используемых для воспроизведения и остановки экземпляров Audio :

AudioEngine.play()

AudioEngine.stop()

AudioEngine.stopAll()

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

AudioEngine.onSampleData()

Итак, в первой конструкции if мы проверяем, по-прежнему ли m_soundChannel имеет значение null. Это нужно нам, потому что событие SAMPLE_DATA отправляется сразу при вызове метода m_soundStream.play() и ещё до того, как метод получит возможность вернуть экземпляр SoundChannel .

Цикл while обходит звуковые сэмплы, запрошенные m_soundStream и записывает их в экземпляр ByteArray . Звуковые сэмплы генерируются следующим методом:

AudioEngine.generateSamples()

Наконец, для того, чтобы всё завершить, нам нужно добавить геттер/сеттер для приватной переменной m_amplitude :

Демо аудиопроцессора

В этой части мы добавим к базовому движку аудиопроцессоры и создадим простой процессор дилэя. В этой демонстрации показан процессор дилэя в действии (Flash): демо.

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

Класс AudioProcessor

Первое, что нужно сделать — создать базовый класс для аудиопроцессоров:

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

Класс AudioDelay

Класс AudioDelay — это класс, создающий сам дилэй звука. Он расширяет класс AudioProcessor . Вот скелет пустого класса, с которым мы будем работать:

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

Теперь давайте добавим приватные свойства:

Вектор m_buffer — это цикл обратной связи: он содержит все звуковые сэмплы, передаваемые методу process , и эти сэмплы постоянно модифицируются (в нашем случае снижается их амплитуда) в процессе прохода m_bufferIndex через буфер. Это будет иметь смысл, когда мы доберёмся до метода process() .

Свойства m_bufferSize и m_bufferIndex используются для отслеживания состояния буфера. Свойство m_time — это время последовательности дилэя в секундах. Свойство m_gain — это множитель, используемый для уменьшения со временем амплитуды буферизированных звуковых сэмплов.

Этот класс имеет только один метод, и это внутренний метод process() , переопределяющий метод process() в классе AudioProcessor :

Наконец, нам нужно добавить геттеры/сеттеры для приватных свойств m_time и m_gain :

Верите или нет, но на этом класс AudioDelay завершён. На самом деле реализация дилэев звука очень проста, если понять, как работае цикл обратной связи (свойство m_buffer ).

Обновление класса AudioEngine

Последнее, что нужно сделать — обновить класс AudioEngine , чтобы к нему можно было добавлять аудиопроцессоры. Во-первых, давайте добавим вектор для хранения экземпляров аудиопроцессора:

Чтобы действительно добавлять и удалять процессоры из класса AudioEngine , нужно также использовать два общих метода:

AudioEngine.addProcessor()

AudioEngine.removeProcessor()

Всё достаточно просто — все эти методы будут добавлять и удалять экземпляры AudioProcessor из вектора m_processorList .

Последний метод, который мы добавим, будет проходить по списку аудиопроцессоров, и ессли процессор включен, передавать звуковые сэмплы методу процессора process() :

Настала пора добавить последнюю часть кода, и это единственная строка, которую необходимо добавить в приватный метод onSampleData() класса AudioEngine :

В класс нужно добавить строку кода processSamples(); . Она просто вызывает метод processSamples() , который мы добавили ранее.

Заключение

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

С этим кодом можно сделать гораздо больше, но важно не забывать, что весь этот объём работы аудиодвижок должен совершать во время выполнения. Если сделать движок слишком изощрённым (а это очень легко), то от этого может пострадать общая производительность игры — даже если перенести аудиодвижок в отдельный поток (или в worker ActionScript 3.0), при неаккуратной реализации он всё равно будет отнимать большую долю времени ЦП.

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

Стоит задуматься и ещё об одном аспекте: звук — важная часть игры, настолько же важная и мощная, как визуальная составляющая. Её нельзя отбрасывать или прикручивать к игре в последний момент разработки, если вас заботит качество игры. Уделите время дизайну звука и это не останется незамеченным.

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

📎📎📎📎📎📎📎📎📎📎