一触即发

在 Windows Phone 中流式处理音频

Charles Petzold

下载代码示例

Charles Petzold
无论是在桌面、Web 还是在您的手掌中运行计算机程序,这些程序有时都需要播放声音或音乐。大多数时候,此类音频通常完全以 MP3 或 WMA 文件的形式进行编码。此方法最大的优势在于操作系统本身通常知道如何解码和播放这些文件。应用程序然后就可以专注处理相对简单一些的工作,即,提供暂停、重新启动或者在曲目之间导航的 UI。

但生活并不总是一帆风顺。有时候,程序需要播放操作系统不支持格式的音频文件,甚至需要动态生成音频数据,或许还需要合成电子音乐。

在 Silverlight 和 Windows Phone 环境中,此过程称为音频“流式传送”。在运行时,应用程序会提供组成音频数据的字节流。这是通过从 MediaStreamSource 派生的类进行的,以便按需将音频数据传输到操作系统的音频播放器。Windows Phone OS 7.5 可以在后台流式处理音频,我将向您展示如何执行此过程。

从 MediaStreamSource 派生

在 Windows Phone 程序中动态生成音频数据的第一个基本步骤就是从抽象类 MediaStreamSource 派生。涉及的代码在某种程度上比较凌乱,您可能希望复制别人的代码,而不是从头开始编写代码。

本文提供的可下载源代码中的 SimpleAudioStreaming 项目展示了一种可行的方法。此项目包含名为 Sine440AudioStreamSource 的 MediaStreamSource 派生类,它只是生成 440 Hz 的正弦波。此频率对应于 A,高于中间 C(通常用作微调标准)。

MediaStreamSource 具有六种需要重写派生的抽象方法,但其中只有两种方法比较重要。第一个是 OpenMediaPlayer,其中您需要创建一些 Dictionary 对象和 List 对象,并定义您的类提供的音频数据的类型和描述此数据的各种参数。音频参数表示为 Win32 WAVEFORMATEX 结构的字段,并且所有多字节数字都采用 little-endian 格式(最重要的字节优先),然后转换为字符串。

我从不将 MediaStreamSource 用于除脉冲代码调制 (PCM) 格式音频以外的其他任何内容,该格式用于大多数未压缩音频,包括 CD 和 Windows WAV 文件格式。PCM 音频包括恒定速率(采样率)大小不变的采样。对于 CD 音质的声音,您的每个采样都具有 16 位,并且采样率为 44,100 Hz。您可以选择任一通道用于单声道声音,或选择两个通道用于立体声声音。

Sine440AudioStreamSource 类硬编码单个通道和 16 位的采样大小,但允许将采样率指定为构造函数参数。

在内部,音频管道维护音频数据的缓冲区,缓冲区的大小由 MediaStreamSource 的 AudioBufferLength 属性指定。默认设置为 1,000 ms,但可以将其设置为最低值 15 ms。为了保持此缓冲区处于充满状态,会调用 MediaStreamSource 派生类的 GetSampleAsync 方法。您的工作就是将一堆音频数据提供给 MemoryStream 并调用 ReportGetSampleCompleted。

硬编码 Sine440AudioStreamSource 类以针对每次调用提供 4,096 个采样。采样率为 44,100 时,每次调用的音频还不足十分之一秒。如果您使用必须快速响应用户输入的用户控制的合成器,则必须按此值进行播放且必须使用 AudioBufferLength 属性。为尽可能降低延迟,您需要保持缓冲区大小比较小,但不能太小,否则播放效果中会出现间隙。

图 1 显示了 GetSampleAsync 重写的 Sine440AudioStreamSource 实现。在该循环中,通过调用 Math.Sin 方法(大小扩展至以下 short 值)获得 16 位的正弦值:

short amplitude = (short)(short.MaxValue * Math.Sin(angle));

图 1 Sine440AudioStreamSource 中的 GetSampleAsync 方法

protected override void GetSampleAsync(MediaStreamType mediaStreamType)
{
  // Reset MemoryStream object
  memoryStream.Seek(0, SeekOrigin.Begin);
  for (int sample = 0; sample < BufferSamples; sample++)
  {
    short amplitude = (short)(short.MaxValue * Math.Sin(angle)); 
    memoryStream.WriteByte((byte)(amplitude & 0xFF));
    memoryStream.WriteByte((byte)(amplitude >> 8));
    angle = (angle + angleIncrement) % (2 * Math.PI);
  }
  // Send out the sample
  ReportGetSampleCompleted(new MediaStreamSample(mediaStreamDescription,
    memoryStream,
    0,
    BufferSize,
    timestamp,
    mediaSampleAttributes));
  // Prepare for next sample
  timestamp += BufferSamples * 10000000L / sampleRate;
}

该振幅然后拆分为 2 个字节并存储在 MemoryStream 中,低字节优先。 对于立体声,每个采样需要两个 16 位值,以便左右交替。

可以采用其他计算。 您可以通过定义如下振幅将正弦波切换为锯齿波:

short amplitude = (short)(short.MaxValue * angle / Math.PI + short.MinValue);

在这两种情况下,名为“angle”的变量的弧度从 0 到 2π(或 360 度),这样便可引用特定波形的一个周期。 每次采样后,angle 都会递增一个 angleIncrement,angleIncrement 是之前在类中根据采样频率和要生成波形的频率计算出的一个变量,此处该值硬编码为 440 Hz:

angleIncrement = 2 * Math.PI * 440 / sampleRate;

请注意,生成波形的频率接近采样率的一半时,angleIncrement 接近 π 或 180 度。 频率为采样率的一半时,生成的波形每个周期只以两个采样为基础,所以结果是方形波,而不是平滑的正弦波。 然而,此方形波中的所有谐波都高于采样率的一半。

另外还请注意,您无法生成高于采样率一半的频率。 如果尝试生成此频率,则实际上生成的都是低于一半采样率的“别名”。 采样率的一半称为“奈奎斯特频率”(Nyquist frequency),它以 Harry Nyquist 的名字命名,Nyquist 曾是 AT&T 的一名工程师,他早期于 1928 年在《信息理论》上发表过一篇论文,由此奠定了音频采样技术的基础。

对于 CD 音频,之所以选择 44,100 Hz 的采样率,其部分原因是因为 44,100 的一半高于人类听力极限的上限,一般认定为 20,000 Hz。

除了 Sine440AudioStreamSource 类以外,SimpleAudioStreaming 项目的其余部分都相当简单: MainPage.xaml 文件包含一个名为 mediaElement 的 MediaElement,并且 MainPage OnNavigatedTo 重写使用 MediaStreamSource 派生类的实例对此对象调用 SetSource:

mediaElement.SetSource(new Sine440AudioStreamSource(44100));

我之前在程序的构造函数中执行了此调用,但我发现如果离开程序,然后再回来并且未逻辑删除该程序,则无法恢复音乐播放。

MediaElement 的 SetSource 方法是您希望 MediaElement 播放通过 Stream 对象引用的音乐文件时调用的相同方法。 调用 SetSource 一般能够开始播放声音,但此特殊的 MediaElement 将其 AutoPlay 属性设置为 false,所以还需要调用 Play。 此程序在其应用程序栏中包括执行这些操作的“播放”和“暂停”按钮。

程序运行期间,您可以从电话的通用音量控制 (UVC) 控制音量,但如果您终止或离开程序,则声音将停止。

移至后台

还可以使用此相同的 MediaStreamSource 派生类在后台播放声音或音乐。 如果您离开程序或者甚至终止程序,会继续在手机中播放后台音乐。 对于后台音频,您不仅可以使用手机的 UVC 控制音量,还可以暂停和重新播放音频,(如果适用)还可以前进或后退到其他曲目。

您会很高兴地发现,您在本专栏上个月发表的以下文章 (msdn.microsoft.com/magazine/hh781030) 中了解到的大部分內容同样适用于后台音频流式处理。 在该专栏中,我介绍了如何在后台播放音乐文件: 您创建了一个包含从 AudioPlayerAgent 派生的类的库项目。 如果您添加了类型为 Windows Phone Audio Playback Agent 的新项目,Visual Studio 将为您生成此类。

应用程序必须具有对包含 AudioPlayerAgent 的 DLL 的引用,但不直接访问此类。 相反,应用程序会访问 BackgroundAudioPlayer 类以设置初始 AudioTrack 对象并调用 Play 和 Pause。 您可以回想一下,AudioTrack 类具有一个构造函数,以允许您指定曲目的标题、艺术家和专辑名,以及指定应该在 UVC 上启用哪些按钮。

通常,AudioTrack 构造函数的第一个参数是一个 Uri 对象,它指示您要播放的音乐文件的源。 如果您希望此 AudioTrack 实例播放流式音频,而不是音乐文件,则需要将此第一个构造函数参数设置为 null。 在该情况下,BackgroundAudioPlayer 将查找从程序引用的 DLL 中的 AudioStreamingAgent 派生的类。 您可以通过添加类型为 Windows Phone Audio Streaming Agent 的项目,在 Visual Studio 中创建此类 DLL。

本文提供的可下载代码中的 SimpleBackgroundAudioStreaming 解决方案演示了如何执行此过程。 该解决方案包含应用程序项目和两个库项目,一个库项目名为 AudioPlaybackAgent,其中包含从 AudioPlayerAgent 派生的 AudioPlayer 类;另一个名为 AudioStreamAgent,其中包含从 AudioStreamingAgent 派生的 AudioTrackStreamer 类。 应用程序对象包含对这两个库项目的引用,但它不尝试访问实际的类。 鉴于某些原因,我在之前的专栏中已对此进行了讨论,这里就不再赘述了。

再次强调一下,应用程序必须包含对后台代理 DLL 的引用。 很容易忽略这些引用,因为您看不到任何错误,但就是不播放音乐。

SimpleBackgroundAudioStreaming 中的大部分逻辑与在后台播放音乐文件的程序的逻辑相同,但只要 BackgroundAudioPlayer 尝试使用空 Uri 对象播放曲目,就会调用 AudioStreamingAgent 派生类中的 OnBeginStreaming 方法。 我使用一种非常简单的方式处理该调用:

protected override void OnBeginStreaming(AudioTrack track, AudioStreamer streamer)
{
  streamer.SetSource(new Sine440AudioStreamSource(44100));
}

就是这样了! 它是我之前介绍的相同 Sine440AudioStreamSource 类,但现在包括在 AudioStreamAgent 对象中。

尽管 SimpleBackgroundAudioStreaming 程序只创建一个音轨,但您可以具有多个音轨对象,并且可以混合引用音乐文件的 AudioTrack 对象和那些使用流式处理的对象。 请注意,AudioTrack 对象是 OnBeginStreaming 重写的参数,所以您可以使用该信息自定义要用于该音轨的特殊 MediaStreamSource。 为了允许您提供更多信息,AudioTrack 具有 Tag 属性,您可以将其设置为任何所需的字符串。

构建合成器

440 Hz 的稳定正弦曲线可能过于沉闷,因此,让我们构建一个电子音乐合成器。 我构建了一个入门级的合成器,它由 SynthesizerDemos 解决方案的 Petzold.MusicSynthesis 库项目中的 12 个类和 2 个接口组成。 (其中某些类与我在 2007 年 7 月所发表博客中为 Silverlight 3 编写的代码相似,可从 charlespetzold.com 访问这些类。)

此合成器的中心是一个名为 DynamicPcmStreamSource 的 MediaStreamSource 派生类,它具有一个名为 SampleProvider 的属性,其定义如下:

public IStereoSampleProvider SampleProvider { get; set; }
The IStereoSampleProvider interface is simple:
public interface IStereoSampleProvider
{
  AudioSample GetNextSample();
}

AudioSample 具有两个类型为 short 的公共字段,名为 Left 和 Right。 在其 GetSampleAsync 方法中,DynamicPcmStreamSource 调用 GetNextSample 方法以获取一对 16 位的采样:

AudioSample audioSample = SampleProvider.GetNextSample();

实现 IStereoSampleProvider 的一个类名为 Mixer。 Mixer 具有一个 Inputs 属性,它是类型为 MixerInput 的对象的集合。 每个 MixerInput 都具有一个类型为 IMonoSampleProvider 的 Input 属性,其定义如下:

public interface IMonoSampleProvider
{
  short GetNextSample();
}

实现 IMonoSampleProvider 的一个类名为 SteadyNoteDurationPlayer,它是一个抽象类,以特定的节拍播放一系列相同持续时间的音符。 它具有一个名为 Oscillator 的属性,该属性还实现 IMonoSample­Provider 以生成实际波形。 从 SteadyNoteDurationPlayer 派生两个类: Sequencer,重复播放一系列音符;Rambler,播放随机音符流。 我在 SynthesizerDemos 解决方案的两个不同的应用程序中使用这两个类,一个类仅在前台运行,而另一个则在后台播放音乐。

仅前台运行的应用程序名为 WaveformManipulator,它所具有的一个控件允许您以交互方式定义用于播放音乐的波形,如图 2 所示。

The WaveformManipulator Program
图 2 WaveformManipulator 程序

将定义波形的点传输给名为 VariableWaveformOscillator 的 Oscil­lator 派生类。 当您将圆形触摸点上下移动时,您将注意到在您实际听到音乐的音色发生改变时大约有一秒的延迟。 这是 MediaStreamSource 定义的默认 1,000 ms 的缓冲区大小的结果。

WaveformManipulator 程序使用两个加载有相同音符序列(E 小调、A 小调、D 小调和 G 大调琶音)的 Sequencer 对象。 虽然两个 Sequencer 对象的节拍稍微有些不同,但它们同步播放,首先是混响或回音效果,然后更多的是配合旋律。 (这是一种受美国作曲家 Steve Reich 早期作品激发而出现的“处理音乐”形式。) “连接”合成器组件的 MainPage.xaml.cs 中的初始化代码如图 3 中所示。

图 3 WaveformManipulator 中的合成器初始化代码

// Initialize Waveformer control
for (int i = 0; i < waveformer.Points.Count; i++)
{
  double angle = (i + 1) * 2 * Math.PI / (waveformer.Points.Count + 1);
  waveformer.Points[i] = new Point(angle, Math.Sin(angle));
}
// Create two Sequencers with slightly different tempi
Sequencer sequencer1 = new Sequencer(SAMPLE_RATE)
{
  Oscillator = new VariableWaveformOscillator(SAMPLE_RATE)
  {
    Points = waveformer.Points
  },
  Tempo = 480
};
Sequencer sequencer2 = new Sequencer(SAMPLE_RATE)
{
  Oscillator = new VariableWaveformOscillator(SAMPLE_RATE)
  {
    Points = waveformer.Points
  },
  Tempo = 470
};
// Set the same Pitch objects in the Sequencer objects
Pitch[] pitches =
{
  ...
};
foreach (Pitch pitch in pitches)
{
  sequencer1.Pitches.Add(pitch);
  sequencer2.Pitches.Add(pitch);
}
// Create Mixer and MixerInput objects
mixer = new Mixer();
mixer.Inputs.Add(new MixerInput(sequencer1) { Space = -0.5 });
mixer.Inputs.Add(new MixerInput(sequencer2) { Space = 0.5 });

在 OnNavigatedTo 重写中将 Mixer、DynamicPcmStreamSource 和 MediaElement 对象连接起来:

DynamicPcmStreamSource dynamicPcmStreamSource =
  new DynamicPcmStreamSource(SAMPLE_RATE);
dynamicPcmStreamSource.SampleProvider = mixer;
mediaElement.SetSource(dynamicPcmStreamSource);

由于 WaveformManipulator 使用 MediaElement 播放音乐,因此它仅在程序在前台运行时播放。 

后台限制

我思考了很久,希望制作可以使用 BackgroundAudioPlayer 在后台播放音乐的 WaveformManipulator 版本。 很明显,您只能在程序在前台运行时操作波形,但我无法解决上个月专栏中讨论的那个难题: 您的程序提供的、用于进行后台处理的后台代理 DLL 运行在不同的任务中,而不是运行在程序本身中,并且我知道这两个任务交换任意数据的唯一方式是通过独立存储。

我决定放弃这项工作,部分原因是因为我对用于在后台播放流式音频的程序有更好的想法。 这是可以播放随机音调的程序,但对于音符,当加速感应器记录的手机方向更改时会略有变化。 晃动手机会产生出全新的音调。

当我尝试将对 Microsoft.Devices.Sensors 程序集的引用添加到 AudioStreamAgent 项目时,此项目就无法继续了。 该移动调用带红色 X 的消息框,并显示消息“尝试添加后台代理不支持的引用”。很明显后台代理不能使用该加速感应器。 对于该程序的想法也到此为止!

我又另外编写一个名为 PentatonicRambler 的程序,它使用后台流式处理以五声音阶(仅由钢琴的五个黑色音符组成)播放永不停止的旋律。 这些音符由名为 Rambler 的合成器组件随机选取,该组件将每个连续的音符限制为比原来的音符高一阶或者低一阶。 没有较大的跳跃使生成的音符流听起来更像合成旋律,而不是纯粹的随机音符。

图 4 显示了 AudioStreamingAgent 派生类中的重写。

图 4 PentatonicRambler 的合成器设置

protected override void OnBeginStreaming(AudioTrack track, AudioStreamer streamer)
{
  // Create a Rambler
  Rambler rambler = new Rambler(SAMPLE_RATE,
  new Pitch(Note.Csharp, 4), // Start
  new Pitch(Note.Csharp, 2), // Minimum
  new Pitch(Note.Csharp, 6)) // Maximum
  {
    Oscillator = new AlmostSquareWave(SAMPLE_RATE),
    Tempo = 480
  };
  // Set allowable note values
  rambler.Notes.Add(Note.Csharp);
  rambler.Notes.Add(Note.Dsharp);
  rambler.Notes.Add(Note.Fsharp);
  rambler.Notes.Add(Note.Gsharp);
  rambler.Notes.Add(Note.Asharp);
  // Create Mixer and MixerInput objects
  Mixer mixer = new Mixer();
  mixer.Inputs.Add(new MixerInput(rambler));
  DynamicPcmStreamSource audioStreamSource =
    new DynamicPcmStreamSource(SAMPLE_RATE);
  audioStreamSource.SampleProvider = mixer;
  streamer.SetSource(audioStreamSource);
}

我更愿意在程序本身中定义合成器组件的聚合,然后将此设置传输给后台代理,但由于程序任务和后台代理任务之间存在进程隔离,所以有些工作要做。 必须完全用文本字符串(也许基于 XML)定义合成器设置,然后从程序通过 AudioTrack 的 Tag 属性将其传递给 AudioStreamingAgent 派生类。

同时,我希望未来增强 Windows Phone 的功能,以包括允许程序与它们调用的后台代理通信的机制。

Charles Petzold 是《MSDN 杂志》的长期供稿人。 他的网站是 charlespetzold.com

衷心感谢以下技术专家对本文的审阅:Eric BieMark Hopkins 和 Chris Pearson