feat(audio): add AudioManager autoload with 5-bus architecture

D-068 bus layout (Music, Ambient, WorldSFX, PlayerActions, UISounds),
directory-scan asset registry, spatial/non-spatial playback, D-069
audio dip profiles (dialogue, confrontation, listening_focus) with
low-pass filter sweep, and D-073 zone crossfade stub. No-op fallback
when audio assets absent. Implements #255.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-17 16:34:42 +01:00
co-authored by Claude Opus 4.6
parent 600cfe506c
commit 9e6b859cdf
2 changed files with 303 additions and 0 deletions
+1
View File
@@ -22,6 +22,7 @@ GameState="*res://scripts/autoloads/game_state.gd"
InputMapper="*res://scripts/autoloads/input_mapper.gd"
UIStrings="*res://scripts/autoloads/ui_strings.gd"
FogState="*res://scripts/autoloads/fog_state.gd"
AudioManager="*res://scripts/autoloads/audio_manager.gd"
[display]
+302
View File
@@ -0,0 +1,302 @@
extends Node
## AudioManager autoload singleton (D-068, D-069, D-073).
## 5-bus architecture with directory-scan registry pattern.
## No-op fallback when audio assets absent (D-038).
## Spatial audio positioning for close-range sounds (D-018).
# --- Bus names (D-068) ---
const BUS_MUSIC := "Music"
const BUS_AMBIENT := "Ambient"
const BUS_WORLD_SFX := "WorldSFX"
const BUS_PLAYER_ACTIONS := "PlayerActions"
const BUS_UI_SOUNDS := "UISounds"
const BUSES := [BUS_MUSIC, BUS_AMBIENT, BUS_WORLD_SFX, BUS_PLAYER_ACTIONS, BUS_UI_SOUNDS]
# D-069: Audio dip profiles — dB offsets relative to player slider setting.
# Mid-range of spec values used (e.g. -6 to -8 → -7).
const DIP_SPECS := {
"dialogue": {
"ease_in": 0.3, "ease_out": 0.5,
"buses": { "Ambient": -7.0 },
},
"confrontation": {
"ease_in": 0.5, "ease_out": 1.0,
"buses": { "Ambient": -11.0, "WorldSFX": -5.0 },
"filter_hz": 800.0,
},
"listening_focus": {
"ease_in": 0.5, "ease_out": 0.5,
"buses": { "WorldSFX": 2.5 },
},
}
# Low-pass filter default cutoff — effectively bypassed at this value.
const FILTER_CUTOFF_DEFAULT := 20500.0
# Asset registry: filename stem (e.g. "amb_station_base") → AudioStream
var _registry: Dictionary = {}
# Player volume settings per bus (dB). Base for proportional dip calculation.
var _bus_volumes: Dictionary = {}
# Active dip state (D-069)
var _active_dip: String = ""
var _dip_tweens: Array = []
# Low-pass filter on Ambient bus (D-069 confrontation sweep)
var _ambient_filter: AudioEffectLowPassFilter = null
# Ambient loop players keyed by asset_key (D-073 zone crossfade)
var _ambient_players: Dictionary = {}
signal dip_changed(profile: String)
func _ready() -> void:
_setup_buses()
_scan_registry()
# --- Bus setup ---
func _setup_buses() -> void:
for bus_name in BUSES:
if AudioServer.get_bus_index(bus_name) < 0:
AudioServer.add_bus()
var idx := AudioServer.bus_count - 1
AudioServer.set_bus_name(idx, bus_name)
AudioServer.set_bus_send(idx, "Master")
_bus_volumes[bus_name] = 0.0
# Low-pass filter on Ambient bus — cutoff starts high (bypassed).
# Confrontation dip sweeps it down to 800 Hz (D-069).
var amb_idx := AudioServer.get_bus_index(BUS_AMBIENT)
if amb_idx >= 0:
_ambient_filter = AudioEffectLowPassFilter.new()
_ambient_filter.cutoff_hz = FILTER_CUTOFF_DEFAULT
AudioServer.add_bus_effect(amb_idx, _ambient_filter)
# --- Asset registry (D-068 directory-scan pattern) ---
func _scan_registry() -> void:
var dir := DirAccess.open("res://audio/")
if dir == null:
print("AudioManager: res://audio/ not found — all play methods no-op")
return
dir.list_dir_begin()
var file_name := dir.get_next()
while file_name != "":
if not dir.current_is_dir() and not file_name.ends_with(".import"):
if file_name.get_extension().to_lower() in ["ogg", "wav", "mp3"]:
var stream := load("res://audio/" + file_name) as AudioStream
if stream:
_registry[file_name.get_basename()] = stream
file_name = dir.get_next()
print("AudioManager: %d assets registered" % _registry.size())
func has_asset(asset_key: String) -> bool:
return _registry.has(asset_key)
func get_registry_size() -> int:
return _registry.size()
# --- Playback: non-spatial ---
## Play a one-shot sound on the given bus. No-ops if asset not in registry.
func play(asset_key: String, bus: String = BUS_UI_SOUNDS) -> void:
var stream := _get_stream(asset_key)
if stream == null:
return
var player := AudioStreamPlayer.new()
player.stream = stream
player.bus = bus
add_child(player)
player.finished.connect(player.queue_free)
player.play()
## Start or replace a looping non-spatial sound. Returns the player or null.
func play_loop(asset_key: String, bus: String = BUS_AMBIENT) -> AudioStreamPlayer:
var stream := _get_stream(asset_key)
if stream == null:
return null
stop_loop(asset_key)
var loop_stream := stream.duplicate() as AudioStream
_enable_loop(loop_stream)
var player := AudioStreamPlayer.new()
player.stream = loop_stream
player.bus = bus
add_child(player)
player.play()
_ambient_players[asset_key] = player
return player
## Stop a looping sound by asset key.
func stop_loop(asset_key: String) -> void:
if _ambient_players.has(asset_key):
var player: AudioStreamPlayer = _ambient_players[asset_key]
if is_instance_valid(player):
player.stop()
player.queue_free()
_ambient_players.erase(asset_key)
## Stop all looping sounds.
func stop_all_loops() -> void:
for key in _ambient_players.keys():
stop_loop(key)
# --- Playback: spatial (D-018 close-range) ---
## Play a one-shot spatial sound at a world position (pixels).
## Added to current_scene for correct AudioListener2D positioning.
func play_at(asset_key: String, world_position: Vector2, bus: String = BUS_WORLD_SFX) -> void:
var stream := _get_stream(asset_key)
if stream == null:
return
var player := AudioStreamPlayer2D.new()
player.stream = stream
player.bus = bus
player.global_position = world_position
var scene := get_tree().current_scene
if scene:
scene.add_child(player)
else:
add_child(player)
player.finished.connect(player.queue_free)
player.play()
# --- Audio dip profiles (D-069) ---
## Apply a dip profile. Interrupts any active dip, restoring unaffected buses.
## Profiles: "dialogue", "confrontation", "listening_focus"
func apply_dip(profile: String) -> void:
if not DIP_SPECS.has(profile):
push_warning("AudioManager: unknown dip profile '%s'" % profile)
return
_kill_tweens()
_active_dip = profile
var spec: Dictionary = DIP_SPECS[profile]
var buses: Dictionary = spec.get("buses", {})
var ease_in: float = spec.get("ease_in", 0.3)
# Tween ALL buses — affected ones get offset, unaffected ones restore to base.
for bus_name in BUSES:
var offset_db: float = buses.get(bus_name, 0.0)
var target_db: float = _bus_volumes.get(bus_name, 0.0) + offset_db
var idx := AudioServer.get_bus_index(bus_name)
if idx < 0:
continue
var current_db := AudioServer.get_bus_volume_db(idx)
if absf(current_db - target_db) < 0.1:
continue
var tween := create_tween()
tween.tween_method(_make_bus_setter(bus_name), current_db, target_db, ease_in)
_dip_tweens.append(tween)
# Confrontation: sweep low-pass filter on Ambient (D-069)
if spec.has("filter_hz") and _ambient_filter:
var tween := create_tween()
tween.tween_property(_ambient_filter, "cutoff_hz", spec["filter_hz"], ease_in)
_dip_tweens.append(tween)
elif _ambient_filter and _ambient_filter.cutoff_hz < FILTER_CUTOFF_DEFAULT - 100.0:
# Leaving confrontation for another dip — restore filter
var tween := create_tween()
tween.tween_property(_ambient_filter, "cutoff_hz", FILTER_CUTOFF_DEFAULT, ease_in)
_dip_tweens.append(tween)
dip_changed.emit(profile)
## Clear the active dip, restoring all bus volumes to player slider settings.
func clear_dip() -> void:
if _active_dip.is_empty():
return
var spec: Dictionary = DIP_SPECS.get(_active_dip, {})
var ease_out: float = spec.get("ease_out", 0.5)
_kill_tweens()
_active_dip = ""
for bus_name in BUSES:
var target_db: float = _bus_volumes.get(bus_name, 0.0)
var idx := AudioServer.get_bus_index(bus_name)
if idx < 0:
continue
var current_db := AudioServer.get_bus_volume_db(idx)
if absf(current_db - target_db) < 0.1:
continue
var tween := create_tween()
tween.tween_method(_make_bus_setter(bus_name), current_db, target_db, ease_out)
_dip_tweens.append(tween)
# Restore low-pass filter
if _ambient_filter and _ambient_filter.cutoff_hz < FILTER_CUTOFF_DEFAULT - 100.0:
var tween := create_tween()
tween.tween_property(_ambient_filter, "cutoff_hz", FILTER_CUTOFF_DEFAULT, ease_out)
_dip_tweens.append(tween)
dip_changed.emit("")
func get_active_dip() -> String:
return _active_dip
# --- Volume control (player sliders, D-069 proportional base) ---
## Set the player's volume preference for a bus. During active dip, immediately
## recalculates effective volume (proportional to new base).
func set_volume(bus: String, volume_db: float) -> void:
_bus_volumes[bus] = volume_db
var idx := AudioServer.get_bus_index(bus)
if idx < 0:
return
if _active_dip.is_empty():
AudioServer.set_bus_volume_db(idx, volume_db)
else:
var spec: Dictionary = DIP_SPECS.get(_active_dip, {})
var buses: Dictionary = spec.get("buses", {})
var offset_db: float = buses.get(bus, 0.0)
AudioServer.set_bus_volume_db(idx, volume_db + offset_db)
func get_volume(bus: String) -> float:
return _bus_volumes.get(bus, 0.0)
# --- Zone crossfade (D-073 stub) ---
## Handle zone transition. Server sends zone_id per tile in ObserverSnapshot.
## Full crossfade implementation deferred — stub for integration point.
func set_zone(_zone_id: String) -> void:
pass
# --- Internal helpers ---
func _get_stream(asset_key: String) -> AudioStream:
return _registry.get(asset_key) as AudioStream
func _make_bus_setter(bus_name: String) -> Callable:
return func(db: float) -> void:
var idx := AudioServer.get_bus_index(bus_name)
if idx >= 0:
AudioServer.set_bus_volume_db(idx, db)
func _kill_tweens() -> void:
for tween in _dip_tweens:
if tween != null and tween.is_valid():
tween.kill()
_dip_tweens.clear()
static func _enable_loop(stream: AudioStream) -> void:
if stream is AudioStreamOggVorbis:
stream.loop = true
elif stream is AudioStreamWAV:
stream.loop_mode = AudioStreamWAV.LOOP_FORWARD
elif stream is AudioStreamMP3:
stream.loop = true