Экспериментальные UI

Генерация звука в WPF-приложениях

Чарльз Петцольд

Несколько недель назад я сидел в новенькой Тойоте Приус, пока агент из компании, выдающей автомобили на прокат, объяснял, как пользоваться незнакомыми мне кнопками и индикаторами на приборной доске. «Ух ты», — подумал я. — «Даже в такой старой технологии, как автомобили, производители постоянно выдумывают что-то новенькое в пользовательском интерфейсе».

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

Лишь крохотная горстка нынешних пользователей персональных компьютеров еще помнит то, что было до пришествия UI от Apple Macintosh и Microsoft Windows. В то время (примерно от середины до конца 1980-х годов) некоторые эксперты опасались, что стандартизация UI может привести к унылому однообразии приложений. Но этого не произошло. Вместо этого появление стандартных элементов управления освободило проектировщиков и программистов от необходимости каждый раз заново изобретать полосы прокрутки, началось реальное развитие UI, и они становились все более интересными.

В этом отношении новые парадигмы, введенные подсистемой Windows Presentation Foundation (WPF), позволили создавать еще более впечатляющие UI. WPF заложила очень прочный фундамент для графики режима сохранения (retained-mode graphics), анимации и трехмерных эффектов. Также были добавлены древовидная иерархическая структура родительских и дочерних элементов и мощный язык разметки, известный как XAML. Результатом стали непревзойденная гибкость в модификации существующих элементов управления на основе шаблонов и возможность создания новых элементов управления путем сборки существующих компонентов.

Но эти новые концепции предназначены не только для программирования на клиентской стороне. С появлением Silverlight весьма большое подмножество Microsoft .NET Framework, XAML и WPF-классов стали доступными и для веб-программирования. Час «Х» наступил еще тогда, когда стало возможным совместно использовать нестандартные элементы управления как в клиентских, так и в веб-приложениях. Уверен, что эта тенденция распространится на мобильные приложения и в конечном счете охватит самые разнообразные типы информационных и развлекательных систем; кроме того, не забудьте о преимуществах новых технологий вроде управления жестами с использованием сенсорных дисплеев.

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

Озвучивание

Хороший и плохой выбор в UI не всегда можно различить сходу. Приснопамятная «скрепка», дебютировавшая в Microsoft Office 97, в свое время, вероятно, казалась неплохой идеей. Вот поэтому я буду уделять больше внимания технологическому потенциалу, а не дизайну. Я вообще постараюсь избегать термина «передовые методики». Оставим это историкам и маркетологам.

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

Эти средства генерации звуков пока не являются официальной частью .NET Framework, но их можно получить в виде библиотеки NAudio, доступной на сайте Codeplex (naudio.codeplex.com). Заодно пройдите по имеющимся там ссылкам и посмотрите в блоке Марка Хиза (Mark Heath) некоторые примеры кода, а также документацию, написанную Себастьяном Греем (Sebastian Gray).

Библиотеку NAudio можно использовать в приложения Windows Forms или WPF. Так как она обращается к Win32-функциям через P/Invoke, ее нельзя задействовать в Silverlight.

В этой статье я использую NAudio версии 1.3.8. Создавая проект с применением NAudio, вы захотите скомпилировать его для 32-разрядной обработки. Откройте вкладку Build на странице Properties и выберите x86 из раскрывающегося списка Platform Target.

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

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

Это работа для генератора волнового звука (waveform).

В наши дни почти все ПК оснащены аппаратными звуковыми устройствами, зачастую реализованными в виде одного или двух чипов, размещенных прямо на материнской плате. Это оборудования обычно представляет собой не более чем пару цифро-аналоговых преобразователей (ЦАП) (digital-to-analog converters, DAC). Передавайте постоянный поток целых значений, описывающих волновой сигнал, этим двум ЦАП и на выходе получите стереозвук.

Сколько при этом требуется данных? Современные приложения, как правило, генерируют звук «качества CD». Частота дискретизации при этом постоянна и равна 44 100 выборок в секунду. (Теорема Найквиста утверждает, что частота дискретизации должна быть минимум в два раза больше самой высокой воспроизводимой частоты. Человеческое ухо обычно слышат звуки в диапазоне 20–20 000 Гц, поэтому частота дискретизации, равная 44 100, вполне адекватна.) Каждая выборка (sample) является знаковым 16-битным целым значением, размер которого предполагает отношение уровня сигнала к шуму в 96 децибел.

Генерация волн

Win32 API обеспечивает доступ к звуковому оборудованию через набор функций, имена которых начинаются со слова «waveOut». Библиотека NAudio инкапсулирует эти функции в классе WaveOut, который берет на себя взаимодействие с Win32 API и скрывает от вас большую часть сложностей такого взаимодействия.

WaveOut требует от вас создания класса, реализующего интерфейс IWaveProvider, а значит, определяющего свойство для чтения типа WaveFormat, которое, как минимум, указывает частоту дискретизации и число каналов. Этот класс также определяет метод Read. Аргументы метода Read включают буфер — байтовый массив, необходимый классу для заполнения волновыми данными. При настройках по умолчанию метод Read вызывается 10 раз в секунду. Попробуйте чуток отстать в заполнении этого буфера и вы услышите неэстетичные провалы звука и кошмарный шум.

NAudio предоставляет несколько абстрактных классов, реализующих IWaveProvider и немного упрощающих стандартные операции, связанные со звуком. Класс WaveProvider16 реализует абстрактный метод Read для заполнения буфера короткими целыми (типа short), а не байтами, поэтому вам не придется разбивать выборки пополам.

На рис. 1 показан простой класс SineWaveOscillator, производный от WaveProvider16. Его конструктор позволяет задавать частоту дискретизации, но вызывает конструктор базового класса со вторым аргументом, указывающим один канал для генерации монофонического звука. Рис.

Рис. 1. Класс, генерирующий синусоидальные волновые выборки для NAudio

class SineWaveOscillator : WaveProvider16 {
  double phaseAngle;

  public SineWaveOscillator(int sampleRate): 
    base(sampleRate, 1) {
  }

  public double Frequency { set; get; }
  public short Amplitude { set; get; }

  public override int Read(short[] buffer, int offset, 
    int sampleCount) {

    for (int index = 0; index < sampleCount; index++) {
      buffer[offset + index] = 
        (short)(Amplitude * Math.Sin(phaseAngle));
      phaseAngle += 
        2 * Math.PI * Frequency / WaveFormat.SampleRate;

      if (phaseAngle > 2 * Math.PI)
        phaseAngle -= 2 * Math.PI;
    }
    return sampleCount;
  }
}

SineWaveOscillator определяет два свойства: Frequency (типа double) и Amplitude (типа short). Программа поддерживает поле phaseAngle, которое всегда варьируется в диапазоне от 0 до 2π. Для каждой выборки содержимое поля phaseAngle передается функции Math.Sin, а затем увеличивается на значение, называемое приращением фазового угла.

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

Чтобы задействовать SineWaveOscillator в программе, нужно сослаться на библиотеку NAudio.dll и указать директиву using:

using NAudio.Wave;

Вот пример кода, запускающего воспроизведение звука:

WaveOut waveOut = new WaveOut();
SineWaveOscillator osc = new SineWaveOscillator(44100);
osc.Frequency = 440;
osc.Amplitude = 8192;
waveOut.Init(osc);
waveOut.Play();

Здесь свойство Frequency инициализируется значением 440 Гц. В музыке это частота ноты Ля первой октавы, часто используемая в качестве стандартной для настройки инструментов. Конечно, пока воспроизводится звук, свойство Frequency можно изменять. Чтобы отключить звук, можно было бы присвоить Amplitude значение 0, но тогда SineWaveOscillator будет по-прежнему получать вызовы метода Play. Чтобы прекратить эти вызовы, вызовите метод Stop объекта WaveOut. Если вам больше не нужен объект WaveOut, вы должны вызвать Dispose для корректного освобождения ресурсов.

Диссонанс

Когда я применил SineWaveOscillator в своей программе-примере, я не добился того, чего хотел. А хотел я того, чтобы звук сопровождал объекты, перетаскиваемые по окну, и чтобы частота этого звука плавно менялась в зависимости от расстояния объекта от центра. Но, начав перемещать объекты, я обнаружил, что частота меняется вовсе не плавно. У меня получилось нечто вроде неровного глиссандо (примерно так, когда проводят пальцами по клавишам пианино), а нужно было плавное портаменто (как звучит вступительная партия на кларнете в «Рапсодии в голубых тонах» Гершвина).

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

Итак, насколько серьезна эта проблема и насколько велики эти буферы?

Класс WaveOut в NAudio включает свойство DesiredLatency, которое по умолчанию установлено в 300 мс. У него также есть свойство NumberOfBuffers, равное 3. (Наличие нескольких буферов увеличивает пропускную способность, так как API может читать один буфер в то время, как приложение заполняет другой.) Следовательно, каждый буфер эквивалентен 0,1 секунды выборки. Поэкспериментировав, я обнаружил, что значительно уменьшать DesiredLatency нельзя — это приводит к слышимым провалам в звуке. Можно увеличить число буферов — при этом выбирайте размер буфера в байтах, кратный значению 4, — но это не дает особого выигрыша. Также можно выполнять метод Play в другом потоке, передав вызов статического метода WaveCallbackInfo.FunctionCallback конструктору WaveOut, но и это не дает ничего особенного.

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

PortamentoSineWaveOscillator

Мне хотелось внести и другие изменения. Восприятие звуковых частот человеком меняется по логарифмической зависимости. Октава определяется удвоением частоты; на слух октавы воспринимаются похоже по всему диапазону. Для нервной системы человека разница между 100 и 200 Гц такая же, как и между 1000 и 2000 Гц. В музыке каждая октава состоит из 12 равно слышимых полутонов. Значит, частоты этих полутонов последовательно увеличиваются мультипликативным множителем, равным корню двенадцатой степени от двух.

Я также хотел, что мое портаменто менялось по логарифмической зависимости, поэтому я определил в PortamentoSineWaveOscillator новое свойство с именем Pitch — оно вычисляет частоту так:

Frequency = 440 * Math.Pow(2, (Pitch - 69) / 12)

Это, в общем-то, стандартная формула, взятая из соглашений, используемых в Musical Instrument Digital Interface (MIDI), о котором мы поговорим в одной из будущих статей этой рубрики. Если вы пронумеруете все ноты пианино от нижней до верхней октавы, где средняя нота До (Middle C) получает значение Pitch, равное 60, то нота Ля выше средней До (A above Middle C) — 69, а по формуле ее частота получается 440 Гц. В MIDI эти значения Pitch являются целыми значениями, но в классе PortamentoSineWaveOscillator свойство Pitch имеет тип double, так что возможны градации между нотами.

В PortamentoSineWaveOscillator метод Play обнаруживает изменение Pitch и постепенно меняет значение, используемое для вычисления частоты (а следовательно, и приращение фазового угла) в зависимости от оставшегося размера буфера. Эта логика позволяет изменять Pitch в процессе выполнения метода, но это возможно, только если Play выполняется в другом потоке.

Как демонстрирует программа AudibleDragging (ее можно скачать с сайта MSDN Magazine), моя логика работает! Программа создает семь небольших блоков разных цветов рядом с центром окна. Когда вы тащите один из них мышью, программа создает объект WaveOut, используя PortamentoSineWaveOscillator. Когда объект перетаскивается, программа просто определяет его расстояние от центра окна и задает Pitch осциллятора по следующей формуле:

60 + 12 * distance / 200;

Иначе говоря, получается средняя нота До плюс одна октава на каждые 200 единиц растояния. Конечно, AudibleDragging — совершенно бесполезная программа, и она вполне способна окончательно убедить вас в том, что приложения не должны издавать никаких звуков. Но сама потенциальная возможность генерации собственных звуков в период выполнения настолько впечатляет, что категорически отбрасывать ее не стоит.

Продолжаем резвиться

Конечно, вас никто не ограничивает только осцилляторами синусоидальных звуковых волн. Вы можете создать из WaveProvider16 микшер и использовать его в комбинации с несколькими осцилляторами. Кроме того, можно комбинировать простые волновые звуки в более сложные. Использование свойства Pitch предполагает простой подход с указанием музыкальных нот.

Но если вы хотите, чтобы приложение отправляло на колонки музыку и звуки музыкальных инструментов, вам будет приятно узнать, что NAudio включает классы, позволяющие генерировать MIDI-сообщения из приложений Windows Forms или WPF. Как это делается, я покажу в одной из ближайших статей.

Чарльз Петцольд (Charles Petzold) — давний «пишущий» редактор журнала «MSDN Magazine». Его самая последняя книга — «The Annotated Turing: A Guided Tour through Alan Turing’s Historic Paper on Computability and the Turing Machine» (Wiley, 2008). Ведет блог на своем веб-сайте charlespetzold.com.

Выражаю благодарность за рецензирование этой статьи эксперту Марку Хизу (Mark Heath).