Best way to organize UI?

Godot Version

4.3

Question

Hi there, im super new to Godot but Im loving it. Im busy implementing a basic UI (Play, Options and Exit buttons). Following some online advice, I created two scenes, one with MainMenu and other with OptionsMenu, the Options button loads the OptionsMenu scene.
Using a MusicController node i managed to play the menu music seamesly between scenes. My problem comes with the UI Sfx sounds, i was following a tutorial online
(https://www.youtube.com/watch?v=QgBecUl_lFs) and I managed to add the hover sounds to my MainMenu scene (the click sound seems to work but doesnt sound, weird).
imagen

But this is a problem, bah, I think, I think I shouldnt add a UiSFix node for each menu, i mean i could but I wanted to create a node more like MusicController.

Is there a way to create a menu that goes back and forth (like for example going from main to options or viceversa) without using differente scenes? Or is having different scenes for each menu section the common way? How would you implement sfx sounds to the menu?

Thanks a lot in advance

EDIT: The click button not going off is realted to this I believe bc I get this error:

E 0:00:03:0712 OptionsUiSfx.gd:37 @ ui_sfx_play(): Playback can only happen when a node is inside the scene tree

You could just have a single sound controller on the top level of your GUI tree and just listen to signals from every node that connects to it. Is that enough to solve the issue? I can try to explain it further.

Create an AudioStreamManager (or w/e name) node that you load as a Global via Project Settings → Autoload tab. You can pull a simple version from here, it will add sounds to a queue (they’ll play immediately unless you’ve reached max number of players).

Because the autoloaded global node will exist always, the sounds will continue playing as you switch between scenes.

If/when your app needs app-wide audio volume control, etc., below is my modified version that has added bells and whistles and also always plays sounds immediately by cutting off oldest sound if necessary:

extends Node

var num_players = 8
var num_max = 64
var bus = "" # empty = default system value

var available = []  # The available players.
var busy = [] # Players currently playing sounds

# internal 
var max_volume_db: float = -5.0 # in decibels
var min_volume_db: float = -42.69 # min volume right before music mutes
var volume_db: float = max_volume_db
var sound_off: bool:
	get:
		return Utility.floats_equal(volume_db, min_volume_db, 0.01)

func _ready():
	# Create the pool of AudioStreamPlayer nodes.
	process_mode = Node.PROCESS_MODE_ALWAYS
	add_players()

func add_players():
	var current_players = get_children().size()
	for i in range(current_players, num_players):
		var p = AudioStreamPlayer.new()
		add_child(p)
		available.append(p)
		p.connect("finished", _on_stream_finished.bind(p))
		if len(bus) > 0:
			p.bus = bus

func _on_stream_finished(stream):
	# When finished playing a stream, make the player available again.
	available.append(stream)

## dict of struct: { filename: volume }
## always returns an instance of player, even if player doesn't play due to sound turned off
func play(sound: Dictionary) -> AudioStreamPlayer:
	var player = get_available_player()
	if sound_off:
		return player
	
	if player: 
		var path = sound.keys()[0]
		var vol = sound[sound.keys()[0]]
		
		player.stream = load(path)
		player.volume_db = volume_db + vol
		player.play()
		
		busy.append(available.pop_front())
	else:
		# this should never happen but still log just in case...
		push_error("Couldn't play sound %s, no available audio stream players" % str(sound))
	
	return player
	
func get_available_player() -> AudioStreamPlayer:
		# if we're running out of players, add 1 more until max allowed
	if available.size() == 0 and num_players < num_max:
		num_players = num_players + 1 if num_max - num_players > 1 else num_max
		add_players()
	
	if available.size() == 0:
		var player = busy.pop_front()
		player.stop()
		available.append(player)
	
	return null if available.size() == 0 else available[0]

func game_sound_volume_changed(value: float):
	volume_db = min_volume_db - (min_volume_db - max_volume_db) * value/100

Oh and Utility.floats_equal is just a func that compares floats (since they can’t be directly compared reliably due to the nature of floating point numbers being imprecise):

extends Node
class_name Utility

const float_epsilon: float = 0.00001

static func floats_equal(a: float, b: float, epsilon: float = float_epsilon) -> bool:
	return abs(a - b) < epsilon

If you have a “sfx volume” slider in your app with range from 0 to 100, whenever value of that slider changes you can call game_sound_volume_changed. You can also call that method after you read the setting from a settings file, if you want to persist audio volume setting in-between app launches.

I would like to add to what @alextheukrainian suggested.

If these sounds are used very often, you could add an autoload with all streams preloaded using ResourcePreloader or individual AudioStreamPlayer nodes as child of the singleton.

Of course you should consider the overhead tradeoff of this approach, if you have many streams to play at any given time then this solution isn’t ideal in terms of memory, but if you only have a few then you wouldn’t need to load a stream that isn’t on memory every time you call it. See what fits best for your use case.

while good point in absolute, I would’t worry about it at all on modern machines unless you have some extreme use case. Each sound effect is normally in double-digit kilobyte range, which is entirely negligible. Engine will take care of offloading/reloading resources based on memory needs.

in general the low-hanging fruit is file sizes / compression, and in a veeeeeeeery distant place from that - singletons like this one / instances of AudioPlayer. I could be wrong, still new to Godot specifically, but in my general programming experience singletons and general-purpose objects like AudioPlayer are the least of your worries (again, given other things/places to optimize).

if for some unique reason memory is indeed a concern, you can adjust var num_max = 64 to a lower number (or even dynamically) as the simplest solution. Basically this is the max concurrent sounds / channels you want user to hear and instances of AudioStreamPlayer to keep in memory. For most cases, 16- 32 is the sweet range. Keep in mind that instances are reused as soon as they’re available, so ACTUAL number of instances will stay below 8 unless you in fact have more sounds needing to play at the very same time, then it will spawn more instances. So 64 is a theoretical maximum that won’t ever be reached if there isn’t an actual need to play 64 sounds simultaneously.