WPF 应用程序中的发声功能
Charles Petzold
几个星期以前,我坐在一辆崭新的丰田普锐斯汽车中,听着租车公司的销售代理讲解着仪表盘上遍布的陌生控制开关和指示器。“哇,”我想,“虽然技术和车一样都那么陈旧了,制造商仍继续美化着用户界面”。
从最广义的层面上说,用户界面是人机交互的地方。虽然这一概念与技术本身一样历史悠久,但用户界面作为一种艺术形式大放异彩倚仗的却是个人计算机革命。
现在,恐怕只有很小一部分个人计算机用户能够记得 Apple Macintosh 和 Microsoft Windows 图形用户界面问世之前的情形了。当时,也就是 80 年代中晚期,一些专家曾担忧用户界面的标准化会导致应用程序千篇一律,单调乏味。但事实并非如此。相反,随着标准控件的推出卸下了设计者和编程者需要自创滚动条的负担,用户界面实际上开始发生变革并变得愈发有趣起来。
就此而言,Windows Presentation Foundation (WPF) 所引入的新模式让用户界面更加光彩。WPF 为保留模式图形、动画和 3-D 奠定了坚实的基础。在此基础上,它增加了父子元素树状层次结构以及一种称为 XAML 的强大标记语言。结果在通过模板化功能自定义现有控件以及通过组装现有组件构建新控件方面,都实现了无与伦比的灵活性。
而且,这些新概念不仅仅用于客户端编程。XAML 和 WPF 类是 Microsoft .NET Framework 的健壮子集,目前通过 Silverlight 即可用在基于 Web 的编程中。您已迎来在客户端应用程序与 Web 应用程序之间真正共享自定义控件的新时代。我确信在利用多点触控等新技术的同时,这种势头会发展到移动应用程序,并最终涵盖各种各样的信息和娱乐系统。
基于这些原因,我相信用户界面已成为应用程序编程中更加关键的部分。本专栏将探讨 WPF 和 Silverlight 中用户界面设计的潜能,包括在可能的情况下使用跨平台代码。
前奏
用户界面选择的好与坏并非总是一目了然。Microsoft Office 97 中首次引入的人性化活页夹 Clippy 在当时看可能是一个非常不错的创意。因此,我将更专注于技术性潜能而不是设计潜能。我将避免使用“最佳做法”一词,这个词更适合用在历史和市场环境。
有一个比较好的现成惯例:计算机若非为响应特定用户命令而播放视频或声音文件,就不该发出任何声音。我打算打破这一限制,在此向您介绍如何通过在运行时生成波形数据,在 WPF 应用程序中播放自定义声音。
虽然此发声功能尚未正式纳入 .NET Framework 之中,但可通过 Codeplex (naudio.codeplex.com) 上提供的 NAudio 库来实现。通过该站点中的链接,您可以到 Mark Heath 的博客中查看一些示例代码,还可以查看 Sebastian Gray 的站点教程。
可以在 Windows 窗体或 WPF 应用程序中使用 NAudio 库。由于该库通过 PInvoke 访问 Win32 API 函数,因此不能用于 Silverlight。
在本文中,我使用的是 NAudio 1.3.8 版。当您创建使用 NAudio 的项目时,需要将其编译为可进行 32 位处理。请转至“属性”页的“生成”选项卡,并从“平台目标”下拉列表中选择 x86。
虽然该库为需要使用声音的专业化应用程序提供了许多功能,但我向您演示的使用方法可能适合更通用的应用程序。
例如,假设您的应用程序允许用户在窗口上四处拖动对象,而您希望伴随拖动播放一个简单的声音(比如一个正弦波),而该声音的频率随着对象到窗口中心距离的增加而提高。
这就是波形音频要做的工作。
如今,几乎所有 PC 都带有发声硬件,该硬件通常通过主板右侧的一两个芯片实现其功能。一般来说,此硬件无非就是一对数字模拟转换器 (DAC)。当向这两个 DAC 传送描述波形的恒定整数流时,就会发出立体声。
那么会涉及多少数据呢?现在的应用程序一般生成“CD 音质”的声音。采样率是恒定的每秒 44,100 个样本。(Nyquist Theorem 指出采样率须至少为最高频率的两倍时才能重现声音。惯常的说法是人耳能够听到频率介于 20Hz 与 20,000Hz 之间的声音,因此 44,100 可谓充裕。)每个样本都是一个有符号 16 位整数,即一个表示 96 分贝信噪比的大小。
创建波形
Win32 API 通过一个以 waveOut 一词开头的函数集合来访问发声硬件。NAudio 库将这些函数封装在一个 WaveOut 类中,该类用于处理 Win32 互操作,并消除大部分杂音。
WaveOut 要求您提供一个实现 IWaveProvider 接口的类,这意味着该类应定义一个 WaveFormat 类型的可获取属性,以(至少)指示采样率和声道数。该类还定义一个名为 Read 的方法。Read 方法的参数包含一个该类需要在其中填充波形数据的字节数组缓冲区。使用默认设置时,每秒将调用 10 次此 Read 方法。在填满此缓冲区后,您便会听到毫无美感的断续声和刺耳的稳态噪声。
NAudio 提供了几个实现 IWaveProvider 的抽象类,这些抽象类可使常见的音频作业更容易实现。WaveProvider16 类实现一个抽象 Read 方法,使您可以用短型数据而不是字节来填充缓冲区,这样不必将样本一分为二。
图 1 是一个派生自 WaveProvider16 的简单 SineWaveOscillator 类。通过该构造函数可以指定采样率,但使用另一个参数调用基类构造函数,该参数指示表示非立体声的单声道。
图 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(双精度类型)和 Amplitude(短型)。该程序维护一个名为 phaseAngle 的字段,该字段始终位于 0 到 2π 范围之间。在每次采样时,都会将 phaseAngle 传递给 Math.Sin 函数,然后按称为相角增量的值递增,这是一项涉及频率和采样率的简单计算。
(如果您要同时生成许多波形,将需要尽量使用整数算术运算优化处理速度,甚至需要以短型数组的形式实现一个正弦波表。但为了简化波形音频的使用,使用浮点计算也可以。)
若要在程序中使用 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 属性初始化为 440Hz。在音乐领域中,该频率为高于中央 C 调的 A 调的频率,通常用作标准音调并用于调音目的。当然,随着声音的播放,Frequency 属性可发生更改。若要关闭声音,可将 Amplitude 设置为 0,但 SineWaveOscillator 将继续接收对 Play 方法的调用。若要停止这些调用,请对 WaveOut 对象调用 Stop。当您不再需要 WaveOut 对象时,应对该对象调用 Dispose 以便正确释放资源。
走调
当我在示例程序中使用 SineWaveOscillator 时,它当时未按预期的方式工作。我想的是伴随窗口中的对象拖动播放一个声音,并希望该声音的频率随该对象到窗口中心距离的不同而变化。但在我移动对象时,频率转换不太流畅。我听到的是起伏不平的滑奏(就像手指滑过一串钢琴键或竖琴弦一样),而我想要的效果是流畅的滑音(就像长号或格什温创作的“蓝色狂想曲”开头的单簧管演奏一样)。
问题在于:每次从 WaveOut 调用 Play 方法时,都会导致整个缓冲区按同一频率值进行填充。在 Play 方法填充缓冲区期间,频率不能在响应用户拖动鼠标操作时更改,因为 Play 正在用户界面线程中执行。
那么该问题有多糟糕,而这些缓冲区有大呢?
NAudio 中的 WaveOut 类包含一个 DesiredLatency 属性,默认情况下该属性设置为 300 毫秒。该类还包含一个设置为 3 的 NumberOfBuffers 属性。(多个缓冲区有助于提高吞吐量,因为在 API 读取一个缓冲区的同时应用程序可以填充另一个缓冲区。)因此,每个缓冲区等同于 0.1 秒的采样。通过试验,我发现无论如何减少 DesiredLatency 都会导致听得到的顿音。可以增加缓冲区数(请务必选择适当的值以使缓冲区字节数大小为 4 的倍数),但这样做似乎帮助不大。还可以通过将静态方法调用 WaveCallbackInfo.FunctionCallback 传递给 WaveOut 构造函数,让 Play 方法在辅助线程上运行,但这样做也没有明显效果。
我很快发现,我需要的是在填充缓冲区期间亲自执行滑音的振荡器。我需要的是 PortamentoSineWaveOscillator,而不是 SineWaveOscillator。
PortamentoSineWaveOscillator
我还需要进行其他一些更改。人对频率的感知是对数级的。八度音定义为频率的翻倍,并且乐谱中的各八度音在听觉上是类似的。对于人的神经系统而言,100Hz 和 200Hz 之间的差异与 1000Hz 和 2000Hz 之间的差异是相同的。在音乐领域,每个八度音包含 12 个在听觉上相等的音阶(称为半音)。因此,这些半音的频率会按等于 2 的 12 次方根的乘法因子连续递增。
我希望我的滑音也是对数级的,所以在 PortamentoSineWaveOscillator 中定义了一个名为 Pitch 的新属性,该属性如下计算频率:
Frequency = 440 * Math.Pow(2, (Pitch - 69) / 12)
这是比较标准的公式,符合乐器数字接口 (MIDI) 中所使用的约定(我将在以后的专栏中对此进行讨论)。如果自下而上地为钢琴的所有音符进行编号,其中中央 C 调分配有 Pitch 值 60,则高于中央 C 调的 A 调为 69,并且该公式确定其频率为 440Hz。在 MIDI 中,这些 Pitch 值是整数,而在 PortamentoSineWaveOscillator 类中,Pitch 是双精度类型,因此音符之间的分级可行的。
在 PortamentoSineWaveOscillator 中,Play 方法检测 Pitch 何时发生更改,然后根据缓冲区的剩余大小逐渐地更改用于计算频率的值(因此,相角会递增)。该逻辑使 Pitch 可以在方法执行期间发生更改,但前提是 Play 在辅助线程上执行。
如代码下载中的 AudibleDragging 程序所示,这样做大获成功!该程序在接近窗口中心的位置创建七个颜色不同的小方块。当您用鼠标捕获到它们时,该程序就会使用 PortamentoSineWaveOscillator 创建一个 WaveOut 对象。随着对象的拖动,该程序只需确定到窗口中心的距离,并根据以下公式设置振荡器的音调即可:
60 + 12 * distance / 200;
换句话说,每移动 200 个单位的距离,中央 C 调就会提高一个八度。当然,AudibleDragging 是一个比较笨的小程序,它可能令您比以往更加确信应用程序应永远是无声的。但是,在运行时生成自定义声音的潜能是如此强大,以致于不能妄加否定。
播放
当然,您并不仅限于使用单正弦波振荡器。您还可以从 WaveProvider16 派生一个混音器,并使用该混音器组合多个振荡器。可以将简单波形组合为更复杂的波形。Pitch 属性的使用为指定音符建议了一种简单方法。
但如果您希望应用程序从扬声器中放出音乐和乐器的声音,您一定乐意知道 NAudio 还包含可用于从 Windows 窗体或 WPF 应用程序生成 MIDI 消息的类。我很快会为您说明如何做到这一点。
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