Вот уже почти 35 лет мое хобби — создание электронных музыкальных инструментов. Я начинал в конце 1970-х, используя чипы CMOS и TTL, а уже гораздо позже пошел по программному пути: с 1991 года я работал с Multimedia Extensions для Windows, а еще позднее — с библиотекой NAudio для Windows Presentation Foundation (WPF) и классом MediaStreamSource в Silverlight и Windows Phone 7. В прошлом году я посвятил пару статей приложениям Windows Phone, воспроизводящим звуки и музыку.
Казалось бы, что к этому моменту я должен был устать и не жаждать исследовать еще один API генерации звука. Но это не так, потому что я считаю, что Windows 8 на данный момент, возможно, является лучшей Windows-платформой для создания музыкальных инструментов. Windows 8 сочетает в себе высокое быстродействие API аудио — DirectX-компонента XAudio2 — с поддержкой сенсорных экранов на планшетных компьютерах. Эта комбинация обладает огромным потенциалом, и я особенно заинтересован в изучении того, как задействовать касание в качестве тонкого интерфейса музыкального инструмента, реализованного полностью в виде программного обеспечения.
Генераторы, выборки и частоты
В основе механизма генерации звука любого музыкального синтезатора лежит множество генераторов, называемых так потому, что они генерируют более-менее периодические волновые колебания с конкретной частотой и громкостью. В синтезе звуков для музыки генераторы, создающие неизменяемые периодические волновые колебания, обычно весьма утомительны для слуха. Интереснее генераторы с поддержкой вибрато, тремоло или меняющихся тембров, а также с отклонениями в периодичности волновых колебаний.
В программе, где нужно создавать генераторы с помощью XAudio2, вы начинаете с вызова функции XAudio2Create. Тем самым вы получаете объект, реализующий интерфейс IXAudio2. Из этого объекта можно лишь раз вызвать CreateMasteringVoice, чтобы получить экземпляр IXAudio2MasteringVoice, который функционирует как главный аудиомикшер. Единовременно существует только один IXAudio2MasteringVoice. В противоположность этому вы обычно многократно вызываете CreateSourceVoice для создания множества экземпляров интерфейса IXAudio2SourceVoice. Каждый из этих экземпляров IXAudio2SourceVoice может работать как независимый генератор. Комбинируя различные генераторы, вы можете получить звучание мультифонического инструмента, ансамбля или целого оркестра.
Объект IXAudio2SourceVoice генерирует звук, создавая и передавая буферы, которые содержат последовательность числовых значений, описывающих волновой сигнал. Эти значения часто называют выборками. Обычно они являются 16-битными (стандарт для CD-аудио), и поступают с постоянной частотой — обычно 44 100 Гц (тоже стандарт для CD-аудио) или около того. Эта методика имеет замысловатое название: импульсно-кодовая модуляция (Pulse Code Modulation, PCM).
Хотя такая последовательность выборок может описывать очень сложное волновое колебание, синтезатор зачастую генерирует весьма простой поток выборок; наиболее распространенными являются прямоугольные, треугольные или пилообразные сигналы с периодичностью, соответствующей частоте волнового сигнала (воспринимаемой как высота звука) и средней амплитудой, воспринимаемой как громкость.
Например, если частота дискретизации равна 44 100 Гц и каждый цикл из 100 выборок имеет значения, которые постепенно нарастают, потом уменьшаются, принимают отрицательные величины и возвращаются к нулю, то частота получаемого звука будет равна 44 100, деленной на 100, или 441 Гц — эта частота близка к центру восприятия диапазона слышимости для человека. (Частота 440 Гц соответствует ноте ля над нотой до средней октавы и используется как настроечный стандарт.)
TИнтерфейс IXAudio2SourceVoice наследует метод SetVolume от IXAudio2Voice и определяет собственный метод SetFrequencyRatio. Последний метод меня особенно заинтриговал, так как он вроде бы предоставляет довольно простой способ создания генератора, выдающего конкретный периодический волновой сигнал с варьируемой частотой.
На рис. 1 показана основная часть класса SawtoothOscillator1, реализующего этот способ. Хотя я использовал привычные 16-битные целочисленные выборки для определения волнового сигнала, XAudio2 на внутреннем уровне оперирует 32-битными выборками и значениями с плавающей точкой. Для приложений, которым крайне важно быстродействие, вы, вероятно, захотите исследовать разницу в производительности при работе с целочисленными выборками и выборками, состоящими из значений с плавающей точкой.
Рис. 1. Большая часть класса SawtoothOscillator1
SawtoothOscillator1::SawtoothOscillator1(IXAudio2* pXAudio2)
{
// Создаем голос источника
WAVEFORMATEX waveFormat;
waveFormat.wFormatTag = WAVE_FORMAT_PCM;
waveFormat.nChannels = 1;
waveFormat.nSamplesPerSec = 44100;
waveFormat.nAvgBytesPerSec = 44100 * 2;
waveFormat.nBlockAlign = 2;
waveFormat.wBitsPerSample = 16;
waveFormat.cbSize = 0;
HRESULT hr = pXAudio2->CreateSourceVoice(&pSourceVoice,
&waveFormat, 0, XAUDIO2_MAX_FREQ_RATIO);
if (FAILED(hr))
throw ref new COMException(hr, "CreateSourceVoice failure");
// Инициализируем буфер для волнового сигнала
for (int sample = 0; sample < BUFFER_LENGTH; sample++)
waveformBuffer[sample] =
(short)(65535 * sample / BUFFER_LENGTH - 32768);
// Передаем этот буфер
XAUDIO2_BUFFER buffer = {0};
buffer.AudioBytes = 2 * BUFFER_LENGTH;
buffer.pAudioData = (byte *)waveformBuffer;
buffer.Flags = XAUDIO2_END_OF_STREAM;
buffer.PlayBegin = 0;
buffer.PlayLength = BUFFER_LENGTH;
buffer.LoopBegin = 0;
buffer.LoopLength = BUFFER_LENGTH;
buffer.LoopCount = XAUDIO2_LOOP_INFINITE;
hr = pSourceVoice->SubmitSourceBuffer(&buffer);
if (FAILED(hr))
throw ref new COMException(hr, "SubmitSourceBuffer failure");
// Запускаем воспроизведение голоса
pSourceVoice->Start();
}
void SawtoothOscillator1::SetFrequency(float freq)
{
pSourceVoice->SetFrequencyRatio(freq / BASE_FREQ);
}
void SawtoothOscillator1::SetAmplitude(float amp)
{
pSourceVoice->SetVolume(amp);
}
В заголовочном файле базовая частота задается такой, чтобы частоту дискретизации 44 100 Гц можно было делить без остатка. На основе этого можно вычислить размер буфера, который должен вмещать данные для одного цикла волнового сигнала этой частоты:
static const int BASE_FREQ = 441;
static const int BUFFER_LENGTH = (44100 / BASE_FREQ);
Кроме того, в заголовочной файле буфер определяется как поле:
short waveformBuffer[BUFFER_LENGTH];
После создания объекта IXAudio2SourceVoice конструктор SawtoothOscillator1 заполняет буфер данными одного цикла пилообразного волнового сигнала — простой волны, амплитуда которой изменяется от –32 768 до 32 767. Этот буфер передается в IXAudio2SourceVoice с инструкциями о том, что он должен повторяться вечно.
Без дальнейшего кода вы получите генератор, постоянно воспроизводящий пилообразный волновой сигнал, имеющий частоту 441 Гц. Это прекрасно, но не особо гибко. Чтобы придать SawtoothOscillator1 чуть больше гибкости, я также включил метод SetFrequency. Он принимает аргумент — частоту, которую класс использует при вызове SetFrequencyRatio. Значение, передаваемое SetFrequencyRatio, может варьироваться от величин с плавающей точкой XAUDIO2_MIN_FREQ_RATIO (или 1/1024.0) до максимума, ранее указанного в аргументе для CreateSourceVoice. Я задал XAUDIO2_MAX_FREQ_RATIO (или 1024.0) в этом аргументе. Диапазон слышимости для человека (примерно от 20 до 20 000 Гц) полностью укладывается в границы, определенные этими двумя константами, применяемыми к базовой частоте 441 Гц.
Буферы и обратные вызовы
Должен признаться, что поначалу я испытывал некоторый скепсис к методу SetFrequencyRatio. Цифровое увеличение и уменьшение частоты волнового сигнала — задача не тривиальная. Я чувствовал, что просто обязан сравнить результаты с волновым сигналом, генерируемым по алгоритму. Это и было побудительным мотивом к созданию проекта OscillatorCompare, который вы найдете в пакете скачиваемого кода для этой статьи.
Проект OscillatorCompare включает класс SawtoothOscillator1 (уже описанный мной) и класс SawtoothOscillator2. Второй класс имеет метод SetFrequency, который управляет тем, как класс динамически генерирует выборки, определяющие волновой сигнал. Этот сигнал постоянно конструируется в буфере и передается в реальном времени объекту IXAudio2SourceVoice в ответ на обратные вызовы.
Класс может принимать обратные вызовы от IXAudio2SourceVoice, реализуя интерфейс IXAudio2VoiceCallback. Экземпляр класса, реализующего этот интерфейс, передается как аргумент методу CreateSourceVoice. Класс SawtoothOscillator2 реализует этот интерфейс сам и передает в CreateSourceVoice собственный экземпляр, также указывая, что он не будет использовать SetFrequencyRatio:
pXAudio2->CreateSourceVoice(&pSourceVoice, &waveFormat,
XAUDIO2_VOICE_NOPITCH, 1.0f, this);
Класс, реализующий IXAudio2VoiceCallback, может использовать метод OnBufferStart для получения уведомлений о тех моментах, когда он должен передавать новый буфер с данными волнового сигнала. В целом, при использовании OnBufferStart для поддержания актуальности данных волнового сигнала вы наверняка предпочтете задействовать пару буферов и использовать их попеременно. Вероятно, это лучшее решение, если вы получаете аудиоданные из другого источника, например из аудиофайла. Ваша цель — не позволить «голодать» аудиопроцессору. Наличие второго буфера, заполняемого заблаговременно, помогает предотвратить «голодание», но не исключает его.
Но я тяготел к другому методу, определенному в IXAudio2VoiceCallback, — OnVoiceProcessingPassStart. Если только вы не работаете с очень малыми буферами, OnVoiceProcessingPassStart, в целом, вызывается чаще, чем OnBufferStart, и указывает, когда должна начаться обработка очередной порции аудиоданных и сколько байт потребуется. В документации XAudio2 этот метод обратного вызова позиционируется как один из методов с наименьшей задержкой, что крайне желательно при создании интерактивных музыкальных инструментов. Вряд ли вам понравится задержка между нажатием клавиши и воспроизведением ноты!
В заголовочном файле SawtoothOscillator2 определены две константы:
static const int BUFFER_LENGTH = 1024;
static const int WAVEFORM_LENGTH = 8192;
Первая константа — длина буфера, используемого для передачи данных волнового сигнала. Здесь он функционирует как круговой буфер. Вызовы метода OnVoiceProcessingPassStart запрашивают конкретное количество байтов. Метод отвечает на это, помещая нужно число байтов в буфер (начиная с того места, с которого он закончил в прошлый раз) и вызывая SubmitSourceBuffer только для обновленного сегмента буфера. Этот буфер должен быть достаточно большим, чтобы ваша программа не перезаписывала ту его часть, которая еще воспроизводится в фоне.
Оказывается, что для голоса с частотой дискретизации 44 100 Гц вызовы OnVoiceProcessingPassStart всегда запрашивают 882 байта, или 441 16-битную выборку. Иначе говоря, OnVoiceProcessingPassStart вызывается с постоянной частотой 100 раз в секунду, или каждые 10 мс. Хотя это не документировано, данный интервал в 10 мс может интерпретироваться как «квант» обработки аудиоданных в XAudio2, и эту цифру стоит запомнить. Соответственно код, который вы пишете для этого метода, не имеет права мешкать. Избегайте в нем вызовов API и библиотек исполняющей среды.
Вторая константа — длина одного цикла нужного волнового сигнала. Она могла бы быть равной размеру массива, содержащего выборки этого волнового сигнала, но в SawtoothOscillator2 она применяется только для вычислений.
Метод SetFrequency в SawtoothOscillator2 использует эту константу для расчета углового приращения, пропорционального необходимой частоте волнового сигнала:
angleIncrement = (int)(65536.0
* WAVEFORM_LENGTH
* freq / 44100.0);
Хотя angleIncrement является целочисленным, он интерпретируется так, будто состоит из целого (integral word) и дробного слов (fractional word). Это значение, применяемое для определения каждой последующей выборки волнового сигнала.
Допустим, аргумент, передаваемый в SetFrequency, равен 440 Гц. Вычисленное значение angleIncrement составляет 5 356 535. В шестнадцатеричной форме это 0x51BBF7, что интерпретируется как целое значение 0x51 (или 81 в десятичной форме) с дробной частью 0xBBF7, эквивалентной 0.734. В полном цикле волнового сигнала — 8192 байта, и вы используете только целую часть, пропуская 81 байт для каждой выборки, что в итоге дает частоту, примерно равную 436.05 Гц. (То есть 44 100, умноженное на 81 и деленное на 8192.) Если вы пропускаете 82 байта, конечная частота получится равной 441.43 Гц. Вам же требуется нечто между этими двумя частотами.
Вот почему в вычисления требуется вводить и дробную часть. Все это, по-видимому, было бы проще при использовании чисел с плавающей точкой и могло бы быть даже быстрее на некоторых современных процессорах, но на рис. 2 показан более «традиционный» подход с использованием только целых значений. Заметьте, что при каждом вызове SubmitSourceBuffer указывается лишь обновленная часть кругового буфера.
Рис. 2. OnVoiceProcessingPassStart в SawtoothOscillator2
void _stdcall SawtoothOscillator2::OnVoiceProcessingPassStart(UINT32 bytesRequired)
{
if (bytesRequired == 0)
return;
int startIndex = index;
int endIndex = startIndex + bytesRequired / 2;
if (endIndex <= BUFFER_LENGTH)
{
FillAndSubmit(startIndex, endIndex - startIndex);
}
else
{
FillAndSubmit(startIndex, BUFFER_LENGTH - startIndex);
FillAndSubmit(0, endIndex % BUFFER_LENGTH);
}
index = (index + bytesRequired / 2) % BUFFER_LENGTH;
}
void SawtoothOscillator2::FillAndSubmit(int startIndex, int count)
{
for (int i = startIndex; i < startIndex + count; i++)
{
pWaveformBuffer[i] = (short)(angle / WAVEFORM_LENGTH - 32768);
angle = (angle + angleIncrement) % (WAVEFORM_LENGTH * 65536);
}
XAUDIO2_BUFFER buffer = {0};
buffer.AudioBytes = 2 * BUFFER_LENGTH;
buffer.pAudioData = (byte *)pWaveformBuffer;
buffer.Flags = 0;
buffer.PlayBegin = startIndex;
buffer.PlayLength = count;
HRESULT hr = pSourceVoice->SubmitSourceBuffer(&buffer);
if (FAILED(hr))
throw ref new COMException(hr, "SubmitSourceBuffer");
}
SawtoothOscillator1 и SawtoothOscillator2 можно параллельно сравнивать в программе OscillatorCompare. В MainPage есть две пары элементов управления Slider для изменения частоты и громкости каждого генератора. Элемент управления Slider для контроля частоты приводит к генерации только целых значений в диапазоне 24–132. Я позаимствовал эти значения из кодов, используемых в стандарте Musical Instrument Digital Interface (MIDI) для представления высот звука. Значение 24 соответствует ноте до (C) на три октавы ниже ноты до средней октавы (middle-C), называется C 1 (C в октаве 1) и имеет частоту около 32.7 Гц. Значение 132 соответствует C 10, и ее частота составляет примерно 16 744 Гц. Всплывающие подсказки на этих ползунках отображают текущее значение как в нотной записи, так и в эквивалентной частоте.
Экспериментируя с этими двумя генераторами, я не услышал никакой разницы между ними. Я также установил программный генератор на другой компьютер, чтобы визуально отслеживать конечные волновые сигналы, и опять же не заметил никакой разницы. Это убедило меня, что метод SetFrequencyRatio реализован весьма «интеллектуально», чего и следовало ожидать в столь сложной подсистеме, как DirectX. Подозреваю, что для изменения частоты данные волнового сигнала после передискретизации подвергаются интерполяции. Если вас это нервирует, можете задать BASE_FREQ очень низкой, например 20 Гц, и класс будет генерировать детализированный волновой сигнал, состоящий из 2205 выборок. Кроме того, вы можете поэкспериментировать с высоким значением: скажем, 8820 Гц приведет к тому, что волновой сигнал будет состоять всего из пяти выборок! Разумеется, у него будет несколько другое звучание, так как интерполированный волновой сигнал представляет собой нечто среднее между пилообразным и треугольным, но конечный сигнал все равно будет плавным, без зазубрин.
Это не означает, что все работает изумительно. С любым из генераторов пилообразных сигналов пара верхних октав становятся довольно хаотичными. Дискретизация волнового сигнала приводит к генерации высоких и низких обертонов того типа, который мне доводилось слышать, и в будущем я планирую подробнее изучить этот вопрос.
Не задирайте громкость!
Метод SetVolume, определенный в IXAudio2Voice и наследуемый IXAudio2SourceVoice, документирован как множитель с плавающей точкой (floating-point multiplier), которому можно присваивать значения в диапазоне от –224 до 224, что равно 16 777 216.
Однако на практике вы, по-видимому, предпочтете указывать громкость в объекте IXAudio2SourceVoice как значение между 0 и 1. Значение 0 соответствует тишине, а 1 — без усиления или уменьшения громкости. Учтите: какой бы источник волнового сигнала ни был сопоставлен с IXAudio2SourceVoice — генерируется сигнал алгоритмически или извлекается из аудиофайла, — он скорее всего имеет 16-битные выборки, которые, весьма вероятно, приближаются к минимальному и максимальному значениям: –32 768 и 32 767. Если вы попытаетесь усилить эти сигналы до уровня громкости более 1, размер выборок превысит размер 16-битного целого значения и будет обрезан на минимальном и максимальном значениях. Это приведет к искажениям и появлению шума.
Это становится особенно важным при комбинировании нескольких экземпляров IXAudio2SourceVoice. Волновые сигналы от этих экземпляров микшируются добавлением друг к другу. Если вы допустите громкость, равную 1, для каждого из этих экземпляров, сумма голосов с высокой вероятностью даст выборки, размер которых превышает размер 16-битных целых. Это может происходить спорадически (давая лишь периодические искажения) или хронически (что приведет к настоящей мешанине звуков).
При использовании нескольких экземпляров IXAudio2SourceVoice, генерирующих волновые сигналы с полными 16-битными выборками, одна из мер предосторожности — задание громкости каждого генератора как значения 1, деленного на количество голосов. Это гарантирует, что сумма никогда не превысит размер 16-битного значения. Регулировка общей громкости звучания может также осуществляться через мастеринговый голос (mastering voice). Кроме того, вы, возможно, захотите изучить функцию XAudio2CreateVolumeMeter, позволяющую создавать объект обработки звука, который помогает отслеживать громкость в отладочных целях.
Наш первый музыкальный инструмент
Музыкальные инструменты на планшетах обычно имеют клавиатуру в стиле пианино, но недавно я был заинтригован кнопочной клавиатурой, которая есть у аккордеонов, например на русском баяне (с его звучанием я знаком по работам российского композитора Софии Губайдулиной). Поскольку каждая клавиша является кнопкой, а не длинным рычагом, это позволяет включить в ограниченное экранное пространство планшета гораздо больше клавиш, как показано на рис. 3.
Рис. 3. Программа ChromaticButtonKeyboard
Нижние два ряда дублируют клавиши в верхних двух рядах и облегчают игру распространенных аккордов и мелодических последовательностей. Каждая группа из 12 клавиш в верхних трех рядах дает все ноты октавы, которые обычно восходят слева направо. Общий диапазон здесь составляет четыре октавы, что почти в два раза больше, чем в случае клавиатуры в стиле пианино того же размера.
У настоящего баяна есть дополнительная октава, но она не умещалась на экране без чрезмерного уменьшения кнопок. Исходный код позволяет вам задавать константы, чтобы опробовать дополнительную октаву или исключить еще одну октаву и сделать кнопки еще крупнее.
Поскольку я претендую на то, что эта программа позволяет добиться звучания какого-либо реального инструмента, я просто назвал ее ChromaticButtonKeyboard. Клавиши являются экземплярами пользовательского элемента управления Key, производного от ContentControl, но выполняющего кое-какую обработку сенсорного ввода для поддержки свойства IsPressed и генерации события IsPressedChanged. Разница между обработкой касания в этом элементе управления и аналогичной обработкой в обычной кнопке (у которой тоже есть свойство IsPressed) заметна, когда вы проводите пальцем по клавиатуре. Стандартная кнопка установит свойство IsPressed в true, только если вы нажали пальцем на поверхность кнопки, а пользовательский элемент управления Key считает клавишу нажатой, если вы просто проводите пальцем по клавише.
Программа создает шесть экземпляров класса SawtoothOscillator, который практически идентичен классу SawtoothOscillator1 из предыдущего проекта. Если ваш сенсорный экран поддерживает это, вы можете играть сразу шесть нот. Никаких обратных вызовов нет, и частота генератора контролируется вызовами метода SetFrequencyRatio.
Чтобы отслеживать, какие генераторы доступны и какие из них играют, в файле MainPage.xaml.h определены два стандартных объекта-набора в виде полей:
std::vector<SawtoothOscillator *> availableOscillators;
std::map<int, SawtoothOscillator *> playingOscillators;
Изначально свойство Tag каждого объекта Key содержит MIDI-код ноты. Благодаря этому обработчик IsPressedChanged определяет, какая клавиша нажата и какую частоту следует вычислять. Этот MIDI-код также использовался для набора playingOscillators. Все это прекрасно работало, пока я не попытался сыграть ноту из двух нижних рядов, которая дублирует уже проигрываемую ноту. Это привело к исключению из-за дублирования клавиши. Эту проблему я легко решил включением в свойство Tag значения, указывающего ряд, в котором находится клавиша: теперь Tag содержит MIDI-код ноты плюс 1000, умноженную на номер ряда.
На рис. 4 показан обработчик IsPressedChanged для экземпляров Key. Когда нажимается какая-либо клавиша, соответствующий генератор удаляется из набора availableOscillators, получает свою частоту и громкость, отличную от нуля, а затем помещается в набор playingOscillators. Когда клавиша отпускается, громкость этого генератора выводится в ноль, и он перемещается обратно в availableOscillators.
Рис. 4. Обработчик IsPressedChanged для экземпляров Key
void MainPage::OnKeyIsPressedChanged(Object^ sender, bool isPressed)
{
Key^ key = dynamic_cast<Key^>(sender);
int keyNum = (int)key->Tag;
if (isPressed)
{
if (availableOscillators.size() > 0)
{
SawtoothOscillator* pOscillator = availableOscillators.back();
availableOscillators.pop_back();
double freq = 440 * pow(2, (keyNum % 1000 - 69) / 12.0);
pOscillator->SetFrequency((float)freq);
pOscillator->SetAmplitude(1.0f / NUM_OSCILLATORS);
playingOscillators[keyNum] = pOscillator;
}
}
else
{
SawtoothOscillator * pOscillator = playingOscillators[keyNum];
if (pOscillator != nullptr)
{
pOscillator->SetAmplitude(0);
availableOscillators.push_back(pOscillator);
playingOscillators.erase(keyNum);
}
}
}
Программа проста, насколько может быть простым многоголосый инструмент, и, конечно же, в ней сидит крупная проблема: звуки нельзя включать и выключать подобно включателям. Громкость должна быстро, но плавно нарастать при начале воспроизведения ноты и затухать по ее окончании. Многие реальные инструменты также меняют громкость и тембр по мере проигрывания ноты. Так что здесь широкий простор для усовершенствований.
Но, учитывая простоту кода, он работает на удивление хорошо и быстро. Если вы скомпилируете эту программу для процессора ARM, то сможете развернуть ее на устройстве Microsoft Surface, которое базируется на ARM, и разгуливать с этим потрясающим планшетом, держа его в одной руке и наигрывая мелодию другой рукой, что, должен заметить, производит впечатление.