Audio: mood music, ambience, sound effects and creature voices (Kenney CC0 + synthesised); protocol 13

This commit is contained in:
Paul Bailey
2026-11-05 21:03:54 +00:00
parent 2a9d47630e
commit caa611fac6
159 changed files with 1814 additions and 3 deletions

282
scripts/core/sfx.gd Normal file
View File

@@ -0,0 +1,282 @@
extends Node
## Sound effects. Recorded ones are Kenney's CC0 packs (assets/audio/sfx/kenney,
## see assets/audio/CREDITS.md); the rest are synthesised by
## tools/audio/make_audio.py (assets/audio/sfx/synth). Each sound has a few
## variants and a little pitch spread, so repeats don't sound mechanical.
##
## - `at(id, pos)`: something happened in the world. In single-player it plays
## there. On a server (which has no speakers) it's sent to every player
## within earshot, who hear it from that spot: you hear a friend chopping.
## - `for_player(player, id)`: a sound only that player hears, close up
## (eating, getting hurt). On a server, sent to that player's game.
## - `ui(id)`: menus and the HUD, only ever on this machine.
## Headless (tests, a dedicated server) nothing is played, but every sound
## asked for is logged in `heard`.
##
## Volumes: buses Music, SFX and Ambience under Master, set in the pause menu
## and kept in user://audio.json.
const K := "res://assets/audio/sfx/kenney/"
const S := "res://assets/audio/sfx/synth/"
const C := "res://assets/audio/sfx/creatures/"
const EARSHOT := 45.0 ## a server sends a sound to players this close
## id -> files (or a [prefix, count] pair), volume (dB), pitch range, reach (m),
## and an optional second sound layered on top.
const SOUNDS := {
"chop": {"set": [K + "chop_%d.ogg", 5], "db": -2.0, "pitch": [0.9, 1.1]},
"tree_fall": {"set": [K + "wood_heavy_%d.ogg", 5], "db": 0.0, "pitch": [0.6, 0.7], "reach": 60.0, "with": "creak"},
"creak": {"set": [K + "creak_%d.ogg", 3], "db": -2.0, "pitch": [0.8, 0.95], "reach": 50.0},
"mine": {"set": [K + "mine_%d.ogg", 5], "db": -1.0, "pitch": [0.9, 1.1]},
"rock_break": {"set": [K + "mine_%d.ogg", 5], "db": 1.0, "pitch": [0.6, 0.7], "reach": 50.0},
"rustle": {"set": [K + "cloth_%d.ogg", 4], "db": -4.0, "pitch": [0.7, 0.9]},
"pickup": {"set": [K + "leather_%d.ogg", 3], "db": -6.0, "pitch": [0.95, 1.15], "reach": 15.0},
"craft": {"set": [K + "metal_click_%d.ogg", 2], "db": -6.0, "pitch": [0.9, 1.1], "reach": 15.0, "with": "pickup"},
"place_wood": {"set": [K + "plank_%d.ogg", 5], "db": -1.0, "pitch": [0.85, 1.05]},
"place_stone": {"set": [K + "mine_%d.ogg", 5], "db": -3.0, "pitch": [1.1, 1.3]},
"place_soft": {"set": [K + "cloth_%d.ogg", 4], "db": -3.0, "pitch": [0.8, 1.0], "reach": 15.0},
"dismantle": {"set": [K + "wood_heavy_%d.ogg", 5], "db": -2.0, "pitch": [1.0, 1.15]},
"door_open": {"set": [K + "door_open_%d.ogg", 2], "db": -3.0, "pitch": [0.95, 1.05]},
"door_close": {"set": [K + "door_close_%d.ogg", 4], "db": -3.0, "pitch": [0.95, 1.05]},
"swing": {"set": [S + "swing_%d.ogg", 3], "db": -9.0, "pitch": [0.9, 1.15], "reach": 12.0},
"hit_flesh": {"set": [K + "punch_%d.ogg", 5], "db": -2.0, "pitch": [0.85, 1.05]},
"hit_bone": {"set": [K + "wood_light_%d.ogg", 5], "db": -1.0, "pitch": [1.2, 1.4]},
"creature_die": {"set": [K + "soft_heavy_%d.ogg", 5], "db": 0.0, "pitch": [0.7, 0.85]},
"butcher": {"set": [K + "knife_%d.ogg", 2], "db": -3.0, "pitch": [0.9, 1.1], "reach": 15.0},
"eat": {"set": [S + "eat_%d.ogg", 3], "db": -5.0, "pitch": [0.9, 1.15], "reach": 8.0},
"hurt": {"set": [K + "punch_heavy_%d.ogg", 5], "db": -2.0, "pitch": [0.8, 0.95], "with": "thump"},
"thump": {"set": [S + "thump_%d.ogg", 2], "db": -5.0, "pitch": [0.9, 1.1]},
"death": {"files": [S + "death.ogg"], "db": -2.0, "pitch": [1.0, 1.0]},
"cast": {"files": [S + "cast.ogg"], "db": -6.0, "pitch": [0.95, 1.1], "reach": 20.0},
"splash": {"set": [S + "splash_%d.ogg", 2], "db": -4.0, "pitch": [0.9, 1.15], "reach": 30.0},
"swim": {"set": [S + "splash_%d.ogg", 2], "db": -14.0, "pitch": [1.2, 1.5], "reach": 15.0},
"bite": {"files": [S + "plop.ogg"], "db": -3.0, "pitch": [0.9, 1.1], "reach": 25.0},
"reel": {"files": [S + "reel.ogg"], "db": -7.0, "pitch": [0.95, 1.05], "reach": 15.0},
"catch": {"files": [S + "splash_big.ogg"], "db": -3.0, "pitch": [0.95, 1.05], "reach": 30.0},
"bow": {"files": [S + "bow.ogg"], "db": -4.0, "pitch": [0.92, 1.08], "reach": 25.0},
"throw": {"set": [S + "swing_%d.ogg", 3], "db": -5.0, "pitch": [0.7, 0.8], "reach": 20.0},
"fuel": {"set": [K + "wood_light_%d.ogg", 5], "db": -4.0, "pitch": [0.8, 1.0], "reach": 15.0},
"wear": {"set": [K + "cloth_%d.ogg", 4], "db": -4.0, "pitch": [0.9, 1.1], "reach": 10.0},
"step_grass": {"set": [K + "step_grass_%d.ogg", 5], "db": -15.0, "pitch": [0.9, 1.1], "reach": 12.0},
"step_snow": {"set": [K + "step_snow_%d.ogg", 5], "db": -13.0, "pitch": [0.9, 1.1], "reach": 12.0},
"step_wood": {"set": [K + "step_wood_%d.ogg", 5], "db": -12.0, "pitch": [0.9, 1.1], "reach": 12.0},
"step_stone": {"set": [K + "step_stone_%d.ogg", 5], "db": -13.0, "pitch": [0.9, 1.1], "reach": 12.0},
"ui_open": {"files": [K + "ui_open_0.ogg"], "db": -10.0, "pitch": [1.0, 1.0]},
"ui_close": {"files": [K + "ui_close_0.ogg"], "db": -10.0, "pitch": [1.0, 1.0]},
"ui_tick": {"files": [K + "ui_tick_0.ogg"], "db": -14.0, "pitch": [0.95, 1.05]},
"ui_good": {"files": [K + "ui_good_0.ogg"], "db": -8.0, "pitch": [1.0, 1.0]},
"ui_step": {"files": [K + "ui_step_0.ogg"], "db": -8.0, "pitch": [1.0, 1.0]},
"ui_catch": {"files": [K + "ui_catch_0.ogg"], "db": -8.0, "pitch": [1.0, 1.0]},
"ui_tip": {"files": [K + "ui_tip_0.ogg"], "db": -12.0, "pitch": [1.0, 1.0]},
# Creature voices (creature.gd's VOICES picks them).
"wolf_growl": {"set": [C + "wolf_growl_%d.ogg", 3], "db": -3.0, "pitch": [0.92, 1.08], "reach": 30.0},
"wolf_snarl": {"set": [C + "wolf_snarl_%d.ogg", 2], "db": -1.0, "pitch": [0.92, 1.1], "reach": 25.0},
"wolf_yelp": {"set": [C + "wolf_yelp_%d.ogg", 2], "db": -3.0, "pitch": [0.92, 1.1], "reach": 30.0},
"alpha_growl": {"set": [C + "wolf_growl_%d.ogg", 3], "db": 0.0, "pitch": [0.68, 0.76], "reach": 40.0},
"alpha_snarl": {"set": [C + "wolf_snarl_%d.ogg", 2], "db": 1.0, "pitch": [0.7, 0.78], "reach": 30.0},
"alpha_yelp": {"set": [C + "wolf_yelp_%d.ogg", 2], "db": -1.0, "pitch": [0.72, 0.8], "reach": 35.0},
"spirit_growl": {"set": [C + "wolf_growl_%d.ogg", 3], "db": -7.0, "pitch": [1.15, 1.3], "reach": 25.0},
"spirit_snarl": {"set": [C + "wolf_snarl_%d.ogg", 2], "db": -5.0, "pitch": [1.15, 1.3], "reach": 20.0},
"bear_roar": {"set": [C + "bear_roar_%d.ogg", 2], "db": 2.0, "pitch": [0.92, 1.05], "reach": 70.0},
"bear_grunt": {"set": [C + "bear_grunt_%d.ogg", 2], "db": 0.0, "pitch": [0.85, 1.05], "reach": 30.0},
"bear_hurt": {"set": [C + "bear_grunt_%d.ogg", 2], "db": 1.0, "pitch": [1.2, 1.35], "reach": 35.0},
"boar_grunt": {"set": [C + "boar_grunt_%d.ogg", 2], "db": -3.0, "pitch": [0.9, 1.1], "reach": 22.0},
"boar_squeal": {"files": [C + "boar_squeal_0.ogg"], "db": -2.0, "pitch": [0.9, 1.1], "reach": 35.0},
"stag_bellow": {"set": [C + "stag_bellow_%d.ogg", 2], "db": 3.0, "pitch": [0.92, 1.05], "reach": 90.0},
"stag_snort": {"files": [C + "stag_snort_0.ogg"], "db": -1.0, "pitch": [0.85, 1.0], "reach": 30.0},
"guard_rattle": {"set": [C + "guard_rattle_%d.ogg", 2], "db": -2.0, "pitch": [0.85, 1.1], "reach": 25.0},
"crow_caw": {"set": [C + "crow_caw_%d.ogg", 2], "db": -4.0, "pitch": [0.92, 1.1], "reach": 40.0},
"fox_yip": {"set": [C + "fox_yip_%d.ogg", 2], "db": -4.0, "pitch": [0.92, 1.12], "reach": 30.0},
}
const BUSES := ["Music", "SFX", "Ambience"]
const DEFAULT_VOLUMES := {"Master": 0.85, "Music": 0.6, "SFX": 0.9, "Ambience": 0.7}
const POOL_3D := 20
const POOL_2D := 6
var prefs_path := "user://audio.json"
var volumes: Dictionary = DEFAULT_VOLUMES.duplicate()
var heard: Array = [] ## [id, position or null] of each sound asked for (newest last)
var ids: Array = [] ## SOUNDS' keys, sorted: a sound's number on the network
var _streams: Dictionary = {} ## id -> [AudioStream]
var _p3: Array = []
var _p2: Array = []
var _i3 := 0
var _i2 := 0
var _rng := RandomNumberGenerator.new()
var _quiet := false ## headless: log, don't play
func _ready() -> void:
process_mode = Node.PROCESS_MODE_ALWAYS
ids = SOUNDS.keys()
ids.sort()
_rng.randomize()
_quiet = DisplayServer.get_name() == "headless"
for b in BUSES:
if AudioServer.get_bus_index(b) == -1:
AudioServer.add_bus()
var i := AudioServer.bus_count - 1
AudioServer.set_bus_name(i, b)
AudioServer.set_bus_send(i, "Master")
load_prefs()
if _quiet:
return
for i in range(POOL_3D):
var p := AudioStreamPlayer3D.new()
p.bus = "SFX"
p.attenuation_model = AudioStreamPlayer3D.ATTENUATION_INVERSE_DISTANCE
p.unit_size = 4.0
add_child(p)
_p3.append(p)
for i in range(POOL_2D):
var p := AudioStreamPlayer.new()
p.bus = "SFX"
add_child(p)
_p2.append(p)
# --- volumes ---------------------------------------------------------------------
func load_prefs() -> void:
volumes = DEFAULT_VOLUMES.duplicate()
if FileAccess.file_exists(prefs_path):
var d: Variant = JSON.parse_string(FileAccess.get_file_as_string(prefs_path))
if d is Dictionary:
for k in volumes:
if d.has(k):
volumes[k] = clampf(float(d[k]), 0.0, 1.0)
for k in volumes:
_apply_volume(k)
func set_volume(bus: String, v: float) -> void:
volumes[bus] = clampf(v, 0.0, 1.0)
_apply_volume(bus)
var f := FileAccess.open(prefs_path, FileAccess.WRITE)
if f != null:
f.store_string(JSON.stringify(volumes, "\t"))
func _apply_volume(bus: String) -> void:
var i := AudioServer.get_bus_index(bus)
if i == -1:
return
var v: float = volumes[bus]
AudioServer.set_bus_mute(i, v <= 0.001)
AudioServer.set_bus_volume_db(i, linear_to_db(maxf(v, 0.001)))
# --- playing -----------------------------------------------------------------------
## Something happened at `pos` in the world.
func at(id: String, pos: Vector3) -> void:
if not SOUNDS.has(id):
push_warning("[sfx] no sound '%s'" % id)
return
_log(id, pos)
if Net.is_server():
var n := ids.find(id)
for peer in Net.players:
var pp: Vector3 = Net.player_pos(int(peer))
if pp != Vector3.INF and pp.distance_to(pos) <= float(SOUNDS[id].get("reach", 30.0)) + 5.0:
_s2c_at.rpc_id(int(peer), n, pos)
return
_play3(id, pos)
## Only `player` hears it, close up (their own eating, getting hurt).
func for_player(player: Node, id: String) -> void:
if not SOUNDS.has(id):
return
if Net.is_server():
_log(id, null)
if player != null and player.get("peer") != null:
_s2c_me.rpc_id(int(player.peer), ids.find(id))
return
me(id)
## A sound for this machine's own player (not sent anywhere).
func me(id: String) -> void:
_log(id, null)
_play2(id)
## Menus and the HUD.
func ui(id: String) -> void:
_log(id, null)
_play2(id)
@rpc("authority", "call_remote", "unreliable_ordered")
func _s2c_at(n: int, pos: Vector3) -> void:
if n >= 0 and n < ids.size():
_log(ids[n], pos)
_play3(ids[n], pos)
@rpc("authority", "call_remote", "unreliable_ordered")
func _s2c_me(n: int) -> void:
if n >= 0 and n < ids.size():
_log(ids[n], null)
_play2(ids[n])
func _log(id: String, pos: Variant) -> void:
heard.append([id, pos])
if heard.size() > 400:
heard = heard.slice(200)
## How often `id` was asked for since the log was last cleared (tests).
func count(id: String) -> int:
var n := 0
for h in heard:
if h[0] == id:
n += 1
return n
func _play3(id: String, pos: Vector3) -> void:
if _quiet or _p3.is_empty():
return
var d: Dictionary = SOUNDS[id]
var st := _pick(id)
if st == null:
return
var p: AudioStreamPlayer3D = _p3[_i3]
_i3 = (_i3 + 1) % _p3.size()
p.stop()
p.stream = st
p.volume_db = float(d.get("db", 0.0))
p.max_distance = float(d.get("reach", 30.0))
p.pitch_scale = _rng.randf_range(d.pitch[0], d.pitch[1])
p.global_position = pos
p.play()
if d.has("with"):
_play3(String(d["with"]), pos)
func _play2(id: String) -> void:
if _quiet or _p2.is_empty():
return
var d: Dictionary = SOUNDS[id]
var st := _pick(id)
if st == null:
return
var p: AudioStreamPlayer = _p2[_i2]
_i2 = (_i2 + 1) % _p2.size()
p.stop()
p.stream = st
p.volume_db = float(d.get("db", 0.0))
p.pitch_scale = _rng.randf_range(d.pitch[0], d.pitch[1])
p.play()
if d.has("with"):
_play2(String(d["with"]))
func _pick(id: String) -> AudioStream:
if not _streams.has(id):
var got: Array = []
for path in files(id):
var s: Variant = load(path)
if s is AudioStream:
got.append(s)
_streams[id] = got
var l: Array = _streams[id]
return l[_rng.randi() % l.size()] if not l.is_empty() else null
## A sound's files.
static func files(id: String) -> Array:
var d: Dictionary = SOUNDS[id]
if d.has("files"):
return d.files
var out: Array = []
for i in range(int(d.set[1])):
out.append(String(d.set[0]) % i)
return out