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.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.
