diff --git a/client/project.godot b/client/project.godot index 1180aa656..633e15686 100644 --- a/client/project.godot +++ b/client/project.godot @@ -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] diff --git a/client/scripts/autoloads/audio_manager.gd b/client/scripts/autoloads/audio_manager.gd new file mode 100644 index 000000000..5b871db76 --- /dev/null +++ b/client/scripts/autoloads/audio_manager.gd @@ -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