Files
train-game/tests/audio_test.gd
2026-09-16 17:55:38 +02:00

250 lines
8.2 KiB
GDScript

extends Node
## Prüft Klangerzeugung und Wiedergabe.
var main
var step := 0
var wait := 0.0
var ok := 0
var fail := 0
var worst := 0.0
var elapsed := 0.0
var sfx_first := false
func chk(n: String, c: bool, i := "") -> void:
if c: ok += 1; print(" OK %s %s" % [n, i])
else: fail += 1; print(" FAIL %s %s" % [n, i])
func _key(c: int, d: bool) -> void:
var e := InputEventKey.new(); e.physical_keycode = c; e.keycode = c; e.pressed = d
Input.parse_input_event(e)
func _process(d):
if main == null:
main = get_tree().current_scene
return
if not main.started:
main._start_solo()
return
if not main.audio.music_loaded:
elapsed += d
worst = maxf(worst, d)
if main.audio.loaded:
sfx_first = true
return
if step == 0:
chk("Klänge vor der Musik verfügbar", sfx_first)
chk("Kein Ruckler beim Erzeugen", worst < 0.2,
"längster Frame %.0f ms in %.1f s Ladezeit" % [worst * 1000.0, elapsed])
if wait > 0.0:
wait -= d
return
var a = main.audio
match step:
0:
_check_harmony()
_check_shapes()
_check_loops()
var names := ["clack", "clack2", "rumble", "squeal", "whistle", "hiss",
"bell", "coin", "pour", "clink", "thud", "step", "click", "deny"]
var missing: Array[String] = []
for n in names:
if not a.sounds.has(n):
missing.append(n)
chk("Alle Klänge da", missing.is_empty(), "fehlt: %s" % [missing])
var silent: Array[String] = []
for n in a.sounds.keys():
var w: AudioStreamWAV = a.sounds[n]
if w.data.size() < 200 or _peak(w) < 1000:
silent.append(String(n))
chk("Kein Klang ist stumm", silent.is_empty(), "still: %s" % [silent])
chk("Musik erzeugt", a._music.stream != null and a._music.playing)
chk("Thread beendet", a._thread == null)
var m: AudioStreamWAV = a._music.stream
chk("Musik ist eine Schleife", m.loop_mode == AudioStreamWAV.LOOP_FORWARD)
chk("Musik hat drei Teile", m.data.size() / 2 > 75 * 22050,
"%.1f s" % (m.data.size() / 2.0 / 22050.0))
chk("Musik nicht stumm", _peak(m) > 3000, "peak=%d" % _peak(m))
chk("Rumble läuft leise", a._rumble.playing and a._rumble.volume_db < -40.0,
"db=%.0f" % a._rumble.volume_db)
wait = 0.3
1:
# Losfahren: Rumble muss lauter werden, Radschläge kommen
main._srv_toggle_drive(1)
a.clacks = 0
_key(KEY_W, true)
wait = 9.0
2:
chk("Rumble folgt dem Tempo", a._rumble.volume_db > -30.0,
"v=%.1f db=%.0f" % [main.train.speed, a._rumble.volume_db])
chk("Radschläge kommen", a.clacks >= 3, "clacks=%d" % a.clacks)
_key(KEY_W, false)
_key(KEY_S, true)
wait = 0.5
3:
chk("Bremsen quietscht", a._squeal.volume_db > -60.0,
"db=%.0f" % a._squeal.volume_db)
# Pfeife
_key(KEY_H, true); _key(KEY_H, false)
wait = 0.2
4:
var whistling := false
for p in a._pool3d:
if p.playing and p.stream == a.sounds["whistle"]:
whistling = true
chk("H pfeift", whistling)
wait = 3.0
5:
_key(KEY_S, false)
chk("Zug steht", main.train.speed < 0.2, "v=%.2f" % main.train.speed)
chk("Rumble wieder still", a._rumble.volume_db < -40.0,
"db=%.0f" % a._rumble.volume_db)
_key(KEY_M, true); _key(KEY_M, false)
wait = 0.2
6:
chk("M schaltet Musik aus", not a.music_on and a._music.volume_db < -60.0)
_key(KEY_M, true); _key(KEY_M, false)
wait = 0.2
7:
chk("M schaltet Musik wieder an", a.music_on and a._music.volume_db > -30.0)
# Ankunftsglocke und Auszischen gezielt auslösen
main._last_dock = null
main._park_at(main.stations[1])
a.last_played = ""
main._update_audio(0.016)
chk("Glocke bei Ankunft", a.last_played == "bell", "zuletzt=%s" % a.last_played)
main._last_speed = 6.0
main.train.speed = 0.0
a.last_played = ""
main._update_audio(0.016)
chk("Zischen beim Halten", a.last_played == "hiss", "zuletzt=%s" % a.last_played)
wait = 0.2
8:
print("== %d ok, %d fail ==" % [ok, fail])
get_tree().quit(1 if fail > 0 else 0)
step += 1
func _peak(w: AudioStreamWAV) -> int:
var d := w.data
var n: int = mini(d.size() / 2, 300000)
var peak := 0
for i in range(0, n, 7):
var v: int = absi(d.decode_s16(i * 2))
if v > peak:
peak = v
return peak
## Kein Ton darf eine kleine Sekunde oder kleine None gegen eine andere
## Stimme bilden. Große Septimen sind erlaubt - die gehören zum Klang.
## An genau dieser Regel ist das C über G6 aufgefallen.
func _check_harmony() -> void:
var Sfx = load("res://scripts/sfx.gd")
var bad: Array[String] = []
for stage in Sfx.PROGRESSION + Sfx.PROGRESSION_B:
var voices: Array[float] = []
for f in stage["pad"]:
voices.append(float(f))
voices.append(float(stage["bass"]))
# Flächen und Bass untereinander
for i in voices.size():
for j in range(i + 1, voices.size()):
_flag(bad, String(stage["name"]), voices[i], voices[j])
# Melodie gegen jede Stimme
for f in stage["melody"]:
for v in voices:
_flag(bad, String(stage["name"]), float(f), v)
chk("Keine Halbton- oder None-Reibung", bad.is_empty(), "%s" % [bad])
func _flag(bad: Array[String], chord: String, a: float, b: float) -> void:
var d: int = absi(int(round(12.0 * (log(a / b) / log(2.0)))))
if d == 1 or d == 13:
bad.append("%s: %s gegen %s (%d HT)" % [chord, _name(_pc(a)), _name(_pc(b)), d])
func _pc(f: float) -> int:
return int(round(69.0 + 12.0 * (log(f / 440.0) / log(2.0)))) % 12
func _name(pc: int) -> String:
return ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"][pc]
## Form der Schlaggeräusche: lang genug und mit Tiefe, nicht nur ein Tick.
func _check_shapes() -> void:
for n in ["clack", "clack2", "step", "thud"]:
var x := _samples(n)
chk("%s klingt aus" % n, _tail(x) > 0.18, "%.2f s hörbar" % _tail(x))
chk("%s hat Tiefe" % n, _zcr(x, 0, x.size()) < 0.22,
"Nulldurchgänge %.3f je Sample" % _zcr(x, 0, x.size()))
for n in ["clack", "clack2"]:
var r := _double_hit(_samples(n))
chk("%s ist ein Doppelschlag" % n, bool(r["ok"]),
"zweiter Schlag bei %.3f s, Einbruch davor auf %.0f %%"
% [r["at"], float(r["dip"]) * 100.0])
## Schleifen dürfen an der Naht nicht knicken, und das Quietschen darf
## seine Tonhöhe nicht weglaufen lassen.
func _check_loops() -> void:
for n in ["squeal", "rumble"]:
var x := _samples(n)
var kink: float = absf(x[0] - (2.0 * x[x.size() - 1] - x[x.size() - 2]))
var worst := 0.0
for i in range(1, x.size()):
worst = maxf(worst, absf(x[i] - x[i - 1]))
chk("%s läuft nahtlos" % n, kink < worst * 1.5,
"Knick %.4f, größter Schritt %.4f" % [kink, worst])
var sq := _samples("squeal")
var h: int = sq.size() / 2
var a := _zcr(sq, 0, h)
var b := _zcr(sq, h, sq.size())
chk("Quietschen bleibt auf Tonhöhe", absf(a - b) / maxf(a, 0.0001) < 0.15,
"Nulldurchgänge %.3f vorne, %.3f hinten" % [a, b])
func _samples(name: String) -> PackedFloat32Array:
var d: PackedByteArray = (main.audio.sounds[name] as AudioStreamWAV).data
var out := PackedFloat32Array()
out.resize(d.size() / 2)
for i in out.size():
out[i] = d.decode_s16(i * 2) / 32768.0
return out
## Sekunde, bis der Klang unter 4 % seines Höchstwerts fällt.
func _tail(x: PackedFloat32Array) -> float:
var peak := 0.0
for v in x:
peak = maxf(peak, absf(v))
var last := 0
for i in x.size():
if absf(x[i]) > peak * 0.04:
last = i
return float(last) / 22050.0
func _zcr(x: PackedFloat32Array, a: int, b: int) -> float:
var c := 0
for i in range(a + 1, b):
if (x[i] >= 0.0) != (x[i - 1] >= 0.0):
c += 1
return float(c) / float(maxi(b - a, 1))
## Sucht einen zweiten, deutlich getrennten Schlag: eine Spitze im
## Bereich 0,10-0,25 s, davor ein echter Einbruch der Hüllkurve.
func _double_hit(x: PackedFloat32Array) -> Dictionary:
var win := 66
var env := PackedFloat32Array()
env.resize(x.size() / win)
var peak := 0.0
for i in env.size():
var m := 0.0
for k in win:
m = maxf(m, absf(x[i * win + k]))
env[i] = m
peak = maxf(peak, m)
var a: int = int(0.10 * 22050.0 / win)
var b: int = mini(int(0.25 * 22050.0 / win), env.size() - 1)
var best := 0.0
var best_i := a
for i in range(a, b + 1):
if env[i] > best:
best = env[i]
best_i = i
var dip := peak
for i in range(int(0.03 * 22050.0 / win), best_i):
dip = minf(dip, env[i])
var ok: bool = best > peak * 0.22 and dip < best * 0.6
return {"ok": ok, "at": float(best_i * win) / 22050.0, "dip": dip / maxf(best, 0.0001)}