Files
aetherbound-guild/runtime/presentation/audio_service.gd
T

232 lines
7.5 KiB
GDScript

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()
)