a6a13003c2
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>
306 lines
12 KiB
Python
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()
|