extends Node ## Autoload: central sound playback with variation + pitch randomization. ## ## Why: identical samples played back-to-back sound robotic. Real games ship ## 3-5 variations per event and randomize pitch a few percent per shot. This ## manager makes that the default for every sound in the game. ## ## Usage: ## AudioManager.play_3d("ak47_fire", muzzle_global_pos) ## AudioManager.play_ui("hit_confirm") ## AudioManager.register_sound("footstep_metal", [ ## "res://assets/sounds/footstep_metal_01.wav", ## "res://assets/sounds/footstep_metal_02.wav", ## ], "Footsteps") ## ## Sounds are auto-registered from assets/sounds/*.wav on startup (the file ## stem is the sound id). Drop variations next to the original with _01/_02/… ## suffixes and they are picked up as one id with random selection: ## footstep.wav, footstep_01.wav, footstep_02.wav -> id "footstep" (3 vars) ## ## Buses: Master <- SFX <- {Weapons, Footsteps, UI}, Master <- Music. ## See docs/SOUND_DESIGN.md for where to get good source audio. const SOUNDS_DIR := "res://assets/sounds" const POOL_SIZE_3D := 32 const POOL_SIZE_2D := 8 # id -> { "streams": Array[AudioStream], "bus": String, "pitch_var": float, "volume_db": float } var _library: Dictionary = {} var _pool_3d: Array[AudioStreamPlayer3D] = [] var _pool_2d: Array[AudioStreamPlayer] = [] var _next_3d: int = 0 var _next_2d: int = 0 # Default routing/tuning per id prefix (applied during auto-registration). const BUS_HINTS := { "footstep": "Footsteps", "fire": "Weapons", "reload": "Weapons", } ## weapon_name (as broadcast in fire RPCs) -> fire sound id. const WEAPON_SOUND_IDS := { "AK-47": "ak47_fire", "M4": "m4_fire", "MP7": "mp7_fire", "DMR": "dmr_fire", "AWP": "awp_fire", "Double Barrel Shotgun": "shotgun_fire", "Nail Gun": "nailgun_fire", "Plasma Gun": "plasma_fire", "Rocket Launcher": "rocket_fire", "Rocket Swarm": "swarm_fire", "Mortar": "mortar_fire", "Knife": "knife_swing", } ## Sounds loud enough to slap back off nearby walls (early reflections). const OCCLUSION_TICK := 0.12 var _occlusion_accum := 0.0 func _ready() -> void: _setup_buses() _build_pools() _auto_register_sounds() print("AudioManager: %d sounds registered" % _library.size()) func _physics_process(delta: float) -> void: # Re-filter playing positional sounds as the listener/source move so # walking behind a wall audibly muffles loops and long tails too. _occlusion_accum += delta if _occlusion_accum < OCCLUSION_TICK: return _occlusion_accum = 0.0 var cam := get_viewport().get_camera_3d() if cam == null: return var listener := cam.global_position for p in _pool_3d: if p.playing and not p.get_meta("is_reflection", false): Acoustics.apply_occlusion(p, Acoustics.occlusion( cam.get_world_3d(), listener, p.global_position)) # External looping players (projectile flight sounds, etc.) opt in by # joining this group. for node in get_tree().get_nodes_in_group("acoustic_occluded"): if node is AudioStreamPlayer3D and node.playing and node.is_inside_tree(): Acoustics.apply_occlusion(node, Acoustics.occlusion( cam.get_world_3d(), listener, node.global_position)) func weapon_sound_id(weapon_name: String) -> String: return WEAPON_SOUND_IDS.get(weapon_name, "") # ── Bus layout ──────────────────────────────────────────────────────────────── func _setup_buses() -> void: _ensure_bus("SFX", "Master") _ensure_bus("Weapons", "SFX") _ensure_bus("Footsteps", "SFX") _ensure_bus("UI", "SFX") _ensure_bus("Music", "Master") func _ensure_bus(bus_name: String, send_to: String) -> void: if AudioServer.get_bus_index(bus_name) != -1: return var idx := AudioServer.bus_count AudioServer.add_bus(idx) AudioServer.set_bus_name(idx, bus_name) AudioServer.set_bus_send(idx, send_to) func set_bus_volume_linear(bus_name: String, linear: float) -> void: var idx := AudioServer.get_bus_index(bus_name) if idx != -1: AudioServer.set_bus_volume_db(idx, linear_to_db(clampf(linear, 0.0001, 1.0))) # ── Registration ────────────────────────────────────────────────────────────── func register_sound(id: String, paths: Array, bus: String = "SFX", pitch_var: float = 0.06, volume_db: float = 0.0) -> void: var streams: Array = [] for p in paths: if ResourceLoader.exists(p): var s = load(p) if s: streams.append(s) if streams.is_empty(): return _library[id] = { "streams": streams, "bus": bus, "pitch_var": pitch_var, "volume_db": volume_db, } func _auto_register_sounds() -> void: var dir := DirAccess.open(SOUNDS_DIR) if not dir: return # Group files: "footstep_01.wav" and "footstep.wav" both map to "footstep". var groups: Dictionary = {} for fname in dir.get_files(): if not (fname.ends_with(".wav") or fname.ends_with(".ogg") or fname.ends_with(".mp3")): continue var stem := fname.get_basename() var base := stem var parts := stem.rsplit("_", true, 1) if parts.size() == 2 and parts[1].is_valid_int(): base = parts[0] if not groups.has(base): groups[base] = [] groups[base].append(SOUNDS_DIR + "/" + fname) for id in groups: var bus := "SFX" for hint in BUS_HINTS: if id.findn(hint) != -1: bus = BUS_HINTS[hint] break register_sound(id, groups[id], bus) func has_sound(id: String) -> bool: return _library.has(id) ## Build an AudioStreamRandomizer for a registered id: every play() picks a ## random variation with random pitch. For nodes that own their own player ## (weapons) instead of going through the pools. Falls back to a plain load ## of fallback_path if the id isn't registered. func stream_for(id: String, fallback_path: String = "") -> AudioStream: var entry = _library.get(id) if entry == null: return load(fallback_path) if fallback_path != "" and ResourceLoader.exists(fallback_path) else null var r := AudioStreamRandomizer.new() r.random_pitch = 1.0 + entry["pitch_var"] for s in entry["streams"]: r.add_stream(-1, s) return r # ── Playback ────────────────────────────────────────────────────────────────── func _build_pools() -> void: for i in POOL_SIZE_3D: var p := AudioStreamPlayer3D.new() p.name = "Pool3D_%d" % i p.max_distance = 80.0 p.unit_size = 8.0 add_child(p) _pool_3d.append(p) for i in POOL_SIZE_2D: var p := AudioStreamPlayer.new() p.name = "Pool2D_%d" % i add_child(p) _pool_2d.append(p) ## Play a positional sound with material-aware occlusion; loud sounds also ## spawn early reflections off nearby surfaces so they can be located. ## Safe to call every frame — players are pooled. func play_3d(id: String, global_pos: Vector3, volume_db_offset: float = 0.0, pitch_scale: float = 1.0) -> AudioStreamPlayer3D: var entry = _library.get(id) if entry == null: return null var player := _pool_3d[_next_3d] _next_3d = (_next_3d + 1) % POOL_SIZE_3D player.stream = entry["streams"].pick_random() player.bus = entry["bus"] player.volume_db = entry["volume_db"] + volume_db_offset player.pitch_scale = pitch_scale * randf_range(1.0 - entry["pitch_var"], 1.0 + entry["pitch_var"]) player.global_position = global_pos player.set_meta("is_reflection", false) var cam := get_viewport().get_camera_3d() if cam: var occ := Acoustics.occlusion(cam.get_world_3d(), cam.global_position, global_pos) Acoustics.apply_occlusion(player, occ) if _is_loud(id): _spawn_reflections(player.stream, global_pos, cam, entry, volume_db_offset) else: Acoustics.apply_occlusion(player, { "db": 0.0, "cutoff": Acoustics.OPEN_CUTOFF, "surfaces": 0 }) player.play() return player func _is_loud(id: String) -> bool: return id.findn("fire") != -1 or id.findn("explosion") != -1 ## Delayed, filtered copies of a loud sound from nearby wall mirror points. func _spawn_reflections(stream: AudioStream, source: Vector3, cam: Camera3D, entry: Dictionary, volume_db_offset: float) -> void: var refls := Acoustics.reflections(cam.get_world_3d(), source, cam.global_position) for r in refls: get_tree().create_timer(r.delay).timeout.connect(func(): var p := _pool_3d[_next_3d] _next_3d = (_next_3d + 1) % POOL_SIZE_3D p.stream = stream p.bus = entry["bus"] p.volume_db = entry["volume_db"] + volume_db_offset + r.db p.pitch_scale = randf_range(0.97, 1.0) p.global_position = r.point p.set_meta("is_reflection", true) p.attenuation_filter_cutoff_hz = r.cutoff p.attenuation_filter_db = Acoustics.DEFAULT_FILTER_DB p.play() ) ## Play a non-positional sound (UI clicks, local confirms). func play_ui(id: String, volume_db_offset: float = 0.0) -> AudioStreamPlayer: var entry = _library.get(id) if entry == null: return null var player := _pool_2d[_next_2d] _next_2d = (_next_2d + 1) % POOL_SIZE_2D player.stream = entry["streams"].pick_random() player.bus = "UI" player.volume_db = entry["volume_db"] + volume_db_offset player.pitch_scale = randf_range(1.0 - entry["pitch_var"], 1.0 + entry["pitch_var"]) player.play() return player