232 lines
7.5 KiB
GDScript
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()
|
|
)
|