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

MIDI-музыка в WPF-приложениях

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

Загрузите кода примера

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

Эта группа с 16 инструментами — синтезатор электронной музыки, реализованный аппаратно или программно и удовлетворяющий стандарту под названием MIDI — Musical Instrument Digital Interface. В Win32 API проигрывание музыки через MIDI-синтезатор поддерживается функциями, имена которых начинаются с «midiOut».

Однако поддержка MIDI не является частью .NET Framework, поэтому, если вам нужен доступ к этому MIDI-синтезатору в приложении Windows Forms или Windows Presentation Foundation (WPF), вам придется использовать P/Invoke или какую-то внешнюю библиотеку.

Я был очень рад обнаружить поддержку MIDI в звуковой библиотеке NAudio, доступной на CodePlex (о ней я уже рассказывал в предыдущей статье из этой рубрики). Вы можете скачать эту библиотеку с исходным кодом по ссылке codeplex.com/naudio. В этой статье я использовал NAudio версии 1.3.8.

Краткий пример

MIDI можно считать высокоуровневым интерфейсом к генератору волнового звука, где вы работаете с музыкальными инструментами и нотами.

Стандарт MIDI был разработан в начале 1980-х годов. Производителям синтезаторов музыки нужен был стандартный способ подключения контроллеров (вроде клавиатур) к синтезаторам, и они создали систему, которая передает небольшие сообщения (в основном длиной по одному, два или три байта) через кабель с пятью штырьками со смехотворной в наши дни скоростью 3125 байт в секунду.

Два самых важных сообщения называются Note On и Note Off. Когда музыкант нажимает клавишу на MIDI-клавиатуре, та генерирует сообщение Note On, указывающее, что была нажата клавиша с такой-то нотой и такой-то силой удара. Синтезатор реагирует на это сообщение проигрыванием соответствующей ноты — и чем сильнее был удар по клавише, тем громче. Когда музыкант отпускает клавишу, клавиатура генерирует сообщение Note Off, и синтезатор прекращает воспроизведение этой ноты. Никакие аудиоданные по MIDI-кабелю не передаются.

Хотя MIDI по-прежнему используется для соединения соответствующего оборудования, его можно применять и полностью в рамках ПК за счет программного обеспечения. Звуковые карты могут быть оборудованы MIDI-синтезаторами, а сама Windows программно эмулирует MIDI-синтезатор.

Для доступа к синтезатору в приложении WinForms или WPF с применением библиотеки NAudio добавьте ссылку на NAudio.dll и включите ее в свой исходный код директивой using:

using NAudio.Midi;

Допустим, вы хотите, чтобы ваше приложение проигрывало единственную ноту «среднее до» длительностью в одну секунду. Тогда вы пишете:

MidiOut midiOut = new MidiOut(0);
midiOut.Send(MidiMessage.StartNote(60, 127, 0).RawData);
Thread.Sleep(1000);
midiOut.Send(MidiMessage.StopNote(60, 0, 0).RawData);
Thread.Sleep(1000);
midiOut.Close();
midiOut.Dispose();

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

Программа может получить информацию о MIDI-синтезаторах, сначала считав значение статического свойства MidiOut.NumberOfDevices, чтобы определить, сколько синтезаторов присутствует в системе. Числовые идентификаторы варьируются от 0 до количества устройств за вычетом единицы. Статический метод MidiOut.DeviceInfo принимает числовой идентификатор и возвращает объект типа MidiOutCapabilities, который описывает синтезатор. (Эти средства я не буду использовать. В остальной части статьи всегда применяется MIDI-синтезатор по умолчанию с нулевым идентификатором.)

Метод Send класса MidiOut посылает сообщение MIDI-синтезатору. MIDI-сообщение состоит из одного, двух или трех байтов, но Win32 API (и NAudio) упаковывает их в единственное 32-битное целочисленное значение. Упаковку выполняют методы MidiMessage.StartNote и MidiMessage.StopNote. Вы можете заменить два аргумента для Send на 0x007F3C90 и 0x00003C80 соответственно.

Первый аргумент для StartNote и StopNote — числовой код в диапазоне 0–127, указывающий реальную ноту, где значение 60 соответствует ноте «до» на средней октаве. Октавой выше — 72, а октавой ниже — 48. Второй аргумент — сила удара по клавише («скорость» нажатия или отпускания). (Скорости отпускания клавиш обычно игнорируются синтезаторами.) Это значение тоже варьируется в диапазоне 0–127. Чтобы нота звучала мягче, уменьшите второй аргумент в MidiMessage.StartNote. Третий аргумент я поясню позже.

Два вызова Thread.Sleep приостанавливают поток на 1000 мс. Это очень простой пример синхронизации сообщений, но его следует избегать в UI-потоке. Второй вызов Sleep нужен, чтобы дать ноте затихнуть до того, как ее звучание будет резко оборвано вызовом Close.

Как насчет полифонии?

Так можно проиграть одну ноту. А как быть, если нужно воспроизвести несколько нот одновременно? Это тоже возможно. Скажем, если вы хотите проиграть аккорд до-мажор вместо одной ноты до, замените первое сообщение Send следующим:

midiOut.Send(MidiMessage.StartNote(60, 127, 0).RawData);
midiOut.Send(MidiMessage.StartNote(64, 127, 0).RawData);
midiOut.Send(MidiMessage.StartNote(67, 127, 0).RawData);
midiOut.Send(MidiMessage.StartNote(72, 127, 0).RawData);

А потом замените второе сообщение Send на:

midiOut.Send(MidiMessage.StopNote(60, 0, 0).RawData);
midiOut.Send(MidiMessage.StopNote(64, 0, 0).RawData);
midiOut.Send(MidiMessage.StopNote(67, 0, 0).RawData);
midiOut.Send(MidiMessage.StopNote(72, 0, 0).RawData);

Чтобы разные ноты начинались и заканчивались в разные периоды, откажитесь от использования Thread.Sleep и задействуйте таймер, особенно если вы воспроизводите музыку в UI-потоке. Подробнее об этом чуть позже.

Существует файловый формат MIDI, который комбинирует MIDI-сообщения с информацией о таймингах, но для создания этих файлов требуется специализированное ПО, и я не стану обсуждать это здесь.

Инструменты и каналы

До сих пор я проигрывал только звуки пианино. Вы можете переключить синтезатор на любой другой инструмент, используя MIDI-сообщение Program Change, которое реализовано в NAudio в форме метода ChangePatch:

midiOut.Send(MidiMessage.ChangePatch(47, 0).RawData);

Первый аргумент в ChangePatch является числовым кодом в диапазоне 0–127, который указывает звук конкретного инструмента.

На заре развития MIDI реальные звуки, создаваемые синтезаторами, полностью контролировались исполнителем колесиками и патч-кабелями (patch cables), определяющими тембр. (Вот почему определенную настройку синтезатора или звучание инструмента часто называют тембром.) Впоследствии создателям MIDI-файлов понадобился стандартный набор инструментов, чтобы файлы воспроизводились примерно одинаково независимо от конкретного синтезатора. Это привело к появлению стандарта General MIDI.

Хороший справочник по General MIDI — статья в википедии en.wikipedia.org/wiki/General_midi. Под заголовком «Melodic sounds» перечислены 128 инструментов с кодами от 1 до 128. Так как в методе ChangePatch используются коды с отсчетом от 0, то код 47 в предыдущем примере соответствует инструменту под номером 48 в этом списке, т. е. литавре.

В самом начале я упомянул, что MIDI-синтезатор эквивалентен группе с 16 инструментами. На самом деле MIDI-синтезатор поддерживает 16 каналов. В любой момент каждый канал может быть сопоставлен с конкретным инструментом на основе самого последнего сообщения Program Change. Номер канала варьируется в диапазоне 0–15 и указывается в последнем аргументе методов StartNote, StopNote и ChangePatch.

Канал 9 особый. Это канал ударных инструментов. (Он часто называется каналом 10, но это только в том случае, если каналы нумеруются от 1.) Для канала 9 коды, передаваемые в методы StartNote и StopNote, называют атональными перкуссионными звуками, а не тонами. В разделе Википедии по General MIDI посмотрите список под заголовком «Percussion». Например, следующий вызов проиграет звук колокольчика, который задается кодом 56:

midiOut.Send(MidiMessage.StartNote(56, 127, 9).RawData);

О MIDI можно говорить еще долго, но самые основы мы обсудили.

MIDI на основе XAML

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

Ноты записываются заглавными буквами от A до G, за которыми следует любое количество знаков «+» или «#» (повышение на полтона) либо знаков «–» или букв «b» (понижение на полтона), а за ними ставится необязательный номер октавы (октава, которая начинается со средней до, является четвертой). (Это стандартный способ нумерации октав.) Таким образом, C# на октаву ниже средней до записывается так:

C#3

Буква «R» сама по себе является паузой (rest). За нотой или паузой можно (но не обязательно) указать длительность, которая указывает период времени до следующей ноты. Например, вот это четвертная нота, каковой по умолчанию являются все ноты, если не указывается длительность:

1/4

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

Эта длительность задает время до следующей ноты. Она также обозначает длительность ноты, т. е. время ее звучания. Для более отрывистого звучания (стаккато) вам понадобится, чтобы нота звучала короче, чем задано ее длительностью. А иногда нужно, чтобы ноты последовательности в какой-то мере перекрывались. Период звучания ноты указывается так же, как и длительность, но со знаком «минус»:

–3/16

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

Нотам также могут предшествовать лексемы (tokens). Чтобы задать голос инструмента, ставится буква «I» с номером патча, который отсчитывается от 0. Например, ниже задается скрипка с последовательными нотами:

I40

Патч по умолчанию — пианино.

Чтобы задать новую громкость (т. е. силу удара по клавишам) для последующих нот, используйте V:

V64

Как для I, так и для V, последующие числа должны укладываться в диапазон 0–127.

По умолчанию темп равен 60 четвертным нотам в минуту. Чтобы указать новый темп для следующих нот, используйте T с числом четвертных нот в минуту, например:

T120

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

(C4 E4 G4 C5)

В скобках могут появляться только ноты. Вертикальная линия «|» разделяет каналы. Каналы проигрываются одновременно, и они полностью независимы, в том числе могут иметь свой темп.

Если в любом месте канала содержится заглавная буква «P», этот канал становится перкуссионным. Такой канал может содержать ноты или паузы в обычной нотации; кроме того, допускается задание перкуссионных голосов с помощью чисел. Например, это колокольчик:

P56

Если вы зайдете по ссылке en.wikipedia.org/wiki/Charge_(fanfare), то увидите нотную запись для мелодии «Charge!», часто звучащей на спортивных соревнованиях. В формате MIDI-строки ее можно выразить так:

"T100 I56 G4 /12 C5 E5 G5 3/16 -3/32 E5 /16 G5 /2"

MidiStringPlayer

MidiStringPlayer — единственный открытый класс в проекте библиотеки Petzold.Midi, включенном в комплект исходного кода, который можно скачать для этой статьи. Он наследует от FrameworkElement, поэтому вы можете встраивать его в визуальное дерево в XAML-файле, но он не является видимым элементом. В свойство MidiString запишите строку в формате, показанном в предыдущем примере, и вызовите Play (а если хотите, то и Stop, чтобы остановить воспроизведение последовательности до ее окончания).

В MidiStringPlayer также есть свойство PlayOnLoad для воспроизведения последовательности при загрузке элемента, и свойство IsPlaying только для чтения. Этот элемент генерирует событие Ended по окончании воспроизведения последовательности и событие Failed, если в синтаксисе MIDI-строки обнаруживается ошибка. Это событие включает смещение в текстовой строке, указывая на проблематичную лексему, и текстовое описание ошибки.

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

Рис. WpfMusicDemo.xaml кодирует несколько простых MIDI-строк

<Window x:Class="WpfMusicDemo.Window1"
  xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
  xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
  xmlns:midi="clr-namespace:Petzold.Midi;assembly=Petzold.Midi"
  Title="WPF Music Demo" 
  Height="300" Width="300">
  <Grid>
    <midi:MidiStringPlayer Name="player"
      PlayOnLoad="True"
      MidiString="{Binding ElementName=chargeButton, Path=Tag}" />
        
    <UniformGrid Rows="2"
      ButtonBase.Click="OnButtonClick">
      <UniformGrid.Resources>
        <Style TargetType="Button">
          <Setter Property="HorizontalAlignment" Value="Center" />
          <Setter Property="VerticalAlignment" Value="Center" /> 
          <Style.Triggers>
            <DataTrigger 
              Binding="{Binding ElementName=player, Path=IsPlaying}"
              Value="True">
              <Setter Property="IsEnabled" Value="False" />
            </DataTrigger>
          </Style.Triggers>
        </Style>
      </UniformGrid.Resources>

      <Button Name="chargeButton"
        Content="Charge!"
        Tag="T100 I56 G4 /12 C5 E5 G5 3/16 -3/32 E5 /16 G5 /2" />
            
      <Button Content="Bach D-Minor Toccata"
        Tag="T24 I19 A5 /64 G5 A5 5/32 R /32 G5 /64 F5 E5 D5 C#5 /32 D5 /16 R 4/16 A4 /64 G4 A4 5/32 R /32 E4 F4 C#4 D4 /16 R 4/16 | T24
I19 A4 /64 G4 A4 5/32 R /32 G4 /64 F4 E4 D4 C#4 /32 D4 /16 R 4/16 A3 /64 G3 A3 5/32 R /32 E3 F3 C#3 D3 /16 R 4/16"/>

      <Button Content="Shave &amp; a Haircut"
        Tag="T130 I58 C5 G4 /8 G4 Ab4 /4 G4 R I75 B4 C5" />

      <Button Content="Beethoven Fifth"
        Tag="T200 I71 R /8 G4 G4 G4 Eb4 7/8 R /8 F4 F4 F4 D4 5/4 | T200 I40 R /8 G4 G4 G4 Eb4 7/8 R /8 F4 F4 F4 D4 5/4 | T200 I40 R /8 G4 
G4 G4 Eb4 7/8 R /8 F4 F4 F4 D4 5/4 | T200 I41 R /8 G3 G3 G3 Eb3 7/8 R /8 F3 F3 F3 D3 5/4 | T200 I43 R /8 G2 G2 G2 Eb2 7/8 R /8 F2 F2 F2 D2 
5/4 | T200 I43 R /8 G2 G2 G2 Eb2 7/8 R /8 F2 F2 F2 D2 5/4"/>
            
    </UniformGrid>
  </Grid>
</Window>

Критически важная часть любой программы, воспроизводящей музыку, — таймер, но для MidiStringPlayer я использовал очень простой DispatcherTimer, который выполняется в UI-потоке. Это, конечно, не оптимально. Если другая программа будет нагружать процессор, воспроизведение музыки станет прерывистым. Кроме того, DispatcherTimer не может генерировать события Tick быстрее 60 раз в секунду, что годится для простых мелодий, но не обеспечивает необходимой точности для музыки с более сложным ритмическим рисунком.

В Win32 API есть таймер высокого разрешения, предназначенный специально для воспроизведения MIDI-последовательностей, но он пока отсутствует в библиотеке NAudio. Возможно, позднее я заменю DispatcherTimer на что-нибудь более точное и качественное, но пока я счастлив, что все и так работает, несмотря на простоту решения.

Чарльз Петцольд (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).