Files
airdef/tools/gen_sounds.py
T
chexit a6a13003c2
build / mod (push) Failing after 5m36s
build / game-linux (push) Has been skipped
build / game-macos (push) Has been cancelled
build / game-windows (push) Has been cancelled
build / release (push) Has been cancelled
AirDef: drones and air defence for a Horizon fork
The mod (airdef-mod/), its generated models and sounds, the fork's patches
over upstream Horizon (horizon-patches/, HORIZON_COMMIT), a cross-platform
build/install/run script (tools/airdef.py), CI that builds and releases on
every push (.gitea/workflows/build.yml) and a setup guide (docs/INSTALL.md).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 19:16:52 +03:00

306 lines
12 KiB
Python

#!/usr/bin/env python3
"""Синтезирует звуки AirDef (airdef-mod/assets/airdef/sounds/*.ogg) и sounds.json.
Никаких сэмплов: шум, тоны, фильтры и реверберация (синтетический импульсный отклик), всё на numpy;
кодирует ffmpeg (libvorbis). Звуки моно, записанные в 2 одинаковых канала (встроенный vorbis в ffmpeg пишет только стерео; Horizon сводит в моно).
Дальность слышимости у каждого события своя (attenuation_distance в sounds.json).
Запуск: python3 tools/gen_sounds.py
"""
import json
import os
import subprocess
import tempfile
import wave
import numpy as np
SR = 44100
ROOT = os.path.normpath(os.path.join(os.path.dirname(__file__), "..", "airdef-mod", "assets", "airdef"))
rng = np.random.default_rng(1945)
# ---------------------------------------------------------------- основа
def t(d):
return np.arange(int(SR * d)) / SR
def noise(d):
return rng.standard_normal(int(SR * d))
def brown(d):
x = np.cumsum(noise(d))
x -= np.convolve(x, np.ones(2001) / 2001, mode="same") # убрать дрейф
return x / (np.abs(x).max() + 1e-9)
def band(x, lo=None, hi=None, order=2):
X = np.fft.rfft(x)
f = np.fft.rfftfreq(len(x), 1 / SR)
g = np.ones_like(f)
if lo:
g /= 1 + (lo / np.maximum(f, 1e-3)) ** (2 * order)
if hi:
g /= 1 + (f / hi) ** (2 * order)
return np.fft.irfft(X * g, len(x))
def peak(x, f0, q=8.0):
"""Резонанс на f0."""
X = np.fft.rfft(x)
f = np.fft.rfftfreq(len(x), 1 / SR)
g = 1 / (1 + ((f - f0) / (f0 / q)) ** 2)
return np.fft.irfft(X * g, len(x))
def smooth(d, rate):
"""Плавная случайная кривая в [-1, 1], rate значений в секунду."""
n = int(SR * d)
pts = rng.uniform(-1, 1, int(d * rate) + 3)
xs = np.linspace(0, len(pts) - 1, n)
return np.interp(xs, np.arange(len(pts)), pts)
def env(d, attack, decay):
x = t(d)
return np.minimum(x / max(attack, 1e-4), 1.0) * np.exp(-np.maximum(x - attack, 0) / decay)
def fade(x, a=0.01, b=0.05):
n = len(x)
w = np.ones(n)
na, nb = int(SR * a), int(SR * b)
w[:na] = np.linspace(0, 1, na)
w[n - nb:] = np.linspace(1, 0, nb)
return x * w
def crackle(d, rate, decay=0.004):
"""Треск: редкие импульсы со случайной силой."""
x = np.zeros(int(SR * d))
n = int(d * rate)
for i in rng.integers(0, len(x) - 1, n):
k = int(SR * decay * 4)
seg = np.exp(-np.arange(k) / (SR * decay)) * rng.standard_normal(k)
x[i:i + k] += seg[: len(x) - i] * rng.uniform(0.2, 1.0)
return x
def reverb(x, seconds, wet, predelay=0.03, dark=4000):
"""Эхо: свёртка с затухающим тёмным шумом и парой ранних отражений."""
n = int(SR * seconds)
ir = band(rng.standard_normal(n), hi=dark) * np.exp(-np.arange(n) / (SR * seconds / 6.0))
for delay, gain in ((0.11, 0.5), (0.23, 0.35), (0.41, 0.25)):
k = int(SR * delay)
if k < n:
ir[k] += gain * 8
ir = np.concatenate([np.zeros(int(SR * predelay)), ir])
ir /= np.abs(ir).sum() ** 0.5 * 6
size = len(x) + len(ir)
y = np.fft.irfft(np.fft.rfft(x, size) * np.fft.rfft(ir, size), size)
out = np.concatenate([x, np.zeros(len(ir))]) * (1 - wet) + y * wet
return out
def norm(x, level=0.9):
return x / (np.abs(x).max() + 1e-9) * level
def mix(*parts):
n = max(len(p) for p in parts)
out = np.zeros(n)
for p in parts:
out[: len(p)] += p
return out
# ---------------------------------------------------------------- двигатели (короткие петли)
def shahed():
"""Двухтактный мопедный мотор: импульсы ~70 Гц, хриплый выхлоп, «плавающие» обороты."""
d = 2.6
f0 = 72 * (1 + 0.04 * smooth(d, 3))
phase = np.cumsum(2 * np.pi * f0 / SR)
pulses = sum(np.sin(k * phase) / k ** 0.6 for k in range(1, 28))
rasp = band(noise(d), 600, 3500) * (0.6 + 0.4 * np.sin(phase)) ** 6
body = band(pulses, 90, 2800) + rasp * 0.9
return fade(norm(reverb(body, 0.8, 0.15)), 0.05, 0.3)
def geran3():
"""Реактивный: ровный рёв, свист турбины."""
d = 2.2
roar = band(noise(d), 80, 3500) * 0.8 + band(brown(d), 30, 300) * 1.5
whine = np.sin(2 * np.pi * np.cumsum(3100 * (1 + 0.01 * smooth(d, 2))) / SR) * 0.25
return fade(norm(reverb(roar + whine, 1.0, 0.2)), 0.1, 0.3)
def lancet():
"""Электромотор: высокий вой и жужжание винта."""
d = 1.8
f = 820 * (1 + 0.03 * smooth(d, 4))
ph = np.cumsum(2 * np.pi * f / SR)
whine = np.sin(ph) * 0.4 + np.sin(2 * ph) * 0.2 + np.sin(3 * ph) * 0.1
prop = band(noise(d), 100, 400) * (0.5 + 0.5 * np.sin(2 * np.pi * 140 * t(d)))
return fade(norm(whine + prop * 1.2), 0.1, 0.3)
def cruise():
"""Турбовентиляторный рёв крылатой ракеты, низкий и тяжёлый."""
d = 2.8
body = band(brown(d), 25, 250) * 2.0 + band(noise(d), 200, 2200) * 0.6
whistle = np.sin(2 * np.pi * np.cumsum(1750 * (1 + 0.006 * smooth(d, 1))) / SR) * 0.12
return fade(norm(reverb(body + whistle, 1.6, 0.3)), 0.2, 0.4)
def rocket():
"""Ракетный двигатель в полёте: рокочущий рёв с треском."""
d = 2.6
body = band(brown(d), 20, 180) * 2.2 + band(noise(d), 150, 5000) * 0.7
body += band(crackle(d, 900, 0.002), 300, 6000) * 0.6
return fade(norm(reverb(body, 2.0, 0.35)), 0.1, 0.4)
def ballistic():
"""Падающая баллистика: нарастающий визг-свист и рёв обтекания."""
d = 4.0
x = t(d)
f = 300 * (1 + 7 * (x / d) ** 2)
scream = band(noise(d), 100, 9000)
scream = peak(scream, 1200, 3) * 0.6 + np.sin(2 * np.pi * np.cumsum(f) / SR) * 0.35
roar = band(brown(d), 20, 200) * 1.5
amp = (x / d) ** 1.5
return fade(norm(reverb((scream + roar) * (0.15 + amp), 2.5, 0.35)), 0.3, 0.2)
def sonic():
"""Двойной хлопок ударной волны с долгим эхом."""
d = 0.5
x = np.zeros(int(SR * d))
for at in (0.0, 0.11):
i = int(SR * at)
k = int(SR * 0.012)
x[i:i + k] += np.linspace(1, -1, k)
x = band(x, 20, 6000) + band(noise(d), 30, 300) * env(d, 0.005, 0.15) * 0.6
return norm(reverb(x, 4.0, 0.55, dark=2500))
# ---------------------------------------------------------------- пуски
def launch_patriot():
"""Резкий хлопок, затем рвущийся рёв, уходящий вверх, долгое эхо."""
d = 4.5
crack = band(noise(0.05), 800) * env(0.05, 0.0005, 0.01) * 3
roar = (band(noise(d), 120, 6000) * 0.8 + band(brown(d), 25, 250) * 1.8) * env(d, 0.05, 1.1)
roar += band(crackle(d, 1400, 0.002), 500, 7000) * env(d, 0.05, 0.8) * 0.8
return norm(reverb(mix(crack, roar), 3.5, 0.45))
def eject(depth):
"""Холодный старт: глухой удар газа «ТХУМП»."""
d = 1.2
x = t(d)
thump = np.sin(2 * np.pi * np.cumsum(depth * (1 + 2 * np.exp(-x * 20))) / SR) * env(d, 0.003, 0.18)
gas = band(noise(d), 60, 900) * env(d, 0.01, 0.15) * 0.9
return norm(reverb(thump * 1.6 + gas, 3.0, 0.4, dark=1500))
def ignite(depth, d):
"""Зажигание маршевого двигателя: огромный рёв, суббас, треск, гулкое эхо."""
x = t(d)
swell = np.minimum(x / 0.25, 1.0) * np.exp(-np.maximum(x - 0.6, 0) / (d / 3.2))
sub = np.sin(2 * np.pi * np.cumsum(depth * (1 + 0.1 * smooth(d, 3))) / SR)
body = band(brown(d), 15, 220) * 2.5 + band(noise(d), 100, 4500) * 0.9 + sub * 0.8
body += band(crackle(d, 2200, 0.0025), 400, 6000) * 1.0
return norm(reverb(body * swell, 5.0, 0.5, dark=3000))
def launch_pantsir():
"""Быстрый свистящий старт: шипение с ростом тона."""
d = 2.6
x = t(d)
hiss = peak(band(noise(d), 1500, 9000), 3500 + 2500 * np.clip(x[len(x) // 2] / d, 0, 1), 2) * 1.2
whoosh = band(noise(d), 300, 3000) * env(d, 0.02, 0.5)
body = (hiss * env(d, 0.01, 0.7) + whoosh + band(brown(d), 40, 200) * env(d, 0.01, 0.4))
return norm(reverb(body, 2.5, 0.35))
# ---------------------------------------------------------------- пушки
def gun(shots, interval, thud_hz, crack_hi, room):
d = shots * interval + 0.35
x = np.zeros(int(SR * d))
for i in range(shots):
s = int(SR * i * interval)
k = int(SR * 0.25)
tt_ = np.arange(k) / SR
thud = np.sin(2 * np.pi * thud_hz * tt_ * (1 + 0.5 * np.exp(-tt_ * 60))) * np.exp(-tt_ * 25)
crack = band(rng.standard_normal(k), crack_hi) * np.exp(-tt_ * 120) * 0.9
x[s:s + k] += (thud * 1.2 + crack) * rng.uniform(0.85, 1.0)
return norm(reverb(x, room, 0.35))
# ---------------------------------------------------------------- взрывы
def blast(size):
"""Взрыв: хлопок, суббас, рокот, треск обломков, долгое эхо (size 0..1)."""
d = 3.0 + 3.0 * size
x = t(d)
crack = band(noise(0.08), 400) * env(0.08, 0.0005, 0.02) * 2.5
sub = np.sin(2 * np.pi * np.cumsum(45 - 22 * np.minimum(x / 1.5, 1)) / SR) * env(d, 0.004, 0.6 + size)
rumble = band(brown(d), 15, 160 + 200 * (1 - size)) * env(d, 0.01, 1.0 + 1.5 * size) * 2.0
debris = band(crackle(d, 300, 0.003), 800, 6000) * env(d, 0.05, 0.8) * 0.5
body = mix(crack, sub * (1.0 + size), rumble, debris)
return norm(reverb(body, 3.0 + 3.0 * size, 0.5, dark=2500 if size > 0.5 else 4000))
# ---------------------------------------------------------------- запись
SOUNDS = {
# событие: (функция, дальность слышимости в блоках)
"drone.shahed": (shahed, 220),
"drone.geran3": (geran3, 260),
"drone.lancet": (lancet, 120),
"drone.cruise": (cruise, 320),
"drone.rocket": (rocket, 420),
"drone.ballistic": (ballistic, 700),
"boom.sonic": (sonic, 900),
"launch.patriot": (launch_patriot, 600),
"launch.s400_eject": (lambda: eject(55), 350),
"launch.s400": (lambda: ignite(38, 6.0), 800),
"launch.s500_eject": (lambda: eject(38), 450),
"launch.s500": (lambda: ignite(27, 7.5), 1000),
"launch.pantsir": (launch_pantsir, 450),
"gun.gepard": (lambda: gun(3, 0.055, 70, 1800, 1.6), 380),
"gun.pantsir": (lambda: gun(6, 0.034, 95, 2600, 1.4), 350),
"blast.big": (lambda: blast(1.0), 900),
"blast.medium": (lambda: blast(0.55), 600),
"blast.air": (lambda: blast(0.25), 700),
}
def write_ogg(path, x):
pcm = (np.clip(x, -1, 1) * 32767).astype(np.int16)
with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as tmp:
wav = tmp.name
with wave.open(wav, "wb") as w:
w.setnchannels(1)
w.setsampwidth(2)
w.setframerate(SR)
w.writeframes(pcm.tobytes())
subprocess.run(["ffmpeg", "-y", "-loglevel", "error", "-i", wav, "-ac", "2", "-c:a", "vorbis", "-strict", "-2", "-q:a", "5", path], check=True)
os.unlink(wav)
def main():
out = os.path.join(ROOT, "sounds")
os.makedirs(out, exist_ok=True)
events = {}
for event, (fn, distance) in SOUNDS.items():
name = event.replace(".", "_")
write_ogg(os.path.join(out, name + ".ogg"), fn())
events[event] = {"sounds": [{"name": f"airdef:{name}", "attenuation_distance": distance}]}
with open(os.path.join(ROOT, "sounds.json"), "w") as f:
json.dump(events, f, indent=1)
print(f"{len(SOUNDS)} звуков -> {out}")
if __name__ == "__main__":
main()