class_name ABGAudioService extends Node ## Runtime audio for Aetherbound Guild. ## ## Owns the contract's mix tree (`docs/04_ART_ANIMATION_AUDIO.md` section 3): ## Master -> Music (MusicBed/MusicLayer), Ambience, SFX (SFXCritical, ## SFXPlayerCommit, SFXCombat, SFXWorld, SFXUI) and Voice. Music beds switch per ## page family with crossfades; the same family never restarts. SFX play by ## semantic contract event name with per-event cooldown and priority class. ## ## Muted or disabled output never stops the dispatch journal: the contract keeps ## semantic events for captions and logs when audio is unavailable. const MANIFEST_PATH := "res://assets/audio/audio_manifest.json" const JOURNAL_LIMIT := 256 signal event_dispatched(record: Dictionary) var manifest: Dictionary = {} var events: Dictionary = {} var music_families: Dictionary = {} var crossfade_seconds := 0.9 var max_simultaneous := 12 var journal: Array[Dictionary] = [] var music_family := "" var music_switches := 0 var music_restarts := 0 var output_enabled := true var _music_players: Array[AudioStreamPlayer] = [] var _music_active := 0 var _sfx_players: Array[AudioStreamPlayer] = [] var _sfx_next := 0 var _stream_cache: Dictionary = {} var _last_played_ms: Dictionary = {} var _music_fade_generation := 0 var _rng := RandomNumberGenerator.new() func _ready() -> void: _rng.randomize() load_manifest() _build_players() func load_manifest(path := MANIFEST_PATH) -> bool: if not FileAccess.file_exists(path): push_warning("audio manifest missing: %s" % path) return false var parser := JSON.new() if parser.parse(FileAccess.get_file_as_string(path)) != OK: push_warning("audio manifest unreadable: %s" % path) return false var data: Dictionary = Dictionary(parser.data) manifest = data events = Dictionary(data.get("events", {})) var music: Dictionary = Dictionary(data.get("music", {})) music_families = Dictionary(music.get("families", {})) crossfade_seconds = float(music.get("crossfade_seconds", 0.9)) max_simultaneous = maxi(1, int(Dictionary(data.get("sfx", {})).get("max_simultaneous", 12))) return true func apply_settings(settings: Dictionary) -> void: var master := AudioServer.get_bus_index(&"Master") if master >= 0: AudioServer.set_bus_mute(master, bool(settings.get("master_muted", false))) set_bus_volume("Music", float(settings.get("music_volume", 0.8))) set_bus_volume("Ambience", float(settings.get("ambience_volume", 0.8))) set_bus_volume("SFX", float(settings.get("sfx_volume", 0.9))) set_bus_volume("Voice", float(settings.get("voice_volume", 0.8))) output_enabled = not bool(settings.get("master_muted", false)) func set_bus_volume(bus_name: String, linear: float) -> void: var index := AudioServer.get_bus_index(StringName(bus_name)) if index >= 0: AudioServer.set_bus_volume_db(index, linear_to_db(maxf(0.001, linear))) func music_available() -> bool: return not music_families.is_empty() func set_music(family: String) -> bool: if not music_families.has(family): return false if family == music_family: return false var entry: Dictionary = Dictionary(music_families[family]) var stream := _stream_for(String(entry.get("file", "")), true) if stream == null: return false music_family = family music_switches += 1 if music_switches > 1: music_restarts += 1 var bus := StringName(String(entry.get("bus", "Music"))) _crossfade_to(stream, bus) _record({"kind": "music", "name": family, "bus": String(bus), "priority": "P2", "suppressed": "" if output_enabled else "muted"}) return true func play_sfx(event_name: String, transaction_id := "") -> bool: if not events.has(event_name): return false var entry: Dictionary = Dictionary(events[event_name]) var priority := String(entry.get("priority", "P3")) var cooldown := int(entry.get("cooldown_ms", 0)) var now := Time.get_ticks_msec() var last := int(_last_played_ms.get(event_name, -100000)) if not output_enabled: # Muted output keeps the semantic event for captions and logs. _record({"kind": "sfx", "name": event_name, "bus": String(entry.get("bus", "SFX")), "priority": priority, "suppressed": "muted", "transaction_id": transaction_id}) return false if priority != "P0" and cooldown > 0 and now - last < cooldown: _record({"kind": "sfx", "name": event_name, "bus": String(entry.get("bus", "SFX")), "priority": priority, "suppressed": "cooldown"}) return false _last_played_ms[event_name] = now var stream := _stream_for(String(entry.get("file", "")), false) if stream == null: _record({"kind": "sfx", "name": event_name, "bus": String(entry.get("bus", "SFX")), "priority": priority, "suppressed": "missing_asset"}) return false var player := _next_sfx_player() player.stream = stream player.bus = StringName(String(entry.get("bus", "SFX"))) player.volume_db = float(entry.get("volume_db", 0.0)) player.play() _record({"kind": "sfx", "name": event_name, "bus": String(player.bus), "priority": priority, "suppressed": "", "transaction_id": transaction_id}) return true func dispose() -> void: stop_all() for player in _music_players + _sfx_players: player.stream = null _stream_cache.clear() func stop_all() -> void: for player in _music_players: player.stop() for player in _sfx_players: player.stop() func journal_size() -> int: return journal.size() func last_record() -> Dictionary: return journal[journal.size() - 1] if not journal.is_empty() else {} func records_for(kind: String) -> Array[Dictionary]: var result: Array[Dictionary] = [] for record in journal: if String(record.get("kind", "")) == kind: result.append(record) return result func _record(record: Dictionary) -> void: record["at_ms"] = Time.get_ticks_msec() journal.append(record) if journal.size() > JOURNAL_LIMIT: journal.remove_at(0) event_dispatched.emit(record) func _build_players() -> void: for index in 2: var music := AudioStreamPlayer.new() music.name = "MusicPlayer%d" % index music.bus = &"MusicBed" music.volume_db = -80.0 add_child(music) _music_players.append(music) for index in max_simultaneous: var sfx := AudioStreamPlayer.new() sfx.name = "SfxPlayer%d" % index sfx.bus = &"SFX" add_child(sfx) _sfx_players.append(sfx) func _next_sfx_player() -> AudioStreamPlayer: _sfx_next = (_sfx_next + 1) % _sfx_players.size() return _sfx_players[_sfx_next] func _stream_for(relative: String, looped: bool) -> AudioStream: if relative.is_empty(): return null if _stream_cache.has(relative): return _stream_cache[relative] as AudioStream var path := "res://assets/audio/%s" % relative if not ResourceLoader.exists(path): return null var stream := ResourceLoader.load(path, "", ResourceLoader.CACHE_MODE_IGNORE) as AudioStream if stream == null: return null if looped and stream is AudioStreamMP3: (stream as AudioStreamMP3).loop = true if looped and stream is AudioStreamWAV: (stream as AudioStreamWAV).loop_mode = AudioStreamWAV.LOOP_FORWARD _stream_cache[relative] = stream return stream func _crossfade_to(stream: AudioStream, bus: StringName) -> void: var generation := _music_fade_generation + 1 _music_fade_generation = generation var from := _music_players[_music_active] var to := _music_players[1 - _music_active] _music_active = 1 - _music_active to.stream = stream to.bus = bus to.volume_db = -80.0 to.play() var tween := create_tween() tween.set_parallel(true) tween.tween_property(to, "volume_db", 0.0, crossfade_seconds) if from.playing: tween.tween_property(from, "volume_db", -80.0, crossfade_seconds) tween.chain().tween_callback(func() -> void: if generation == _music_fade_generation and from != to: from.stop() )