Из этой статьи вы узнаете, как создать аудиодвижок на основе синтезатора, способный генерировать звуки для игр в ретро-стиле. Звуковой движок будет генерировать все звуки во время выполнения и ему не требуются никакие внешние зависимости, например, файлы MP3 или WAV. В конечном результате у нас получится рабочая библиотека, которую можно удобно встраивать в игры.
Прежде чем приступать к созданию аудиодвижка, нам нужно разобраться с парой понятий. Во-первых, с волнами, которые будет использовать движок для генерирования звуков. Во-вторых, надо понимать, как хранятся и обозначаются звуковые волны в цифровом виде.
В этом туториале используется язык программирования ActionScript 3.0, но применяемые техники и концепции можно легко преобразовать в любой другой язык, предоставляющий доступ к низкоуровневому API работы со звуком.
Волны
Создаваемый нами аудиодвижок будет использовать четыре базовых типа волн (также известных как периодические волны, потому что их основные формы периодически повторяются). Все они очень часто используются и в аналоговых, и в цифровых синтезаторах. Каждая форма волны имеет собственную уникальную характеристику звучания.
Ниже представлены визуальное представление каждой из форм волн, звуковые примеры и код, необходимый для генерирования каждой из форм волн как массива сэмплированных данных.
Пульс
Пульсовая волна создаёт резкий и гармоничный звук.
Для генерирования представляющих пульсовую волну массива значений (в интервале от -1.0 до 1.0), можно использовать следующий код, в котором n
— количество значений, необходимых для заполнения массива, a
— массив, p
— нормализованное положение внутри волны:
var i:int = 0;
var n:int = 100;
var p:Number;
while( i < n ) {
p = i / n;
a[i] = p < 0.5 ? 1.0 : -1.0;
i ++;
}
Пила
Пилообразная волна создаёт резкий и жёсткий звук.
Чтобы сгенерировать представляющий пилообразную волну массив значений (в интервале от -1.0 до 1.0), где n
— количество значений, необходимых для заполнения массива, a
— массив, p
— нормализованное положение внутри волны:
var i:int = 0;
var n:int = 100;
var p:Number;
while( i < n ) {
p = i / n;
a[i] = p < 0.5 ? p * 2.0 : p * 2.0 - 2.0;
i ++;
}
Синусоида
Синусоидальная волна создаёт плавный и чистый звук.
Чтобы сгенерировать представляющий синусоидальную волну массив значений (в интервале от -1.0 до 1.0), можно использовать следующий код, где n
— количество значений, необходимых для заполнения массива, a
— массив, p
— нормализованное положение внутри волны:
var i:int = 0;
var n:int = 100;
var p:Number;
while( i < n ) {
p = i / n;
a[i] = Math.sin( p * 2.0 * Math.PI );
i ++;
}
Треугольник
Треугольная волна создаёт плавный и гармоничный звук.
Чтобы сгенерировать представляющий треугольную волну массив значений (в интервале от -1.0 до 1.0), можно использовать следующий код, где n
— количество значений, необходимых для заполнения массива, a
— массив, p
— нормализованное положение внутри волны:
var i:int = 0;
var n:int = 100;
var p:Number;
while( i < n ) {
p = i / n;
a[i] = p < 0.25 ? p * 4.0 : p < 0.75 ? 2.0 - p * 4.0 : p * 4.0 - 4.0;
i ++;
}
Вот развёрнутая версия строки 6:
if (p < 0.25) {
a[i] = p * 4.0;
}
else if (p < 0.75) {
a[i] = 2.0 - (p * 4.0);
}
else {
a[i] = (p * 4.0) - 4.0;
}
Амплитуда и частота волны
Звуковая волна имеет два важных свойства — амплитуду и частоту волны: от них зависят, соответственно, громкость и высота звука. Амплитуда — это абсолютное пиковое значение волны, а частота — количество раз, которое волна повторяется за секунду. Обычно частота измеряется в герцах (Гц, Hz).
На рисунке ниже показан 200-миллисекундный снимок состояния пилообразной волны с амплитудой 0,5 и частотой 20 Гц:
Приведу пример того, как частота волны непосредственно влияет на высоту звука: волна с частотой 440 Гц имеет ту же высоту, что и стандартная нота ля первой октавы (A4) современного концертного пианино. С учётом этой частоты мы можем вычислить частоту любой другой ноты с помощью следующего кода:
f = Math.pow( 2, n / 12 ) * 440.0;
Переменная n
в этом коде — это количество нот от A4 до интересующей нас ноты. Например, чтобы найти частоту ля второй октавы (A5), на одну октаву выше A4, нам нужно присвоить n
значение 12
, потому что A5 на 12 нот выше A4. Чтобы найти частоту ми большой октавы (E2), нам нужно присвоить n
значение -5
, потому что E2 на 5 нот ниже A4. Можно также сделать обратную операцию и найти ноту (относительно A4) по заданной частоте:
n = Math.round( 12.0 * Math.log( f / 440.0 ) * Math.LOG2E );
Эти вычисления работают, потому что частоты нот являются логарифмическими — умножение частоты на два смещает ноту вверх на одну октаву, а деление частоты на два опускает ноту на одну октаву.
Цифровые звуковые волны
В цифровом мире звуковые волны нужно хранить как двоичные данные, и обычно это реализуется созданием периодических снимков состояния (или сэмплов) звуковой волны. Количество сэмплов волны, получаемых за каждую секунду длительности звука называется частотой сэмплирования, то есть звук с частотой сэмплирования 44100 будет содержать 44100 сэмплов волны (на канал) в секунду длительности звука.
На рисунке ниже показан способ сэмплирования звуковой волны:
Белыми точками на рисунке показаны точки амплитуды волны, сэмплируемые и сохраняемые в цифровом формате. Можно воспринимать их как разрешение битового изображения: чем больше пикселей содержится в битовом изображении, тем больше визуальной информации оно может хранить, а увеличение количества информации приводит к увеличению размера файлов (здесь мы пока не учитываем сжатие). То же самое справедливо и для цифровых звуков: чем больше сэмплов волны содержит звуковой файл, тем более точной будет воссозданная звуковая волна.
Кроме частоты сэмплирования цифровые звуки имеют и скорость передачи битов, измеряемую в битах в секунду. От скорости передачи битов (битрейта) зависит количество двоичных битов, используемых для хранения каждого сэмпла волны. Это похоже на количество битов, используемых для хранения информации ARGB каждого пикселя битового изображения. Например, звук с частотой сэмплирования 44100 и скоростью передачи битов 705600 будет сохранять каждый из сэмплов волн как 16-битное значение, и мы можем довольно просто вычислить его с помощью следующего кода:
bitsPerSample = bitRate / sampleRate;
Вот практический пример, в котором используются приведённые выше значения:
trace( 705600 / 44100 ); // "16"
Самое важное здесь — понять, что же такое звуковые сэмплы. Создаваемый нами движок будет генерировать необработанные звуковые сэмплы и управлять ими.
Модуляторы
Прежде чем приступить к программированию звукового движка, нужно познакомиться с ещё одним понятием — модуляторами, которые активно используются и в аналоговых, и в цифровых синтезаторах. В сущности, модулятор — это стандартная волна, но вместо создания звука их обычно используют для модулирования одного или нескольких свойств звуковой волны (т.е. её амплитуды или частоты).
Для примера возьмём вибрато. Вибрато — это периодическое пульсирующее изменение высоты. Для создания такого эффекта с помощью модулятора можно задать волну модулятора для синусоидальной волны и установить частоту модулятора равной, например, 8 Гц. Если затем подсоединить этот модулятор к частоте звуковой волны, то в результате получится эффект вибрато — модулятор будет плавно увеличивать и снижать частоту (высоту) звуковой волны восемь раз в секунду.
Создаваемый нами движок позволит присоединят к звукам модуляторы для обеспечения широкого диапазона различных эффектов.
Демо аудиодвижка
В этой части мы напишем весь базовый код, необходимый для полного аудиодвижка. Вот простая демонстрация работы аудиодвижка (Flash): демо.
В этой демонстрации проигрывается только один звук, но частота звука случайным образом изменяется. К звуку также подсоединён модулятор, создающий эффект вибрато (модулированием амплитуды звука), частота модулятора тоже изменяется случайным образом.
Класс AudioWaveform
Первый создаваемый нами класс будет просто хранить значения-константы для волн, которые движок будет использовать для генерирования звуков.
Начнём с создания нового пакета класса под названием noise
, а затем добавим в этот пакет следующей класс:
package noise {
public final class AudioWaveform {
static public const PULSE:int = 0;
static public const SAWTOOTH:int = 1;
static public const SINE:int = 2;
static public const TRIANGLE:int = 3;
}
}
Также мы добавим к классу статичный общий метод, который можно будет использовать для проверки значения волны. Метод будет возвращать true
или false
в зависимости от правильности значения волны.
static public function validate( waveform:int ):Boolean {
if( waveform == PULSE ) return true;
if( waveform == SAWTOOTH ) return true;
if( waveform == SINE ) return true;
if( waveform == TRIANGLE ) return true;
return false;
}
Наконец, нам нужно защитить класс от создания его экземпляров, потому что нет никаких причин для их создания. Это можно сделать внутри конструктора класса:
public function AudioWaveform() {
throw new Error( "AudioWaveform class cannot be instantiated" );
}
На этом мы завершили создание класса.
Защита классов в стиле enum, полностью статичных классов и классов-синглтонов от непосредственного создания экземпляров является хорошей практикой, потому что экземпляры таких классов не должны создаваться, для этого нет никаких причин. В некоторых языках программирования, например, в Java для большинства таких типов классов это делается автоматически, но в ActionScript 3.0 необходимо принудительно обеспечивать такое поведение внутри конструктора класса.
Класс Audio
Следующий в списке — класс Audio
. По своей природе этот класс схож с нативным классом ActionScript 3.0 Sound
, каждый аудиодвижок будет представлен как экземпляр класса Audio
.
Добавим в пакет noise
следующий скелет класса:
package noise {
public class Audio {
public function Audio() {}
}
}
Первое, что нужно добавить в класс — это свойства, сообщающие аудиодвижку, как генерировать звуковую волну при воспроизведении звука. Этими свойствами являются тип волны, использованный в звуке, частота и амплитуда волны, длительность звука и время затухания. Все эти свойства будут приватными, а доступ к ним будет осуществляться через геттеры/сеттеры:
private var m_waveform:int = AudioWaveform.PULSE;
private var m_frequency:Number = 100.0;
private var m_amplitude:Number = 0.5;
private var m_duration:Number = 0.2;
private var m_release:Number = 0.2;
Как вы видите, мы задали разумные значения по умолчанию для каждого свойства. amplitude
— это значение в интервале от 0.0
до 1.0
, frequency
указывается в Гц, а duration
и release
— в секундах.
Также нам нужно добавить ещё два приватных свойства для модуляторов, подсоединяемых к звуку. Доступ к этим свойствам тоже будет осуществляться через геттеры/сеттеры:
private var m_frequencyModulator:AudioModulator = null;
private var m_amplitudeModulator:AudioModulator = null;
Наконец, класс Audio
должен содержать несколько внутренних свойств, к которым будет иметь доступ только класс AudioEngine
(его мы вскоре напишем). Эти свойства не нужно прятать за геттерами/сеттерами:
internal var position:Number = 0.0;
internal var playing:Boolean = false;
internal var releasing:Boolean = false;
internal var samples:Vector.<Number> = null;
position
задаётся в секундах и позволяет классу AudioEngine
отслеживать положение звука при его воспроизведении. Это необходимо для вычисления звуковых сэмплов волны. Свойства playing
и releasing
сообщают классу AudioEngine
, в каком состоянии находится звук, а свойство samples
является ссылкой на кэшированные сэмплы волн, используемые звуком. Как используются эти свойства, мы поймём, когда напишем класс AudioEngine
.
Чтобы закончить класс Audio
, нужно добавить геттеры/сеттеры:
Audio.waveform
public final function get waveform():int {
return m_waveform;
}
public final function set waveform( value:int ):void {
if( AudioWaveform.isValid( value ) == false ) {
return;
}
switch( value ) {
case AudioWaveform.PULSE: samples = AudioEngine.PULSE; break;
case AudioWaveform.SAWTOOTH: samples = AudioEngine.SAWTOOTH; break;
case AudioWaveform.SINE: samples = AudioEngine.SINE; break;
case AudioWaveform.TRIANGLE: samples = AudioEngine.TRIANGLE; break;
}
m_waveform = value;
}
Audio.frequency
[Inline]
public final function get frequency():Number {
return m_frequency;
}
public final function set frequency( value:Number ):void {
// ограничиваем frequency интервалом 1.0 - 14080.0
m_frequency = value < 1.0 ? 1.0 : value > 14080.0 ? 14080.0 : value;
}
Audio.amplitude
[Inline]
public final function get amplitude():Number {
return m_amplitude;
}
public final function set amplitude( value:Number ):void {
// ограничиваем amplitude интервалом 0.0 - 1.0
m_amplitude = value < 0.0 ? 0.0 : value > 1.0 ? 1.0 : value;
}
Audio.duration
[Inline]
public final function get duration():Number {
return m_duration;
}
public final function set duration( value:Number ):void {
// ограничиваем duration интервалом 0.0 - 60.0
m_duration = value < 0.0 ? 0.0 : value > 60.0 ? 60.0 : value;
}
Audio.release
[Inline]
public final function get release():Number {
return m_release;
}
public function set release( value:Number ):void {
// ограничиваем время release интервалом 0.0 - 10.0
m_release = value < 0.0 ? 0.0 : value > 10.0 ? 10.0 : value;
}
Audio.frequencyModulator
[Inline]
public final function get frequencyModulator():AudioModulator {
return m_frequencyModulator;
}
public final function set frequencyModulator( value:AudioModulator ):void {
m_frequencyModulator = value;
}
Audio.amplitudeModulator
[Inline]
public final function get amplitudeModulator():AudioModulator {
return m_amplitudeModulator;
}
public final function set amplitudeModulator( value:AudioModulator ):void {
m_amplitudeModulator = value;
}
Вы конечно заметили метку метаданных [Inline]
, связанную с некоторыми из функций геттеров. Эта метка метаданных — особенность ActionScript 3.0 Compiler, и делает она именно то, что следует из её названия: она встраивает (расширяет) содержимое функции. При разумном использовании эта особенность невероятно полезна при оптимизации, а задача генерирования динамического аудиосигнала во время выполнения программы оптимизации точно требует.
Класс AudioModulator
Задача AudioModulator
— обеспечить возможность модуляции амплитуды и частоты экземпляров Audio
для создания разнообразных полезных эффектов. Модуляторы на самом деле похожи на экземпляры Audio
, у них есть форма волны, амплитуда и частота, но они не создают никакого слышимого звука, а только модифицируют другие звуки.
Начнём с начала — создадим в пакете noise
следующий скелет класса:
package noise {
public class AudioModulator {
public function AudioModulator() {}
}
}
Теперь добавим приватные свойства:
private var m_waveform:int = AudioWaveform.SINE;
private var m_frequency:Number = 4.0;
private var m_amplitude:Number = 1.0;
private var m_shift:Number = 0.0;
private var m_samples:Vector.<Number> = null;
Если вы думаете, что это очень похоже на класс Audio
, то вы не ошибаетесь: здесь всё то же самое, за исключением свойства shift
.
Чтобы понять, что делает свойство shift
, вспомните одну из базовых волн, используемых аудиодвижком (пульсовую, пилообразную, синусоидальную или треугольную) и представьте вертикальную линию, проходящую через волну в любом месте. Горизонтальное положение этой вертикальной линии будет значением shift
; это значение в интервале от 0.0
до 1.0
, сообщающее модулятору, откуда нужно начинать считывать волну. В свою очередь, она имеет абсолютное влияние на модификации, вносимые модулятором в амплитуду или частоту звука.
Например, если модулятор использует синусоидальную волну для модулирования частоты звука, а shift
имеет значение 0.0
, то частота звука сначала увеличится, а потом опустится в соответствии с кривизной синусоиды. Однако если shift
задать значение 0.5
, то частота звука сначала уменьшится, а потому увеличится.
Ну, вернёмся к коду. AudioModulator
содержит один внутренний метод, используемый только AudioEngine
. Метод имеет следующий вид:
[Inline]
internal final function process( time:Number ):Number {
var p:int = 0;
var s:Number = 0.0;
if( m_shift != 0.0 ) {
time += ( 1.0 / m_frequency ) * m_shift;
}
p = ( 44100 * m_frequency * time ) % 44100;
s = m_samples[p];
return s * m_amplitude;
}
Эта функция встроена, потому что часто используется, и под «часто» я имею в виду «44100 раз в секунду» для каждого воспроизводимого звука, к которому подсоединён модулятор (именно здесь встраивание оказывается невероятно полезным). Функция просто получает звуковой сэмпл из используемой модулятором формы волны, изменяет амплитуду сэмпла, а затем возвращает результат.
Чтобы завершить класс AudioModulator
, нужно добавить геттеры/сеттеры:
AudioModulator.waveform
public function get waveform():int {
return m_waveform;
}
public function set waveform( value:int ):void {
if( AudioWaveform.isValid( value ) == false ) {
return;
}
switch( value ) {
case AudioWaveform.PULSE: m_samples = AudioEngine.PULSE; break;
case AudioWaveform.SAWTOOTH: m_samples = AudioEngine.SAWTOOTH; break;
case AudioWaveform.SINE: m_samples = AudioEngine.SINE; break;
case AudioWaveform.TRIANGLE: m_samples = AudioEngine.TRIANGLE; break;
}
m_waveform = value;
}
AudioModulator.frequency
public function get frequency():Number {
return m_frequency;
}
public function set frequency( value:Number ):void {
// ограничиваем frequency интервалом 0.01 - 100.0
m_frequency = value < 0.01 ? 0.01 : value > 100.0 ? 100.0 : value;
}
AudioModulator.amplitude
public function get amplitude():Number {
return m_amplitude;
}
public function set amplitude( value:Number ):void {
// ограничиваем amplitude интервалом 0.0 - 8000.0
m_amplitude = value < 0.0 ? 0.0 : value > 8000.0 ? 8000.0 : value;
}
AudioModulator.shift
public function get shift():Number {
return m_shift;
}
public function set shift( value:Number ):void {
// ограничиваем shift интервалом 0.0 - 1.0
m_shift = value < 0.0 ? 0.0 : value > 1.0 ? 1.0 : value;
}
И на этом класс AudioModulator
можно считать завершённым.
Класс AudioEngine
А теперь серьёзная задача: класс AudioEngine
. Это полностью статичный класс. Он управляет почти всем, что связано с экземлярами Audio
и генерированием звука.
Давайте как обычно начнём со скелета класса в noise
:
package noise {
import flash.events.SampleDataEvent;
import flash.media.Sound;
import flash.media.SoundChannel;
import flash.utils.ByteArray;
//
public final class AudioEngine {
public function AudioEngine() {
throw new Error( "AudioEngine class cannot be instantiated" );
}
}
}
Как сказано выше, для полностью статичных классов не должны создаваться экземпляры, поэтому если кто-то пытается создать экземпляр, то в конструкторе класса выбрасывается исключение. Класс также является final
, потому что нет причин расширять полностью статичный класс.
Первое, что мы добавим к этому классу — внутренние константы. Эти константы будут использоваться для кэширования сэмплов каждой из четырёх форм волн, используемых аудиодвижком. Каждый кэш содержит 44 100 сэмплов, что равно одногерцовым формам волн. Это позволяет аудиодвижку создавать очень чистые низкочастотные звуковые волны.
Используются следующие константы:
static internal const PULSE:Vector.<Number> = new Vector.<Number>( 44100 );
static internal const SAWTOOTH:Vector.<Number> = new Vector.<Number>( 44100 );
static internal const SINE:Vector.<Number> = new Vector.<Number>( 44100 );
static internal const TRIANGLE:Vector.<Number> = new Vector.<Number>( 44100 );
Также классом используются две приватные константы:
static private const BUFFER_SIZE:int = 2048;
static private const SAMPLE_TIME:Number = 1.0 / 44100.0;
BUFFER_SIZE
— это количество звуковых сэмплов, передаваемых звуковому API ActionScript 3.0 при совершении запроса звуковых сэмплов. Это наименьшее допустимое количество сэмплов, которое обеспечивает наименьшую возможную латентность звука. Количество сэмплов можно увеличить, чтобы снизить уровень нагрузки на ЦП, но это увеличит латентность звука. SAMPLE_TIME
— это длительность одного звукового сэмпла в секундах.
А теперь приватные переменные:
static private var m_position:Number = 0.0;
static private var m_amplitude:Number = 0.5;
static private var m_soundStream:Sound = null;
static private var m_soundChannel:SoundChannel = null;
static private var m_audioList:Vector.<Audio> = new Vector.<Audio>();
static private var m_sampleList:Vector.<Number> = new Vector.<Number>( BUFFER_SIZE );
m_position
используется для отслеживания потокового времени звука в секундах.m_amplitude
— это глобальная вторичная амплитуда для всех воспроизводимых экземпляровAudio
.m_soundStream
иm_soundChannel
не требуют объяснений.m_audioList
содержит ссылки на все воспроизводимые экземплярыAudio
.m_sampleList
— это временный буфер, используемый для хранения звуковых сэмплов, когда они запрашиваются звуковым API ActionScript 3.0.
Теперь нам нужно инициализировать класс. Для этого существует множество способов, но я предпочитаю простый и понятный — статический конструктор класса:
static private function $AudioEngine():void {
var i:int = 0;
var n:int = 44100;
var p:Number = 0.0;
//
while( i < n ) {
p = i / n;
SINE[i] = Math.sin( Math.PI * 2.0 * p );
PULSE[i] = p < 0.5 ? 1.0 : -1.0;
SAWTOOTH[i] = p < 0.5 ? p * 2.0 : p * 2.0 - 2.0;
TRIANGLE[i] = p < 0.25 ? p * 4.0 : p < 0.75 ? 2.0 - p * 4.0 : p * 4.0 - 4.0;
i++;
}
//
m_soundStream = new Sound();
m_soundStream.addEventListener( SampleDataEvent.SAMPLE_DATA, onSampleData );
m_soundChannel = m_soundStream.play();
}
$AudioEngine();
В этом коде происходит следующее: генерируются и кэшируются сэмплы для каждой из четырёх форм волн, и это происходит только один раз. Также создаётся экземпляр звукового потока, который запускается и воспроизводится до завершения приложения.
Класс AudioEngine
имеет три общих метода, используемых для воспроизведения и остановки экземпляров Audio
:
AudioEngine.play()
static public function play( audio:Audio ):void {
if( audio.playing == false ) {
m_audioList.push( audio );
}
// это позволяет нам точно знать, когда было запущено воспроизведение звука
audio.position = m_position - ( m_soundChannel.position * 0.001 );
audio.playing = true;
audio.releasing = false;
}
AudioEngine.stop()
static public function stop( audio:Audio, allowRelease:Boolean = true ):void {
if( audio.playing == false ) {
// звук не воспроизводится
return;
}
if( allowRelease ) {
// переход к концу звука и установка флага затухания
audio.position = audio.duration;
audio.releasing = true;
return;
}
audio.playing = false;
audio.releasing = false;
}
AudioEngine.stopAll()
static public function stopAll( allowRelease:Boolean = true ):void {
var i:int = 0;
var n:int = m_audioList.length;
var o:Audio = null;
//
if( allowRelease ) {
while( i < n ) {
o = m_audioList[i];
o.position = o.duration;
o.releasing = true;
i++;
}
return;
}
while( i < n ) {
o = m_audioList[i];
o.playing = false;
o.releasing = false;
i++;
}
}
И здесь мы переходим к основным методам обработки звука, каждый из которых является приватным:
AudioEngine.onSampleData()
static private function onSampleData( event:SampleDataEvent ):void {
var i:int = 0;
var n:int = BUFFER_SIZE;
var s:Number = 0.0;
var b:ByteArray = event.data;
//
if( m_soundChannel == null ) {
while( i < n ) {
b.writeFloat( 0.0 );
b.writeFloat( 0.0 );
i++;
}
return;
}
//
generateSamples();
//
while( i < n ) {
s = m_sampleList[i] * m_amplitude;
b.writeFloat( s );
b.writeFloat( s );
m_sampleList[i] = 0.0;
i++;
}
//
m_position = m_soundChannel.position * 0.001;
}
Итак, в первой конструкции if
мы проверяем, по-прежнему ли m_soundChannel
имеет значение null. Это нужно нам, потому что событие SAMPLE_DATA
отправляется сразу при вызове метода m_soundStream.play()
и ещё до того, как метод получит возможность вернуть экземпляр SoundChannel
.
Цикл while
обходит звуковые сэмплы, запрошенные m_soundStream
и записывает их в экземпляр ByteArray
. Звуковые сэмплы генерируются следующим методом:
AudioEngine.generateSamples()
static private function generateSamples():void {
var i:int = 0;
var n:int = m_audioList.length;
var j:int = 0;
var k:int = BUFFER_SIZE;
var p:int = 0;
var f:Number = 0.0;
var a:Number = 0.0;
var s:Number = 0.0;
var o:Audio = null;
// обход экземпляров audio
while( i < n ) {
o = m_audioList[i];
//
if( o.playing == false ) {
// экземпляр audio полностью остановлен
m_audioList.splice( i, 1 );
n--;
continue;
}
//
j = 0;
// генерирование и буферизация звуковых сэмплов
while( j < k ) {
if( o.position < 0.0 ) {
// экземпляр audio ещё не начал воспроизведение
o.position += SAMPLE_TIME;
j++;
continue;
}
if( o.position >= o.duration ) {
if( o.position >= o.duration + o.release ) {
// экземпляр audio остановлен
o.playing = false;
j++;
continue;
}
// экземпляр audio в процессе затухания
o.releasing = true;
}
// получение частоты и амплитуды экземпляра audio
f = o.frequency;
a = o.amplitude;
//
if( o.frequencyModulator != null ) {
// модуляция частоты
f += o.frequencyModulator.process( o.position );
}
//
if( o.amplitudeModulator != null ) {
// модуляция амплитуды
a += o.amplitudeModulator.process( o.position );
}
// вычисление положения в кэше волн
p = ( 44100 * f * o.position ) % 44100;
// получение сэмпла волны
s = o.samples[p];
//
if( o.releasing ) {
// вычисление амплитуды затухания для сэмпла
s *= 1.0 - ( ( o.position - o.duration ) / o.release );
}
// добавление сэмпла в буфер
m_sampleList[j] += s * a;
// обновление положения экземпляра audio
o.position += SAMPLE_TIME;
j++;
}
i++;
}
}
Наконец, для того, чтобы всё завершить, нам нужно добавить геттер/сеттер для приватной переменной m_amplitude
:
static public function get amplitude():Number {
return m_amplitude;
}
static public function set amplitude( value:Number ):void {
// ограничение amplitude интервалом 0.0 - 1.0
m_amplitude = value < 0.0 ? 0.0 : value > 1.0 ? 1.0 : value;
}
Демо аудиопроцессора
В этой части мы добавим к базовому движку аудиопроцессоры и создадим простой процессор дилэя. В этой демонстрации показан процессор дилэя в действии (Flash): демо.
В этой демонстрации воспроизводится только один звук, но частота звука меняется случайным образом, а генерируемые движком сэмплы проходят через процессор дилэя, что создаёт затухающий эффект эхо.
Класс AudioProcessor
Первое, что нужно сделать — создать базовый класс для аудиопроцессоров:
package noise {
public class AudioProcessor {
//
public var enabled:Boolean = true;
//
public function AudioProcessor() {
if( Object(this).constructor == AudioProcessor ) {
throw new Error( "AudioProcessor class must be extended" );
}
}
//
internal function process( samples:Vector.<Number> ):void {}
}
}
Как вы видите, класс очень прост, он содержит внутренний метод process()
, вызываемый классом AudioEngine
, когда необходимо обработать сэмплы, и общее свойство enabled
, которое можно использовать для включения и выключения процессора.
Класс AudioDelay
Класс AudioDelay
— это класс, создающий сам дилэй звука. Он расширяет класс AudioProcessor
. Вот скелет пустого класса, с которым мы будем работать:
package noise {
public class AudioDelay extends AudioProcessor {
//
public function AudioDelay( time:Number = 0.5 ) {
this.time = time;
}
}
}
Аргумент time
, передаваемый конструктору класса, — это время (в секундах) последовательности дилэя, то есть количество времени между каждым дилэем звука.
Теперь давайте добавим приватные свойства:
private var m_buffer:Vector.<Number> = new Vector.<Number>();
private var m_bufferSize:int = 0;
private var m_bufferIndex:int = 0;
private var m_time:Number = 0.0;
private var m_gain:Number = 0.8;
Вектор m_buffer
— это цикл обратной связи: он содержит все звуковые сэмплы, передаваемые методу process
, и эти сэмплы постоянно модифицируются (в нашем случае снижается их амплитуда) в процессе прохода m_bufferIndex
через буфер. Это будет иметь смысл, когда мы доберёмся до метода process()
.
Свойства m_bufferSize
и m_bufferIndex
используются для отслеживания состояния буфера. Свойство m_time
— это время последовательности дилэя в секундах. Свойство m_gain
— это множитель, используемый для уменьшения со временем амплитуды буферизированных звуковых сэмплов.
Этот класс имеет только один метод, и это внутренний метод process()
, переопределяющий метод process()
в классе AudioProcessor
:
internal override function process( samples:Vector.<Number> ):void {
var i:int = 0;
var n:int = samples.length;
var v:Number = 0.0;
//
while( i < n ) {
v = m_buffer[m_bufferIndex]; // получение буферизированного сэмпла
v *= m_gain; // снижение амплитуды
v += samples[i]; // добавление нового сэмпла
//
m_buffer[m_bufferIndex] = v;
m_bufferIndex++;
//
if( m_bufferIndex == m_bufferSize ) {
m_bufferIndex = 0;
}
//
samples[i] = v;
i++;
}
}
Наконец, нам нужно добавить геттеры/сеттеры для приватных свойств m_time
и m_gain
:
public function get time():Number {
return m_time;
}
public function set time( value:Number ):void {
// ограничиваем time интервалом 0.0001 - 8.0
value = value < 0.0001 ? 0.0001 : value > 8.0 ? 8.0 : value;
// если time не изменилось, нет необходимости изменять размер буфера
if( m_time == value ) {
return;
}
// задаём time
m_time = value;
// обновляет размер буфера
m_bufferSize = Math.floor( 44100 * m_time );
m_buffer.length = m_bufferSize;
}
public function get gain():Number {
return m_gain;
}
public function set gain( value:Number ):void {
// ограничиваем gain интервалом 0.0 - 1.0
m_gain = value < 0.0 ? 0.0 : value > 1.0 ? 1.0 : value;
}
Верите или нет, но на этом класс AudioDelay
завершён. На самом деле реализация дилэев звука очень проста, если понять, как работае цикл обратной связи (свойство m_buffer
).
Обновление класса AudioEngine
Последнее, что нужно сделать — обновить класс AudioEngine
, чтобы к нему можно было добавлять аудиопроцессоры. Во-первых, давайте добавим вектор для хранения экземпляров аудиопроцессора:
static private var m_processorList:Vector.<AudioProcessor> = new Vector.<AudioProcessor>();
Чтобы действительно добавлять и удалять процессоры из класса AudioEngine
, нужно также использовать два общих метода:
AudioEngine.addProcessor()
static public function addProcessor( processor:AudioProcessor ):void {
if( m_processorList.indexOf( processor ) == -1 ) {
m_processorList.push( processor );
}
}
AudioEngine.removeProcessor()
static public function removeProcessor( processor:AudioProcessor ):void {
var i:int = m_processorList.indexOf( processor );
if( i != -1 ) {
m_processorList.splice( i, 1 );
}
}
Всё достаточно просто — все эти методы будут добавлять и удалять экземпляры AudioProcessor
из вектора m_processorList
.
Последний метод, который мы добавим, будет проходить по списку аудиопроцессоров, и ессли процессор включен, передавать звуковые сэмплы методу процессора process()
:
static private function processSamples():void {
var i:int = 0;
var n:int = m_processorList.length;
//
while( i < n ) {
if( m_processorList[i].enabled ) {
m_processorList[i].process( m_sampleList );
}
i++;
}
}
Настала пора добавить последнюю часть кода, и это единственная строка, которую необходимо добавить в приватный метод onSampleData()
класса AudioEngine
:
if( m_soundChannel == null ) {
while( i < n ) {
b.writeFloat( 0.0 );
b.writeFloat( 0.0 );
i++;
}
return;
}
//
generateSamples();
processSamples();
//
while( i < n ) {
s = m_sampleList[i] * m_amplitude;
b.writeFloat( s );
b.writeFloat( s );
m_sampleList[i] = 0.0;
i++;
}
В класс нужно добавить строку кода processSamples();
. Она просто вызывает метод processSamples()
, который мы добавили ранее.
Заключение
Вот, собственно, и всё. В первой части туториала мы рассмотрели различные формы волн и способ хранения звуковых волн в цифровом виде. Затем мы создали код базового аудиодвижка, а теперь закончили работу, добавив аудиопроцессоры.
С этим кодом можно сделать гораздо больше, но важно не забывать, что весь этот объём работы аудиодвижок должен совершать во время выполнения. Если сделать движок слишком изощрённым (а это очень легко), то от этого может пострадать общая производительность игры — даже если перенести аудиодвижок в отдельный поток (или в worker ActionScript 3.0), при неаккуратной реализации он всё равно будет отнимать большую долю времени ЦП.
Однако многие профессиональные и не очень профессиональные игры выполняют большую часть обработки звука во время выполнения, потому что динамические звуковые эффекты и музыка сильно обогащают игровой процесс и позволяют игроку глубже погрузиться в мир игры. Созданный нами аудиодвижок может запросто работать и с обычными (несгенерированными) сэмплами звуковых эффектов, загруженными из файлов: в сущности, все цифровые звуки в своём простейшем виде есть последовательность сэмплов.
Стоит задуматься и ещё об одном аспекте: звук — важная часть игры, настолько же важная и мощная, как визуальная составляющая. Её нельзя отбрасывать или прикручивать к игре в последний момент разработки, если вас заботит качество игры. Уделите время дизайну звука и это не останется незамеченным.
Надеюсь, вам понравился туториал, и вы сможете извлечь из него что-то полезное. Даже если вы просто чуть больше задумаетесь о звуке в своих играх, я буду считать свою работу ненапрасной.
Весь исходный код аудиодвижка можно скачать здесь.
Развлекайтесь!
Автор: PatientZero