DirectX 因素

在 Windows 8 中流式载入和处理音频文件

Charles Petzold

下载代码示例

如今,许多 Windows 用户的硬盘中都有一个音乐库,其中包含多达数千甚至上万个 MP3 和 WMA 文件。若要在电脑上播放此音乐,这类用户一般运行 Windows Media Player 或 Windows 8 Music 应用程序。但对于程序员来说,知道我们可以编写自己的程序来播放这些文件再好不过了。Windows 8 提供编程接口,用来访问音乐库,获取各个音乐文件的信息(如艺术家、标题和播放时长)以及用 MediaElement 播放这些文件。

MediaElement 方法简单,当然也有其他方法,虽然更难驾驭,但增添了多钟用途。通过两个 DirectX 组件,即 Media Foundation 和 XAudio2,应用程序可以更多地参与此过程。你可以在播放音乐之前(也可不播放音乐)从音乐文件载入解压缩的音频数据块,并对其进行分析或进行某些处理。你可曾想过,将肖邦练习曲以半速向后播放,听起来会是什么效果?嗯,我也不曾想过,但本文附带的程序之一会让你找到答案。

选取器和批量访问

当然,Windows 8 程序访问音乐库的最简单方法是使用 FileOpenPicker,将其在 C++ 程序中初始化以加载音频文件,就像这样:

 

FileOpenPicker^ fileOpenPicker = ref new FileOpenPicker(); fileOpenPicker->SuggestedStartLocation = PickerLocationId::MusicLibrary; fileOpenPicker->FileTypeFilter->Append(".wma"); fileOpenPicker->FileTypeFilter->Append(".mp3"); fileOpenPicker->FileTypeFilter->Append(".wav");

调用 PickSingleFileAsync 来显示 FileOpenPicker,然后让用户选择一个文件。

若要对文件夹和文件进行自由式浏览,可由应用程序清单实现,这也表明它想要更广泛地访问音乐库。 这样,该程序便可以使用 Windows::Storage::BulkAccess 命名空间中的类自行枚举文件夹和音乐文件。

无论采用哪种方法,每个文件都由一个 StorageFile 对象表示。 你可以从该对象得到一个缩略图,该缩略图是音乐专辑封面的图像(如果存在)。 而从 StorageFile 的 Properties 属性可得到一个 MusicProperties 对象,该对象提供了艺术家、专辑、曲目名称、播放时长等与音乐文件相关的标准信息。

通过对该 StorageFile 调用 openAsync,你也可以将其打开进行读取,并获得一个 IRandomAccessStream 对象,甚至将整个文件读入内存。 如果它是 WAV 文件,你可能会考虑解析该文件,解压波形数据,并通过 XAudio2 播放声音,正如我在本专栏最近几期中介绍的那样。

但如果它是 MP3 或 WMA 文件,就不那么容易了。 你需要解压缩音频数据,但可能并不想自己完成这一工作。 幸运的是,Media Foundation API 提供了解压缩 MP3 和 WMA 文件的工具,可将数据转换为相应的格式,直接发送到 XAudio2 进行播放。

另一种获得解压缩音频数据的方法是采用 MediaElement 附带的音频效果。 我希望在后期的文章中演示此方法。

Media Foundation 流式载入

若要使用我将在这里讨论的 Media Foundation 函数和接口,你的 Windows 8 程序需与 mfplat.lib 和 mfreadwrite.lib 导入库链接,还需要在 pch.h 文件中添加 mfapi.h、mfidl.h 和 mfreadwrite.h 的 #include 语句。 (另外,请务必将 Initguid.h 放在 mfapi.h 之前,否则会出现令人困惑的链接错误,浪费许多无谓的时间。) 如果你还使用 XAudio2 播放文件(正如我会在这里做的那样),则需要 xaudio2.lib 导入库和 xaudio2.h 头文件。

在本专栏的可下载代码中有一个名为 StreamMusicFile 的 Windows 8 项目,该项目演示了将文件从电脑的音乐库加载,通过 Media Foundation 解压缩,然后通过 XAudio2 播放的最精简的代码。 通过一个按钮调用 FileOpenPicker,然后在你选择了一个文件后,程序会显示一些标准信息(如图 1 所示),并立即开始播放该文件。 默认情况下,底部的音量滑动条设置为 0,所以需要滑动音量滑动条才能听到声音。 文件的播放无法暂停或停止,除非终止该程序或将另一个程序切换到前台。

The StreamMusicFile Program Playing a Music File
图 1 StreamMusicFile 程序正在播放音乐文件

实际上,即使单击按钮加载另一个文件,程序也不会停止播放音乐文件。 相反,你会发现这两个文件同时播放,但很可能不会以任何类型的一致同步播放。 因此,这正是此程序能够处理,而 Windows 8 Music 应用程序和 Media Player 不能处理的问题: 同时播放多个音乐文件!

图 2 所示的方法演示该程序如何运用来自 StorageFile 的 IRandomAccessStream 创建一个 IMFSourceReader 对象,该对象能够读取音频文件并发送未压缩的音频数据块。

图 2 创建并初始化 IMFSourceReader

ComPtr<IMFSourceReader> MainPage::CreateSourceReader(IRandomAccessStream^ randomAccessStream) {   // Start up Media Foundation   HRESULT hresult = MFStartup(MF_VERSION);   // Create a IMFByteStream to wrap the IRandomAccessStream   ComPtr<IMFByteStream> mfByteStream;   hresult = MFCreateMFByteStreamOnStreamEx((IUnknown *)randomAccessStream,                                             &mfByteStream);   // Create an attribute for low latency operation   ComPtr<IMFAttributes> mfAttributes;   hresult = MFCreateAttributes(&mfAttributes, 1);   hresult = mfAttributes->SetUINT32(MF_LOW_LATENCY, TRUE);   // Create the IMFSourceReader   ComPtr<IMFSourceReader> mfSourceReader;   hresult = MFCreateSourceReaderFromByteStream(mfByteStream.Get(),                                                mfAttributes.Get(),                                                &mfSourceReader);   // Create an IMFMediaType for setting the desired format   ComPtr<IMFMediaType> mfMediaType;   hresult = MFCreateMediaType(&mfMediaType);   hresult = mfMediaType->SetGUID(MF_MT_MAJOR_TYPE, MFMediaType_Audio);   hresult = mfMediaType->SetGUID(MF_MT_SUBTYPE, MFAudioFormat_Float);   // Set the media type in the source reader   hresult = mfSourceReader->SetCurrentMediaType(MF_SOURCE_READER_FIRST_AUDIO_STREAM,                                           0, mfMediaType.Get());   return mfSourceReader; }

为简洁起见,图 2 不含用于处理错误 HRESULT 返回值的所有代码。 实际代码引发 COMException 类型的异常,但该程序不会像真正的应用程序一样捕获这些异常。

总之,此方法使用 IRandomAccessStream 创建一个封装输入流的 IMFByteStream 对象,然后用它创建一个可执行实际解压缩的 IMFSourceReader。

请注意,代码中使用了 IMFAttributes 对象指定一个低延迟操作。 但并非必需如此,并且你可以将 MFCreateSourceReaderFromByteStream 函数的第二个参数设置为 nullptr。 但是,当该文件正在被读取和播放时,硬盘驱动器也正在被访问,你不希望这些磁盘操作导致在播放中产生顿音。 如果确实担心这个问题,可以考虑将整个文件读取到一个 InMemoryRandomAccessStream 对象中,并使用该对象创建 IMFByteStream。

当程序使用 Media Foundation 解压缩音频文件时,程序无法控制从文件接收到的未压缩数据的采样率或信道数。 这由文件控制。 但是,该程序可以指定样本使用两个不同的格式之一: 16 位整数(用于 CD 音频)或 32 位浮点值(C 浮点类型)。 XAudio2 内部采用 32 位浮点样本,因此,当 32 位浮点样本传递到 XAudio2 来播放文件时,需要较少的内部转换。 我决定在此程序中采用这个路线。 因此,图 2 中的方法用两个标识符 MFMediaType_Audio 和 MFAudioFormat_Float 指定其所需要的音频数据格式。 如果需要解压缩的数据,第二个标识符的唯一选择是用于 16 位整数样本的 MFAudioFormat_PCM。

此时,我们有一个 IMFSourceReader 类型的对象,准备好读取并解压缩音频文件的数据块。

播放该文件

我原本想要将第一个程序的所有代码放入 MainPage 类中,但我也想使用 XAudio2 回调函数。 这是个问题,因为(我发现)如 MainPage 的 Windows 运行时类型无法实现如 IXAudio2VoiceCallback 的非 Windows 运行时接口,所以我需要另一个类,名为 AudioFilePlayer。

采用图 2 所示的方法获得一个 IMFSourceReader 对象之后,MainPage 创建一个新的 AudioFilePlayer 对象,并为其传递一个在 MainPage 构造函数中创建的 IXAudio2 对象:

new AudioFilePlayer(pXAudio2, mfSourceReader);

此后,AudioFilePlayer 对象完全依靠自身,几乎是独立的。 这就是该程序实现多个音乐文件同时播放的方法。

如果要播放音乐文件,AudioFilePlayer 需要创建一个 IXAudio2SourceVoice 对象。 这需要一个 WAVEFORMATEX 结构,指示要传递给源语音的音频数据的格式,该格式应与由 IMFSourceReader 对象提供的音频数据一致。 你可对正确参数进行猜测(如双信道和 44,100 赫兹采样率),如果得到的采样率错误,XAudio2 可以进行内部采样率转换。 然而,最好从 IMFSourceReader 获得 WAVEFORMATEX 结构并使用该结构,如图 3 中的 AudioFilePlayer 构造函数所示。

图 3 StreamMusicFile 中的 AudioFilePlayer 构造函数

AudioFilePlayer::AudioFilePlayer(ComPtr<IXAudio2> pXAudio2,                                  ComPtr<IMFSourceReader> mfSourceReader) {   this->mfSourceReader = mfSourceReader;   // Get the Media Foundation media type   ComPtr<IMFMediaType> mfMediaType;   HRESULT hresult = mfSourceReader->GetCurrentMediaType(MF_SOURCE_READER_                                                         FIRST_AUDIO_STREAM,                                                         &mfMediaType);   // Create a WAVEFORMATEX from the media type   WAVEFORMATEX* pWaveFormat;   unsigned int waveFormatLength;   hresult = MFCreateWaveFormatExFromMFMediaType(mfMediaType.Get(),                                                 &pWaveFormat,                                                 &waveFormatLength);   // Create the XAudio2 source voice   hresult = pXAudio2->CreateSourceVoice(&pSourceVoice, pWaveFormat,                                         XAUDIO2_VOICE_NOPITCH, 1.0f, this);   // Free the memory allocated by function   CoTaskMemFree(pWaveFormat);   // Submit two buffers   SubmitBuffer();   SubmitBuffer();   // Start the voice playing   pSourceVoice->Start();   endOfFile = false; }

获取该 WAVEFORMATEX 结构会带来一点麻烦,涉及到必须显式释放的内存块,当 AudioFilePlayer 构造函数的完成后,该文件准备好进行播放。

如果要保持这类程序的内存占用最低,文件应以小块读取和播放。 Media Foundation 和 XAudio2 都非常有利于这种方法。 对 IMFSourceReader 对象的 ReadSample 方法的每次调用都可以获得对下一个未压缩数据块的访问,直到该文件被完全读取。 对于 44,100 赫兹、双信道、32 位浮点样本的采样率,我的经验是这些块的大小通常为 16,384 或 32,768 字节,有时仅 12,288 字节(但总是 4,096 的倍数),表示每个音频约 35 至 100 毫秒。

在每次调用 IMFSourceReader 的 ReadSample 方法后,程序可以简单分配一个本地的内存块,将数据复制到其中,然后用 SubmitSourceBuffer 将这个本地块提交到 IXAudio2SourceVoice 对象。

AudioFilePlayer 采用双缓冲区的方式播放该文件: 一个缓冲区在填充数据时,另一个缓冲区进行播放。 图 4 显示了整个过程,同样无错误检查。

图 4 StreamMusicFile 中的 Audio-Streaming 流水线

void AudioFilePlayer::SubmitBuffer() {   // Get the next block of audio data   int audioBufferLength;   byte * pAudioBuffer = GetNextBlock(&audioBufferLength);   if (pAudioBuffer != nullptr)   {     // Create an XAUDIO2_BUFFER for submitting audio data     XAUDIO2_BUFFER buffer = {0};     buffer.AudioBytes = audioBufferLength;     buffer.pAudioData = pAudioBuffer;     buffer.pContext = pAudioBuffer;     HRESULT hresult = pSourceVoice->SubmitSourceBuffer(&buffer);   } } byte * AudioFilePlayer::GetNextBlock(int * pAudioBufferLength) {   // Get an IMFSample object   ComPtr<IMFSample> mfSample;   DWORD flags = 0;   HRESULT hresult = mfSourceReader->ReadSample(MF_SOURCE_READER_FIRST_AUDIO_STREAM,                                                0, nullptr, &flags, nullptr,                                                &mfSample);   // Check if we’re at the end of the file   if (flags & MF_SOURCE_READERF_ENDOFSTREAM)   {     endOfFile = true;     *pAudioBufferLength = 0;     return nullptr;   }   // If not, convert the data to a contiguous buffer   ComPtr<IMFMediaBuffer> mfMediaBuffer;   hresult = mfSample->ConvertToContiguousBuffer(&mfMediaBuffer);   // Lock the audio buffer and copy the samples to local memory   uint8 * pAudioData = nullptr;   DWORD audioDataLength = 0;   hresult = mfMediaBuffer->Lock(&pAudioData, nullptr, &audioDataLength);   byte * pAudioBuffer = new byte[audioDataLength];   CopyMemory(pAudioBuffer, pAudioData, audioDataLength);   hresult = mfMediaBuffer->Unlock();   *pAudioBufferLength = audioDataLength;   return pAudioBuffer; } // Callback methods from IXAudio2VoiceCallback void _stdcall AudioFilePlayer::OnBufferEnd(void* pContext) {   // Remember to free the audio buffer!
delete[] pContext;   // Either submit a new buffer or clean up   if (!endOfFile)   {     SubmitBuffer();   }   else   {     pSourceVoice->DestroyVoice();     HRESULT hresult = MFShutdown();   } }

如果要对音频数据进行临时访问,该程序需要调用表示新数据块的 IMFMediaBuffer 对象上的 Lock,然后调用其上的 Unlock。 在这些调用之间,图 4 中的 GetNextBlock 方法将该块复制到一个新分配的字节数组中。

图 4 中的 SubmitBuffer 方法负责设置 XAUDIO2_BUFFER 结构中的字段,准备提交要播放的音频数据。 请注意,这种方法还如何将 pContext 字段设置为分配的音频缓冲区。 此指针传递给图 4 末尾的 OnBufferEnd 回调方法,这样就可以删除数组内存。

文件被完全读取后,下一个 ReadSample 调用设置一个 MF_SOURCE_READERF_ENDOFSTREAM 标志,并且 IMFSample 对象为 null。 该程序通过设置一个 endOfFile 字段变量进行响应。 此时,另一个缓冲区仍在播放,并且会出现对 OnBufferEnd 的最后一次调用,这样利用这次机会释放一些系统资源。

还有一个 OnStreamEnd 回调方法,该方法通过设置 XAUDIO2_BUFFER 中的 XAUDIO2_END_OF_STREAM 标志触发,但在这种情况下难以使用。 问题在于无法设置这个标志,直到从 ReadSample 调用收到 MF_SOURCE_READERF_ENDOFSTREAM 标志。 但 SubmitSourceBuffer 不允许缓冲区为空或缓冲区的大小为零,这意味着无论如何必须提交一个非空缓冲区,即使不再有可用数据!

旋转 Metaphor 乐队的唱片

当然,将音频数据从 Media Foundation 传递到 XAudio2 不像使用 Windows 8 Media­Element 那么容易,不值得这样做,除非要用音频数据做一些有趣的事情。 你可以使用 XAudio2 设置一些特殊效果(如回声或混响),在本专栏的下一期中,我会将 XAudio2 过滤器应用于声音文件。

同时,图 5 显示一个名为 DEEJAY 的程序,该程序在屏幕上显示一张唱片,并在音乐播放时以每分钟 33 1/3 转的默认速度旋转唱片。

The DeeJay Program
图 5 DeeJay 程序

此处未显示应用程序栏,该应用程序栏上有一个加载文件按钮和两个分别控制音量和播放速度的滑动条。 此滑动条的范围从 -3 到 3,这些值表示速度比。 默认值是 1。 值 0.5 表示以半速播放文件,值 3 表示以三倍速度播放文件,值 0 表示基本上暂停播放,负值表示向后播放文件(或许你会听到被编码在音乐中的隐藏声音)。

当然,这可是 Windows 8,你也可以用手指旋转唱片,这正体现了程序名称的由来。 DEEJAY 支持单指惯性旋转,所以可以向任一方向旋转唱片,动作要平稳。 你也可以点击唱片,将“唱针”移到该位置。

我非常,非常,非常想采用交替调用 ReadSample 和 SubmitSourceBuffer 的方法以类似于 StreamMusicFile 项目的方式实现此程序。 但是,在试图倒着播放文件时问题出现了。 我确实需要 IMFSourceReader 支持 ReadPreviousSample 方法,但它并不支持。

IMFSourceReader 确实支持的是 SetCurrentPosition 方法,这个方法允许你移动到文件中的先前位置。 但是,随后的 ReadSample 调用开始返回早于该位置的块。 在大多数情况下,一系列对 ReadSample 调用最终会返回到 SetCurrentPosition 之前最后一次 ReadSample 调用的位置,但有时并非如此,结果一团糟。

我最终放弃了,程序只是简单地将整个未压缩的音频文件加载到内存中。 为了降低内存占用量,我指定 16 位整数样本,而不是 32 位浮点样本,但每分钟音频仍然占用约 10 MB 内存,加载一首马勒交响曲的长乐章将占用约 300 MB。

这些马勒交响曲还要求整个文件加载方法在次级线程中执行,采用 Windows 8 中提供的 create_task 功能使其大大简化。

为了简化对单个样本的处理,我创建了一个名为 AudioSample 的简单结构:

struct AudioSample {   short Left;   short Right; };

因此,此程序中的 AudioFilePlayer 类使用一个 AudioSample 值的数组,而不使用字节数组。 但是,这意味着该程序基本上是硬编码的,仅适用于立体声文件。 如果它加载的音频文件并非刚好有两个信道,则无法播放该文件!

异步文件读取方法从称为 LoadedAudioFileInfo 的结构中获得的数据,然后存储:

struct LoadedAudioFileInfo {   AudioSample* pBuffer;   int bufferLength;   WAVEFORMATEX waveFormat; };

pbuffer 是大内存块,bufferLength 为采样率(可能是 44,100 赫兹)和文件播放时长(以秒计)的乘积。 该结构直接传递给 AudioFilePlayer 类。 为每个加载的文件创建新的 AudioFilePlayer,取代任何先前的 AudioFilePlayer 实例。 AudioFilePlayer 有一个析构函数用于清理,可以删除容纳整个文件的大数组,以及用于将缓冲区提交到 IXAudio2SourceVoice 对象的两个较小数组。

以不同速度向前和向后播放文件的键值是 AudioFilePlayer 中 double 类型的两个字段: audioBuffer­Index 和 speedRatio。 audioBufferIndex 变量指向包含整个未压缩文件的大数组内的某个位置。 speedRatio 变量被设置为与滑动条相同的值,即从 -3 到 3。 当 AudioFilePlayer 需要将音频数据从大缓冲区传输到较小的缓冲区进行提交时,它按照 speedRatio 为每个样本递增 audioBufferIndex。 所得到的 audioBufferIndex(一般来说)处于两个文件样本之间,所以采用图 6 中的方法进行插值获得一个值,然后将其传输到提交缓冲区。

图 6 在 DeeJay 中的两个样本之间插值

AudioSample AudioFilePlayer::InterpolateSamples() {   double left1 = 0, left2 = 0, right1= 0, right2 = 0;   for (int i = 0; i < 2; i++)   {     if (pAudioBuffer == nullptr)       break;     int index1 = (int)audioBufferIndex;     int index2 = index1 + 1;     double weight = audioBufferIndex - index1;     if (index1 >= 0 && index1 < audioBufferLength)     {       left1 = (1 - weight) * pAudioBuffer[index1].Left;       right1 = (1 - weight) * pAudioBuffer[index1].Right;     }     if (index2 >= 0 && index2 < audioBufferLength)     {       left2 = weight * pAudioBuffer[index2].Left;       right2 = weight * pAudioBuffer[index2].Right;     }   }   AudioSample audioSample;   audioSample.Left = (short)(left1 + left2);   audioSample.Right = (short)(right1 + right2);   return audioSample; }

触摸界面

为了使程序简单,整个触摸界面由一个点击事件(可将“唱针”放在唱片的不同位置)和三个操作事件组成: ManipulationStarting 处理程序初始化单指旋转;ManipulationDelta 处理程序为 AudioFilePlayer 设置速度比,覆盖来自滑动条的速度比;ManipulationCompleted 处理程序在所有的惯性运动完成后将 AudioFilePlayer 中的速度比恢复为滑动条的值。

旋转速度值可直接从 ManipulationDelta 处理程序的事件参数获得。 这些都是以每毫秒的旋转角度为单位。 长时间播放唱片的标准速度是每分钟 33 1/3 转,这相当于每秒 200° 或每毫秒 0.2°,我只需要将 ManipulationDelta 事件中的值除以 0.2 即可得到我所需要的速度比。

但是,我发现 ManipulationDelta 报告的速度相当不稳定,所以我不得不采用一些简单逻辑使其平滑,这里涉及一个名为 smoothVelocity 的字段变量:

smoothVelocity = 0.95 * smoothVelocity +                  0.05 * args->Velocities.Angular / 0.2; pAudioFilePlayer->SetSpeedRatio(smoothVelocity);

在真正的转盘上,只需用手指压住唱片就可以停止旋转。 但是在这里无效。 手指的实际运动是操作事件产生的必要条件,因此如果要停止唱片,需要先按下,然后再稍微移动一下手指(或鼠标或笔)。

惯性减速逻辑也不符合现实。 此程序允许惯性运动在将速度比恢复到滑动条指示的值之前完全完成。 现实场景中,该滑动条的值应相对惯性值表现出某种滞后感,但这将使逻辑相当复杂。

此外,我确实无法检测到“不自然”的惯性效应。 毫无疑问,真正的 DJ 会立即察觉到差异。

Charles Petzold 是 MSDN 杂志的长期撰稿人,他是“Programming Windows, 6th edition”(O'Reilly Media,2012)一书的作者,这本书讲授如何编写 Windows 8 应用程序。他的网站是charlespetzold.com

衷心感谢以下技术专家对本文的审阅: Richard Fricks (Microsoft)