はじめに
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
なのでプラグインを作りました
oggファイルのパスと、開始サンプル点、ループするサンプル数を書くだけ。これなら簡単でしょ?
実装にあたって
最初は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のサブクラスとして作る方針で進めました。
ヘッダ
/**
* 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の音を鳴らすようにします。
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が取得できるようになります。
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の位置(サンプル数)を示します。
// Initialize internal params
DecodedBuffer.Reset(0);
BufferSize = 0;
CurrentPosition = 0;
今度はUSoundWaveProceduralを初期化。
USoundWaveProcedural::OnSoundWaveProceduralUnderflowには、再生に必要なバッファが足りない場合に呼ばれるコールバック関数を設定。ここではGenerateDataというメソッドを指定しておきます。
// 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はどうもチャンネル数込みのサンプル数のようなので、あちこちにチャンネル数で割る処理が入ってます。
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等々ぜひぜひご指摘願います。