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). # 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 # 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). # 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 _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 to Sprint 9+ (D-073). ## Stub exists so server integration can call it without conditional checks. 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