Initial audio subystem for music testing and first song to loop over

This commit is contained in:
2026-10-13 17:21:21 +02:00
parent adc5756866
commit 31164687c8
9 changed files with 656 additions and 0 deletions

View File

@@ -0,0 +1,407 @@
// Fill out your copyright notice in the Description page of Project Settings.
#include "EleriAudioSubsystem.h"
#include "Components/AudioComponent.h"
#include "Engine/GameInstance.h"
#include "Engine/World.h"
#include "Kismet/GameplayStatics.h"
#include "ProjectEleri/GameEventSystem/GameEventDataAsset.h"
#include "ProjectEleri/Settings/EleriGameSettings.h"
#include "Sound/SoundBase.h"
#include "Sound/SoundClass.h"
#include "TimerManager.h"
void UEleriAudioSubsystem::Initialize(FSubsystemCollectionBase& Collection)
{
Super::Initialize(Collection);
// The rotation starts on its own, so that a level does not have to remember to ask for music. Safe
// here because a game instance is handed the world it will run in before its subsystems are
// initialised, so GetWorld() already answers with the world the music is going to live in.
if (UWorld* World = GetWorld())
{
World->GetTimerManager().SetTimer(SoundtrackTimerHandle, this, &UEleriAudioSubsystem::StartDefaultMusic, 1.f, false);
}
}
void UEleriAudioSubsystem::Deinitialize()
{
if (UWorld* World = GetWorld())
{
World->GetTimerManager().ClearTimer(SoundtrackTimerHandle);
}
if (UAudioComponent* Component = MusicComponent)
{
Component->OnAudioFinished.RemoveDynamic(this, &UEleriAudioSubsystem::HandleMusicFinished);
Component->Stop();
}
Super::Deinitialize();
}
UEleriAudioSubsystem* UEleriAudioSubsystem::Get(const UObject* WorldContext)
{
if (WorldContext == nullptr || WorldContext->GetWorld() == nullptr)
{
return nullptr;
}
if (UGameInstance* GameInstance = WorldContext->GetWorld()->GetGameInstance())
{
return GameInstance->GetSubsystem<UEleriAudioSubsystem>();
}
return nullptr;
}
void UEleriAudioSubsystem::PlayMusic(USoundBase* Cue, float FadeTime)
{
if (Cue == nullptr)
{
StopMusic(FadeTime);
return;
}
// An explicit request outranks whatever an event had put on, so the override is dropped and the
// track the caller named becomes the one that plays.
MusicOverride = nullptr;
CancelPendingSoundtrack();
PlayMusicInternal(Cue, FadeTime);
}
void UEleriAudioSubsystem::OverrideMusic(USoundBase* Cue, float FadeTime)
{
if (Cue == nullptr)
{
ClearMusicOverride(FadeTime);
return;
}
// Recorded before the track starts, so that a track which finishes at once, a short one-shot for
// instance, cannot be mistaken for the rotation's own and have the next one queued over it.
MusicOverride = Cue;
CancelPendingSoundtrack();
PlayMusicInternal(Cue, FadeTime);
}
void UEleriAudioSubsystem::ClearMusicOverride(float FadeTime)
{
if (MusicOverride == nullptr)
{
return;
}
MusicOverride = nullptr;
CancelPendingSoundtrack();
// Fades the event's track out and a fresh rotation track in, through the same crossfade any other
// music change uses, so coming back from an override sounds like every other track change.
PlayMusicInternal(PickNextDefaultSoundtrack(), FadeTime);
}
void UEleriAudioSubsystem::PlayMusicForEvent(const UGameEventDataAsset* Event)
{
if (Event == nullptr || Event->MusicCue.IsNull())
{
return;
}
if (!Event->MusicCue.IsValid())
{
Event->MusicCue.LoadAsync(FLoadSoftObjectPathAsyncDelegate::CreateWeakLambda(this, [this, Event](const FSoftObjectPath& Path, UObject* Object)
{
PlayMusicForEvent(Event);
}));
return;
}
OverrideMusic(Event->MusicCue.Get());
}
void UEleriAudioSubsystem::StopMusic(float FadeTime)
{
CancelPendingSoundtrack();
MusicOverride = nullptr;
if (UAudioComponent* Old = MusicComponent)
{
// Detached first: the fade-out will finish the component off, and that finish must not be read
// as the current track playing out and put a new one on.
Old->OnAudioFinished.RemoveDynamic(this, &UEleriAudioSubsystem::HandleMusicFinished);
const float EffectiveFade = FadeTime >= 0.f ? FadeTime : GetMusicFadeTime();
if (EffectiveFade > 0.f)
{
Old->FadeOut(EffectiveFade, 0.f);
Old->StopDelayed(EffectiveFade);
}
else
{
Old->Stop();
}
}
MusicComponent = nullptr;
CurrentMusic = nullptr;
OnMusicChanged.Broadcast(nullptr);
}
void UEleriAudioSubsystem::PlaySfx(USoundBase* Cue, float VolumeScale, float Pitch)
{
if (Cue == nullptr)
{
return;
}
// Created rather than spawned for the same reason the music is: the sfx channel's class has to be
// on the component before it plays, not after. The component destroys itself when the cue is done,
// which is the whole lifetime an sfx needs.
UAudioComponent* Component = UGameplayStatics::CreateSound2D(this, Cue, GetEffectiveVolume(EEleriAudioChannel::SFX) * VolumeScale, Pitch);
if (Component == nullptr)
{
return;
}
Component->bIsUISound = false;
Component->SoundClassOverride = GetRoutingSoundClass(EEleriAudioChannel::SFX);
Component->Play();
}
float UEleriAudioSubsystem::GetChannelVolume(EEleriAudioChannel Channel) const
{
switch (Channel)
{
case EEleriAudioChannel::Master:
return MasterVolume;
case EEleriAudioChannel::Music:
return MusicVolume;
case EEleriAudioChannel::SFX:
return SfxVolume;
default:
return 1.f;
}
}
void UEleriAudioSubsystem::SetChannelVolume(EEleriAudioChannel Channel, float Volume)
{
const float Clamped = FMath::Clamp(Volume, 0.f, 1.f);
switch (Channel)
{
case EEleriAudioChannel::Master:
MasterVolume = Clamped;
break;
case EEleriAudioChannel::Music:
MusicVolume = Clamped;
break;
case EEleriAudioChannel::SFX:
SfxVolume = Clamped;
break;
default:
break;
}
// Music already playing is retargeted straight away, because the multiplier is the level a fade
// moves towards and not something a fade would have to be restarted to change. Sfx already in
// flight is left alone: it is a one-shot and will be gone before the change could be heard.
if (UAudioComponent* Component = MusicComponent)
{
Component->SetVolumeMultiplier(GetEffectiveVolume(EEleriAudioChannel::Music));
}
}
float UEleriAudioSubsystem::GetEffectiveVolume(EEleriAudioChannel Channel) const
{
// Master multiplies rather than replaces, so a cue plays at Master * channel and the getters above
// stay the plain per-channel numbers a menu would show rather than already-folded ones.
if (Channel == EEleriAudioChannel::Master)
{
return MasterVolume;
}
return GetChannelVolume(Channel) * MasterVolume;
}
USoundClass* UEleriAudioSubsystem::GetRoutingSoundClass(EEleriAudioChannel Channel) const
{
const UEleriGameSettings* Settings = GetDefault<UEleriGameSettings>();
if (Settings == nullptr)
{
return nullptr;
}
if (const TSoftObjectPtr<USoundClass>* Found = Settings->AudioChannelSoundClasses.Find(Channel))
{
return Found->LoadSynchronous();
}
return nullptr;
}
float UEleriAudioSubsystem::GetMusicFadeTime() const
{
const UEleriGameSettings* Settings = GetDefault<UEleriGameSettings>();
return Settings != nullptr ? FMath::Max(0.f, Settings->MusicFadeTime) : 2.f;
}
float UEleriAudioSubsystem::GetNextSoundtrackDelay() const
{
const UEleriGameSettings* Settings = GetDefault<UEleriGameSettings>();
if (Settings == nullptr)
{
return 0.f;
}
// A range rather than one number, so the gaps between tracks do not settle into a rhythm the ear
// can predict and start listening for instead of the music.
const float Low = FMath::Min(Settings->SoundtrackMinDelay, Settings->SoundtrackMaxDelay);
const float High = FMath::Max(Settings->SoundtrackMinDelay, Settings->SoundtrackMaxDelay);
return FMath::FRandRange(Low, High);
}
void UEleriAudioSubsystem::StartDefaultMusic()
{
if (MusicOverride != nullptr)
{
return;
}
PlayMusicInternal(PickNextDefaultSoundtrack(), GetMusicFadeTime());
}
USoundBase* UEleriAudioSubsystem::PickNextDefaultSoundtrack() const
{
const UEleriGameSettings* Settings = GetDefault<UEleriGameSettings>();
if (Settings == nullptr)
{
return nullptr;
}
// Both pools are built so that a soundtrack of one track still loops: leaving the current pick out
// keeps the rotation from repeating itself, and when that would leave nothing to play the
// exclusion is dropped rather than the music.
TArray<USoundBase*> AllCues;
TArray<USoundBase*> OtherCues;
for (const TSoftObjectPtr<USoundBase>& SoftCue : Settings->DefaultSoundtracks)
{
if (USoundBase* Cue = SoftCue.LoadSynchronous())
{
AllCues.Add(Cue);
if (Cue != CurrentMusic)
{
OtherCues.Add(Cue);
}
}
}
const TArray<USoundBase*>& Pool = OtherCues.IsEmpty() ? AllCues : OtherCues;
if (Pool.IsEmpty())
{
return nullptr;
}
return Pool[FMath::RandRange(0, Pool.Num() - 1)];
}
void UEleriAudioSubsystem::CancelPendingSoundtrack()
{
if (UWorld* World = GetWorld())
{
World->GetTimerManager().ClearTimer(SoundtrackTimerHandle);
}
}
void UEleriAudioSubsystem::PlayMusicInternal(USoundBase* Cue, float FadeTime)
{
if (GetWorld() == nullptr)
{
return;
}
if (Cue == nullptr)
{
StopMusic(FadeTime);
return;
}
const float EffectiveFade = FadeTime >= 0.f ? FadeTime : GetMusicFadeTime();
// The outgoing track is detached before it is faded, so that its own finish, which the fade-out
// brings forward, cannot be read as the current track playing out and queue a new one over it.
if (UAudioComponent* Old = MusicComponent)
{
Old->OnAudioFinished.RemoveDynamic(this, &UEleriAudioSubsystem::HandleMusicFinished);
if (EffectiveFade > 0.f)
{
Old->FadeOut(EffectiveFade, 0.f);
Old->StopDelayed(EffectiveFade);
}
else
{
Old->Stop();
}
}
// Created rather than spawned so the routing can be set before a single sample is heard, and so
// the component is silent until the fade decides how loud it should fade up to.
UAudioComponent* NewComponent = UGameplayStatics::CreateSound2D(this, Cue, GetEffectiveVolume(EEleriAudioChannel::Music));
if (NewComponent == nullptr)
{
MusicComponent = nullptr;
CurrentMusic = nullptr;
return;
}
// The channel's class is what puts this cue where an artist would look for it in the mixer, and it
// is deliberately the override and not the cue's own class, so that everything played as music ends
// up under the music channel whatever it shipped with.
NewComponent->bIsUISound = false;
NewComponent->SoundClassOverride = GetRoutingSoundClass(EEleriAudioChannel::Music);
NewComponent->OnAudioFinished.AddDynamic(this, &UEleriAudioSubsystem::HandleMusicFinished);
MusicComponent = NewComponent;
CurrentMusic = Cue;
if (EffectiveFade > 0.f)
{
// Fades the fader and leaves the multiplier alone, so the level the setters keep in step with
// stays the level the fade moves towards rather than something the fade overwrites.
NewComponent->FadeIn(EffectiveFade, 1.f);
}
else
{
NewComponent->Play();
}
OnMusicChanged.Broadcast(Cue);
}
void UEleriAudioSubsystem::HandleMusicFinished()
{
if (MusicOverride != nullptr)
{
// The override belongs to whoever set it, so the rotation does not queue itself underneath it.
return;
}
// The gap is taken here, when the track has run out, rather than when it started, which is what
// makes it a gap between tracks and not a cut-off partway through a long one.
if (UWorld* World = GetWorld())
{
World->GetTimerManager().SetTimer(SoundtrackTimerHandle, this, &UEleriAudioSubsystem::StartDefaultMusic, GetNextSoundtrackDelay(), false);
}
else
{
StartDefaultMusic();
}
}

View File

@@ -0,0 +1,173 @@
// Fill out your copyright notice in the Description page of Project Settings.
#pragma once
#include "CoreMinimal.h"
#include "Subsystems/GameInstanceSubsystem.h"
#include "EleriAudioTypes.h"
#include "EleriAudioSubsystem.generated.h"
class UAudioComponent;
class UGameEventDataAsset;
class USoundBase;
class USoundClass;
/** Fired with the cue that is playing now, or null when the music has stopped. */
DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FOnMusicChanged, USoundBase*, Music);
/**
* The one place the game asks for sound to come out of.
*
* Cues are handed to a channel (Music or SFX) rather than played directly, and each channel's cues are
* routed through the sound class the project settings name for it. The subsystem owns one volume per
* channel plus a master that multiplies the rest, and it is the only thing that reads them, so a menu
* only ever has to talk to SetChannelVolume and the playing audio follows along.
*
* Music is more than a cue it was handed: when nothing has taken it over the subsystem rotates through
* the default soundtracks in the settings, drawing the next one at random after a random gap, and it
* remembers what is playing so a caller can ask. A gameplay event can take the music over with its own
* cues, and the change in either direction is crossfaded.
*/
UCLASS()
class PROJECTELERI_API UEleriAudioSubsystem : public UGameInstanceSubsystem
{
GENERATED_BODY()
public:
virtual void Initialize(FSubsystemCollectionBase& Collection) override;
virtual void Deinitialize() override;
/** The subsystem for the game instance behind WorldContext, or null when there is not one yet. */
static UEleriAudioSubsystem* Get(const UObject* WorldContext);
UPROPERTY(BlueprintAssignable, Category = "Eleri|Audio")
FOnMusicChanged OnMusicChanged;
// -- Music --
/**
* Plays Cue as the music, crossfading away whatever was on. A call here is a command, so it also
* drops any event override: the track the caller named is the one that plays. A null Cue is the
* same as StopMusic.
*/
UFUNCTION(BlueprintCallable, Category = "Eleri|Audio")
void PlayMusic(USoundBase* Cue, float FadeTime = 1.f);
/**
* Takes the music over with Cue, crossfading it in. The override stands until ClearMusicOverride,
* unlike PlayMusic, and the default rotation stays quiet underneath it for as long as it does. A
* null Cue is the same as ClearMusicOverride, so an event whose cues all failed to load can call
* this and get its own music back.
*/
UFUNCTION(BlueprintCallable, Category = "Eleri|Audio")
void OverrideMusic(USoundBase* Cue, float FadeTime = 1.f);
/** Hands the music back to the default rotation, crossfading a fresh track in over the override. */
UFUNCTION(BlueprintCallable, Category = "Eleri|Audio")
void ClearMusicOverride(float FadeTime = 1.f);
/** Overrides the music with one of the event's cues, picked at random. Does nothing when the event
* has no cues, which is the common case and is not an error. */
UFUNCTION(BlueprintCallable, Category = "Eleri|Audio")
void PlayMusicForEvent(const UGameEventDataAsset* Event);
/** Fades the music out and leaves it stopped, override and all. */
UFUNCTION(BlueprintCallable, Category = "Eleri|Audio")
void StopMusic(float FadeTime = 1.f);
UFUNCTION(BlueprintCallable, BlueprintPure, Category = "Eleri|Audio")
USoundBase* GetCurrentMusic() const { return CurrentMusic; }
UFUNCTION(BlueprintCallable, BlueprintPure, Category = "Eleri|Audio")
bool IsMusicOverridden() const { return MusicOverride != nullptr; }
// -- SFX --
/** Plays Cue on the SFX channel. VolumeScale is a per-call trim on top of the channel's volume,
* for a cue that wants to be quieter or louder than its neighbours without a settings entry. */
UFUNCTION(BlueprintCallable, Category = "Eleri|Audio")
void PlaySfx(USoundBase* Cue, float VolumeScale = 1.f, float Pitch = 1.f);
// -- Volumes --
/** The channel's own volume, before Master is folded in, which is the number a menu would show. */
UFUNCTION(BlueprintCallable, BlueprintPure, Category = "Eleri|Audio")
float GetChannelVolume(EEleriAudioChannel Channel) const;
/** Sets a channel's volume, clamped to 0..1. Not saved anywhere yet: it lasts for the session. */
UFUNCTION(BlueprintCallable, Category = "Eleri|Audio")
void SetChannelVolume(EEleriAudioChannel Channel, float Volume);
UFUNCTION(BlueprintCallable, BlueprintPure, Category = "Eleri|Audio")
float GetMasterVolume() const { return MasterVolume; }
UFUNCTION(BlueprintCallable, BlueprintPure, Category = "Eleri|Audio")
float GetMusicVolume() const { return MusicVolume; }
UFUNCTION(BlueprintCallable, BlueprintPure, Category = "Eleri|Audio")
float GetSfxVolume() const { return SfxVolume; }
protected:
/** Draws the next default soundtrack and plays it, unless an override owns the music. */
void StartDefaultMusic();
/** A random default soundtrack, avoiding the one playing now unless that would leave nothing to
* play, so a soundtrack of a single track still loops. Null when nothing is configured. */
USoundBase* PickNextDefaultSoundtrack() const;
/**
* The one that puts a cue on the music component: crossfades the old one out, routes the new one
* through the music channel's class and fades it in. Null Cue stops the music. Shared by every
* caller that changes the music so that none of them can change it differently.
*/
void PlayMusicInternal(USoundBase* Cue, float FadeTime);
/** Drops the pending next-track timer, which is what has to happen before an override, or a
* rotation track, takes the music somewhere the timer did not plan for. */
void CancelPendingSoundtrack();
/** Called by the current music component when its track plays out, so the rotation can queue the
* next one after the configured gap. */
UFUNCTION()
void HandleMusicFinished();
/** The post-Master volume a channel actually plays at. */
float GetEffectiveVolume(EEleriAudioChannel Channel) const;
/** The sound class a channel is routed through in the settings, or null when none is set, in
* which case the cue plays on whatever class it shipped with. */
USoundClass* GetRoutingSoundClass(EEleriAudioChannel Channel) const;
float GetMusicFadeTime() const;
float GetNextSoundtrackDelay() const;
/** The track playing now, or the one still fading out, whichever is newer. */
UPROPERTY()
TObjectPtr<UAudioComponent> MusicComponent;
/** What MusicComponent holds, kept apart from it because the component outlives nothing and the
* question "what is playing" has an answer even between components. */
UPROPERTY()
TObjectPtr<USoundBase> CurrentMusic;
/** Set only while a gameplay event has taken the music over. Non-null means the default rotation
* is held back and the music is this instead. */
UPROPERTY()
TObjectPtr<USoundBase> MusicOverride;
UPROPERTY()
FTimerHandle SoundtrackTimerHandle;
UPROPERTY()
float MasterVolume = 1.f;
UPROPERTY()
float MusicVolume = 1.f;
UPROPERTY()
float SfxVolume = 1.f;
};

View File

@@ -0,0 +1,24 @@
// Fill out your copyright notice in the Description page of Project Settings.
#pragma once
#include "CoreMinimal.h"
#include "EleriAudioTypes.generated.h"
/**
* The mixing channels every cue the game plays is routed through.
*
* The audio subsystem keeps one volume per channel and folds them into each other as Master * channel
* rather than replacing one with the other, so pulling Master down takes the whole game with it while
* a channel keeps its own setting. Which sound class each channel actually plays through is hooked up
* in the Eleri Game Settings; Master has none, because it scales the other two rather than carrying a
* sound of its own.
*/
UENUM(BlueprintType)
enum class EEleriAudioChannel : uint8
{
Master UMETA(DisplayName = "Master"),
Music UMETA(DisplayName = "Music"),
SFX UMETA(DisplayName = "SFX")
};

View File

@@ -13,6 +13,7 @@
class UTexture2D;
class USoundBase;
UENUM(BlueprintType)
enum class EEventStage : uint8
@@ -78,6 +79,12 @@ public:
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Data")
TArray<FTagValue> TagValueArray;
/** Soundtracks this event can put on. One is drawn at random and crossfaded in over whatever is
* playing already, and it holds the music until the override is cleared. An event with none
* leaves the music alone, which is what most of them will do. */
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Audio")
TSoftObjectPtr<USoundBase> MusicCue;
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Newspaper event")
bool bIsNewspaperEvent;
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Newspaper event", meta = (EditCondition = "bIsNewspaperEvent", EditConditionHides))

View File

@@ -6,6 +6,7 @@
#include "AssetRegistry/IAssetRegistry.h"
#include "Kismet/GameplayStatics.h"
#include "GameFramework/Character.h"
#include "ProjectEleri/Audio/EleriAudioSubsystem.h"
void UGameEventSubsystem::Initialize(FSubsystemCollectionBase &Collection)
{
@@ -59,6 +60,17 @@ bool UGameEventSubsystem::StartEvent(FSoftObjectPath EventPath)
EventData->EventStage = EEventStage::STARTED;
else
EventData->EventStage = EEventStage::COMPLETED;
// A starting event takes the music over with its own cues, when it has any. Done here rather
// than at whatever happens to start an event, so that every caller changes the music the same
// way and an event with no cues stays silent on the subject.
if (EventData->EventStage == EEventStage::STARTED)
{
if (UEleriAudioSubsystem* AudioSubsystem = UEleriAudioSubsystem::Get(this))
{
AudioSubsystem->PlayMusicForEvent(EventData->EventPtr);
}
}
}
return false;
}

View File

@@ -8,6 +8,7 @@
#include "Data/GameplayStatEffect.h"
#include "Templates/SubclassOf.h"
#include "UObject/SoftObjectPtr.h"
#include "ProjectEleri/Audio/EleriAudioTypes.h"
#include "EleriGameSettings.generated.h"
@@ -17,6 +18,8 @@ class UNpcDataAsset;
class UNpcScheduleAsset;
class UItemDataAsset;
class UGameplayEffect;
class USoundBase;
class USoundClass;
/**
*
@@ -56,6 +59,25 @@ public:
UPROPERTY(Config, EditAnywhere, BlueprintReadOnly, Category = "Materials")
TSoftObjectPtr<UMaterialInterface> SpriteShadowMaterial;
/** Which sound class each channel's cues are routed through when the audio subsystem plays them.
* Master has no entry because it carries no sound of its own, it scales the other two. */
UPROPERTY(Config, EditAnywhere, BlueprintReadOnly, Category = "Audio")
TMap<EEleriAudioChannel, TSoftObjectPtr<USoundClass>> AudioChannelSoundClasses;
/** The soundtracks the audio subsystem rotates through while no event has taken the music over. */
UPROPERTY(Config, EditAnywhere, BlueprintReadOnly, Category = "Audio")
TArray<TSoftObjectPtr<USoundBase>> DefaultSoundtracks;
/** Bounds of the random gap left between two default soundtracks, in seconds. */
UPROPERTY(Config, EditAnywhere, BlueprintReadOnly, Category = "Audio", meta = (ClampMin = "0.0"))
float SoundtrackMinDelay = 20.f;
UPROPERTY(Config, EditAnywhere, BlueprintReadOnly, Category = "Audio", meta = (ClampMin = "0.0"))
float SoundtrackMaxDelay = 60.f;
/** How long the audio subsystem takes to crossfade from one track to the next, in seconds. */
UPROPERTY(Config, EditAnywhere, BlueprintReadOnly, Category = "Audio", meta = (ClampMin = "0.0"))
float MusicFadeTime = 2.f;
public:
UFUNCTION(BlueprintCallable, BlueprintPure, Category = "Eleri Game Settingss")