本文章是由機器翻譯。

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,所以需要滑動音量滑動條才能聽到聲音。檔的播放無法暫停或停止,除非終止該程式或將另一個程式切換到前臺。


圖 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 轉的預設速度旋轉唱片。


圖 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)