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