UI 前沿技术

在 Windows Phone 7 中录音

Charles Petzold

下载代码示例

image: Charles Petzold
早在 1984 年,Apple 在第一批推出 Macintosh 的平面广告中,就宣传过它的鼠标设计,广告语非常吸引人:“有些鼠标有两个按钮。Macintosh 只有一个。想按错都难。”

当然事实并非完全如此。通过一个按钮执行诸多功能,可能和多个按钮一样让人困扰。但是,就 UI 设计的简易性而言,不会按错按钮的设计当然非常有说服力。

尽可能精简 UI,这一点在为智能手机编程时甚至更为重要。手机尺寸有限。容不下太多按钮,用手指按这些按钮不可能像鼠标一样准确。按钮太多意味着更容易按错按钮。

不利的一面是,限制 UI 通常会限制程序的功能,权衡这两方面并非易事。生活离不开利弊权衡。

设计思路

我想过,编写一个 Windows Phone 7 程序来录制一小段语音备忘录(例如“记得取干洗衣服”和“想出了一个不错的电影情节:男孩遇见女孩”)很有趣。

当然,这类程序很有用,在公共场合使用这些程序炫耀新 Windows Phone 也是一个理由。对我而言,更重要的是,使用录音功能可以录制某些实践经验,可以播放手机支持的课程。

但是,程序设计方面的问题超出了我的预期。即使我之前编写过代码,在脑海中反复思考程序的设计。

开始,我认为只有两个按钮(“Record”和“Play”)很好,它们可以通过切换方式工作。按“Record”按钮开始录制,再按一次则停止录制。程序将音频数据保存在独立的存储中。按“Play”按钮开始播放。每按一次“Record”按钮将覆盖之前的备忘录,因此程序不需要“Delete”按钮。

我甚至草率地通过实现语音激活功能将程序精简为一个“Play”按钮。程序将连续录制,仅在它包含语音时保存数据。但是这样一来,如果不引入某种手动阈值设置,很难区分背景声音与真正的语音数据。我放弃了单按钮设计。

我最初的方案适合一个备忘录,要录制多个备忘录就不行了。这之后,我想将程序设计为只维护一个音频文件,在以前的备忘录末尾追加新的备忘录。因为只有一个大文件,“Play”按钮将按顺序播放所有备忘录。当然,程序不会让这个文件无限增大,因此,肯定需要设计一个“Delete”按钮来删除整个文件,即所有备忘录。

不,这不是一个好办法。我需要为每个备忘录维护单独的文件,允许逐个删除这些备忘录。但这样一来,需要通过某种方法为用户提供所有单个的文件供其播放和删除,程序的复杂性增加了。毫无疑问,我需要一个 ListBox 和某种为用户标识每个备忘录的方法,或许可以使用用户提供的关键字或者(更糟糕的)实际文件名进行标识。

不,不,决不能用这种方法!我看了一眼电话应答机。每个呼叫或备忘录都分别录制下来,它们只是通过编号显示在简单的显示屏上。“Previous”和“Next”方向按钮可以转到上一呼叫或下一呼叫,对“Play”按钮进行了补充。每当删除备忘录或呼叫时,备忘录会重新编号。我知道我不需要对备忘录进行编号,但可以在手机的大显示屏上显示每个备忘录的详细信息,包括录制日期、持续时间和文件大小。

当我意识到,在程序的主屏幕上放置一个 ListBox,不仅可用于选择,也可以用于播放,我终于找到了突破口。

使用程序

当然,最终设计是以极简的形式实现完备的备忘录管理系统。可下载的 SpeakMemo 项目是为 Silverlight for Windows Phone 编写的,需要使用 Windows Phone 7 开发工具。您可以在手机仿真器上运行该程序,它看起来可以正常运行,但不会实际录制或播放任何语音。

SpeakMemo 程序第一次运行时,屏幕显示内容如图 1 所示。

image: The Initial SpeakMemo

图 1 初始 SpeakMemoScreen

一个按钮!或者,至少在相当简洁的屏幕上显示了一个启用的 按钮。该按钮显示独立存储中还有多少空间,该空间对应多大的录音文件。(不,这个程序不允许录制长达 17 小时的备忘录!)

按“Record”按钮,它将变为红色并闪烁,同时实时更新持续时间指示符,如图 2 所示。

image: SpeakMemo While Recording

图 2 录制时的 SpeakMemo

再次按“Record”按钮,录制的备忘录将显示在屏幕上,包括录制的日期和时间、持续时间、存储空间和“Play”按钮,如图 3 所示。

image: SpeakMemo with One Memo

图 3 包含一个备忘录的 SpeakMemo

当然,您可以按“Play”按钮播放备忘录,该按钮会在“播放”与“暂停”模式间切换。

只有一个备忘录时可能并不明显,但录制的备忘录是按时间顺序逆序存储在 ListBox 中的,如图 4 所示,因此,如果积累有多个备忘录,可以滚动查看和播放这些备忘录。

image: The SpeakMemo ListBox

图 4 SpeakMemo ListBox

Silverlight 的一项强大功能是 DataTemplate,它可用于定义 ListBox 中各项的外观。DataTemplate 可以包含其他控件,例如按钮。我很高兴编写了一个在 DataTemplate 中放置一个按钮的实际应用程序。

您还可以通过删除单个备忘录来管理所收集的备忘录。选中一个备忘录时,将启用“Delete”按钮。可能是受在 DataTemplate 中放置按钮的启示,我又利用了一下 Silverlight 技巧,在“Delete”按钮内放置了两个按钮。按下“Delete”时,会显示这些按钮,它们执行传统的确认功能,如图 5 所示。

image: Confirming a Delete

图 5 确认删除

播放某个备忘录时,该备忘录会处于选中状态,但是,如果按“Play”按钮右侧区域选中备忘录,是不会播放相应备忘录的。该程序可以同时进行备忘录的播放、录制和删除操作。

手机和语音

人们一度希望 Windows Phone 7 能具有 Microsoft .NET Framework System.Speech 命名空间中提供的某些语音识别和合成支持。可能以后会实现这些支持。

到那时,可以从手机的麦克风捕获语音,然后使用 Microsoft.Xna.Framework.Audio 命名空间中的类通过手机扬声器播放语音。这些是 XNA 类,但也可以用在 Silverlight 程序中。要在 Silverlight 项目中使用 XNA 类,只需将对 Microsoft.Xna.Framework.dll 的引用添加到项目的引用并忽略警告消息。

Microsoft.Xna.Framework.Audio 命名空间中的类与 Microsoft.Xna.Framework.Media 命名空间中的类是完全独立的。Media 命名空间包含用于从手机音乐库播放音乐的类,这些音乐是 MP3 或 WMA 格式的压缩音频文件,是 Song 类型的对象。我撰写的书《Programming Windows Phone 7》(Microsoft Press, 2010) 的第 18 章中介绍了如何访问音乐库,这本书可从 bit.ly/dr0Hdz 免费下载。在我的网站博客文章中,也演示了如何播放存储在程序自身或者通过 Internet (bit.ly/ea73Fz) 下载的 MP3 或 WMA 文件。

与此不同,Microsoft.Xna.Framework.Audio 命名空间中的类使用标准 PCM 格式的未压缩音频数据,这与音频 CD 和 Windows WAV 文件所用的方法相同。使用 PCM,模拟语音振幅按统一速率(通常为每秒 8,000 至 48,000 个样本)进行采样,每个样本通常存储为 8 位或 16 位值。特殊语音所要求的存储为持续时间(以秒为单位)乘以采样率,再乘以每个采样的字节数(立体声为两倍)。

如果需要在 Windows Phone 7 应用程序中提供语音识别支持,您必须自己提供该支持,极有可能通过 Web 服务提供。同样,要求将文本转换为语音的程序也可能会使用 Web 服务,或者等到手机提供该支持。面向 Windows Phone 的 Microsoft Translator 应用程序使用 Microsoft Translator 服务 (microsofttranslator.com) 实现该支持。Translator Starter Kit 的代码和文档在 MSDN (msdn.microsoft.com/library/gg521144(VS.92).aspx) 和 AppHub (create.msdn.com/education/catalog/sample/translatorstarterkit) 上发布。

使用 XNA 音频服务时,Silverlight 程序必须以与视频刷新率大致相同的速率调用静态 FrameworkDispatcher.Update 方法,Windows Phone 7 上的视频刷新率大约为每秒 30 次。有关如何执行此操作的描述,请参见 XNA 联机文档 (msdn.microsoft.com/library/ff842408) 中的文章“在 Windows Phone 应用程序中支持 XNA Framework 事件”。在 SpeakMemo 中,XnaFrameworkDispatcherService 类处理此任务。此类在 App.xaml 文件中实例化。

录音

要通过手机麦克风录音,请使用 Microphone 类。可能需要使用静态 Default 属性创建此类的实例:

Microphone microphone = Microphone.Default;

或者,使用静态 All 属性提供 Microphone 对象的集合,但这样可能需要向用户显示列表以便选择一项。

采样率是固定的,不能更改,SampleRate 属性报告的采样率为每秒 16,000 个样本。 根据 Nyquist 采样定理,在录制音频不超过 8,000 Hz 的声音时此采样率是合适的。 这适合语音,但无法实现很好的音乐效果。 每个样本都是 2 个字节宽的单声道,这意味着每秒录音需要 32,000 个字节,每分钟为 1.9MB。

麦克风数据传递到程序的缓冲区,缓冲区实际上是字节数组。 您将安装 BufferReader 事件的处理程序,接着调用 Start 开始录制。 Microphone 对象触发 BufferReady 事件时,代码使用一个字节数组调用 GetData。 从 GetData 返回时,缓冲区已填充 PCM 数据。 需要程序停止录制时,再次调用 GetData 获取最新的部分缓冲区。 此方法返回传递给数组的字节数。 然后调用 Stop。

Microphone 仅允许指定传递给 GetData 的缓冲区的字节大小。 BufferSize 属性是 TimeSpan 值,必须介于 100 毫秒到 1,000 毫秒(一秒)之间,以 10 毫秒为增量。 在 SpeakMemo 中,我保留了默认值 1,000。

为方便起见,Microphone 类提供两个方法用于转换缓冲区大小与时间。 遗憾的是,这些方法有点让人困惑,因为它们的名称包含“sample”。GetSampleDuration 方法基本上是将字节大小除以 32,000 并返回指示秒数的 TimeSpan。 GetSampleSizeInBytes 是将 TimeSpan 持续时间(以秒为单位)乘以 32,000。

当 SpeakMemo 进行录制时,它将多个 32,000 字节的缓冲区聚合到一个泛型 List 集合中。 录制停止后,程序在单独的存储中将所有单个缓冲区保存为一个文件。

在决定不包括关键字功能来标识备忘录后,我希望文件只包含 PCM 数据,不包含任何补充信息。 但是,我十分惊讶地意识到 Silverlight for Windows Phone 中的 IsolatedStorageFile 类没有用于访问文件创建时间或最后写入时间的方法,我认为这一信息对于用户来说很重要。

这意味着文件名本身必须包含日期和时间。 我首先尝试通过 DateTime 对象使用“s”和“u”格式设置选项来创建文件名,但这不起作用。 (至于为什么不起作用,我将它作为一个简单的练习留给读者。)接着,我将日期和时间的各部分组合在一起,构造了我自己的文件名字符串。

XNA 声音播放

使用 Microsoft.Xna.Framework.Audio 命名空间中的相关 SoundEffect 和 SoundEffectInstance 类可以播放预先录制的声音,在 XNA 游戏上下文中,这两个类名称与其常见功能不一致! 但静态 SoundEffect.FromStream 方法要求 Stream 对象引用一个包含 RIFF 头的标准 Windows WAV 文件,而我不想为文件格式费心。

为了使用原始的 PCM 数据而不是 WAV 文件,您可能希望改为使用 DynamicSoundEffectInstance 类,该类派生自 SoundEffectInstance。 此类适用于从 Microphone 类生成的数据或者动态创建自己的波形数据的程序(例如音乐合成器程序)。

DynamicSoundEffectInstance 构造函数需要一个采样率和许多声道;如果要使用此类处理从 Microphone 生成的数据,显然需要使之保持一致:

DynamicSoundEffectInstance playback = 
  new DynamicSoundEffectInstance(
  microphone.SampleRate, AudioChannels.Mono);

另一方面,如果要让播放的语音听起来像语速很快的数来宝一样,只需将第一个参数乘以 2。 DynamicSoundEffectInstance 需要具有 16 位样本大小的数据。 此类有 Play、Pause、Resume 和 Stop 方法,可用于控制播放,State 属性指示当前状态。 此类与 Microphone 的情形在某些方面相反:它在需要新缓冲区时触发 BufferNeeded 事件。 您的任务是使用 PCM 数据填充缓冲区并调用 SubmitBuffer。

为避免语音中出现听得见的间隙,一般情况下,需要在 DynamicSoundEffectInstance 类中维护一个缓冲区队列,在上一缓冲区仍在播放时提交新的缓冲区。 此类的作用在于,通过 PendingBufferCount 属性来指示队列中的缓冲区数目。 BufferNeeded 事件在 PendingBufferCount 更改且小于或等于 2 时触发。

但是,如果只是需要播放 PCM 数据的整个区块,则可以调用 SubmitBuffer 而无需触发 BufferNeeded 事件。 刚开始,我就是这样在 SpeakMemo 程序中使用此类的,但我发现它无法确定缓冲区何时完成播放。 此类没有“状态已更改”事件,即使有,DynamicSoundEffectInstance 也不会在缓冲区完成播放时从“播放”状态切换为“停止”状态。 它需要更多的缓冲区。 如果不知道这一信息,则无法使程序正确切换“Play/Pause”按钮的显示。

我最后处理 BufferNeeded 事件,不过,这只是为了检查 PendingBufferCount 属性。 当 PendingBufferCount 降至零时,缓冲区就完成了播放。

存储问题

SpeakMemo 在独立的存储中存储录制的备忘录。 从概念上讲,独立的存储是应用程序的私有空间,但从物理上讲,它是整个存储区域的一部分,与台式计算机的硬盘相似。 这里存储了所有的应用程序可执行文件以及手机的照片库、音乐库、视频库等。 Windows Phone 7 的硬件规范要求手机至少有 8GB 的闪存作为此存储区域,当此存储容量不足时,手机会提醒用户。

存储备忘录文件不是我主要关心的问题。 我更关注程序的堆。 除了闪存外,Windows Phone 7 硬件规范还要求具有 256MB 的 RAM。 这是应用程序在运行时占用的内存,它提供程序的本地堆。 根据我的经验,SpeakMemo 可以分配最多 90MB 的数组,再大就会引发内存不足异常。 这相当于使用麦克风录制大约 47 分钟的语音。

这并不表示 Windows Phone 7 程序必须限制为 47 分钟的录制时间。 但如果程序要录制这么长时间的连续语音,则必须逐步将缓冲区保存到独立的存储才能释放内存,然后在播放文件时增量式加载文件。 这不是 SpeakMemo 的构造方式。 该程序会保存并加载整个文件,我不愿意放弃这么简单的结构。

因此,我只是将备忘录持续时间设置为最大 10 分钟。 录制达到该长度后,即会停止并保存(保存本身需要数秒)。 为了使程序简单,没有任何警告。 录制直接停止,就像用户按下停止按钮一样。 当程序终止或停用(例如逻辑删除期间)时也会出现这种自动停止并保存的过程。

当然,播放 10 分钟的备忘录也不方便。 “Play”按钮将在播放和暂停模式间切换,但没有快退和快进方式。 这些功能是可以添加的,相信您知道该怎样做,对吧?

是的,更多按钮。 甚至可能是一个滑块。

Charles Petzold  MSDN 杂志 *的长期特约编辑。*他的新书《Programming Windows Phone 7》(Microsoft Press, 2010) 可从 bit.ly/dr0Hdz 免费下载。

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