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, Explosions, Footsteps, Movement, UI, ## Wind}, Master <- Music. The master hard limiter is a safety net, not a ## loudness tool: checked-in assets are already mastered with headroom. 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 var _last_ui_play_ms: Dictionary = {} var _last_variant: Dictionary = {} # Default routing/tuning per id prefix (applied during auto-registration). const BUS_HINTS := { "footstep": "Footsteps", "explosion": "Explosions", "movement": "Movement", "projectile": "Movement", "ui_": "UI", "weapon": "Weapons", "fire": "Weapons", "reload": "Weapons", } ## Old gameplay ids remain API-compatible while resolving to the remastered ## event families. Keeping aliases here avoids sound-path knowledge spreading ## back into every weapon and HUD script. const PREMIUM_ALIASES := { "ak47_fire": "weapon_ak47", "m4_fire": "weapon_m4", "mp7_fire": "weapon_mp7", "dmr_fire": "weapon_dmr", "awp_fire": "weapon_awp", "shotgun_fire": "weapon_shotgun", "nailgun_fire": "weapon_nailgun", "plasma_fire": "weapon_plasma", "rocket_fire": "weapon_rocket", "swarm_fire": "weapon_swarm", "mortar_fire": "weapon_mortar", "knife_swing": "weapon_knife", "shotgun_reload": "weapon_reload", "explosion": "explosion_deep", "bullet_impact": "impact_bullet", "footstep": "footstep_concrete", "wind": "movement_wind", "slide": "movement_slide_concrete", "wallrun": "movement_wallrun", "dash": "movement_dash", "jump": "movement_jump", "double_jump": "movement_double_jump", "vault": "movement_vault", "land": "movement_land", "grapple_launch": "movement_grapple_launch", "grapple_latch": "movement_grapple_latch", "grapple_reel": "movement_grapple_reel", "rocket_flight": "projectile_rocket_loop", "swarm_flight": "projectile_swarm_loop", "hit_confirm": "ui_confirm_premium", "kill_confirm": "ui_kill_premium", "ui_hover": "ui_hover_premium", "ui_click": "ui_click_premium", "ui_confirm": "ui_confirm_premium", "ui_error": "ui_error_premium", "ui_equip": "ui_equip_premium", } ## Mix intent belongs beside event registration. Source files have controlled ## peaks; these values establish hierarchy and variation without per-call magic ## numbers. Loops opt into sample-accurate looping when loaded. const EVENT_TUNING := { "weapon_ak47": {"bus": "Weapons", "pitch": 0.025, "db": -3.0}, "weapon_m4": {"bus": "Weapons", "pitch": 0.022, "db": -3.5}, "weapon_mp7": {"bus": "Weapons", "pitch": 0.028, "db": -5.0}, "weapon_dmr": {"bus": "Weapons", "pitch": 0.018, "db": -2.5}, "weapon_awp": {"bus": "Weapons", "pitch": 0.015, "db": -1.5}, "weapon_shotgun": {"bus": "Weapons", "pitch": 0.020, "db": -2.0}, "weapon_nailgun": {"bus": "Weapons", "pitch": 0.025, "db": -4.0}, "weapon_plasma": {"bus": "Weapons", "pitch": 0.025, "db": -4.0}, "weapon_rocket": {"bus": "Weapons", "pitch": 0.018, "db": -3.0}, "weapon_swarm": {"bus": "Weapons", "pitch": 0.025, "db": -4.0}, "weapon_mortar": {"bus": "Weapons", "pitch": 0.015, "db": -2.0}, "weapon_knife": {"bus": "Weapons", "pitch": 0.035, "db": -5.0}, "weapon_reload": {"bus": "Weapons", "pitch": 0.018, "db": -7.0}, "explosion_deep": {"bus": "Explosions", "pitch": 0.018, "db": 0.5}, "impact_bullet": {"bus": "Impacts", "pitch": 0.045, "db": -5.0}, "footstep_concrete": {"bus": "Footsteps", "pitch": 0.035, "db": -4.0}, "footstep_metal": {"bus": "Footsteps", "pitch": 0.030, "db": -5.0}, "footstep_wood": {"bus": "Footsteps", "pitch": 0.040, "db": -4.0}, "footstep_glass": {"bus": "Footsteps", "pitch": 0.025, "db": -6.0}, "movement_wind": {"bus": "Wind", "pitch": 0.0, "db": 0.0, "loop": true}, "movement_slide_concrete": {"bus": "Movement", "pitch": 0.0, "db": 0.0, "loop": true}, "movement_slide_metal": {"bus": "Movement", "pitch": 0.0, "db": -1.0, "loop": true}, "movement_slide_wood": {"bus": "Movement", "pitch": 0.0, "db": 0.0, "loop": true}, "movement_slide_glass": {"bus": "Movement", "pitch": 0.0, "db": -2.0, "loop": true}, "movement_wallrun": {"bus": "Movement", "pitch": 0.0, "db": -1.0, "loop": true}, "movement_grapple_reel": {"bus": "Movement", "pitch": 0.0, "db": -4.0, "loop": true}, "movement_grapple_launch": {"bus": "Movement", "pitch": 0.010, "db": -3.0}, "movement_grapple_latch": {"bus": "Movement", "pitch": 0.008, "db": -3.0}, "movement_dash": {"bus": "Movement", "pitch": 0.015, "db": -2.5}, "movement_jump": {"bus": "Movement", "pitch": 0.010, "db": -3.5}, "movement_double_jump": {"bus": "Movement", "pitch": 0.010, "db": -3.2}, "movement_vault": {"bus": "Movement", "pitch": 0.025, "db": -6.0}, "movement_land": {"bus": "Movement", "pitch": 0.025, "db": -4.0}, "projectile_rocket_loop": {"bus": "Movement", "pitch": 0.015, "db": -4.0, "loop": true}, "projectile_swarm_loop": {"bus": "Movement", "pitch": 0.020, "db": -6.0, "loop": true}, "ui_hover_premium": {"bus": "UI", "pitch": 0.008, "db": -5.0, "cooldown": 55}, "ui_click_premium": {"bus": "UI", "pitch": 0.006, "db": -3.5, "cooldown": 30}, "ui_confirm_premium": {"bus": "UI", "pitch": 0.005, "db": -3.0}, "ui_kill_premium": {"bus": "UI", "pitch": 0.005, "db": -1.5}, "ui_error_premium": {"bus": "UI", "pitch": 0.005, "db": -3.0}, "ui_equip_premium": {"bus": "UI", "pitch": 0.006, "db": -3.0}, } ## 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_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("Explosions", "SFX") _ensure_bus("Impacts", "SFX") _ensure_bus("Footsteps", "SFX") _ensure_bus("Movement", "SFX") _ensure_bus("Ambience", "SFX") _ensure_bus("UI", "SFX") _ensure_bus("Music", "Master") _ensure_bus("Wind", "SFX") _install_mix_effects() func _ensure_bus(bus_name: String, send_to: String) -> void: var idx := AudioServer.get_bus_index(bus_name) if idx == -1: idx = AudioServer.bus_count AudioServer.add_bus(idx) AudioServer.set_bus_name(idx, bus_name) if bus_name != "Master": AudioServer.set_bus_send(idx, send_to) func _install_mix_effects() -> void: var master := AudioServer.get_bus_index("Master") if master >= 0 and not _bus_has_effect(master, "AudioEffectHardLimiter"): var limiter := AudioEffectHardLimiter.new() limiter.ceiling_db = -1.0 limiter.pre_gain_db = 0.0 limiter.release = 0.10 AudioServer.add_bus_effect(master, limiter) var weapons := AudioServer.get_bus_index("Weapons") if weapons >= 0 and not _bus_has_effect(weapons, "AudioEffectCompressor"): var punch := AudioEffectCompressor.new() punch.threshold = -9.0 punch.ratio = 2.0 punch.attack_us = 1800.0 punch.release_ms = 95.0 punch.gain = 0.5 punch.mix = 0.42 AudioServer.add_bus_effect(weapons, punch) var explosions := AudioServer.get_bus_index("Explosions") if explosions >= 0 and not _bus_has_effect(explosions, "AudioEffectCompressor"): var depth := AudioEffectCompressor.new() depth.threshold = -10.0 depth.ratio = 1.8 depth.attack_us = 2000.0 depth.release_ms = 240.0 depth.gain = 0.5 depth.mix = 0.35 AudioServer.add_bus_effect(explosions, depth) func _bus_has_effect(bus: int, effect_class: String) -> bool: for i in AudioServer.get_bus_effect_count(bus): var effect := AudioServer.get_bus_effect(bus, i) if effect and effect.is_class(effect_class): return true return false 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 var tuning: Dictionary = EVENT_TUNING.get(id, {}) register_sound(id, groups[id], str(tuning.get("bus", bus)), float(tuning.get("pitch", 0.045)), float(tuning.get("db", 0.0))) func has_sound(id: String) -> bool: return _library.has(_resolve_id(id)) func _resolve_id(id: String) -> String: var premium: String = str(PREMIUM_ALIASES.get(id, id)) return premium if _library.has(premium) else 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 resolved := _resolve_id(id) var entry = _library.get(resolved) 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"]: if bool(EVENT_TUNING.get(resolved, {}).get("loop", false)): _enable_loop(s) r.add_stream(-1, s) return r ## Configure an owned AudioStreamPlayer/AudioStreamPlayer3D from the same event ## metadata used by pooled playback. This erases the old per-weapon gain/bus ## lottery while letting weapons keep their existing player nodes. func configure_player(player: Node, id: String, fallback_path: String = "", volume_db_offset: float = 0.0) -> void: if player == null: return var resolved := _resolve_id(id) var entry = _library.get(resolved) player.set("stream", stream_for(id, fallback_path)) if entry: player.set("bus", entry["bus"]) player.set("volume_db", float(entry["volume_db"]) + volume_db_offset) if player is AudioStreamPlayer3D: var spatial := player as AudioStreamPlayer3D spatial.unit_size = 7.0 spatial.max_distance = 85.0 # Automatic fire must not amputate the body/tail of the previous shot. # Godot defaults to one voice per player, which was making every rifle # turn into a dry click train under sustained fire. spatial.max_polyphony = 8 if resolved.begins_with("weapon_") \ and resolved != "weapon_reload" else 1 elif player is AudioStreamPlayer: (player as AudioStreamPlayer).max_polyphony = 4 func _enable_loop(stream: AudioStream) -> void: if stream is AudioStreamWAV: var wav := stream as AudioStreamWAV wav.loop_mode = AudioStreamWAV.LOOP_FORWARD wav.loop_begin = 0 wav.loop_end = maxi(1, roundi(wav.get_length() * wav.mix_rate)) elif stream is AudioStreamOggVorbis: (stream as AudioStreamOggVorbis).loop = true func _pick_stream(id: String, entry: Dictionary) -> AudioStream: var streams: Array = entry["streams"] if streams.size() <= 1: return streams[0] var previous := int(_last_variant.get(id, -1)) var pick := randi_range(0, streams.size() - 2) if pick >= previous: pick += 1 _last_variant[id] = pick return streams[pick] # ── 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 resolved := _resolve_id(id) var entry = _library.get(resolved) if entry == null: return null var player := _pool_3d[_next_3d] _next_3d = (_next_3d + 1) % POOL_SIZE_3D player.stream = _pick_stream(resolved, entry) 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(resolved): _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.begins_with("weapon_") or id.begins_with("explosion") ## 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 event on its authored bus. Local movement feedback ## uses this too; play_ui adds hover/click rate limiting on top. func play_local(id: String, volume_db_offset: float = 0.0, pitch_scale: float = 1.0) -> AudioStreamPlayer: var resolved := _resolve_id(id) var entry = _library.get(resolved) if entry == null: return null var player := _pool_2d[_next_2d] _next_2d = (_next_2d + 1) % POOL_SIZE_2D player.stream = _pick_stream(resolved, entry) 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.play() return player ## Play a non-positional UI sound. A small per-event cooldown stops a pointer ## sweeping across a dense menu from turning gentle hovers into a grating buzz. func play_ui(id: String, volume_db_offset: float = 0.0) -> AudioStreamPlayer: var resolved := _resolve_id(id) var cooldown := int(EVENT_TUNING.get(resolved, {}).get("cooldown", 0)) var now := Time.get_ticks_msec() if cooldown > 0 and now - int(_last_ui_play_ms.get(resolved, -cooldown)) < cooldown: return null _last_ui_play_ms[resolved] = now return play_local(id, volume_db_offset)