UI 前沿技术

WPF 应用程序中的 MIDI 音乐

Charles Petzold

下载示例代码

每一台 PC 都包含一个内置的 16 人乐队,可播放一些音乐。人们不容易注意此乐队的成员,因为它们表示的可能是 Windows 所支持的声音和视频功能阵列中利用最不充分的组件。

此 16 人乐队是在符合 MIDI(乐器数字接口)标准的硬件或软件中实现的电子音乐合成器。在 Win32 API 中,以单词 midiOut 开头的函数支持使用 MIDI 合成器播放音乐。

MIDI 支持不是 .NET Framework 的一部分,但如果要在 Windows 窗体或 Windows Presentation Foundation (WPF) 应用程序中访问此 MIDI 合成器,则需要使用 P/Invoke 或外部库。

我在上一期专栏文章中讨论的 CodePlex 上的 NAudio 声音库中提供了 MIDI 支持,发现这一点时,我感到非常高兴。您可以从 codeplex.com/naudio 下载该库及其源代码。在本文中,我使用的是 NAudio 1.3.8 版。

简单示例

MIDI 可视为波形音频的高级接口,用于处理乐器和乐音。

MIDI 标准形成于 20 世纪 80 年代早期。电子音乐合成器制造商希望通过标准方法来连接电子音乐控制器(如键盘)和合成器,因此开发出一种系统,通过具有 5 针接头的电缆以 3,125 字节/秒的速度传输短消息(长度大多为 1、2 或 3 个字节)。

其中两条最重要的消息称为 Note On 和 Note Off。当音乐家按下 MIDI 键盘的某个键时,键盘生成一条 Note On 消息,指示所按的乐音和键的速度。合成器通过演奏该乐音进行响应,通常键速度越高,声音越大。音乐家释放键时,键盘生成 Note Off 消息,生成器通过关闭乐音进行响应。没有实际音频数据通过 MIDI 电缆。

尽管 MIDI 仍用于连接电子音乐硬件,它还是可以完全通过软件在 PC 中使用。声卡可以包含 MIDI 合成器,Windows 本身完全通过软件模拟 MIDI 合成器。

若要在使用 NAudio 库的 WinForms 或 WPF 应用程序中访问该合成器,请将 NAudio.dll 添加为引用,并在源代码中包含以下 using 指令:

using NAudio.Midi;

假设应用程序需要演奏单个 1 秒长的乐音,声音类似于钢琴的中央 C。使用以下代码可实现这一功能:

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();

PC 可能拥有多个 MIDI 合成器的访问权限;MidiOut 构造函数的参数是一个数字 ID,用于选择要打开的 MIDI 合成器。如果 MIDI 输出设备已被使用,该构造函数将引发异常。

程序可以先使用静态 MidiOut.NumberOfDevices 属性发现存在多少合成器,从而获取有关 MIDI 合成器的信息。数字 ID 的范围从 0 到设备数减 1。静态 MidiOut.DeviceInfo 方法接受数字 ID,返回一个描述合成器的 MidiOutCapabilities 类型的对象。(我不准备使用这些功能,在本文后面部分,我只使用 ID 为 0 的默认 MIDI 合成器。)

MidiOut 类的 Send 方法向 MIDI 合成器发送一条消息。MIDI 消息包含 1、2 或 3 个字节,但 Win32 API(和 Naudio)需要将其打包为一个 32 位整数。MidiMessage.StartNote 和 MidiMessage.StopNote 方法执行此打包操作。Send 的两个参数可分别替换为 0x007F3C90 和 0x00003C80。

StartNote 和 StopNote 的第一个参数是 0 到 127 范围内的代码,用于指示实际乐音,其中值 60 是中央 C。高八度是 72。低八度是 48。第二个参数是按下或释放键的速度。(合成器通常会忽略释放速度。)这些参数在 0 到 127 范围内。MidiMessage.StartNote 的第二个参数越小,乐音越柔和。(我很快会讨论第三个参数。)

对 Thread.Sleep 的两次调用会将线程挂起 1,000 毫秒。这是用来确定消息时间的很简单的方法,但应避免在用户界面线程中使用。为了使乐音在被 Close 调用突然截断之前消失,需要第二次调用 Sleep。

如何处理复调?

上面介绍了如何演奏单个乐音。那么如何同时演奏多个乐音呢?这也是可以实现的。例如,如果要演奏 C 主和弦而不是简单的乐音 C,可将第一条 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 而使用实际计时器,特别是在用户界面线程中播放音乐时。稍后将对此进行详细介绍。

有一种 MIDI 文件格式可组合 MIDI 消息与计时信息,但需要使用专门软件来创建这些文件,这里不做论述。

乐器和频道

到目前为止,我只演奏了钢琴的乐音。您可以使用在 NAudio 中通过 ChangePatch 方法实现的 MIDI“程序更改”消息切换合成器,以演奏其他乐器的乐音:

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

ChangePatch 的第一个参数是在 0 到 127 范围内的数字代码,指示特定乐器的乐音。

回顾早期的 MIDI,由合成器发出的实际声音完全由执行者通过调节盘和插线电缆来控制。(因此,特定合成器设置或乐器乐音通常称为“音色”。)后来,MIDI 文件的创建者需要一组标准乐器,这样无论使用哪个合成器,文件播放起来都有同样的效果。从而产生了称为“General MIDI”(常规 MIDI)的标准。

有关 General MIDI 的信息,请参阅 Wikipedia 词条 en.wikipedia.org/wiki/General_midi。在标题“Melodic sounds”下,是代码在 1 到 128 范围内的 128 种乐器乐音。您可以在 ChangePatch 方法中使用从零开始的代码,这样前一示例中的代码 47 是此列表中的乐器 48,即定音鼓乐音。

在本文开头我提到过,MIDI 合成器相当于一个 16 人乐队。MIDI 合成器支持 16 个频道。在任何时候,每个频道都根据最新的“程序更改”消息与特定乐器相关联。频道数介于 0 到 15 之间,在 StartNote、StopNote 和 ChangePatch 方法的最后一个参数中指定。

频道 9 比较特殊。这是打击乐器频道。(通常将其称为频道 10,不过这是从 1 开始编号的情况。)对于频道 9,传递给 StartNote 和 StopNote 方法的代码引用特定的非乐音的打击乐器声音而不是标准音高。在 Wikipedia 的 General MIDI 词条中,请参阅标题“Percussion”下的列表。例如,下面的调用将演奏代码 56 所指示的铃铛乐音:

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

有关 MIDI 的信息还有很多,但这些是最基本的。

基于 XAML 的 MIDI

按照 WPF 和 XAML 的实质,我认为,开发一种基于字符串的格式,从而直接在 XAML 文件中嵌入音乐片段,然后播放这些音乐,会是一种有趣的尝试。我将这种格式称为 MIDI 字符串,即乐音和计时信息的文本字符串。所有标记都由空白分隔。

乐音由 A 至 G 的大写字母、任意数量的 + 号或 # 号(每个符号使音高提高一个半音)或者 – 号或字母 b(使音高降低一个半音)以及可选的八度音阶序号(其中八度音阶开始处的中央 C 的序号是 4)组成。(这是对八度音阶进行编号的标准方法。)因此,中央 C 下的 C# 为:

C#3

字母 R 本身是一个休止符。乐音或休止符可以后跟持续时间,指示与下一个乐音的间隔时间。例如,下面是一个四分音符,这也是在未指定持续时间时的默认值:

1/4

持续时间是“粘滞的”,也就是说,如果持续时间后面没有乐音,则使用最后一个持续时间。如果持续时间以斜线开头,则假定分子为 1。

持续时间指示与下一个乐音的间隔时间。此持续时间也用作乐音的长度,即与乐音关闭的间隔时间。对于不连贯的声音,可能希望乐音的长度小于它的持续时间。或者,您可能希望连续乐音有部分重叠。指示乐音长度的方法与指示持续时间的方法相同,但带有一个减号:

–3/16

持续时间和长度始终出现在应用它们的乐音之后,但顺序并不重要。长度不是“粘滞的”。如果未出现乐音长度,则将持续时间用作长度。

乐音前面也可以添加标记。要设置乐器声音,请在字母 I 后面使用从零开始的音色号。例如,下面的代码指示小提琴连续乐音:

I40

钢琴是默认音色。

若要为连续乐音设置新音量(即速度),请使用 V,如:

V64

对于 I 和 V,其后的数字必须在 0 到 127 之间。

默认情况下,节拍为 60 个四分音符/分钟。若要为以下乐音设置新节拍,请使用 T 后跟每分钟四分音符数的形式,例如:

T120

如果要使用完全相同的参数演奏一组乐音,可将这些乐音放置在括号中。下面是 C 主和弦:

(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 事件,在 MIDI 字符串出现语法错误时激发 Failed 事件。该事件包含文本字符串格式的偏移值,指示有问题的标记和错误的文本说明。

可下载代码中还包含两个 WPF 程序。MusicComposer 程序允许您以交互方式集中 MIDI 字符串。WpfMusicDemo 程序将一些简单序列编码在一个 MIDI 文件中,如图 1 所示。

图 1 WpfMusicDemo.xaml 对多个简单 MIDI 字符串进行编码

<Window x:Class="WpfMusicDemo.Window1"
  xmlns="https://schemas.microsoft.com/winfx/2006/xaml/presentation"
  xmlns:x="https://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 线程上运行。这当然不是最优方案。如果另一个程序过多占用 CPU,音乐播放将不规律。DispatcherTimer 生成 Tick 事件的速度也无法超过 60 个/秒,此速度可满足简单音乐,但无法提供更有节奏的复杂音乐所需要的精度。

Win32 API 包括一个专门用来播放 MIDI 序列的高分辨率计时器,但 NAudio 库尚不包括该计时器。可能在以后某个时候,我会将 DispatcherTimer 替换为更精确、更普通的计时器,但目前为止,我很高兴地看到,在这个简单的解决放案中,DispatcherTimer 工作正常。

Charles Petzold 是《MSDN 杂志》的长期特约编辑。他的最新著作是“The Annotated Turing:A Guided Tour through Alan Turing's Historic Paper on Computability and the Turing Machine”(Wiley,2008)。Petzold 的博客网站是 charlespetzold.com

衷心感谢以下技术专家对本文的审阅:Mark Heath