Dict Based Audio Manager #

One of my most reused global scripts is a modified version of the Audio Manager singleton from KidsCanCode because of how easy it makes playing non-directional audio in a scene. The issue I ran into was that due to the nature of the queue, sometimes if a lot of sounds played at once I’d end up with a bunch of delayed audio. So instead I made a Dictionary of AudioStreamPlayers so each sound has a dedicated player, and that solved my issues!

While this works well for my scenario, if you have a lot of sounds to play or complex custom resources, you may be better off using the original pool based approach from KidsCanCode

Pros and Cons #

Pros to the Dictionary approach:

  • Only creates a AudioStreamPlayer when a sound is first played, few sounds means few AudioStreamPlayers
  • Repetitive audio doesn’t delay other sounds from being played, only cutting off itself
  • Doesn’t use the idle _process loop, players are only created or played when called

Cons to this approach:

  • Godot’s Resources can be finicky, so finding a unique identifier or path for them can vary based on your resources
  • If you have a lot of sounds, there will be a lot of AudioStreamPlayers
  • Since each sound has only one player, this isn’t ideal for positional audio

By now you can probably tell the use-case for this is pretty situational, but for most projects the difference will just be the old way holds the players in an array while this one holds them in a dictionary.

If you have sounds with multiple variations like a footstep or gunshot for instance, instead of treating each variance as its own sound, as of 4.0 you can combine those variants into a single AudioStreamRandomizer and play the variations that way. This means you have a single resource for your varied sounds so only one AudioStreamPlayer will be assigned to it.

The Code #

extends Node
## Global audio node to simplify playing non-directional sounds on demand. Creates a new
## AudioStreamPlayer if one assigned to the played sound doesnt exist.

## Default max polyphony for the AudioStreamPlayers
var polyphony := 3
## Default bus for the AudioStreamPlayers
var default_bus := "SFX"
## Default volume_db for the AudioStreamPlayers
var default_volume := -6.0

## Dictionary of all current AudioStreamPlayers
var player_map : Dictionary = {}
## The template AudioStreamPlayer that is duplicated as needed
var template_player : AudioStreamPlayer


func _ready() -> void:
	_create_player()


## Creates an AudioStreamPlayer to use as a template using the default values set above.  This
## player is duplicated as needed when new sounds are played.
func _create_player() -> void:
		template_player = AudioStreamPlayer.new()
		
		template_player.volume_db = default_volume
		template_player.bus = default_bus
		template_player.max_polyphony = polyphony


## Simplified play function, can be given either an AudioStream or a path string as the sound.
## The bus is the target Audio Bus and the volume is the target player volume_db
func play(sound, bus := default_bus, volume := default_volume) -> void:
	if sound is String:
		play_by_path(sound, bus, volume)
	elif sound is AudioStream:
		play_by_stream(sound, bus, volume)


## Play function that only takes string paths as a sound.
## The bus is the target Audio Bus and the volume is the target player volume_db
func play_by_path(sound_path: String, bus : String, volume : float) -> void:
	var sounds = sound_path.split(",")
	var audio_stream = load("res://" + sounds[randi() % sounds.size()].strip_edges())
	play_by_stream(audio_stream, bus, volume)


## Play function that only accepts an AudioStream as the sound
## The bus is the target Audio Bus and the volume is the target player volume_db
func play_by_stream(audio_stream: AudioStream, bus : String, volume : float) -> void:
	var player_id
	if audio_stream.resource_path:
		# Use the resource path as the key, not using UID saves the call to fetch it since its
		# just using the path anyway
		player_id = audio_stream.resource_path
	elif audio_stream.resource_name:
		# If for whatever reason the path is missing (resource duplication etc), the optional resource name is used
		player_id = audio_stream.resource_name
	
	# If the above fails, throw an error and play no sound. Other methods such as instance id
	# are unreliable and cause redundant audio players etc if used.
	assert(player_id, "Tried to play an AudioStream resource without a path or name set")
	# If an issue makes it to a release build, do nothing instead of making a bunch of
	# redundant players
	if not player_id:
		return
	
	var target_player : AudioStreamPlayer = player_map.get(player_id)
	
	if not target_player:
		var new_player : AudioStreamPlayer = template_player.duplicate()
		new_player.bus = bus
		new_player.volume_db = volume
		new_player.stream = audio_stream
		add_child(new_player)
		player_map.set(player_id, new_player)
		target_player = new_player
	else:
		if target_player.bus != bus:
			target_player.bus = bus
		if target_player.volume_db != volume:
			target_player.volume_db = volume
	
	# Slight random variance to keep sounds from feeling repetitive, comment this out to disable
	target_player.pitch_scale = randf_range(0.9, 1.1)
	
	target_player.play()

The code and its comments should be pretty self-explanatory, but here’s a quick overview:

  1. On ready, we create a template AudioStreamPlayer to duplicate as needed so we don’t instantiate a new node every time.
  2. When Audio.play(audio_stream) is called, determine if the passed sound an AudioStream resource or String path.
    • If the sound is a String, we load the sound resource, then pass the resource and other params to play_by_stream
    • If the sound is an AudioStream, we just pass the resource and params to play_by_stream
  3. play_by_stream will then determine the unique identifier for the resource, either the path or name of the resource.
    • If there’s an AudioStreamPlayer related to the found ID, we update the audio bus and volume if needed, then play the sound
    • If there isn’t an AudioStreamPlayer related to the found ID, we duplicate the template player, set its params as needed, then add it to the dictionary

Bonus Thoughts #

If you prefer the dictionary based approach but want to limit the number of players, you could cycle out players in a few different ways

  • You could just find a player not being played and remove it from the dict
  • You could keep track of the last time a player has been played by making the dictionary entry for the AudioStream resource be something like {player: AudioStreamPlayer, last_played: timestamp} and then determine which is the oldest, removing old or less used players
  • You could keep track of the order the players were added and remove the oldest, following a first in first out pattern

When removing AudioStreamPlayers from your dictionary you don’t want to cut off any playing sound, so instead of freeing the node immediately you can connect the finished signal to queue_free() directly. Though this only works for non-looping audio, so if you have a looping sound it’ll have to be cut off. You should be able to check the loop property of the player’s AudioStream to determine how to handle removing the player.