#146: shots, deaths and collapses are heard
Three events get a sound: a shot leaving a gun, a vessel dying, a structure coming down. The sounds are SYNTHESISED rather than downloaded - audio/make_sfx.py generates all three from scratch, and is committed alongside them. The ticket asked for free sources and the good ones are in its comments (Kenney's CC0 packs, OpenGameArt's CC0 sets, Freesound filtered to CC0), but a generated sound has no licence to track, no attribution to keep, and no binary in the repository whose provenance nobody can check a year from now. It also makes the sounds DATA: too sharp a shot is a number in that file and a re-run, not a hunt through a library. Dropping in a pack file later means replacing a .wav and nothing else. A voice POOL, not a player, because these overlap constantly - a fleet firing is several shots inside one shot's length. Twelve voices, and when they are all busy the one furthest through its own sound is recycled, which is the one with the least left to lose. Parented to the tree root, for the reason that matters: every one of these sounds is started by something that is being freed on the same frame. A dying ship cannot own the sound of its own death. Levels are per sound rather than shared - a shot fires constantly and a death happens once, so equal volumes are not proportionate ones - and shots get a little pitch spread so a stream of them does not flatten into one machine-gun note. The deaths get none: a wobbling explosion sounds broken rather than varied. The test goes at the failures a play-through would hide for weeks: firing three times the pool size must leave exactly the pool's worth sounding and no leaked voices, and the ship-death case is checked from a node that really has been freed on that frame.
This commit is contained in:
parent
080e3bb928
commit
940540d60c
14 changed files with 433 additions and 0 deletions
96
audio/Sfx.gd
Normal file
96
audio/Sfx.gd
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
class_name Sfx
|
||||
extends RefCounted
|
||||
|
||||
# Forgejo #146: the three things that happen to something and should be
|
||||
# heard - a shot leaving a gun, a vessel dying, a structure coming down.
|
||||
#
|
||||
# The sounds themselves are synthesised (see audio/make_sfx.py) rather than
|
||||
# downloaded. The ticket asked for free sources and the good ones are in its
|
||||
# comments, but a generated sound has no licence to track, no attribution to
|
||||
# keep, and no binary in the repository whose provenance nobody can check
|
||||
# later. Swapping in a file from one of those packs is a matter of replacing
|
||||
# the .wav - nothing here knows or cares which it is.
|
||||
#
|
||||
# Parented to the tree root for the same reason Music is: a sound started by
|
||||
# something that is about to be freed - which is every one of these, a dying
|
||||
# ship being the obvious case - cannot live on the thing that started it.
|
||||
#
|
||||
# A POOL rather than one player, because these overlap constantly: a fleet
|
||||
# firing is several shots inside one shot's length. The oldest is recycled
|
||||
# when they are all busy, which is the right thing to lose.
|
||||
|
||||
const NODE_NAME: String = "SfxPlayers"
|
||||
const VOICES: int = 12
|
||||
|
||||
const SHOT: String = "shot"
|
||||
const SHIP_DEATH: String = "ship_death"
|
||||
const BUILDING_DEATH: String = "building_death"
|
||||
|
||||
const SOUNDS: Dictionary = {
|
||||
SHOT: "res://audio/sfx_shot.wav",
|
||||
SHIP_DEATH: "res://audio/sfx_ship_death.wav",
|
||||
BUILDING_DEATH: "res://audio/sfx_building_death.wav",
|
||||
}
|
||||
|
||||
# Per-sound level. A shot is fired constantly and a death happens once, so
|
||||
# they cannot share a volume and stay in proportion.
|
||||
const LEVELS: Dictionary = {
|
||||
SHOT: -22.0,
|
||||
SHIP_DEATH: -6.0,
|
||||
BUILDING_DEATH: -4.0,
|
||||
}
|
||||
|
||||
# Shots vary a little in pitch so a stream of them does not turn into one
|
||||
# flat machine-gun note. The deaths do not: they are events, and a wobbling
|
||||
# explosion sounds broken rather than varied.
|
||||
const SHOT_PITCH_SPREAD: float = 0.12
|
||||
|
||||
|
||||
static func _pool(tree: SceneTree) -> Node:
|
||||
var existing: Node = tree.root.get_node_or_null(NODE_NAME)
|
||||
if existing != null:
|
||||
return existing
|
||||
var pool := Node.new()
|
||||
pool.name = NODE_NAME
|
||||
# #128 stops the tree for dialogs. A sound already in flight should
|
||||
# finish rather than freeze mid-bang.
|
||||
pool.process_mode = Node.PROCESS_MODE_ALWAYS
|
||||
tree.root.add_child(pool)
|
||||
for i in range(VOICES):
|
||||
var voice := AudioStreamPlayer.new()
|
||||
voice.process_mode = Node.PROCESS_MODE_ALWAYS
|
||||
pool.add_child(voice)
|
||||
return pool
|
||||
|
||||
|
||||
# Plays one sound. Safe from a node on its way out of the tree, and safe to
|
||||
# call far more often than there are voices.
|
||||
static func play(tree: SceneTree, id: String) -> void:
|
||||
if tree == null or not SOUNDS.has(id):
|
||||
return
|
||||
var stream: AudioStream = load(String(SOUNDS[id]))
|
||||
if stream == null:
|
||||
return
|
||||
var pool: Node = _pool(tree)
|
||||
var voice: AudioStreamPlayer = null
|
||||
for child in pool.get_children():
|
||||
if not (child as AudioStreamPlayer).playing:
|
||||
voice = child
|
||||
break
|
||||
if voice == null:
|
||||
# All busy. The oldest is the one furthest through its own sound, so
|
||||
# it is the one with the least left to lose.
|
||||
var most: float = -1.0
|
||||
for child in pool.get_children():
|
||||
var at: float = (child as AudioStreamPlayer).get_playback_position()
|
||||
if at > most:
|
||||
most = at
|
||||
voice = child
|
||||
if voice == null:
|
||||
return
|
||||
voice.stream = stream
|
||||
voice.volume_db = float(LEVELS.get(id, -10.0))
|
||||
voice.pitch_scale = 1.0
|
||||
if id == SHOT:
|
||||
voice.pitch_scale = 1.0 + randf_range(-SHOT_PITCH_SPREAD, SHOT_PITCH_SPREAD)
|
||||
voice.play()
|
||||
1
audio/Sfx.gd.uid
Normal file
1
audio/Sfx.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://nhg0c87ie053
|
||||
88
audio/make_sfx.py
Normal file
88
audio/make_sfx.py
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Generates the game's sound effects, from scratch.
|
||||
|
||||
Forgejo #146 asked for free SFX sources, and the ones worth using are listed
|
||||
in the ticket - Kenney's CC0 packs, OpenGameArt's CC0 sets, Freesound's CC0
|
||||
filter. What ships here is synthesised instead, for one reason: a generated
|
||||
sound has no licence, no attribution to keep track of, and no binary in the
|
||||
repository whose provenance nobody can check a year from now.
|
||||
|
||||
It also means the sounds are DATA rather than assets. If a shot is too sharp
|
||||
or an explosion too short, the fix is a number in this file and a re-run,
|
||||
not a hunt through a sound library.
|
||||
|
||||
Run: python3 audio/make_sfx.py
|
||||
"""
|
||||
import math
|
||||
import random
|
||||
import struct
|
||||
import wave
|
||||
|
||||
RATE = 44100
|
||||
|
||||
|
||||
def _write(name, samples):
|
||||
peak = max(1e-9, max(abs(s) for s in samples))
|
||||
frames = b"".join(struct.pack("<h", int(max(-1.0, min(1.0, s / peak * 0.89)) * 32767))
|
||||
for s in samples)
|
||||
with wave.open("audio/%s.wav" % name, "w") as w:
|
||||
w.setnchannels(1)
|
||||
w.setsampwidth(2)
|
||||
w.setframerate(RATE)
|
||||
w.writeframes(frames)
|
||||
print("audio/%s.wav %5.2fs %6d bytes" % (name, len(samples) / RATE, len(frames)))
|
||||
|
||||
|
||||
def _env(i, n, attack, decay_power):
|
||||
"""Fast attack, curved decay. `attack` is a fraction of the whole."""
|
||||
a = max(1, int(n * attack))
|
||||
if i < a:
|
||||
return i / a
|
||||
return (1.0 - (i - a) / max(1, n - a)) ** decay_power
|
||||
|
||||
|
||||
def shot(seconds=0.16, start_hz=1500.0, end_hz=320.0):
|
||||
"""A short descending zap. The fleet's guns are energy weapons, so this
|
||||
is a swept tone rather than a crack - it has to sit under a stream of
|
||||
itself without turning into noise."""
|
||||
n = int(RATE * seconds)
|
||||
out = []
|
||||
phase = 0.0
|
||||
for i in range(n):
|
||||
t = i / n
|
||||
hz = start_hz * (end_hz / start_hz) ** t
|
||||
phase += 2.0 * math.pi * hz / RATE
|
||||
# A little square in it, so it reads as synthetic rather than as a flute.
|
||||
tone = 0.7 * math.sin(phase) + 0.3 * (1.0 if math.sin(phase) > 0 else -1.0)
|
||||
out.append(tone * _env(i, n, 0.02, 2.2))
|
||||
return out
|
||||
|
||||
|
||||
def explosion(seconds, low_hz, brightness, rumble_hz):
|
||||
"""Noise through a one-pole low-pass, plus a falling sine underneath it.
|
||||
The sine is what gives it a size: without it a burst of noise is a hiss,
|
||||
however loud."""
|
||||
n = int(RATE * seconds)
|
||||
out = []
|
||||
last = 0.0
|
||||
phase = 0.0
|
||||
rng = random.Random(7)
|
||||
for i in range(n):
|
||||
t = i / n
|
||||
# The filter opens for an instant and closes as it decays, which is
|
||||
# what a real blast does - the crack first, the roar after.
|
||||
cutoff = brightness * (1.0 - t) ** 1.6 + 0.02
|
||||
last += cutoff * (rng.uniform(-1.0, 1.0) - last)
|
||||
hz = low_hz * (rumble_hz / low_hz) ** t
|
||||
phase += 2.0 * math.pi * hz / RATE
|
||||
body = 0.55 * math.sin(phase)
|
||||
out.append((last * 1.4 + body) * _env(i, n, 0.004, 1.7))
|
||||
return out
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
_write("sfx_shot", shot())
|
||||
# A vessel: sharp, bright, over quickly.
|
||||
_write("sfx_ship_death", explosion(0.85, 220.0, 0.55, 45.0))
|
||||
# A structure: slower, deeper, and it keeps falling down afterwards.
|
||||
_write("sfx_building_death", explosion(1.6, 130.0, 0.34, 26.0))
|
||||
BIN
audio/sfx_building_death.wav
Normal file
BIN
audio/sfx_building_death.wav
Normal file
Binary file not shown.
24
audio/sfx_building_death.wav.import
Normal file
24
audio/sfx_building_death.wav.import
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
[remap]
|
||||
|
||||
importer="wav"
|
||||
type="AudioStreamWAV"
|
||||
uid="uid://cucu8do3xu62b"
|
||||
path="res://.godot/imported/sfx_building_death.wav-5a0672915182205beec566265df53340.sample"
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://audio/sfx_building_death.wav"
|
||||
dest_files=["res://.godot/imported/sfx_building_death.wav-5a0672915182205beec566265df53340.sample"]
|
||||
|
||||
[params]
|
||||
|
||||
force/8_bit=false
|
||||
force/mono=false
|
||||
force/max_rate=false
|
||||
force/max_rate_hz=44100
|
||||
edit/trim=false
|
||||
edit/normalize=false
|
||||
edit/loop_mode=0
|
||||
edit/loop_begin=0
|
||||
edit/loop_end=-1
|
||||
compress/mode=2
|
||||
BIN
audio/sfx_ship_death.wav
Normal file
BIN
audio/sfx_ship_death.wav
Normal file
Binary file not shown.
24
audio/sfx_ship_death.wav.import
Normal file
24
audio/sfx_ship_death.wav.import
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
[remap]
|
||||
|
||||
importer="wav"
|
||||
type="AudioStreamWAV"
|
||||
uid="uid://cr6bt4w8n6d24"
|
||||
path="res://.godot/imported/sfx_ship_death.wav-0969b7a92e0e9fb802408222617228e0.sample"
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://audio/sfx_ship_death.wav"
|
||||
dest_files=["res://.godot/imported/sfx_ship_death.wav-0969b7a92e0e9fb802408222617228e0.sample"]
|
||||
|
||||
[params]
|
||||
|
||||
force/8_bit=false
|
||||
force/mono=false
|
||||
force/max_rate=false
|
||||
force/max_rate_hz=44100
|
||||
edit/trim=false
|
||||
edit/normalize=false
|
||||
edit/loop_mode=0
|
||||
edit/loop_begin=0
|
||||
edit/loop_end=-1
|
||||
compress/mode=2
|
||||
BIN
audio/sfx_shot.wav
Normal file
BIN
audio/sfx_shot.wav
Normal file
Binary file not shown.
24
audio/sfx_shot.wav.import
Normal file
24
audio/sfx_shot.wav.import
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
[remap]
|
||||
|
||||
importer="wav"
|
||||
type="AudioStreamWAV"
|
||||
uid="uid://c0le6ax67g411"
|
||||
path="res://.godot/imported/sfx_shot.wav-cdb41c709bd7357ff93382562f01a47e.sample"
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://audio/sfx_shot.wav"
|
||||
dest_files=["res://.godot/imported/sfx_shot.wav-cdb41c709bd7357ff93382562f01a47e.sample"]
|
||||
|
||||
[params]
|
||||
|
||||
force/8_bit=false
|
||||
force/mono=false
|
||||
force/max_rate=false
|
||||
force/max_rate_hz=44100
|
||||
edit/trim=false
|
||||
edit/normalize=false
|
||||
edit/loop_mode=0
|
||||
edit/loop_begin=0
|
||||
edit/loop_end=-1
|
||||
compress/mode=2
|
||||
|
|
@ -37,6 +37,7 @@ func _on_building_destroyed(building_id: int, _type: String) -> void:
|
|||
continue
|
||||
if int(b.building_id) == building_id:
|
||||
BuildingExplosionVFX.spawn(get_tree().root, b.global_position)
|
||||
Sfx.play(get_tree(), Sfx.BUILDING_DEATH)
|
||||
b.queue_free()
|
||||
continue
|
||||
kept.append(b)
|
||||
|
|
|
|||
167
tests/test_sfx.gd
Normal file
167
tests/test_sfx.gd
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
extends SceneTree
|
||||
|
||||
# Headless test: run with
|
||||
# godot --headless -s res://tests/test_sfx.gd
|
||||
# Forgejo #146: a shot, a vessel dying and a structure coming down should be
|
||||
# heard.
|
||||
#
|
||||
# The interesting failures here are not "no sound came out" - they are the
|
||||
# ones a play-through would not reveal for weeks. A voice pool that runs dry
|
||||
# under a firing fleet and silently drops every shot after the twelfth. A
|
||||
# sound started by a node that is being freed on the same frame, which is
|
||||
# every death in the game. So those are what is checked.
|
||||
#
|
||||
# Covers:
|
||||
# A. every sound the catalog names actually exists and loads
|
||||
# B. firing plays a shot, and it survives a fleet firing together - no
|
||||
# voice is lost, none is left stuck
|
||||
# C. a dying ship is heard, from a node that is freed on the same frame
|
||||
# D. a destroyed structure is heard
|
||||
# E. levels are in proportion: a shot fires constantly and a death happens
|
||||
# once, so a shot must be the quieter of the two
|
||||
# F. sound keeps working while a dialog has the game stopped (#128)
|
||||
|
||||
var game_state: Node
|
||||
var scene: Node
|
||||
var fleet: Node2D
|
||||
var stage: int = 0
|
||||
var elapsed: float = 0.0
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
# GameState is an autoload: a bare GameState.<instance member> does not
|
||||
# compile in a SceneTree script, so the node is fetched by name instead.
|
||||
game_state = root.get_node("GameState")
|
||||
game_state.reset_new_game()
|
||||
game_state.intro_active = false
|
||||
game_state.reset_quests()
|
||||
game_state.rocks = 500
|
||||
|
||||
|
||||
func _fail(message: String) -> bool:
|
||||
print("TEST_SFX: FAIL (%s)" % message)
|
||||
quit(1)
|
||||
return true
|
||||
|
||||
|
||||
func _pool() -> Node:
|
||||
return root.get_node_or_null(Sfx.NODE_NAME)
|
||||
|
||||
|
||||
func _sounding() -> int:
|
||||
var pool: Node = _pool()
|
||||
if pool == null:
|
||||
return 0
|
||||
var n: int = 0
|
||||
for voice in pool.get_children():
|
||||
if (voice as AudioStreamPlayer).playing:
|
||||
n += 1
|
||||
return n
|
||||
|
||||
|
||||
func _process(delta: float) -> bool:
|
||||
elapsed += delta
|
||||
match stage:
|
||||
0:
|
||||
# --- A. the catalog is real -----------------------------
|
||||
for id in Sfx.SOUNDS:
|
||||
var path: String = String(Sfx.SOUNDS[id])
|
||||
var stream: AudioStream = load(path)
|
||||
if stream == null:
|
||||
return _fail("the \"%s\" sound (%s) does not load" % [id, path])
|
||||
if stream.get_length() <= 0.02:
|
||||
return _fail("the \"%s\" sound is %.3fs long - that is silence" \
|
||||
% [id, stream.get_length()])
|
||||
if not Sfx.LEVELS.has(id):
|
||||
return _fail("the \"%s\" sound has no level set" % id)
|
||||
print("%d sounds, all loading, all with a level" % Sfx.SOUNDS.size())
|
||||
|
||||
# --- E. and in proportion -------------------------------
|
||||
if float(Sfx.LEVELS[Sfx.SHOT]) >= float(Sfx.LEVELS[Sfx.SHIP_DEATH]):
|
||||
return _fail("a shot is as loud as a death - one happens constantly, the other once")
|
||||
print("shot at %.0fdB, ship death at %.0fdB, structure at %.0fdB" \
|
||||
% [Sfx.LEVELS[Sfx.SHOT], Sfx.LEVELS[Sfx.SHIP_DEATH],
|
||||
Sfx.LEVELS[Sfx.BUILDING_DEATH]])
|
||||
|
||||
scene = load("res://world/Main.tscn").instantiate()
|
||||
root.add_child(scene)
|
||||
current_scene = scene
|
||||
fleet = scene.get_node("Fleet")
|
||||
elapsed = 0.0
|
||||
stage = 1
|
||||
return false
|
||||
1:
|
||||
if elapsed < 0.4:
|
||||
return false
|
||||
scene.get_node("AsteroidField").set_process(false)
|
||||
scene.get_node("EnemySpawner").set_process(false)
|
||||
if _pool() != null and _sounding() > 0:
|
||||
return _fail("something was already sounding before anything happened")
|
||||
|
||||
# --- B. a shot, and then far more than there are voices --
|
||||
Sfx.play(self, Sfx.SHOT)
|
||||
if _sounding() != 1:
|
||||
return _fail("firing played %d sounds, expected 1" % _sounding())
|
||||
if _pool().get_parent() != root:
|
||||
return _fail("the sound pool hangs off the scene - a death would take it with it")
|
||||
for i in range(Sfx.VOICES * 3):
|
||||
Sfx.play(self, Sfx.SHOT)
|
||||
var busy: int = _sounding()
|
||||
if busy != Sfx.VOICES:
|
||||
return _fail("%d voices sounding after %d shots, the pool holds %d" \
|
||||
% [busy, Sfx.VOICES * 3, Sfx.VOICES])
|
||||
if _pool().get_child_count() != Sfx.VOICES:
|
||||
return _fail("the pool grew to %d voices" % _pool().get_child_count())
|
||||
print("%d shots into a %d-voice pool: %d sounding, none leaked" \
|
||||
% [Sfx.VOICES * 3, Sfx.VOICES, busy])
|
||||
|
||||
# --- F. and a dialog does not silence it -----------------
|
||||
var narration: Control = scene.get_node("UI/NarrationBanner")
|
||||
narration.say("Everything stops but this.", "TEST")
|
||||
if not paused:
|
||||
return _fail("the dialog did not stop the game, so this proves nothing")
|
||||
for voice in _pool().get_children():
|
||||
if not (voice as AudioStreamPlayer).can_process():
|
||||
return _fail("sound stops whenever a dialog is up")
|
||||
print("a dialog stops the game and sound carries on")
|
||||
while narration.dismiss():
|
||||
pass
|
||||
|
||||
for voice in _pool().get_children():
|
||||
(voice as AudioStreamPlayer).stop()
|
||||
elapsed = 0.0
|
||||
stage = 2
|
||||
return false
|
||||
2:
|
||||
if elapsed < 0.3:
|
||||
return false
|
||||
# --- C. a death, from a node freed on the same frame -----
|
||||
fleet.spawn_ships(2, Vector2.ZERO,
|
||||
PackedStringArray([GameState.VESSEL_INTERCEPTOR,
|
||||
GameState.VESSEL_INTERCEPTOR]))
|
||||
for voice in _pool().get_children():
|
||||
(voice as AudioStreamPlayer).stop()
|
||||
var doomed: Node2D = fleet.ships[0]
|
||||
doomed.take_damage(1)
|
||||
if not is_instance_valid(doomed) or not doomed.is_queued_for_deletion():
|
||||
return _fail("the ship was not freed, so this is not the case that matters")
|
||||
if _sounding() == 0:
|
||||
return _fail("a vessel died in silence")
|
||||
print("a vessel freed on the frame it dies is still heard")
|
||||
|
||||
# --- D. a structure ------------------------------------
|
||||
for voice in _pool().get_children():
|
||||
(voice as AudioStreamPlayer).stop()
|
||||
var field: Node2D = scene.get_node("BuildingField")
|
||||
var id: int = game_state.add_building(GameState.BUILDING_SHIP_CRAFTER,
|
||||
Vector2(400.0, 0.0))
|
||||
field.rebuild_all()
|
||||
game_state.destroy_building(id)
|
||||
if _sounding() == 0:
|
||||
return _fail("a structure came down in silence")
|
||||
print("a structure coming down is heard")
|
||||
|
||||
print("TEST_SFX: PASS")
|
||||
quit(0)
|
||||
return true
|
||||
return false
|
||||
1
tests/test_sfx.gd.uid
Normal file
1
tests/test_sfx.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://cru3clxyxahgn
|
||||
|
|
@ -175,6 +175,10 @@ func take_damage(_amount: int) -> bool:
|
|||
# still freed on this frame, so nothing about who is in the fleet, who
|
||||
# can be shot, or who is a target changes.
|
||||
ShipWreckVFX.spawn(get_tree().root, self, _breakaway_direction())
|
||||
# #146: the bang goes with the wreck being thrown clear, not with the
|
||||
# explosion at the end of its drift - the hit is what the player needs
|
||||
# to hear, and hearing it late reads as a different event.
|
||||
Sfx.play(get_tree(), Sfx.SHIP_DEATH)
|
||||
destroyed.emit()
|
||||
queue_free()
|
||||
return false
|
||||
|
|
|
|||
|
|
@ -109,4 +109,7 @@ func tick(delta: float) -> void:
|
|||
# #87: sparks off the hull that fired, so a shot is visibly attached to
|
||||
# the vessel it came from.
|
||||
MUZZLE_SPARK.spawn(ship.get_tree().root, ship.global_position, heading)
|
||||
# #146: and heard. Beside the spark rather than inside it, because the
|
||||
# spark is about this hull and the sound is about the shot.
|
||||
Sfx.play(ship.get_tree(), Sfx.SHOT)
|
||||
fire_timer = fire_cooldown
|
||||
|
|
|
|||
Loading…
Reference in a new issue