🎧

Media Foundation (Source Reader) + XAudio2 でオーディオソースをストリーミング再生する

2023/11/27に公開

はじめに

マルチメディア系技術の復習シリーズです。

前回 は Media Foundation の一番ベーシックなプッシュモデルでのビデオファイル再生を行いました。その次だと順当に考えるとカスタムレンダラーの実装になるのですが、カスタムレンダラーは大分やることが多くてちょっと大変なのと、プッシュモデルは Unity などのゲームエンジンへの組み込みにあまり向かないので、一旦置いといてプルモデル (Source Reader) をベースにプレイヤーを作っていくことにします。

というわけで今回はオーディオソースのストリーミング再生の自前実装を行います。オーディオ API として XAudio2 を利用します。

サンプルコードはこちらです。

https://github.com/aosoft/simple_mf_xaudio_play

API の概要

XAudio2 とは

https://learn.microsoft.com/ja-jp/windows/win32/xaudio2/xaudio2-introduction

XAudio2 は Windows のオーディオ API の一種です。元々は Xbox 向けの API だった XAudio を元に Windows / Xbox の両対応となった API です。

XAudio2 は内部的には Core Audio (Vista 以降) か DirectSound (XP) を元にした API です。 XAudio2 は自前でスレッドを立てなくてもストリーミング再生ができたりと割と便利かつ性能もよいのですが、いわゆるコピー保護デバイス (Protected Device) の制御ができないので、著作権保護が必要な場合には使用できません。が、それが必要なケースはほとんどないでしょう。

Media Foundation Source Reader とは

https://learn.microsoft.com/ja-jp/windows/win32/medfound/source-reader

Source Reader は Media Foundation の Media Source からサンプルを取得するためのものです。Topology の場合、再生開始をするとソースからどんどんサンプルが出力されます (プッシュモデル) 。プッシュモデルはサンプルの取得タイミングを任意でコントロールできないため、扱いづらいケースがあります。プルモデルはプル (サンプルを取得する) を任意のタイミングで行えるので自分で細かくコントロールしたい場合や単にサンプルを取得したいといったケースに適しています。

一方で Topology の場合、 Topology が組めれば後は全てやってくれますが、 Source Reader はソースからサンプルの取得しかできないので、プレイヤーを作る場合はレンダラー、タイマー制御も含めて全て自身で実装する必要があります。

XAudio2 でストリーミング再生をする

https://github.com/aosoft/simple_mf_xaudio_play/blob/main/libs/xaudio/include/xaudio_player_core.h

コア実装はここにまとめました。

構成

XAudio2 は IXAudio2 インターフェースが全体の全てを担っています。まず、 IXAudio2 (のインスタンス) を取得します。

IXAudio2MasteringVoice は音声デバイス、 IXAudio2SourceVoice は音声ソースを表します。イメージとしては MateringVoice はミキサーで、ここに複数の SourceVoice が投入、ミキシングして音声が出力されます。

初期化する

com_ptr<IXAudio2> _xaudio;
IXAudio2MasteringVoice* _mastering_voice;
IXAudio2SourceVoice* _source_voice;

WAVEFORMATEX wfx;
IXAudio2VoiceCallback* callback;

XAudio2Create(&_xaudio, 0, XAUDIO2_DEFAULT_PROCESSOR);
_xaudio->CreateMasteringVoice(&_mastering_voice, XAUDIO2_DEFAULT_CHANNELS, XAUDIO2_DEFAULT_SAMPLERATE, 0, nullptr, nullptr);
_xaudio->CreateSourceVoice(&_source_voice, &wfx, 0, XAUDIO2_DEFAULT_FREQ_RATIO, callback, nullptr, nullptr);

IXAudio2 のインスタンスを取得し、そこから MasteringVoice と SourceVoice を生成します。なお、 COM であるのは IXAudio2 のみで、 IXAudio2MasteringVoice, IXAudio2SourceVoice は COM ポインターではないので注意です (IXAudio2 で管理されていると思われます) 。

CreateSourceVoice ですが、callback を渡しています。これは XAudio2 から再生状況に応じて通知を受け取りたい場合に指定します。必要がなければ nullptr で OK です。今回の実装では必要なので xaudio_player_core クラス自体に実装をして this を渡すようにしています。

音を出す

音を出すには出したい音のデーターを SourceVoice のバッファーに追加し、 SourceVoice の再生開始 (Start) をすることで再生されます。

std::vector<std::uint8_t> audio_data;

XAUDIO2_BUFFER buffer = {};
buffer.pAudioData = &audio_data[0];
buffer.AudioBytes = audio_data.size();

_source_voice->SubmitSourceBuffer(&buffer);
_source_voice->Start();

audio_data に鳴らしたいデータが入っていたとした場合、上記のようなコードになりますが、 SubmitSourceBuffer はバッファーへのポインターを登録するだけで実際のバッファーの生存管理はしません。また、音声再生は非同期に行われるので SubmitSourceBuffer で登録したポインターはいつアクセスがされるタイミングも非同期です。 よって SourceVoice が Start 状態中はバッファー管理を適切に行う必要があります (上記のコードは正しくありません) 。

バッファー管理をしながらストリーミング再生をする

XAudio2 でストリーミング再生をする場合、 2 つの方法が考えられます。

  • a) callback から通知されるイベントに合わせて再生データ追加、削除の管理を行う
  • b) 自分で再生状態を把握し、状況に合わせて再生データの更新を行う

DirectSound を用いる場合は b) のパターンになります。オーディオ再生用スレッドを用意し、そのスレッド内で再生状態を把握し、残り再生データがなくならないように、なくなりそうになったら追加していくように実装します。

XAudio2 では a) のパターンで実装する場合は IXAudio2VoiceCallback の実装、登録が必要になります。 b) の場合は callback なしでも実装できます。

b) は再生状態管理を自前でやらなくてはいけないので、他 API を使用したストリーミング再生の実装を流用する、などではなくスクラッチに XAudio2 で実装するなら a) の方がベターではないかと思います。後述しますが、極低レイテンシーの再生をする場合は a) で実装するしかないと思います。

IXAudio2VoiceCallback を実装する

IXAudio2VoiceCallback はいくつかメソッドがありますが、ここで重要なものは 2 つです。扱わないものは空実装にしてください。

  • OnVoiceProcessingPassStart
  • OnBufferEnd
OnVoiceProcessingPassStart

最重要コールバックです。

再生が進み、SubmitSourceBuffer で登録した再生データの残りがなくなりそうになると通知されます。引数で渡される BytesRequired が必須再生データのバイトサイズです (サンプル数ではないので注意) 。

また、 BytesRequired は必要データサイズなので、これより大きいサイズのデータを入れても問題ありません。 BytesRequired は "演奏をするのに最低限このサイズのデータが必要" ということなので、再生環境で最も低レイテンシーに再生をしたい場合は BytesRequired サイズ分ぴったりで投入し続けるようにします。

OnBufferEnd

OnBufferEnd は SubmitSourceBuffer で入力したデータが使用された (再生された) ら通知されるもので、このタイミングまで入力データを保持するバッファを維持するようにします。通知されたバッファは解放してもよいですが、通常は効率面から BufferPool に戻したり空き領域を調整したり等、再利用をすることになると思います。

引数の pBufferContext は XAUDIO2_BUFFER::pContext そのもので、この値を見てどの領域のデータの使用が終わったかを判断します。

実装例

説明の都合上、malloc/free で記述していますが前述の通り、バッファは再利用するように実装すべきです。

void STDMETHODCALLTYPE OnVoiceProcessingPassStart(UINT32 BytesRequired) override
{
    std::int16_t* audio_data = static_cast<std::int16_t*>(malloc(BytesRequired));

    // audio_data にデータを設定

    XAUDIO2_BUFFER buffer = {};
    buffer.pAudioData = audio_data;
    buffer.AudioBytes = BytesRequired;
    buffer.pContext = audio_data;  // audio_data のポインターをコンテキストとする

    _source_voice->SubmitSourceBuffer(&buffer);
}

void STDMETHODCALLTYPE OnBufferEnd(void* pBufferContext) override
{
    if (pBufferContext != nullptr)
    {
        // pBufferContext = audio_data なので解放
        free(pBufferContext);
    }
}

Media Foundation Source Reader を用いて音声データを取得する

デバイスで音を鳴らす方法が分かったところで、次にメディアファイルから再生できるリニア PCM にデコードした音声データを取得します。

Media Foundation を使うので 前回 と同様に MFStartup で初期化、終了時に MFShutdown で解放をします。

MFStartup(MF_VERSION, 0);
MFShutdown();

ソースを開く

サンプルの取得は IMFSourceReader インターフェースから行いますが、ソースを開いてこのインターフェースを開くのに通常は MFCreateSourceReaderFromURL 関数を用います。 URL となっていますがファイルも開けます。

com_ptr<IMFSourceReader> source_reader;
MFCreateSourceReaderFromURL(url, nullptr, &source_reader);

第 2 パラメーターである pAttributes は指定をしない場合は nullptr で OK です。成功すれば開いたソースに対する IMFSourceReader が取得できます。

フォーマット情報を確定する

前回の ToplogyNode を使用した実装ではほぼ全て Media Foundation にお任せだったため何もしないで音が出ましたが、今回は音声の出力は自身で行うためフォーマットは自身で取り扱う必要があります。

Media Foundation では各種リソースのメタデータは全て Media Type (IMFMediaType) で取り扱います。

com_ptr<IMFMediaType> media_type;

MFCreateMediaType(&media_type);
media_type->SetGUID(MF_MT_MAJOR_TYPE, MFMediaType_Audio);
media_type->SetGUID(MF_MT_SUBTYPE, MFAudioFormat_PCM);
source_reader->SetCurrentMediaType(MF_SOURCE_READER_FIRST_AUDIO_STREAM, nullptr, media_type.Get());

ソースを開いた段階では各ストリームにソースのフォーマットが設定されています。その状態でのサンプル取得はもちろん可能ですが、少なくともデコードした状態でのサンプル取得はしたいところです。そのためデコードされたサンプルが取得できるように Media Type を設定します。

まず空の Media Type を生成し、Major Type と Sub Type に Audio/PCM を設定します。
そして、その Media Type を対象とするストリームに設定をします。

IMFSourceReader::SetCurrentMediaType の第一引数は対象とするストリーム番号を指定しますが、 MF_SOURCE_READER_FIRST_AUDIO_STREAM を指定すると音声ストリームを選択することになります (同様に MF_SOURCE_READER_FIRST_VIDEO_STREAM もあります) 。映像/音声ストリームが 2 つ以上あるマルチストリームソースの場合、ストリームを列挙して Major Type の確認が必要になりますが、大抵の場合は映像と音声が一つずつですし、マルチストリームでも 2 番目以降は無視してよいケースであれば MF_SOURCE_READER_FIRST_AUDIO_STREAM を用いて問題ありません。

media_type.Reset();
source_reader->GetCurrentMediaType(MF_SOURCE_READER_FIRST_AUDIO_STREAM, &media_type);

セットした Media Type を取得し直しをします。上記で Media Type をセットした際はフォーマット情報 (サンプリングレートなど) の指定はしていませんでした。セットしたことにより Media Type にデフォルトのフォーマットが反映されたので、それを取得し直します。

WAVEFORMATEXTENSIBLE wfex;
WAVEFORMATEXTENSIBLE* wfex_temp;

MFCreateWaveFormatExFromMFMediaType(media_type.Get(), reinterpret_cast<WAVEFORMATEX**>(&wfex_temp), nullptr, MFWaveFormatExConvertFlag_ForceExtensible);
wfex = *wfex_temp;
CoTaskMemFree(wfex_temp);

Media Type には独自の形でフォーマット情報が設定されています。 MFCreateWaveFormatExFromMFMediaType 関数を使用することで扱いやすい WAVEFORMATEX 形式の情報で取得できます。確保しておいた WAVEFORMATEX のバッファに書き込みがされるのではなく、情報の入った WAVEFORMATEX のポインターを返す形なので、利用後は CoTaskMemFree で解放する必要があります。そのため、取得直後にローカルにコピーして解放してしまいます。

また、 MFWaveFormatExConvertFlag_ForceExtensible をつけて常に WAVEFORMATEXTENSIBLE で取得するようにしています。

サンプルを取得する

サンプル取得は IMFSourceReader::ReadSample メソッドで行います。

com_ptr<IMFSample> sample;
DWORD stream_flags;
DWORD buffer_count;

source_reader->ReadSample(MF_SOURCE_READER_FIRST_AUDIO_STREAM, 0, nullptr, &stream_flags, nullptr, &sample);
if (sample != nullptr && SUCCEEDED(sample->GetBufferCount(&buffer_count)) && buffer_count > 0) {
    com_ptr<IMFMediaBuffer> buffer;
    if (SUCCEEDED(sample->GetBufferByIndex(0, &buffer))) {
        const std::uint8_t* locked_buffer;
        DWORD max_length;
        DWORD current_length;

        buffer->Lock(const_cast<std::uint8_t**>(&locked_buffer), &max_length, &current_length);
        // データ取得
        buffer->Unlock();
    }
}

ReadSample を呼ぶと現在サンプルが取得され、続けて呼ぶと次のサンプル (IMFSample) を取得します。

サンプルの終端の位置に到達すると pdwStreamFlags (上記例では stream_flags) に MF_SOURCE_READERF_ENDOFSTREAM がセットされるので、このフラグで終了判定ができます。

実際のデータは IMFSample が内部で持つ IMFMediaBuffer にあるので、 IMFSample::GetBufferByIndex で取得します。

実データへは IMFMediaBuffer::Lock から IMFMediaBuffer::Unlock をしている間のみ Lock メソッドで取得したポインターからアクセスする事ができます。この系の API ではよくある形態ですが、論理的には実際のデータは通常はどこに存在しているのかあやふやで、 Lock している間のみ具体的にアクセスができる、というものです。 GPU 上にデータがある場合などを想定しているためです。なので Lock している時間は極力短い方が望ましいです。

XAudio2 + Media Foundation で実際にストリーミング再生をしてみる

ではこれらの事を踏まえて実際にストリーミング再生をするコードを書いてみます。

クラス図で書くとこんな感じになります。なお、 play_buffer は concept なので下記の図は正しい表現とは言い難いのですが、意図としては近いのでご理解ください。 "play_buffer concept を満たしたものが play_buffer_mf_locked" です。

xaudio_player_core クラスは XAudio2 でストリーミング再生をする処理を実装したクラスで再生するソースは依存しない形になっています。 xaudio_player_mf クラスは xaudio_player_core を継承し、ソースを Media Foundation Source Reader 特化した実装となっています。

xaudio_player_core

ストリーミング再生

IXAudio2VoiceCallback::OnVoiceProcessingPassStart のタイミングで指定のコールバックを呼び、その中でサンプル取得、 submit_audio_data する、としています。

バッファ管理

今回は再生バッファ管理を Source Reader の仕組みで行うようにしています。

まず音声データを登録する submit_audio_data メソッドですが要点を絞ると次のように実装しています。

HRESULT submit_audio_data(T&& audio_data) {
    _buffers.push_back(std::move(audio_data));
    const auto it = _buffers.rbegin();

    XAUDIO2_BUFFER buffer = {};
    buffer.pAudioData = it->get_audio_data();
    buffer.AudioBytes = it->get_audio_bytes();
    buffer.pContext = const_cast<std::uint8_t*>(buffer.pAudioData);

    _source_voice->SubmitSourceBuffer(&buffer);
    return S_OK;
}

audio_data は play_buffer concept を実装したデータです。

ポイントは

  • audio_data をそのまま vector に格納する
  • pContext に実データへのポインター (get_audio_data() の値) を指定する

としているところです。

これに対して IXAudio2VoiceCallback::OnBufferEnd の実装では

void STDMETHODCALLTYPE OnBufferEnd(void* pBufferContext) override
{
    for (auto itr = _buffers.begin(); itr != _buffers.end(); itr++) {
        if (pBufferContext == itr->get_audio_data()) {
            _buffers.erase(itr);
            break;
        }
    }
}

コールバックで渡された Context と get_audio_data() と一致する要素を vector から削除します。このようにすることで音声データを維持する期間を正確に管理できるのと、音声データの後始末の方法を play_buffer concept 実装クラスに一任することができるようになっています。つまり固定的なリングバッファの一部として play_buffer を定義する (メモリ解放を都度しない) 事や再生時に解放する (あるいはメモリプールに戻す) play_buffer にすることのどちらにも対応できるようにしています。

xaudio_player_mf

xaudio_player_mf は Media Foundation に特化したクラスですが、 xaudio_player_core を継承するのではなく内包する形にしています。

play_buffer_mf_locked

再生バッファは IMFMediaBuffer をそのまま使用することにしました。セオリーとしては別途確保したリングバッファーにコピーすべきなのですが、

  • Media Foundation に特化している
  • 長期間保持するわけではない
  • メモリコピーを省略できる

あたりの理由からこうしています。

play_buffer 実装の play_buffer_mf_locked では IMFMediaBuffer をそのまま保持しているので IXAudio2VoiceCallback::OnBufferEnd 時にデストラクタで解放されます。

また、前述の通り IMFMediaBuffer は Lock しないとポインターにアクセスできないので play_buffer_mf_locked が有効な間は Lock したままになっています (あまり望ましいやり方ではないとは思いますが) 。

HRESULT play_buffer_mf_locked::create(IMFMediaBuffer* buffer, play_buffer_mf_locked& ret)
{
    DWORD max_length;
    DWORD current_length;
    buffer->Lock(const_cast<std::uint8_t**>(&ret._locked_buffer), &max_length, &current_length);
    ret._locked_bytes = current_length;
    ret._buffer = buffer;
    return S_OK;
}

play_buffer_mf_locked::~play_buffer_mf_locked() noexcept
{
    unlock();
}

音声データの投入

IXAudio2VoiceCallback::OnVoiceProcessingPassStart のタイミングで IMFSourceReader::ReadSample で取得したサンプル内のデータを投入します。ここはこれまでの積み重ねになります。

com_ptr<IMFSourceReader> source_reader;
unsigned int bytes_required;

if (bytes_required < 1) {
    return false;
}
com_ptr<IMFSample> sample;
DWORD stream_flags;
auto last_bytes = bytes_required;
while (true) {
    if (SUCCEEDED(source_reader->ReadSample(stream_index, 0, nullptr, &stream_flags, nullptr, &sample))) {
        DWORD buffer_count;
        if (sample != nullptr && SUCCEEDED(sample->GetBufferCount(&buffer_count)) && buffer_count > 0) {
            com_ptr<IMFMediaBuffer> buffer;
            if (SUCCEEDED(sample->GetBufferByIndex(0, &buffer))) {
                play_buffer_mf_locked locked;
                if (SUCCEEDED(play_buffer_mf_locked::create(buffer.Get(), locked))) {
                    auto bytes = locked.get_audio_bytes();
                    core.submit_audio_data(std::move(locked));
                    if (bytes >= last_bytes) {
                        break;
                    }
                    last_bytes -= bytes;
                }
            }
        }
    }
}
  1. IMFSourceReader::ReadSample でサンプル取得
  2. IMFSample::GetBufferByIndex でバッファ取得
  3. play_buffer_mf_locked::create で IMFMediaBuffer を play_buffer_mf_locked にする
  4. xaudio_player_core::submit_audio_data で音声データ登録

おわりに

Media Foundation と XAudio2 を使用して音声ストリーミング再生の方法を網羅的にまとめました。
他の API を使用しても概念としては似てくると思いますので、何かしらの手法を理解すると他でも応用はきくのではないかと思います。

Discussion