Unreal Audio Modulation not working when called from C++

Hello all,

I have been dealing with a strange issue where attempting to deal with Unreal Engine’s audio modulation is flat-out not working in C++.

In a custom game user settings class, I have an API to deal with audio settings, including changing the volume of different audio buses (master, music, sfx, dialogue, ui):

	/**********************************************************************************************/
	/* Audio Settings                                                                             */
	/**********************************************************************************************/

	/* @note(wpt) called on startup by subsystem */
	void InitAudioBusMix(const UObject* WorldContext) const;

	UFUNCTION(BlueprintCallable, Category = "Game User Settings | Audio", BlueprintPure = true)
	static float AudioSliderToLinearGain(float Slider01, float MinDB = -30.f, float MaxDB = 0.f);

	UFUNCTION(BlueprintCallable, Category = "Game User Settings | Audio", BlueprintPure = true)
	static FString AudioBusChannelToAddressFilter(ERefractAudioBusChannel Channel);

	UFUNCTION(BlueprintCallable, Category = "Game User Settings | Audio", BlueprintPure = false)
	void SetControlBusMixValue(ERefractAudioBusChannel Channel, const float LinearValue, const float FadeTime);

	UFUNCTION(BlueprintCallable, Category = "Game User Settings | Audio", BlueprintPure = false)
	void SetChannelVolume(ERefractAudioBusChannel Channel, float Volume01, float FadeSeconds = 0.05f);

	UFUNCTION(BlueprintCallable, Category = "Game User Settings | Audio", BlueprintPure = true)
	float GetChannelVolume(ERefractAudioBusChannel Channel);

	UFUNCTION(BlueprintCallable, Category = "Game User Settings | Audio", BlueprintPure = true)
	USoundControlBusMix* GetControlBusMasterMix() const;

	UFUNCTION(BlueprintCallable, Category = "Game User Settings | Audio", BlueprintPure = true)
	USoundControlBus* GetControlBusForChannel(ERefractAudioBusChannel Channel) const;

	UFUNCTION(BlueprintCallable, Category = "Game User Settings | Audio", BlueprintPure = false)
	void ApplyAllVolumes(float FadeSeconds = 0.05f);

	UFUNCTION(BlueprintCallable, Category = "Game User Settings | Audio", BlueprintPure = false)
	void ResetAllAudioToDefault(float FadeSeconds = 0.05f); /**< @note(wpt) Default is 1.0 volume on all channels. */

And in the implementation:

void URefractGameUserSettings::InitAudioBusMix(const UObject* WorldContext) const
{
	UAudioModulationStatics::ActivateBusMix(WorldContext, MasterMix);
}

float URefractGameUserSettings::AudioSliderToLinearGain(const float Slider01, const float MinDB, const float MaxDB)
{
	if (Slider01 <= KINDA_SMALL_NUMBER)
	{
		return 0.0f; /* @note(wpt) close enough to treat as mute (fp imprecision will probably ■■■■ us otherwise) */
	}
	const float ClampedMinDB = FMath::Min(MinDB, MaxDB); /* @note(wpt) in case some dumbass (me) mixes them up */
	const float DB = FMath::Lerp(ClampedMinDB, MaxDB, Slider01);
	return FMath::Pow(10.0f, DB / 20.0f);
}

FString URefractGameUserSettings::AudioBusChannelToAddressFilter(const ERefractAudioBusChannel Channel)
{
	switch (Channel)
	{
	case ERefractAudioBusChannel::Master:
		return TEXT("Master");
	case ERefractAudioBusChannel::Music:
		return TEXT("Music");
	case ERefractAudioBusChannel::SFX:
		return TEXT("SFX");
	case ERefractAudioBusChannel::Dialogue:
		return TEXT("Dialogue");
	case ERefractAudioBusChannel::UI:
		return TEXT("UI");
	default:
		checkf(false, TEXT("Forgot an audio bus channel?"));
		return TEXT("");
	}
}

void URefractGameUserSettings::SetControlBusMixValue(const ERefractAudioBusChannel Channel, const float LinearValue,
                                                     const float FadeTime)
{
	/* @note(wpt): would rather have these crash on the spot than go unnoticed */
	check(MasterMix);
	check(Buses.Contains(Channel) && Buses[Channel]);

	GEngine->AddOnScreenDebugMessage(INDEX_NONE, 5.f, FColor::Green, FString::Printf(TEXT("Updating control bus mix %s with Volume=%.2f"), *AudioBusChannelToAddressFilter(Channel), LinearValue));
	UAudioModulationStatics::UpdateMix(this, MasterMix, { UAudioModulationStatics::CreateBusMixStage(this, Buses[Channel], LinearValue, FadeTime, FadeTime) }, FadeTime);
	UAudioModulationStatics::UpdateMixFromObject(this, MasterMix, FadeTime);
}

void URefractGameUserSettings::AssignCurrentSettings()
{
	MasterVolume = ActiveVolumes[ERefractAudioBusChannel::Master];
	MusicVolume = ActiveVolumes[ERefractAudioBusChannel::Music];
	SfxVolume = ActiveVolumes[ERefractAudioBusChannel::SFX];
	DialogueVolume = ActiveVolumes[ERefractAudioBusChannel::Dialogue];
	UiVolume = ActiveVolumes[ERefractAudioBusChannel::UI];

	AntiAliasingMethod = static_cast<int32>(ActiveAntiAliasingMethod);
}

void URefractGameUserSettings::ResetCurrentSettings()
{
	ActiveVolumes[ERefractAudioBusChannel::Master] = MasterVolume;
	ActiveVolumes[ERefractAudioBusChannel::Music] = MusicVolume;
	ActiveVolumes[ERefractAudioBusChannel::SFX] = SfxVolume;
	ActiveVolumes[ERefractAudioBusChannel::Dialogue] = DialogueVolume;
	ActiveVolumes[ERefractAudioBusChannel::UI] = UiVolume;

	ActiveAntiAliasingMethod = static_cast<ERefractAntiAliasingMethod>(AntiAliasingMethod);

	PendingResolution = GetCurrentWindowResolution();
	PendingWindowMode = GetCurrentWindowMode();
}

void URefractGameUserSettings::SetChannelVolume(const ERefractAudioBusChannel Channel, const float Volume01,
                                                const float FadeSeconds)
{
	if (!MasterMix || !Buses.Contains(Channel))
	{
		return;
	}
	ActiveVolumes.FindOrAdd(Channel) = Volume01;
	//const float LinearGain = AudioSliderToLinearGain(Volume01);
	SetControlBusMixValue(Channel, Volume01, FadeSeconds);
}

float URefractGameUserSettings::GetChannelVolume(const ERefractAudioBusChannel Channel)
{
	if (const float* V = ActiveVolumes.Find(Channel))
	{
		return *V;
	}
	return 1.f;
}

USoundControlBusMix* URefractGameUserSettings::GetControlBusMasterMix() const
{
	return MasterMix;
}

USoundControlBus* URefractGameUserSettings::GetControlBusForChannel(const ERefractAudioBusChannel Channel) const
{
	return Buses.Contains(Channel) ? Buses[Channel] : nullptr;
}

void URefractGameUserSettings::ApplyAllVolumes(const float FadeSeconds)
{
	for (const TPair<ERefractAudioBusChannel, float>& Pair : ActiveVolumes)
	{
		if (Buses.Contains(Pair.Key))
		{
			const float Linear = AudioSliderToLinearGain(Pair.Value);
			SetControlBusMixValue(Pair.Key, Linear, FadeSeconds);
		}
	}
}

void URefractGameUserSettings::ResetAllAudioToDefault(const float FadeSeconds)
{
	for (TPair<ERefractAudioBusChannel, float>& Pair : ActiveVolumes)
	{
		Pair.Value = 1.0f;
	}

	/* apply to the live mix */
	TArray<ERefractAudioBusChannel> Channels;
	Buses.GetKeys(Channels);
	for (const auto& Channel : Channels)
	{
		const float Linear = AudioSliderToLinearGain(1.0f);
		SetControlBusMixValue(Channel, Linear, FadeSeconds);
	}
}

To initialize the control bus mix, I have a function in a Game Instance Subsystem that initializes it a frame after the game starts (since attempting to do so directly inside Initialize() would fail):

void URefractGameUserSettingsSubsystem::Initialize(FSubsystemCollectionBase& Collection)
{
	Super::Initialize(Collection);

	GetWorld()->GetTimerManager().SetTimerForNextTick(this, &ThisClass::ActuallyActivateBusMix);
}

void URefractGameUserSettingsSubsystem::ActuallyActivateBusMix() const
{
	URefractGameUserSettings::Get()->InitAudioBusMix(this);
}

How do I load the objects? I packaged this in a plugin which lets me place the Control Bus, Control Bus Mix, and Sound Class assets in the plugin’s content folder. I know the control buses and control bus mixes will a) always be loaded at runtime, and therefore b) never, ever, ever, for any reason whatsoever, be unloaded for any reason at all (unless you want audio settings to randomly stop working, which is just stupid), thus my strategy to load the assets is simply:

URefractGameUserSettings::URefractGameUserSettings(const FObjectInitializer& ObjectInitializer)
	: Super(ObjectInitializer)
    // other initializers, not important here...
{
	const ConstructorHelpers::FObjectFinder<USoundControlBusMix> MasterMixRef(
		TEXT("/RefractGameUserSettings/Audio/Mixes/Master.Master"));
	if (MasterMixRef.Succeeded()) MasterMix = MasterMixRef.Object;

	const ConstructorHelpers::FObjectFinder<USoundControlBus> MasterBusRef(
		TEXT("/RefractGameUserSettings/Audio/Buses/Master.Master"));
	if (MasterBusRef.Succeeded()) Buses.Add(ERefractAudioBusChannel::Master, MasterBusRef.Object);

	const ConstructorHelpers::FObjectFinder<USoundControlBus> MusicBusRef(
		TEXT("/RefractGameUserSettings/Audio/Buses/Music.Music"));
	if (MusicBusRef.Succeeded()) Buses.Add(ERefractAudioBusChannel::Music, MusicBusRef.Object);

	const ConstructorHelpers::FObjectFinder<USoundControlBus> SFXBusRef(
		TEXT("/RefractGameUserSettings/Audio/Buses/SFX.SFX"));
	if (SFXBusRef.Succeeded()) Buses.Add(ERefractAudioBusChannel::SFX, SFXBusRef.Object);

	const ConstructorHelpers::FObjectFinder<USoundControlBus> DialogueBusRef(
		TEXT("/RefractGameUserSettings/Audio/Buses/Dialogue.Dialogue"));
	if (DialogueBusRef.Succeeded()) Buses.Add(ERefractAudioBusChannel::Dialogue, DialogueBusRef.Object);

	const ConstructorHelpers::FObjectFinder<USoundControlBus> UIBusRef(
		TEXT("/RefractGameUserSettings/Audio/Buses/UI.UI"));
	if (UIBusRef.Succeeded()) Buses.Add(ERefractAudioBusChannel::UI, UIBusRef.Object);

	// Default slider values
	for (const auto& Pair : Buses)
	{
		ActiveVolumes.Add(Pair.Key, 1.0f);
	}
}

(All because I don’t want to manually set asset references and stuff.)

Well, when I call SetChannelVolume() with a channel that I know exists, a volume that I know is valid, and assets that I know exist… nothing happens!

So, because I got sick of dealing with it, I hacked together a workaround inside of a C++ widget, where I’d pass the information to a slider widget for my settings menu (including the exact Channel Bus and Channel Bus Mix asset that I loaded from the constructor!!!), and when I move the slider, I’d pass that to a blueprint implementable function and then set the control bus mix in Blueprints:

// widget header
	UFUNCTION(BlueprintImplementableEvent, Category = StupidFuckingHacks)
	void K2_OnValueChanged(float NewValue, ERefractAudioBusChannel Channel, USoundControlBus* Bus, USoundControlBusMix* MasterMix);

// widget impl. (this function is bound to the USlider's OnValueChanged delegate
bool URefractSettingsListEntryAudioVolume::OnValueChanged(const float NewValue)
{
	K2_OnValueChanged(NewValue, AudioBus, URefractGameUserSettings::Get()->GetControlBusForChannel(AudioBus), URefractGameUserSettings::Get()->GetControlBusMasterMix());
	//URefractGameUserSettings::Get()->SetChannelVolume(AudioBus, SnapToStep(NewValue));
	return true;
}

(SnapToStep() is a helper function that just snaps the slider value to the nearest “step”. I think it works because it does in my motion blur slider which is based on the same parent class, but maybe I should test it when calling the BP function! My bad.)

And then finally, in the widget blueprint derived from the C++ slider widget class, I implement the function:

This is the result I get:

  1. When I attempt to do it entirely in C++ (no call to K2_OnValueChanged, comment that out), it doesn’t work. Period. Nada, zip, zilch, complete dud.
  2. When I comment out the C++ call (URefractGameUserSettings::Get()→…), and call the K2_OnValueChanged() instead… it works? And prints the asset object names so therefore I know the assets are legit? And changes the volume? And it just… it just magically works?

What I am suspecting, is that I am using the C++ API wrong. Set Control Bus Mix in BP is UpdateMix() in C++. There are some extra calls - UpdateMixFromObject() for example - but regardless of whether that call is present or not, it still does nothing.

Not sure what I should do here! I will try debugging it but I have zero lead on what’s causing the issue so far… Audio Modulation seems obscenely under-documented in the C++ world which seems to leave me completely on my own regarding how to use it in C++, and that’s problematic because I’m trying to keep as much business logic in C++ as possible (simply a matter of preference, as I work much faster with C++).

(And, no, I don’t do all my asset references like that in the constructor, just simple global ones like the control buses and bus mix that I know I will never ever want to change, and don’t have the patience to hook up some UDeveloperSettings or other tool to set the assets within the editor. And, it does seem to work, given that the BP call works just fine.)

Edit: Calling K2_OnValueChanged() with SnapToStep(NewValue) instead of just NewValue still works just fine, so that is not the problem.

TL;DR: For some reason, Audio Modulation calls work fine in BP, do nothing in C++, cannot figure out why.

1 Like

Solved: The importance of World Context

Well, I solved my own problem. For reference to anyone else who ends up here, this is how to fix it.

The problem was a bad world context object.

void URefractGameUserSettings::SetChannelVolume(const ERefractAudioBusChannel Channel, const float Volume01,
                                                const float FadeSeconds)
{
	ActiveVolumes.FindOrAdd(Channel) = Volume01;
	UAudioModulationStatics::UpdateMix(this, GetControlBusMasterMix(), { UAudioModulationStatics::CreateBusMixStage(this, GetControlBusForChannel(Channel), Volume01, FadeSeconds, FadeSeconds) }, FadeSeconds);
}

The issue here is that objects of type UGameUserSettings (of which this is obviously derived) do not actually have a world. So, GetWorld() and GetWorldFromContextObject(this) return a NULL UWorld*. Thus, none of the functionality was working.

The solution was to fix up the InitAudioBusMix() method and retain a world context pointer, like so:

// header (RefractGameUserSettings.h)
private:
	UPROPERTY()
	TObjectPtr<UObject> AudioWorldContext; /* @note(wpt) could make stronger, i.e., UGameInstanceSubsystem */

// source (RefractGameUserSettings.cpp)
void URefractGameUserSettings::InitAudioBusMix(UObject* WorldContext)
{
	check(WorldContext);
	AudioWorldContext = WorldContext;
	UAudioModulationStatics::ActivateBusMix(WorldContext, MasterMix);
}

// source (RefractGameUserSettingsSubsystem.cpp)
void URefractGameUserSettingsSubsystem::Initialize(FSubsystemCollectionBase& Collection)
{
	Super::Initialize(Collection);

	GetWorld()->GetTimerManager().SetTimerForNextTick(this, &ThisClass::ActuallyActivateBusMix);
}

void URefractGameUserSettingsSubsystem::ActuallyActivateBusMix()
{
	// now, this subsystem is the audio world context, which means it'll be valid whenever the Game User Settings tries to change the value (Game Instance subsystem lives for the lifetime of the application)
	URefractGameUserSettings::Get()->InitAudioBusMix(this);
}

When I do this, it works like a charm and now I can change audio settings with effective volume adjustments, as I intended. No weird Blueprint workaround required.

Let this be a lesson to all; when in doubt, and calling a static function library, verify your world context objects! The UObject* might be valid but that does not mean the UWorld* you try to get from it is.

1 Like