UE4 C++ イントロ付きループをoggを直接ストリーミングしてできるようにしてみる

# はじめに
UE4では標準機能でイントロ付きループができない。うん、非常に困りますよね。
対策として主に下記があげられます。

* ADX2/ADX2LEを導入
* SoundCueのConcatenatorやDelay+Mixerでそれっぽくする
* TimeSynthで頑張る

**ADX2/ADX2LE**
ADX2/ADX2LEは非常に強力なミドルウェアですが、ただイントロ付きループをやるだけのために入れるのはちょっと…という方は一定数いると思ってます。
参考：
https://game.criware.jp/products/adx2-le/

**SoundCueのConcatenatorやDelay+Mixer**
SoundCueで頑張るのは職人芸的なスキルが求められます。PAUSEを挟んだり、フォーカスを移動してtickの間隔が空くとどうしても音の接続がきれいにいかず僕は諦めました。
参考：
https://qiita.com/unknown_ds/items/a029a578418ce95cf091

**TimeSynthで頑張る**
TimeSynthは現状エンジンの標準機能で行うには最良の選択に思えます。が、サウンドファイルの分割やBPMの指定（可変の場合は特に）などが若干手間になりそうな印象を受けます。
参考：
https://pafuhana1213.hatenablog.com/entry/2019/12/28/170856

# なのでプラグインを作りました
https://github.com/ishin4762/ue4-simple-ogg-player

oggファイルのパスと、開始サンプル点、ループするサンプル数を書くだけ。これなら簡単でしょ？
![image1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/293957/c3f580af-28fa-1364-dfc7-15d095cf76ae.png)

# 実装にあたって
最初はUSoundWaveの再生終了時のイベントが来たら即時に次の音に切り替えるようにUSoundNodeConcatenatorを改造するとかやってたんですが、USoundNode::ParseNodes()が呼ばれるタイミングがGameThreadのTickに依存してることがわかり、色々頑張っても難しく挫折しかけました。

が、エンジンのソース漁ってみると、FVorbisAudioInfoなるoggの再生に使えそうなクラスを発見。実装を見てみるとoggのデコード処理が書かれていて、これを自前でストリームすればなんとかなりそう！とあたりをつけました。

**FVorbisAudioInfoの使えそうなメソッド**

* ReadCompressedInfo( const uint8* InSrcBufferData, uint32 InSrcBufferDataSize, struct FSoundQualityInfo* QualityInfo )
* ReadCompressedData( uint8* InDestination, bool bLooping, uint32 BufferSize )
* SeekToTime( const float SeekTime )


で、oggのストリームができたとしてもプロシージャルな再生ができないと厳しいよなと思いつつ、いろいろ検索を書けると下記のForumで気になる情報が。
https://forums.unrealengine.com/development-discussion/c-gameplay-programming/29868-how-to-play-raw-pcm-audio

>I'd be interested in any project example you have that can use USoundWaveProcedural (renamed in 4.9 from USoundWaveStreaming) ?
I'm trying to create a procedural audio source too 

USoundWaveProcedural! なんかいかにも名前。今度はこれで検索してみました。
https://forums.unrealengine.com/unreal-engine/feedback-for-epic/93583-stop-using-private-variables-in-virtual-functions-please
>OnSoundWaveProceduralUnderflow = FOnSoundWaveProceduralUnderflow::CreateUObject(this, &UTestSoundWaveProcedural::GenerateAudioData);

OnSoundWaveProceduralUnderflowでデータが不足した時にAudioDataを生成するCallback関数を与えてやれそうに見えますね。
これで材料はそろったので作り始めました。

# 実装
SoundCueで使えた方がなにかと利便性が高そうなので、USoundNodeのサブクラスとして作る方針で進めました。

## ヘッダ
```c++:HeaderFile
/**
 * USoundNodeOggPlayer
 */
UCLASS(hidecategories = Object, editinlinenew, meta = (DisplayName = "OggPlayer"))
class USoundNodeOggPlayer : public USoundNode
{
	GENERATED_UCLASS_BODY()

	// Ogg relative file path from Content Dir.
	UPROPERTY(EditAnywhere, Category = OggPlayer)
		FString OggFilePath;

	UPROPERTY(EditAnywhere, Category = OggPlayer)
		int64 LoopStart;

	UPROPERTY(EditAnywhere, Category = OggPlayer)
		int64 LoopLength;

private:
	TUniquePtr<IFileHandle> FileHandle;
	TUniquePtr<FVorbisAudioInfo> VorbisAudioInfo;
	FSoundQualityInfo QualityInfo;
	TArray<uint8> CompressedFile;
	TArray<uint8> DecodedBuffer;
	USoundWaveProcedural* SoundWaveProcedural;

	bool bIsInitialized;
	int32 BufferSize;
	int64 CurrentPosition;

private:
	// Internal Functions
	void Initialize();
	void GenerateData(USoundWaveProcedural* InProceduralWave, int32 SamplesRequested);

public:
	//~ Begin USoundNode Interface
	virtual int32 GetMaxChildNodes() const override;
	virtual float GetDuration() override;
	virtual bool IsPlayWhenSilent() const override;
	virtual int32 GetNumSounds(const UPTRINT NodeWaveInstanceHash, FActiveSound& ActiveSound) const { return 1; }
	virtual void ParseNodes(FAudioDevice* AudioDevice, const UPTRINT NodeWaveInstanceHash, FActiveSound& ActiveSound, const FSoundParseParameters& ParseParams, TArray<FWaveInstance*>& WaveInstances) override;
	//~ End USoundNode Interface.
};
```

## ParseNodes()
USoundNodeの仕組みの理解が中途半端なのもありoggファイルを読み込む適切なタイミングが分からなかったので、ParseNodes()の初回時にファイルを読み込むことにしました。他のUSoundNode系の処理を見るに、*RequiresInitializationが真の場合は初期化が必要なタイミングらしいので、このタイミングでogg読み込みやUSoundWaveProceduralの初期化を行うようにしました。
初期化が完了していたら、USoundWaveProcedural->Parse()でUSoundWaveProceduralの音を鳴らすようにします。

```c++:ParseNodes
void USoundNodeOggPlayer::ParseNodes(FAudioDevice* AudioDevice, const UPTRINT NodeWaveInstanceHash, FActiveSound& ActiveSound, const FSoundParseParameters& ParseParams, TArray<FWaveInstance*>& WaveInstances)
{
	RETRIEVE_SOUNDNODE_PAYLOAD(sizeof(int32));
	DECLARE_SOUNDNODE_ELEMENT(int32, tempInt);

	if (*RequiresInitialization)
	{
		Initialize();
		*RequiresInitialization = false;
	}

	if (!bIsInitialized)
	{
		return;
	}

	if (!VorbisAudioInfo.IsValid())
	{
		return;
	}

	if (!SoundWaveProcedural)
	{
		return;
	}

	FSoundParseParameters UpdatedParams = ParseParams;
	SoundWaveProcedural->Parse(AudioDevice, NodeWaveInstanceHash, ActiveSound, UpdatedParams, WaveInstances);
```

## Initialize()

初期化処理。まずはoggファイルのデータを読み込み、FVorbisAudioInfo::ReadCompressedInfo()にoggのデータを渡します。こうすると、以降、FVorbisAudioInfo::ReadCompressedData()をすると、oggからRawPCMDataが取得できるようになります。

```c++:Initialize
void USoundNodeOggPlayer::Initialize()
{
	QualityInfo = { 0 };
	bIsInitialized = false;

	// Open ogg file
	IPlatformFile& PlatformFile = FPlatformFileManager::Get().GetPlatformFile();
	FString filePath = FPaths::ProjectContentDir() + OggFilePath;
	FileHandle.Reset(PlatformFile.OpenRead(*filePath));
	if (FileHandle.IsValid())
	{
		// Initialize vorbis audio info.
		int64 FileSize = FileHandle->Size();
		CompressedFile.Reset();
		CompressedFile.AddUninitialized(FileSize);
		FileHandle->Read(CompressedFile.GetData(), FileSize);
		VorbisAudioInfo.Reset(new FVorbisAudioInfo());
		VorbisAudioInfo->ReadCompressedInfo(CompressedFile.GetData(), FileSize, &QualityInfo);
```

つづいて、各種パラメータを初期化します。
DecodedBufferはFVorbisAudioInfo::ReadCompressedData()から読み取った一時データを格納するためのバッファ。
BufferSizeはDecodedBufferのサイズ。
CurrentPositionは現在のoggの位置(サンプル数)を示します。

```c++:Initialize
		// Initialize internal params
		DecodedBuffer.Reset(0);
		BufferSize = 0;
		CurrentPosition = 0;
```

今度はUSoundWaveProceduralを初期化。
USoundWaveProcedural::OnSoundWaveProceduralUnderflowには、再生に必要なバッファが足りない場合に呼ばれるコールバック関数を設定。ここではGenerateDataというメソッドを指定しておきます。

```c++:Initialize
		// Initialize USoundWaveProcedural
		SoundWaveProcedural = NewObject<USoundWaveProcedural>();
		SoundWaveProcedural->SetSampleRate(QualityInfo.SampleRate);
		SoundWaveProcedural->NumChannels = QualityInfo.NumChannels;
		SoundWaveProcedural->SampleByteSize = sizeof(int16);
		SoundWaveProcedural->ResetAudio();
		SoundWaveProcedural->OnSoundWaveProceduralUnderflow =
			FOnSoundWaveProceduralUnderflow::CreateUObject(this, &USoundNodeOggPlayer::GenerateData);

		bIsInitialized = true;
	}
```

## GenerateData

再生に必要なバッファが足りない場合に呼ばれるメソッド。ここがストリーミング処理の要です。
デコードしたRawPCMDataをUSoundWaveProceduralに追加してあげることでストリーム再生が可能。
ここで、oggのデコードする位置がループ終了地点を超えていたらループ開始点に戻すようにしてあげることで、oggのイントロ付きループができるというわけです。
FVorbisAudioInfo::ReadCompressedData()がoggからRawPCMDataを読み取る処理。
FVorbisAudioInfo::SeekToTime()がシーク。ループ開始点に戻すために利用。
UProceduralWave::QueueAudio()でデコードしたRawPCMDataをプロシージャル音源に追加してあげることで、ストリーム再生ができます。
引数のSamplesRequestedはどうもチャンネル数込みのサンプル数のようなので、あちこちにチャンネル数で割る処理が入ってます。

```c++:GenerateData
void USoundNodeOggPlayer::GenerateData(USoundWaveProcedural* InProceduralWave, int32 SamplesRequested)
{
	int32 SampleByteSize = InProceduralWave->SampleByteSize;
	int32 NumChannels = SoundWaveProcedural->NumChannels;
	int32 BufferSizeRequested = SamplesRequested * SampleByteSize;

	// Acquire buffer memory
	if (BufferSize < BufferSizeRequested)
	{
		BufferSize = BufferSizeRequested;
		DecodedBuffer.Reset(BufferSize);
	}

	// Loop check
	int32 WriteSamples = SamplesRequested / NumChannels;
	int32 BufferOffset = 0;
	if (LoopStart >= 0 && LoopLength > 0
		&& CurrentPosition + WriteSamples > LoopStart + LoopLength)
	{
		// Decode ogg before loop end point
		WriteSamples = LoopStart + LoopLength - CurrentPosition;
		int32 WriteBufferSize = WriteSamples * NumChannels * SampleByteSize;
		VorbisAudioInfo->ReadCompressedData(DecodedBuffer.GetData(), false, WriteBufferSize);

		// Set position to loop start point
		CurrentPosition = LoopStart;
		VorbisAudioInfo->SeekToTime((float)CurrentPosition / QualityInfo.SampleRate);

		// Calc rest size
		WriteSamples = SamplesRequested / NumChannels - WriteSamples;
		BufferOffset = WriteBufferSize;
	}

	// Decode ogg
	int32 WriteBufferSize = WriteSamples * NumChannels * SampleByteSize;
	VorbisAudioInfo->ReadCompressedData(DecodedBuffer.GetData() + BufferOffset, false, WriteBufferSize);
	InProceduralWave->QueueAudio(DecodedBuffer.GetData(), BufferSizeRequested);

	// Move position
	CurrentPosition += WriteSamples;
}
```

# おわりに
機能がしょぼいのと、メモリ管理まわりがやや怪しい所はありますが、結構有用なプラグインができたのではと思います。
バグがあったり、もっとよい実装があれば、issueやPull Request等々ぜひぜひご指摘願います。