refactor(audio): ♻️ Split audio system into modular components: AudioManager, AudioLoader, and AudioResolver for better separation of concerns

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
autocommit 2026-04-30 01:33:32 -07:00
parent 5e92fb313d
commit 2decf846a3
3 changed files with 289 additions and 227 deletions

View file

@ -0,0 +1,129 @@
class_name AudioLoader
extends RefCounted
## Pure data transformation: read library + manifest + pools JSON files
## from disk, apply the subscription filter and per-key overrides, and
## return a resolved bundle. Has no Godot-tree side effects (no audio
## node creation, no signal subscription) — that's AudioManager's job.
##
## Mirror of TS-side `resolveManifest()` in design app's AudioSystem.tsx
## so both sides see the same resolved manifest given the same inputs.
const SCHEMA_VERSION_DEFAULT: int = 2
## Result of a successful load. AudioManager unpacks this into its
## instance vars.
class Bundle extends RefCounted:
var sfx_events: Dictionary = {}
var music_tracks: Dictionary = {} # id -> track dict
var music_default_id: String = ""
var crossfade_seconds: float = 2.0
var victory_pool: Dictionary = {}
var defeat_pool: Dictionary = {}
var loaded: bool = false # false when library couldn't be read at all
## Read all three files and resolve. `library_path` is the cross-theme
## library; `theme_dir` is the per-theme directory (manifest.json + pools.json).
## Returns a Bundle; `bundle.loaded == false` when the library is missing
## (caller should warn and play silent).
static func load(library_path: String, theme_dir: String) -> Bundle:
var bundle: Bundle = Bundle.new()
var library: Dictionary = _read_json_dict(library_path, "audio library")
if library.is_empty():
return bundle
var manifest: Dictionary = _read_json_dict(
"%s/manifest.json" % theme_dir, "audio manifest"
)
var pools: Dictionary = _read_json_dict(
"%s/pools.json" % theme_dir, "audio pools"
)
_apply_subscription(bundle, library, manifest)
_apply_pools(bundle, pools)
bundle.loaded = true
return bundle
## Filter the library by `manifest.includes` and merge `manifest.overrides`
## per-key. `includes: true` means subscribe to every entry; an array means
## a whitelist; missing means no subscriptions.
static func _apply_subscription(
bundle: Bundle, library: Dictionary, manifest: Dictionary
) -> void:
var lib_sfx: Dictionary = library.get("sfx", {}) as Dictionary
var lib_music: Dictionary = library.get("music", {}) as Dictionary
# JSON parse returns either bool true or an Array here; branch on type
# without storing the Variant in a typed local (project lint forbids
# Variant locals outside autoload signal boundaries).
var includes_all: bool = (
bool(manifest.get("includes", true))
if manifest.get("includes", true) is bool
else false
)
var includes_list: Array = (
manifest.get("includes", []) as Array
if manifest.get("includes", true) is Array
else []
)
var overrides: Dictionary = manifest.get("overrides", {}) as Dictionary
for key: String in lib_sfx.keys():
if not _includes_key(includes_all, includes_list, key):
continue
var entry: Dictionary = (lib_sfx[key] as Dictionary).duplicate()
if overrides.has(key) and overrides[key] is Dictionary:
var ov: Dictionary = overrides[key] as Dictionary
for k: String in ov.keys():
entry[k] = ov[k]
bundle.sfx_events[key] = entry
for track: Dictionary in (lib_music.get("tracks", []) as Array):
var id: String = String(track.get("id", ""))
if id.is_empty() or not _includes_key(includes_all, includes_list, id):
continue
var resolved: Dictionary = track.duplicate()
if overrides.has(id) and overrides[id] is Dictionary:
var ov: Dictionary = overrides[id] as Dictionary
for k: String in ov.keys():
resolved[k] = ov[k]
bundle.music_tracks[id] = resolved
static func _includes_key(
includes_all: bool, includes_list: Array, key: String
) -> bool:
if includes_all:
return true
return includes_list.has(key)
static func _apply_pools(bundle: Bundle, pools: Dictionary) -> void:
bundle.crossfade_seconds = float(pools.get("crossfade_seconds", 2.0))
bundle.music_default_id = String(pools.get("default_track_id", ""))
bundle.victory_pool = (pools.get("victory_pool", {}) as Dictionary).duplicate()
bundle.defeat_pool = (pools.get("defeat_pool", {}) as Dictionary).duplicate()
## Read a JSON file expected to contain a single object. Returns {} on any
## failure (missing file, bad JSON, non-object root) and warns once.
static func _read_json_dict(path: String, what: String) -> Dictionary:
if not FileAccess.file_exists(path):
push_warning("AudioLoader: %s not found at %s" % [what, path])
return {}
var file: FileAccess = FileAccess.open(path, FileAccess.READ)
if file == null:
push_warning("AudioLoader: failed to open %s (%s)" % [path, what])
return {}
var text: String = file.get_as_text()
file.close()
var json: JSON = JSON.new()
if json.parse(text) != OK:
push_warning(
"AudioLoader: parse error in %s (%s) line %d: %s"
% [path, what, json.get_error_line(), json.get_error_message()]
)
return {}
if not (json.data is Dictionary):
push_warning("AudioLoader: %s is not a JSON object: %s" % [what, path])
return {}
return json.data as Dictionary

View file

@ -0,0 +1,139 @@
class_name AudioResolver
extends RefCounted
## Categorical resolution for entity-keyed audio events.
##
## Given an entity id ("paladin", "barracks", "dire_wolf_apex") and an
## event_kind ("attack", "complete", "spawn"), returns the chain of SFX
## manifest keys the AudioManager should try, in priority order:
##
## <entity_id>.<event_kind> — bespoke per-entity cue
## <kind>.<sub>.<event_kind> — categorical fallback
##
## `kind` is one of `unit` / `building` / `fauna` based on which DataLoader
## category the id belongs to. `sub` is the inferred combat class
## (melee/ranged/siege/civilian/support), the building category, or the
## fauna trophic class.
##
## State: caches the kind+sub lookup per entity_id (DataLoader walks once).
## Pure with respect to the manifest — does not know which keys are
## actually authored. AudioManager is responsible for the fail-loud check
## against `_sfx_events`.
var _entity_category_cache: Dictionary = {}
## Build the candidate-key chain for `entity_id` × `event_kind`.
func resolve(entity_id: String, event_kind: String) -> Array[String]:
var keys: Array[String] = []
keys.append("%s.%s" % [entity_id, event_kind])
var kind_and_sub: PackedStringArray = _entity_kind_and_sub(entity_id)
if kind_and_sub.size() == 2:
var kind: String = kind_and_sub[0]
var sub: String = kind_and_sub[1]
if not sub.is_empty():
keys.append("%s.%s.%s" % [kind, sub, event_kind])
return keys
## Return [kind, sub_category] for an entity id, or empty if the id is
## not registered with DataLoader. Cached.
func _entity_kind_and_sub(entity_id: String) -> PackedStringArray:
if _entity_category_cache.has(entity_id):
return _entity_category_cache[entity_id]
var result: PackedStringArray = []
# Wilds first — wild creatures appear as units of `unit_type: "wild"` AND
# may live in the wilds registry. Route them through the fauna path so
# their roar / spawn / death sounds match.
var wilds: Dictionary = DataLoader.get_data("wilds") as Dictionary
if wilds.has(entity_id) and wilds[entity_id] is Dictionary:
var wild: Dictionary = wilds[entity_id] as Dictionary
if not wild.is_empty():
result.append("fauna")
result.append(_fauna_class(wild))
_entity_category_cache[entity_id] = result
return result
var unit: Dictionary = DataLoader.get_unit(entity_id) as Dictionary
if not unit.is_empty():
# `unit_type: "wild"` units that are not in the wilds registry still
# get fauna classification by trophic semantics from their `attributes`.
if String(unit.get("unit_type", "")) == "wild":
result.append("fauna")
result.append(_fauna_class(unit))
_entity_category_cache[entity_id] = result
return result
result.append("unit")
result.append(_unit_combat_class(unit))
_entity_category_cache[entity_id] = result
return result
var bldg: Dictionary = DataLoader.get_building(entity_id) as Dictionary
if not bldg.is_empty():
result.append("building")
result.append(String(bldg.get("category", "")))
_entity_category_cache[entity_id] = result
return result
_entity_category_cache[entity_id] = result
return result
## Coarse combat class derived from existing unit JSON fields. The data
## doesn't carry an explicit `combat_class`; we infer one for sound routing.
static func _unit_combat_class(unit: Dictionary) -> String:
var unit_type: String = String(unit.get("unit_type", ""))
if unit_type == "civilian":
return "civilian"
if unit_type == "support":
return "support"
var attack_type: String = String(unit.get("attack_type", ""))
if attack_type == "siege":
return "siege"
var ranged: int = int(unit.get("ranged_attack", 0))
if ranged > 0:
return "ranged"
# Default for military / mounted / other: melee.
return "melee"
## Fauna trophic class. Prefers an explicit `trophic_class` field; falls
## back to scanning `attributes` / `flags` for a recognised tier. Defaults
## to "predator" so the resolver never yields a malformed `fauna..attack`
## chain segment (closure test pins this).
static func _fauna_class(creature: Dictionary) -> String:
var explicit: String = String(creature.get("trophic_class", ""))
if not explicit.is_empty():
return explicit
var tags: PackedStringArray = []
for raw_v in (creature.get("attributes", []) as Array):
tags.append(String(raw_v))
for raw_v in (creature.get("flags", []) as Array):
tags.append(String(raw_v))
for tag: String in tags:
if tag == "apex_predator" or tag == "apex":
return "apex_predator"
if tag == "predator":
return "predator"
if tag == "herbivore":
return "herbivore"
if tag == "omnivore":
return "omnivore"
return "predator"
## Extract a unit_id from a payload that EventBus signals carry as
## `unit: Variant`. Tolerates RefCounted entities and Dictionary payloads.
static func unit_id_of(unit: Variant) -> String:
if unit == null:
return ""
if unit is RefCounted:
var rc: RefCounted = unit as RefCounted
if "unit_id" in rc:
return String(rc.get("unit_id"))
return ""
if unit is Dictionary:
return String((unit as Dictionary).get("unit_id", ""))
return ""

View file

@ -11,6 +11,9 @@ extends Node
## is "ship the asset or hear nothing". Each missing key warns once per
## session to avoid log spam.
const AudioResolverScript: GDScript = preload("res://engine/src/audio/audio_resolver.gd")
const AudioLoaderScript: GDScript = preload("res://engine/src/audio/audio_loader.gd")
const SFX_POOL_SIZE: int = 6
## Cross-theme audio library: every SFX entry + every music track lives
## here, alongside the .ogg files. Per-game subscription happens via the
@ -44,10 +47,10 @@ var _active_music_player: AudioStreamPlayer = null
var _current_music_id: String = ""
var _stream_cache: Dictionary = {}
var _last_unit_moved_msec: int = 0
## Cache of entity_id → category bucket ("unit.melee" / "building.civic" /
## "fauna.apex_predator" / "" if unknown). Avoids re-walking DataLoader on
## every play_for_entity call.
var _entity_category_cache: Dictionary = {}
## Categorical resolver: builds the candidate-key chain for an entity-keyed
## audio event. Owns its own DataLoader-cache. Stateless from AudioManager's
## perspective beyond construction.
var _resolver: RefCounted = AudioResolverScript.new()
## RNG used for streams[] random pick + pitch_jitter. Default-seeded; audio
## jitter does not need a deterministic / replayable seed.
var _rng: RandomNumberGenerator = RandomNumberGenerator.new()
@ -66,118 +69,23 @@ func _ready() -> void:
func load_theme(theme_id: String) -> void:
## Idempotent. Called by scenes that need audio after DataLoader.load_theme().
## Subscription-pattern load (mirrors DataLoader's resources/* layout):
## 1. read public/resources/audio/library.json — shared catalogue
## 2. read <theme>/data/audio/manifest.json — subscription gate
## 3. apply overrides from the manifest — per-game tweaks
## 4. read <theme>/data/audio/pools.json — per-game routing
## Delegates to AudioLoader (pure data transformation) and unpacks the
## resolved bundle into instance vars used by playback.
if _loaded and theme_id == _theme_id:
return
_theme_id = theme_id
_sfx_events = {}
_music_tracks.clear()
_victory_pool = {}
_defeat_pool = {}
_music_default_id = ""
_crossfade_seconds = 2.0
var library: Dictionary = _read_json_dict(
AUDIO_LIBRARY_PATH, "audio library"
var bundle: RefCounted = AudioLoaderScript.load(
AUDIO_LIBRARY_PATH, AUDIO_THEME_DIR_FMT % theme_id
)
if library.is_empty():
_loaded = true
return
var theme_dir: String = AUDIO_THEME_DIR_FMT % theme_id
var manifest: Dictionary = _read_json_dict(
"%s/manifest.json" % theme_dir, "audio manifest"
)
var pools: Dictionary = _read_json_dict(
"%s/pools.json" % theme_dir, "audio pools"
)
_apply_subscription(library, manifest)
_apply_pools(pools)
_sfx_events = bundle.sfx_events
_music_tracks = bundle.music_tracks
_music_default_id = bundle.music_default_id
_crossfade_seconds = bundle.crossfade_seconds
_victory_pool = bundle.victory_pool
_defeat_pool = bundle.defeat_pool
_loaded = true
## Filter the library by `manifest.includes` and merge `manifest.overrides`
## per-key. `includes: true` means subscribe to every entry; an array means
## a whitelist; missing means no subscriptions (a degenerate but valid
## state — the theme has no audio).
func _apply_subscription(library: Dictionary, manifest: Dictionary) -> void:
var lib_sfx: Dictionary = library.get("sfx", {}) as Dictionary
var lib_music: Dictionary = library.get("music", {}) as Dictionary
var includes_all: bool = bool(manifest.get("includes", true)) if manifest.get("includes", true) is bool else false
var includes_list: Array = manifest.get("includes", []) as Array if manifest.get("includes", true) is Array else []
var overrides: Dictionary = manifest.get("overrides", {}) as Dictionary
# SFX: filter + merge.
for key: String in lib_sfx.keys():
if not _includes_key(includes_all, includes_list, key):
continue
var entry: Dictionary = (lib_sfx[key] as Dictionary).duplicate()
if overrides.has(key) and overrides[key] is Dictionary:
var ov: Dictionary = overrides[key] as Dictionary
for k: String in ov.keys():
entry[k] = ov[k]
_sfx_events[key] = entry
# Music tracks: array-of-objects keyed by id, same filter rule.
for track_variant: Variant in (lib_music.get("tracks", []) as Array):
if not (track_variant is Dictionary):
continue
var track: Dictionary = track_variant as Dictionary
var id: String = String(track.get("id", ""))
if id.is_empty() or not _includes_key(includes_all, includes_list, id):
continue
var resolved: Dictionary = track.duplicate()
if overrides.has(id) and overrides[id] is Dictionary:
var ov: Dictionary = overrides[id] as Dictionary
for k: String in ov.keys():
resolved[k] = ov[k]
_music_tracks[id] = resolved
func _includes_key(includes_all: bool, includes_list: Array, key: String) -> bool:
if includes_all:
return true
return includes_list.has(key)
func _apply_pools(pools: Dictionary) -> void:
_crossfade_seconds = float(pools.get("crossfade_seconds", 2.0))
_music_default_id = String(pools.get("default_track_id", ""))
_victory_pool = (pools.get("victory_pool", {}) as Dictionary).duplicate()
_defeat_pool = (pools.get("defeat_pool", {}) as Dictionary).duplicate()
## Read a JSON file expected to contain a single object. Returns {} on any
## failure (missing file, bad JSON, non-object root) and warns once per
## failure mode. Caller decides whether {} is fatal.
func _read_json_dict(path: String, what: String) -> Dictionary:
if not FileAccess.file_exists(path):
push_warning("AudioManager: %s not found at %s" % [what, path])
return {}
var file: FileAccess = FileAccess.open(path, FileAccess.READ)
if file == null:
push_warning("AudioManager: failed to open %s (%s)" % [path, what])
return {}
var text: String = file.get_as_text()
file.close()
var json: JSON = JSON.new()
if json.parse(text) != OK:
push_warning(
"AudioManager: parse error in %s (%s) line %d: %s"
% [path, what, json.get_error_line(), json.get_error_message()]
)
return {}
if not (json.data is Dictionary):
push_warning("AudioManager: %s is not a JSON object: %s" % [what, path])
return {}
return json.data as Dictionary
func play_sfx(event_key: String) -> void:
## Public API (also called on EventBus signals). Fail-loud: if the key
## isn't in the manifest or its stream can't load, emit
@ -410,128 +318,14 @@ func _play_stream(stream: AudioStream, entry: Dictionary) -> void:
## 4. <event_kind> generic
## `kind` is `unit` / `building` / `fauna` based on which DataLoader
## category the id resolves into.
# Categorical resolution lives in AudioResolver. Methods kept as thin
# delegates so existing callers and tests don't change shape.
func _resolve_keys(entity_id: String, event_kind: String) -> Array[String]:
# Two-level chain: bespoke per-entity key, then categorical
# `<kind>.<sub>.<event_kind>`. The kind-only and bare fallbacks
# (`<kind>.<event_kind>`, `<event_kind>`) were removed: they were
# unreachable once every concrete category had a manifest entry,
# and keeping them invited silent-fallback drift instead of
# fail-loud authoring discipline.
var keys: Array[String] = []
keys.append("%s.%s" % [entity_id, event_kind])
var kind_and_sub: PackedStringArray = _entity_kind_and_sub(entity_id)
if kind_and_sub.size() == 2:
var kind: String = kind_and_sub[0]
var sub: String = kind_and_sub[1]
if not sub.is_empty():
keys.append("%s.%s.%s" % [kind, sub, event_kind])
return keys
return _resolver.resolve(entity_id, event_kind)
## Return [kind, sub_category] for an entity id, e.g.
## ["unit", "melee"] / ["building", "production"] / ["fauna", "predator"] /
## [] when the id is not registered with DataLoader.
func _entity_kind_and_sub(entity_id: String) -> PackedStringArray:
if _entity_category_cache.has(entity_id):
return _entity_category_cache[entity_id]
var result: PackedStringArray = []
# Wilds first — wild creatures appear as units of `unit_type: "wild"` AND
# may live in the wilds registry. Route them through the fauna path so
# their roar / spawn / death sounds match.
var wilds: Dictionary = DataLoader.get_data("wilds") as Dictionary
if wilds.has(entity_id) and wilds[entity_id] is Dictionary:
var wild: Dictionary = wilds[entity_id] as Dictionary
if not wild.is_empty():
result.append("fauna")
result.append(_fauna_class(wild))
_entity_category_cache[entity_id] = result
return result
var unit: Dictionary = DataLoader.get_unit(entity_id) as Dictionary
if not unit.is_empty():
# `unit_type: "wild"` units that are not in the wilds registry still
# get fauna classification by trophic semantics from their `attributes`.
if String(unit.get("unit_type", "")) == "wild":
result.append("fauna")
result.append(_fauna_class(unit))
_entity_category_cache[entity_id] = result
return result
result.append("unit")
result.append(_unit_combat_class(unit))
_entity_category_cache[entity_id] = result
return result
var bldg: Dictionary = DataLoader.get_building(entity_id) as Dictionary
if not bldg.is_empty():
result.append("building")
result.append(String(bldg.get("category", "")))
_entity_category_cache[entity_id] = result
return result
_entity_category_cache[entity_id] = result
return result
## Coarse combat class derived from existing unit JSON fields. The data
## doesn't carry an explicit `combat_class`; we infer one for sound routing.
func _unit_combat_class(unit: Dictionary) -> String:
var unit_type: String = String(unit.get("unit_type", ""))
if unit_type == "civilian":
return "civilian"
if unit_type == "support":
return "support"
var attack_type: String = String(unit.get("attack_type", ""))
if attack_type == "siege":
return "siege"
var ranged: int = int(unit.get("ranged_attack", 0))
if ranged > 0:
return "ranged"
# Default for military / mounted / other: melee.
return "melee"
## Fauna trophic class for sound routing. Prefers an explicit
## `trophic_class` field; falls back to scanning `attributes` /
## `flags` for a recognised tier.
func _fauna_class(creature: Dictionary) -> String:
var explicit: String = String(creature.get("trophic_class", ""))
if not explicit.is_empty():
return explicit
var tags: PackedStringArray = []
for raw_v in (creature.get("attributes", []) as Array):
tags.append(String(raw_v))
for raw_v in (creature.get("flags", []) as Array):
tags.append(String(raw_v))
for tag: String in tags:
if tag == "apex_predator" or tag == "apex":
return "apex_predator"
if tag == "predator":
return "predator"
if tag == "herbivore":
return "herbivore"
if tag == "omnivore":
return "omnivore"
# Default unclassified wilds to predator so the resolver never yields a
# malformed `fauna..attack` chain segment. Closure test pins this.
return "predator"
## Extract a unit_id from a payload that EventBus signals carry as
## `unit: Variant`. Tolerates RefCounted entities and Dictionary payloads.
func _unit_id_of(unit: Variant) -> String:
if unit == null:
return ""
if unit is RefCounted:
var rc: RefCounted = unit as RefCounted
if "unit_id" in rc:
return String(rc.get("unit_id"))
return ""
if unit is Dictionary:
return String((unit as Dictionary).get("unit_id", ""))
return ""
return AudioResolverScript.unit_id_of(unit)
# ---------------------------------------------------------------------------