Real thunder (Gitea #4): six CC0 recordings, close cracks and distant rolls, in place of the synthesised rumble; a storm's flash is followed by a roll seconds later or a crack sooner, quieter indoors, and a strike on a tree cracks from the tree

This commit is contained in:
Paul Bailey
2026-11-08 20:29:48 +00:00
parent 07b8db1683
commit 8f3c58c810
18 changed files with 132 additions and 14 deletions

View File

@@ -39,6 +39,21 @@ Breviceps (https://freesound.org/people/Breviceps/sounds/465697/, License:
Creative Commons 0; the preview is in `tools/audio/sources/`), cut at 0.12 s Creative Commons 0; the preview is in `tools/audio/sources/`), cut at 0.12 s
and 2.65 s (leaving out the short sound between them), then levelled. Thank you. and 2.65 s (leaving out the short sound between them), then levelled. Thank you.
## Thunder (CC0)
`sfx/thunder/` is cut from six Freesound recordings, all License: Creative
Commons 0 (the previews are in `tools/audio/sources/thunder_*`), each round
its loudest strike, faded out and levelled by
`tools/audio/make_audio.py thunder`. Close cracks: "Thunder 08" by Emulius
(https://freesound.org/people/Emulius/sounds/744723/), "thunder11" by
saha213131 (https://freesound.org/people/saha213131/sounds/696550/) and
"Thunder strike" by AyaDrevis
(https://freesound.org/people/AyaDrevis/sounds/652690/). Distant rolls:
"Short_Thunder_Mid" by SholeColtis
(https://freesound.org/people/SholeColtis/sounds/683421/), "thunder7" by
nednednerb (https://freesound.org/people/nednednerb/sounds/38250/) and
"Thunder Clap OWB KY 441x16.wav" by Dave Welsh
(https://freesound.org/people/Dave%20Welsh/sounds/194364/). Thank you.
## Old Tam's voice ## Old Tam's voice
`voice/` is spoken by **Kokoro** (Kokoro-82M by hexgrad, Apache-2.0, `voice/` is spoken by **Kokoro** (Kokoro-82M by hexgrad, Apache-2.0,
https://huggingface.co/hexgrad/Kokoro-82M) with its British "George" voice, https://huggingface.co/hexgrad/Kokoro-82M) with its British "George" voice,

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -19,6 +19,7 @@ extends Node
const K := "res://assets/audio/sfx/kenney/" const K := "res://assets/audio/sfx/kenney/"
const S := "res://assets/audio/sfx/synth/" const S := "res://assets/audio/sfx/synth/"
const C := "res://assets/audio/sfx/creatures/" const C := "res://assets/audio/sfx/creatures/"
const T := "res://assets/audio/sfx/thunder/"
const EARSHOT := 45.0 ## a server sends a sound to players this close 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), ## id -> files (or a [prefix, count] pair), volume (dB), pitch range, reach (m),
@@ -28,6 +29,10 @@ const SOUNDS := {
"tree_fall": {"set": [K + "wood_heavy_%d.ogg", 5], "db": 0.0, "pitch": [0.6, 0.7], "reach": 60.0, "with": "creak"}, "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}, "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]}, "mine": {"set": [K + "mine_%d.ogg", 5], "db": -1.0, "pitch": [0.9, 1.1]},
# Thunder (0.6.2, CC0 recordings: assets/audio/CREDITS.md). The weather
# plays the storm's own (weather_fx.gd); a strike on a tree cracks from it.
"thunder_near": {"set": [T + "thunder_near_%d.ogg", 3], "db": 0.0, "pitch": [0.92, 1.05], "reach": 400.0},
"thunder_far": {"set": [T + "thunder_far_%d.ogg", 3], "db": -5.0, "pitch": [0.9, 1.05], "reach": 400.0},
"rock_break": {"set": [K + "mine_%d.ogg", 5], "db": 1.0, "pitch": [0.6, 0.7], "reach": 50.0}, "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]}, "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}, "pickup": {"set": [K + "leather_%d.ogg", 3], "db": -6.0, "pitch": [0.95, 1.15], "reach": 15.0},
@@ -196,6 +201,17 @@ func me(id: String) -> void:
_log(id, null) _log(id, null)
_play2(id) _play2(id)
## A random one of sound `id`'s files, and its volume: for a player of your
## own (the weather's thunder). Not logged: call log_heard() when it plays.
func stream_for(id: String) -> AudioStream:
return _pick(id) if SOUNDS.has(id) else null
func db_for(id: String) -> float:
return float(SOUNDS.get(id, {}).get("db", 0.0))
func log_heard(id: String) -> void:
_log(id, null)
## Menus and the HUD. ## Menus and the HUD.
func ui(id: String) -> void: func ui(id: String) -> void:
_log(id, null) _log(id, null)

View File

@@ -92,6 +92,7 @@ func pick_tree() -> Node3D:
func strike(n: Node3D) -> void: func strike(n: Node3D) -> void:
strikes += 1 strikes += 1
show_strike(n) show_strike(n)
Sfx.at("thunder_near", n.global_position + Vector3(0, 6, 0)) # (a crack from the tree itself)
if world.server_mode: if world.server_mode:
WorldNet.struck(n) WorldNet.struck(n)
for p in world.players(): for p in world.players():

View File

@@ -7,8 +7,11 @@ extends Node3D
## - Ground wetness, settled snow and wind strength are global shader uniforms ## - Ground wetness, settled snow and wind strength are global shader uniforms
## (weather_wet, weather_snow, weather_wind) read by the terrain, grass, ## (weather_wet, weather_snow, weather_wind) read by the terrain, grass,
## foliage and rock shaders. ## foliage and rock shaders.
## - Sound is synthesised: filtered noise for rain and wind, a decaying ## - Rain and wind are synthesised (filtered noise). Thunder is recorded
## low rumble for thunder. No audio assets, nothing to license. ## (CC0, 0.6.2: assets/audio/sfx/thunder, Sfx "thunder_far"/"thunder_near"):
## after each flash in a storm, usually a distant roll a few seconds later,
## sometimes a nearer crack sooner. Quieter under a roof. (A strike on a
## tree near you cracks from the tree itself: hazards.gd.)
## ##
## Client-side only: in M2 the server runs weather.gd and never this. ## Client-side only: in M2 the server runs weather.gd and never this.
@@ -31,10 +34,11 @@ var _rng := RandomNumberGenerator.new()
var _rain_lp := 0.0 var _rain_lp := 0.0
var _rain_hp := 0.0 var _rain_hp := 0.0
var _brown := 0.0 var _brown := 0.0
var _brown2 := 0.0
var _gust_t := 0.0 var _gust_t := 0.0
var _thunder_env := 0.0
var _thunder_delay := -1.0 var _thunder_delay := -1.0
var _thunder_id := ""
var _thunder: AudioStreamPlayer
const NEAR_CHANCE := 0.3 ## of a storm's flashes, the share that are close
var _seen_strikes := 0 var _seen_strikes := 0
var _rain_amp := 0.0 var _rain_amp := 0.0
var _wind_amp := 0.0 var _wind_amp := 0.0
@@ -83,6 +87,10 @@ func setup(w: Node, p: Node3D) -> void:
add_child(_audio) add_child(_audio)
_audio.play() _audio.play()
_playback = _audio.get_stream_playback() _playback = _audio.get_stream_playback()
_thunder = AudioStreamPlayer.new()
_thunder.name = "Thunder"
_thunder.bus = "SFX" if AudioServer.get_bus_index("SFX") >= 0 else "Master"
add_child(_thunder)
func _make_particles(amount: int, life: float, pm: ParticleProcessMaterial, mesh: Mesh) -> GPUParticles3D: func _make_particles(amount: int, life: float, pm: ParticleProcessMaterial, mesh: Mesh) -> GPUParticles3D:
var p := GPUParticles3D.new() var p := GPUParticles3D.new()
@@ -173,13 +181,32 @@ func _process(delta: float) -> void:
_wind_amp = weather.wind * weather.wind * 0.22 * (0.35 if sheltered else 1.0) _wind_amp = weather.wind * weather.wind * 0.22 * (0.35 if sheltered else 1.0)
if weather.lightning_count != _seen_strikes: if weather.lightning_count != _seen_strikes:
_seen_strikes = weather.lightning_count _seen_strikes = weather.lightning_count
_thunder_delay = _rng.randf_range(0.5, 2.5) lightning()
if _thunder_delay >= 0.0: if _thunder_delay >= 0.0:
_thunder_delay -= delta _thunder_delay -= delta
if _thunder_delay < 0.0: if _thunder_delay < 0.0:
_thunder_env = 1.0 _thunder_now()
_fill_audio() _fill_audio()
## A flash in the sky: its thunder follows, after the time the sound takes
## to come (a near one sooner, a distant one later).
func lightning() -> void:
var near := _rng.randf() < NEAR_CHANCE
_thunder_id = "thunder_near" if near else "thunder_far"
_thunder_delay = _rng.randf_range(0.3, 0.9) if near else _rng.randf_range(1.5, 4.0)
func _thunder_now() -> void:
Sfx.log_heard(_thunder_id)
if _thunder == null:
return
var st: AudioStream = Sfx.stream_for(_thunder_id)
if st == null:
return
_thunder.stream = st
_thunder.volume_db = Sfx.db_for(_thunder_id) - 8.0 * _muffle
_thunder.pitch_scale = _rng.randf_range(0.9, 1.05)
_thunder.play()
func _set_amount(p: GPUParticles3D, a: float) -> void: func _set_amount(p: GPUParticles3D, a: float) -> void:
p.amount_ratio = a p.amount_ratio = a
p.emitting = a > 0.0 p.emitting = a > 0.0
@@ -194,7 +221,6 @@ func _fill_audio() -> void:
var rain_cut := lerpf(0.55, 0.12, _muffle) var rain_cut := lerpf(0.55, 0.12, _muffle)
var buf := PackedVector2Array() var buf := PackedVector2Array()
buf.resize(n) buf.resize(n)
var thunder_decay := exp(-1.0 / (MIX_RATE * 0.9))
for i in range(n): for i in range(n):
var white := _rng.randf() * 2.0 - 1.0 var white := _rng.randf() * 2.0 - 1.0
# Rain: band-limited hiss (low-pass, minus a slower low-pass). # Rain: band-limited hiss (low-pass, minus a slower low-pass).
@@ -206,12 +232,6 @@ func _fill_audio() -> void:
_gust_t += 1.0 / MIX_RATE _gust_t += 1.0 / MIX_RATE
var gust := 0.6 + 0.4 * sin(_gust_t * 0.7) * sin(_gust_t * 0.23 + 1.0) var gust := 0.6 + 0.4 * sin(_gust_t * 0.7) * sin(_gust_t * 0.23 + 1.0)
var wind_s := _brown * _wind_amp * gust * 2.0 var wind_s := _brown * _wind_amp * gust * 2.0
# Thunder: a heavier brown rumble with a long decay. var s := clampf(rain + wind_s, -1.0, 1.0)
var thunder := 0.0
if _thunder_env > 0.001:
_brown2 = clampf(_brown2 * 0.9995 + white * 0.02, -1.0, 1.0)
thunder = _brown2 * _thunder_env * 2.2
_thunder_env *= thunder_decay
var s := clampf(rain + wind_s + thunder, -1.0, 1.0)
buf[i] = Vector2(s, s) buf[i] = Vector2(s, s)
_playback.push_buffer(buf) _playback.push_buffer(buf)

View File

@@ -269,6 +269,31 @@ func _lightning() -> void:
var tree: Node3D = hz._burning.keys()[0] var tree: Node3D = hz._burning.keys()[0]
check(tree.global_position.distance_to(at) < hz.STRIKE_RANGE, "near you (%.0f m away)" % tree.global_position.distance_to(at)) check(tree.global_position.distance_to(at) < hz.STRIKE_RANGE, "near you (%.0f m away)" % tree.global_position.distance_to(at))
check(_toast_has("Lightning struck"), "you see it (%s)" % [_toasts]) check(_toast_has("Lightning struck"), "you see it (%s)" % [_toasts])
# Gitea #4: thunder is recorded now; the strike cracks from the tree.
var SX: Node = root.get_node("Sfx")
var cracks: Array = SX.heard.filter(func(h): return h[0] == "thunder_near" and h[1] is Vector3 and (h[1] as Vector3).distance_to(tree.global_position) < 8.0)
check(cracks.size() == 1, "and hear it: a thunder crack from the tree")
check(SX.files("thunder_near").size() == 3 and SX.files("thunder_far").size() == 3 and
SX.files("thunder_near").all(func(f): return ResourceLoader.exists(f)) and SX.files("thunder_far").all(func(f): return ResourceLoader.exists(f)),
"(three recorded cracks and three distant rolls)")
var fx: Node3D = load("res://scripts/world/weather_fx.gd").new()
world.add_child(fx)
fx.setup(wx, player)
var kinds := {}
var delays := {"thunder_near": [], "thunder_far": []}
for _i in range(60):
fx.lightning()
kinds[fx._thunder_id] = int(kinds.get(fx._thunder_id, 0)) + 1
delays[fx._thunder_id].append(fx._thunder_delay)
check(kinds.has("thunder_near") and kinds.has("thunder_far") and int(kinds.thunder_far) > int(kinds.thunder_near),
"a storm's flashes: mostly distant rolls, sometimes a crack close by (%s)" % [kinds])
check(delays.thunder_near.all(func(d): return d < 1.0) and delays.thunder_far.all(func(d): return d >= 1.5),
"a close one follows its flash quickly, a distant one seconds later")
var h0: int = SX.heard.size()
fx._thunder_delay = 0.01
fx._process(0.05)
check(SX.heard.size() > h0 and SX.heard.slice(h0).any(func(h): return String(h[0]).begins_with("thunder_")), "and then you hear it")
fx.queue_free()
check(world.heat_at(tree.global_position + Vector3(2.5, 0, 0)) > 0.3, "it burns: you can warm yourself by it") check(world.heat_at(tree.global_position + Vector3(2.5, 0, 0)) > 0.3, "it burns: you can warm yourself by it")
check(not tree.depleted, "still standing while it burns") check(not tree.depleted, "still standing while it burns")
for _i in range(41): for _i in range(41):

View File

@@ -699,6 +699,44 @@ def _decode(src, sr, af=None):
return [x * 0.06 / rms for x in left], [x * 0.06 / rms for x in right] return [x * 0.06 / rms for x in left], [x * 0.06 / rms for x in right]
def make_thunder():
"""Thunder (0.6.2, Gitea #4): CC0 recordings (tools/audio/sources/
thunder_near_* and thunder_far_*, credited in assets/audio/CREDITS.md),
each cut round its loudest strike, faded out and evened out in level, into
assets/audio/sfx/thunder/thunder_near_N.ogg (a crack close by) and
thunder_far_N.ogg (a roll in the distance). Replaces the synthesised rumble."""
d = os.path.join(OUT, "sfx", "thunder")
os.makedirs(d, exist_ok=True)
print("thunder ->", os.path.relpath(d, ROOT))
sr = 44100
for kind, lead, most in (("near", 0.15, 7.0), ("far", 0.6, 10.0)):
for n, src in enumerate(sources("thunder_" + kind)):
raw = subprocess.run(["ffmpeg", "-v", "error", "-i", src, "-ac", "1", "-ar", str(sr), "-f", "s16le", "-"],
capture_output=True, check=True).stdout
x = [struct.unpack_from("<h", raw, i * 2)[0] / 32768.0 for i in range(len(raw) // 2)]
win = sr // 20
env = [math.sqrt(sum(v * v for v in x[i:i + win]) / win) + 1e-9 for i in range(0, len(x) - win, win)]
ip = max(range(len(env)), key=lambda i: env[i])
top = env[ip]
# Back from the loudest moment to where this strike starts (20 dB down).
j = ip
while j > 0 and env[j] > top * 0.1:
j -= 1
start = max(0, int((j * 0.05 - lead) * sr))
# On until it's died away (40 dB down), or `most` seconds.
k = ip
while k < len(env) - 1 and env[k] > top * 0.01 and (k - j) * 0.05 < most:
k += 1
end = min(len(x), int(k * 0.05 * sr) + win)
seg = x[start:end]
fade_in, fade_out = int(0.02 * sr), min(int(1.5 * sr), len(seg) // 3)
for i in range(fade_in):
seg[i] *= i / fade_in
for i in range(fade_out):
seg[-1 - i] *= i / fade_out
_encode(os.path.join(d, "thunder_%s_%d.ogg" % (kind, n)), [seg], sr, 5)
def make_recorded(name, srcs=None): def make_recorded(name, srcs=None):
"""A field-recorded ambience loop (assets/audio/ambience/<name>.ogg) from """A field-recorded ambience loop (assets/audio/ambience/<name>.ogg) from
CC0 recordings (tools/audio/sources/<name>_*, or the files given): each CC0 recordings (tools/audio/sources/<name>_*, or the files given): each
@@ -808,6 +846,9 @@ if __name__ == "__main__":
if what in RECORDED: # one recorded loop: birds | crickets if what in RECORDED: # one recorded loop: birds | crickets
make_recorded(what) make_recorded(what)
sys.exit(0) sys.exit(0)
if what == "thunder": # the recorded thunder (not part of "all": it's from sources/)
make_thunder()
sys.exit(0)
if what in ("all", "sfx"): if what in ("all", "sfx"):
make_sfx() make_sfx()
if what in ("all", "sfx", "voices"): if what in ("all", "sfx", "voices"):

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.