Audio controller gdscript that will print bus-less audio stream nodes to your audio json file!

There comes a time when you really start getting momentum building your games and its better to address player controlled game settings sooner than later, especially when you bigin to explore beyond just graphical placeholders in your game levels. You may already have numerous audio stream node types that you didn't know or perhaps forget to assign to the audio bus system. Individual audio busses allow you to control each type of sound your game may have and allow you to provide individual global controls for both the player and for your own development or scene purposes (such as lowering music during cutscenes with dialog, increasing certain sfx with bosses, etc). 

Here is a free GPLv3 gdscript that you can use as a basic audio controller that will also automatically list any stream node types that don't have the expected buses so you can easily hunt them down in your scenes. You should not rely on the json file to control bus or volume levels as this could effect performance long term.

 

audio controller json file map

# audio_controller.gd

class_name Audio_Controller
extends Node

const CONFIG_PATH: String = "user://audio_config.json"
const DEFAULT_VOLUME: float = 0.75

const MAP: Dictionary = {
	"res://Audio/Dialog/": "dialog",
	"res://Audio/Environ/": "environ",
	"res://Music/": "music",
	"res://Audio/SFX/": "sfx",
	"res://Audio/Voiceovers/": "voiceovers"
}

@export var DEBUG: bool = true

var _config: Dictionary = {}

var _loader_thread: Thread = Thread.new()
var _thread_running: bool = false
var _thread_cancel: bool = false

func _ready() -> void:
	if Engine.is_editor_hint():
		return
	get_tree().connect("current_scene_changed", Callable(self, "_on_current_scene_changed"))
	get_tree().connect("node_added", Callable(self, "_on_node_added"))
	load_config_in_background()

func generate_audio_config() -> bool:
	var cfg: Dictionary = {"buses": {}, "file_map": {}}
	for v in MAP.values():
		var k: String = str(v)
		if not cfg["buses"].has(k):
			cfg["buses"][k] = {
				"volume": DEFAULT_VOLUME,
				"mute": false,
				"solo": false
			}
	var audio_files: Array = collect_audio_files()
	for path in audio_files:
		var pstr: String = str(path)
		var fname: String = pstr.get_file()
		var tag: String = _tag_for_path(pstr)
		cfg["file_map"][fname] = tag
	var existing: Dictionary = _read_config_from_user()
	if existing.size() > 0 and existing.has("buses"):
		for b in existing["buses"].keys():
			var bb: String = str(b)
			if cfg["buses"].has(bb):
				var candidate = existing["buses"][bb]
				if typeof(candidate) == TYPE_DICTIONARY:
					var exd: Dictionary = candidate
					if exd.has("volume"):
						cfg["buses"][bb]["volume"] = float(exd["volume"])
					if exd.has("mute"):
						cfg["buses"][bb]["mute"] = bool(exd["mute"])
					if exd.has("solo"):
						cfg["buses"][bb]["solo"] = bool(exd["solo"])
	if existing.size() > 0 and existing.has("file_map"):
		for k in existing["file_map"].keys():
			cfg["file_map"][str(k)] = existing["file_map"][k]
	cfg["generated_at"] = int(Time.get_unix_time_from_system())
	var json_txt: String = JSON.stringify(cfg, "\t")
	var f: FileAccess = FileAccess.open(CONFIG_PATH, FileAccess.WRITE)
	if f == null:
		printerr("AudioController: Failed to open config for write:", CONFIG_PATH)
		return false
	f.store_string(json_txt)
	f.close()
	return true

func _load_and_apply_config() -> void:
	var cfg: Dictionary = _load_config()
	if cfg.size() == 0:
		return
	_config = cfg
	if _config.has("buses"):
		for bus_name in (_config["buses"] as Dictionary).keys():
			_ensure_bus_exists(str(bus_name))
	apply_all()
	apply_to_scene()

func apply_all() -> void:
	if _config.size() == 0:
		var loaded: Dictionary = _load_config()
		if loaded.size() == 0:
			return
		_config = loaded
	if not _config.has("buses"):
		return
	var buses: Dictionary = _config["buses"] as Dictionary
	for bus_name in buses.keys():
		var info_variant = buses[bus_name]
		if info_variant is Dictionary:
			var info: Dictionary = info_variant as Dictionary
			_apply_bus_settings(str(bus_name), info)

func _find_bus_index_by_name_ci(bus_name: String) -> int:
	var target: String = bus_name.to_lower()
	for i in range(AudioServer.get_bus_count()):
		var n: String = AudioServer.get_bus_name(i)
		if n.to_lower() == target:
			return i
	return -1

func _ensure_bus_exists(bus_name: String) -> void:
	var idx: int = _find_bus_index_by_name_ci(bus_name)
	if idx != -1:
		return
	var new_index: int = AudioServer.get_bus_count()
	AudioServer.add_bus(new_index)
	AudioServer.set_bus_name(new_index, bus_name)

func apply_to_scene() -> void:
	var scene: Node = get_tree().get_current_scene()
	if scene == null:
		return
	var file_map: Dictionary = {}
	if _config.has("file_map"):
		file_map = _config["file_map"] as Dictionary
	_set_buses_on_tree(scene, file_map)

func _set_buses_on_tree(node: Node, file_map: Dictionary) -> void:
	if node is AudioStreamPlayer or node is AudioStreamPlayer2D or node is AudioStreamPlayer3D or node.get_class() == "AudioStreamRNGPlayer":
		var desired: String = _desired_bus_for_player(node, file_map)
		if desired != "":
			var current_bus: String = ""
			if node.has_method("get"):
				var cb = node.get("bus")
				if cb != null:
					current_bus = str(cb)
			else:
				if "bus" in node:
					current_bus = str(node.bus)
			if current_bus != desired:
				var set_ok: bool = false
				if node.has_method("set"):
					node.set("bus", desired)
					set_ok = true
				elif "bus" in node:
					node.bus = desired
					set_ok = true
				if not set_ok:
					node.set_meta("audio_bus", desired)
	for child in node.get_children():
		if child is Node:
			_set_buses_on_tree(child, file_map)

func _desired_bus_for_player(player: Node, file_map: Dictionary) -> String:
	var stream_path: String = ""
	var stream: Object = null
	if player.has_method("get"):
		stream = player.get("stream")
	else:
		if "stream" in player:
			stream = player.stream
	if stream:
		if stream is Resource:
			var rp: String = ""
			if "resource_path" in stream:
				rp = stream.resource_path
			if rp == "":
				if stream.has_method("get_path"):
					rp = stream.get_path()
				else:
					rp = str(stream)
			var idx: int = rp.find("res://")
			if idx != -1:
				stream_path = rp.substr(idx, rp.length() - idx).strip_edges().to_lower()
		else:
			var sstr: String = str(stream)
			var idx2: int = sstr.find("res://")
			if idx2 != -1:
				stream_path = sstr.substr(idx2, sstr.length() - idx2).strip_edges().to_lower()
			else:
				stream_path = sstr.strip_edges().to_lower()
	if stream_path != "":
		var fname: String = stream_path.get_file()
		var fname_lc: String = fname.to_lower()
		if file_map and file_map.has(fname_lc) and str(file_map[fname_lc]) != "":
			return str(file_map[fname_lc])
		var tag: String = _tag_for_path(stream_path)
		if tag != "":
			return tag
	if stream and stream is Resource:
		var rp2: String = ""
		if stream.has_method("get_path"):
			rp2 = stream.get_path()
		else:
			rp2 = str(stream)
		var idx3: int = rp2.find("res://")
		if idx3 != -1:
			var fname2: String = rp2.substr(idx3, rp2.length() - idx3).get_file()
			var fname2_lc: String = fname2.to_lower()
			if file_map and file_map.has(fname2_lc):
				return str(file_map[fname2_lc])
	return ""

func _load_config() -> Dictionary:
	var cfg: Dictionary = _read_config_from_user()
	if cfg.size() == 0:
		return {}
	if not cfg.has("buses"):
		return cfg
	return cfg

func _read_config_from_user() -> Dictionary:
	if not FileAccess.file_exists(CONFIG_PATH):
		return {}

	var f: FileAccess = FileAccess.open(CONFIG_PATH, FileAccess.READ)
	if f == null:
		printerr("AudioController: failed to open config:", CONFIG_PATH)
		return {}

	var txt: String = f.get_as_text()
	f.close()

	var parsed_variant: Dictionary = JSON.parse_string(txt)
	if typeof(parsed_variant) != TYPE_DICTIONARY:
		printerr("AudioController: JSON.parse_string returned unexpected type")
		return {}

	var parsed: Dictionary = parsed_variant

	var parse_error: int = OK
	if parsed.has("error"):
		parse_error = int(parsed["error"])
	if parse_error != OK:
		var err_line: int = -1
		if parsed.has("error_line"):
			err_line = int(parsed["error_line"])
		printerr("AudioController: JSON parse error:", parse_error, "at line:", err_line)
		return {}

	if parsed.has("result") and typeof(parsed["result"]) == TYPE_DICTIONARY:
		return parsed["result"] as Dictionary

	if parsed.has("buses") or parsed.has("file_map"):
		return parsed

	return {}

func load_config_in_background() -> void:
	if _thread_running:
		return
	_thread_cancel = false
	_thread_running = true
	_loader_thread.start(Callable(self, "_thread_load_config"))

func _thread_load_config() -> Dictionary:
	var out: Dictionary = {}

	if not FileAccess.file_exists(CONFIG_PATH):
		generate_audio_config()

	var f: FileAccess = FileAccess.open(CONFIG_PATH, FileAccess.READ)
	if f == null:
		_thread_finished(out)
		return out
	var txt: String = f.get_as_text()
	f.close()
	if _thread_cancel:
		_thread_finished(out)
		return out
	var parsed_variant: Dictionary = JSON.parse_string(txt)
	if typeof(parsed_variant) != TYPE_DICTIONARY:
		_thread_finished(out)
		return out
	var parsed: Dictionary = parsed_variant
	var parse_error: int = OK
	if parsed.has("error"):
		parse_error = int(parsed["error"])
	if parse_error != OK:
		_thread_finished(out)
		return out
	if parsed.has("result") and typeof(parsed["result"]) == TYPE_DICTIONARY:
		out = parsed["result"] as Dictionary
	_thread_finished(out)
	return out

func _thread_finished(result: Dictionary) -> void:
	call_deferred("_on_config_parsed_main_thread", result)
	_thread_running = false

func _on_config_parsed_main_thread(result: Dictionary) -> void:
	if result.size() == 0:
		var sync_cfg: Dictionary = _read_config_from_user()
		if sync_cfg.size() == 0:
			return
		_config = sync_cfg
		if _config.has("file_map") and typeof(_config["file_map"]) == TYPE_DICTIONARY:
			var fm: Dictionary = {}
			for k in (_config["file_map"] as Dictionary).keys():
				var v = _config["file_map"][k]
				fm[str(k).to_lower()] = v
			_config["file_map"] = fm
		if _config.has("buses"):
			for bus_name in (_config["buses"] as Dictionary).keys():
				_ensure_bus_exists(str(bus_name))
		apply_all()
		apply_to_scene()
		return

	_config = result
	if _config.has("file_map") and typeof(_config["file_map"]) == TYPE_DICTIONARY:
		var fm2: Dictionary = {}
		for k2 in (_config["file_map"] as Dictionary).keys():
			var v2 = _config["file_map"][k2]
			fm2[str(k2).to_lower()] = v2
		_config["file_map"] = fm2
	if _config.has("buses"):
		for bus_name in (_config["buses"] as Dictionary).keys():
			_ensure_bus_exists(str(bus_name))
	apply_all()
	apply_to_scene()

func cancel_background_load() -> void:
	if _thread_running:
		_thread_cancel = true

func _exit_tree() -> void:
	if _thread_running:
		_thread_cancel = true
		_loader_thread.wait_to_finish()

func _apply_bus_settings(bus_name: String, info: Dictionary) -> void:
	var idx: int = _find_bus_index_by_name_ci(bus_name)
	if idx == -1:
		_ensure_bus_exists(bus_name)
		idx = _find_bus_index_by_name_ci(bus_name)
		if idx == -1:
			return
	if info.has("volume"):
		var lin: float = float(info["volume"])
		if lin < 0.0:
			lin = 0.0
		if lin > 1.0:
			lin = 1.0
		var db: float = _linear_to_db(lin)
		AudioServer.set_bus_volume_db(idx, db)
	if info.has("mute"):
		var m: bool = bool(info["mute"])
		AudioServer.set_bus_mute(idx, m)
	if info.has("solo"):
		var s: bool = bool(info["solo"])
		AudioServer.set_bus_solo(idx, s)

# Minimal public API additions (no other changes)
func set_bus_volume_and_save(bus_name: String, linear: float) -> void:
	# clamp linear value
	if linear < 0.0:
		linear = 0.0
	if linear > 1.0:
		linear = 1.0

	# apply immediately
	var idx: int = _find_bus_index_by_name_ci(bus_name)
	if idx == -1:
		_ensure_bus_exists(bus_name)
		idx = _find_bus_index_by_name_ci(bus_name)
	if idx != -1:
		var db: float = _linear_to_db(linear)
		AudioServer.set_bus_volume_db(idx, db)

	# update in-memory config
	if not _config.has("buses"):
		_config["buses"] = {}
	if not (_config["buses"] as Dictionary).has(bus_name):
		_config["buses"][bus_name] = {"volume": linear, "mute": false, "solo": false}
	else:
		_config["buses"][bus_name]["volume"] = linear

	# persist to disk
	_save_config_atomic()

func _save_config_atomic() -> void:
	var json_txt: String = JSON.stringify(_config, "\t")
	var f: FileAccess = FileAccess.open(CONFIG_PATH, FileAccess.WRITE)
	if f == null:
		printerr("AudioController: failed to open config for write:", CONFIG_PATH)
		return
	f.store_string(json_txt)
	f.close()

func _linear_to_db(lin: float) -> float:
	if lin <= 0.0:
		return -80.0
	return 20.0 * (log(lin) / log(10.0))

func generate_and_apply() -> bool:
	var ok: bool = generate_audio_config()
	if not ok:
		return false
	if not Engine.is_editor_hint():
		_load_and_apply_config()
	return true

func collect_audio_files() -> Array:
	var out: Array = []
	for folder in MAP.keys():
		_collect_files(str(folder), out)
	return out

func _collect_files(dir_path: String, out: Array) -> void:
	var da: DirAccess = DirAccess.open(dir_path)
	if da == null:
		return
	da.list_dir_begin()
	var entry: String = da.get_next()
	while entry != "":
		if da.current_is_dir():
			if entry != "." and entry != "..":
				var next_dir := _join_path(dir_path, entry)
				_collect_files(next_dir, out)
		else:
			var ext := entry.get_extension().to_lower()
			if ext == "ogg" or ext == "mp3":
				var file_path := _join_path(dir_path, entry)
				out.append(file_path)
		entry = da.get_next()
	da.list_dir_end()

func _tag_for_path(path: String) -> String:
	var p: String = path.strip_edges().to_lower()
	for token in MAP.keys():
		var tk := str(token).to_lower()
		if p.find(tk) != -1:
			return str(MAP[token])
	return ""

func _join_path(dir_path: String, entry_name: String) -> String:
	if dir_path.ends_with("/"):
		return dir_path + entry_name
	return dir_path + "/" + entry_name

func _on_current_scene_changed() -> void:
	apply_to_scene()

func _on_node_added(node: Node) -> void:
	if node is AudioStreamPlayer or node is AudioStreamPlayer2D or node is AudioStreamPlayer3D or node.get_class() == "AudioStreamRNGPlayer":
		var file_map: Dictionary = {}
		if _config.has("file_map"):
			file_map = _config["file_map"] as Dictionary
		var desired := _desired_bus_for_player(node, file_map)
		if desired != "":
			var set_ok: bool = false
			if node.has_method("set"):
				node.set("bus", desired)
				set_ok = true
			elif "bus" in node:
				node.bus = desired
				set_ok = true
			if not set_ok:
				node.set_meta("audio_bus", desired)

We have some other more complex Godot tools, scripts plugins and addons on our Itch.io page.