Files
settled-reach/client/scripts/autoloads/audio_manager.gd
T
jpmschweitzerandClaude Opus 4.6 0e6cc4eef8 feat(audio): Sprint 13 — AudioManager, zone crossfade, dip profiles, murmur wiring (#528, #529, #530, #533)
AudioManager: volume slider persistence (ConfigFile), settings UI with
5 teal-bordered sliders, default_bus_layout.tres for editor. Zone
crossfade: defensive zone_id read from snapshot tiles, 1.5-2s ambient
tween, auto-activates when server ships OQ-09. Dip profiles: dialogue
dip in show/hide_dialogue, ListeningFocus 30-tick gate via
stationary_ticks in game_state.gd. NPC murmur: client plumbing for
event-driven World SFX playback, no-ops until audio asset arrives.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 18:19:25 +01:00

437 lines
14 KiB
GDScript

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).
# --- D-067: Recognition chime asset key ---
# Fires on first fog recognition (cognitive delay onset). UISounds bus (not WorldSFX).
# Matches sfx_monologue_chime.ogg from D-038 — "neural lattice firing" feel.
const CHIME_RECOGNITION := "sfx_monologue_chime"
# --- 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).
# NOTE: listening_focus requires 30+ stationary ticks before activation (D-069/D-071).
# The tick gate is the caller's responsibility — AudioManager only manages bus volumes.
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
# --- D-073: Zone crossfade constants ---
const CROSSFADE_DURATION := 1.8 # D-073: 1.5-2s spec, mid-range
# Maps server zone_id strings to ambient asset keys (filenames in res://audio/).
# Hub and Workplace share the same ambient layer — same location type.
const ZONE_ASSETS: Dictionary = {
"hub": "amb_hub_layer",
"workplace": "amb_hub_layer",
"bar": "amb_bar_layer",
"corridor": "amb_corridor_layer",
}
# 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 = {}
# D-073: Zone crossfade state
var _current_zone_id: String = ""
var _zone_tweens: Array = []
signal dip_changed(profile: String)
const PREFS_PATH := "user://audio_prefs.cfg"
func _ready() -> void:
_setup_buses()
_scan_registry()
_load_prefs()
# --- 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).
# Guard: skip if filter already exists (e.g. duplicate _ready in tests).
var amb_idx := AudioServer.get_bus_index(BUS_AMBIENT)
if amb_idx >= 0 and _ambient_filter == null:
_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:
_scan_dir("res://audio/")
print("AudioManager: %d assets registered" % _registry.size())
func _scan_dir(path: String) -> void:
var dir := DirAccess.open(path)
if dir == null:
if path == "res://audio/":
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 != "":
var full_path := path.path_join(file_name)
if dir.current_is_dir():
_scan_dir(full_path)
elif not file_name.ends_with(".import"):
if file_name.get_extension().to_lower() in ["ogg", "wav", "mp3"]:
var stream := load(full_path) as AudioStream
if stream:
_registry[file_name.get_basename()] = stream
file_name = dir.get_next()
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
if loop_stream == null:
return null
_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)
# --- Audio asset registry: event type → asset key (D-018, #125) ---
# Maps server-sent sound event_type strings to audio asset keys.
# Keys match filename stems in res://audio/ (scanned by _scan_registry).
# Audio assets per D-038: footstep variants (walk / run), NPC murmur (D-072, #532).
# Missing assets no-op gracefully (D-038 fallback pattern).
const SOUND_EVENT_ASSETS: Dictionary = {
"Footstep": "sfx_footstep_metal_walk",
"FootstepWalk": "sfx_footstep_metal_walk",
"FootstepCareful":"sfx_footstep_metal_walk", # D-053: same asset until stance-differentiated audio lands
"FootstepCrouch": "sfx_footstep_metal_walk", # D-053: same asset until stance-differentiated audio lands
"FootstepSprint": "sfx_footstep_metal_run",
"FootstepRun": "sfx_footstep_metal_run",
# D-072 (#533): NPC conversation murmur — single universal asset, World SFX bus.
# Zone ambient provides natural conspicuousness (bar buries it, corridor exposes it).
"Voice": "sfx_npc_murmur",
"VoiceConversation": "sfx_npc_murmur",
}
## Play a close-range sound event at a world tile position (D-018, #125).
## event_type: server RangeCategory::Close event type string (e.g. "Footstep").
## world_tile_pos: server tile coordinates — converted to world pixels internally.
## No-ops if event_type has no registered asset or asset file is absent.
func play_sound_event(event_type: String, world_tile_pos: Vector2) -> void:
var asset_key: String = SOUND_EVENT_ASSETS.get(event_type, "")
if asset_key.is_empty():
return
play_at(asset_key, world_tile_pos * Constants.TILE_SIZE)
# --- 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)
_save_prefs()
func get_volume(bus: String) -> float:
return _bus_volumes.get(bus, 0.0)
# --- Volume persistence (user://audio_prefs.cfg) ---
func _load_prefs() -> void:
var cfg := ConfigFile.new()
if cfg.load(PREFS_PATH) != OK:
return
for bus_name in BUSES:
if cfg.has_section_key("audio", bus_name):
var db: float = cfg.get_value("audio", bus_name, 0.0)
# Apply directly: bypass _save_prefs() on initial load.
_bus_volumes[bus_name] = db
var idx := AudioServer.get_bus_index(bus_name)
if idx >= 0:
AudioServer.set_bus_volume_db(idx, db)
func _save_prefs() -> void:
var cfg := ConfigFile.new()
for bus_name in BUSES:
cfg.set_value("audio", bus_name, _bus_volumes.get(bus_name, 0.0))
var err := cfg.save(PREFS_PATH)
if err != OK:
push_warning("AudioManager: failed to save prefs to %s (error %d)" % [PREFS_PATH, err])
# --- Zone crossfade (D-073) ---
## Handle zone transition. Server sends zone_id per tile in ObserverSnapshot.
## Hard boundary trigger with 1.5-2s audio crossfade between ambient layers.
## Interruptible — mid-crossfade zone change tweens from current position.
## No-op if assets absent (D-038) or same zone.
func set_zone(zone_id: String) -> void:
if zone_id == _current_zone_id:
return
var new_asset: String = ZONE_ASSETS.get(zone_id, "")
var old_asset: String = ZONE_ASSETS.get(_current_zone_id, "")
_current_zone_id = zone_id
_kill_zone_tweens()
# Fade out old ambient layer (if different asset from incoming zone)
if not old_asset.is_empty() and old_asset != new_asset:
if _ambient_players.has(old_asset):
var old_player: AudioStreamPlayer = _ambient_players[old_asset]
if is_instance_valid(old_player):
var tween := create_tween()
tween.tween_property(old_player, "volume_db", -80.0, CROSSFADE_DURATION)
tween.tween_callback(stop_loop.bind(old_asset))
_zone_tweens.append(tween)
# Fade in new ambient layer
if not new_asset.is_empty():
if _ambient_players.has(new_asset):
# Already playing (interrupted reverse crossfade) — tween from current volume
var existing: AudioStreamPlayer = _ambient_players[new_asset]
if is_instance_valid(existing):
var tween := create_tween()
tween.tween_property(existing, "volume_db", 0.0, CROSSFADE_DURATION)
_zone_tweens.append(tween)
elif has_asset(new_asset):
var new_player := play_loop(new_asset, BUS_AMBIENT)
if new_player:
new_player.volume_db = -80.0
var tween := create_tween()
tween.tween_property(new_player, "volume_db", 0.0, CROSSFADE_DURATION)
_zone_tweens.append(tween)
func _kill_zone_tweens() -> void:
for tween in _zone_tweens:
if tween != null and tween.is_valid():
tween.kill()
_zone_tweens.clear()
# --- 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