Files
airdef/tools/gen_sounds.py
T
chexit 1553976484
build / mod (push) Failing after 18s
Share sound identifiers and metadata with resource generation
Co-Authored-By: Codex <noreply@openai.com>
2026-11-04 15:59:18 +03:00

436 lines
18 KiB
Python

#!/usr/bin/env python3
"""Синтезирует звуки AirDef (airdef-mod/assets/airdef/sounds/*.ogg) и sounds.json.
Никаких сэмплов: шум, тоны, фильтры, насыщение и реверберация на numpy; кодирует ffmpeg
(встроенный vorbis пишет только стерео — оба канала одинаковые, Horizon сводит в моно).
Каждый громкий звук собран слоями: удар (короткий треск ударной волны), тело (суббас с падением
тона, рокот), хвост (раскаты: отражения от рельефа, тёмная реверберация). У взрывов и пусков есть
дальний вариант `*_far`: верх срезан, атака мягче, раскатов больше — мод выбирает его по расстоянию
и задерживает звук по скорости звука. Дальность слышимости — attenuation_distance в sounds.json.
Запуск: python3 tools/gen_sounds.py
"""
import json
import os
import subprocess
import tempfile
import wave
import numpy as np
from airdef_assets.catalog import load_sounds, sound_events
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 band(x, lo=None, hi=None, order=2):
"""Фильтр через FFT; с запасом нулей, иначе свёртка круговая и начало звука «заворачивается» в конец."""
n = len(x)
size = n + min(n, SR)
X = np.fft.rfft(x, size)
f = np.fft.rfftfreq(size, 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, size)[:n]
def pink(d):
"""Розовый шум (1/f): «плотный», не шипящий."""
n = int(SR * d)
X = np.fft.rfft(rng.standard_normal(n))
f = np.fft.rfftfreq(n, 1 / SR)
X /= np.sqrt(np.maximum(f, 10.0))
x = np.fft.irfft(X, n)
return x / (np.abs(x).max() + 1e-9)
def brown(d):
n = int(SR * d)
X = np.fft.rfft(rng.standard_normal(n))
f = np.fft.rfftfreq(n, 1 / SR)
X /= np.maximum(f, 8.0)
x = np.fft.irfft(X, n)
return x / (np.abs(x).max() + 1e-9)
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 sat(x, drive):
"""Насыщение: плотность и «злость» без цифрового клиппинга."""
x = x / (np.abs(x).max() + 1e-9)
return np.tanh(x * drive) / np.tanh(drive)
def crackle(d, rate, sharp=0.0015):
"""Треск реактивной струи: несимметричные хлопки (резкий фронт, хвост), частота плавает."""
n = int(SR * d)
x = np.zeros(n)
density = 0.5 + 0.5 * (smooth(d, 6) + 1) / 2
pos = np.flatnonzero(rng.random(n) < rate / SR * density)
k = int(SR * sharp * 6)
shape = np.exp(-np.arange(k) / (SR * sharp))
for p in pos:
amp = rng.uniform(0.3, 1.0) ** 2
seg = shape[: n - p] * amp
x[p:p + len(seg)] += seg
x = band(x, 250)
return x / (np.abs(x).max() + 1e-9)
def boom(d, f0, f1, tau, decay):
"""Суббасовый удар: тон падает с f0 до f1, плюс вторая гармоника."""
x = t(d)
f = f1 + (f0 - f1) * np.exp(-x / tau)
ph = np.cumsum(2 * np.pi * f / SR)
return (np.sin(ph) + 0.35 * np.sin(2 * ph)) * env(d, 0.002, decay)
def nwave(width=0.008, gap=0.0):
"""Хлопок ударной волны (N-волна)."""
k = int(SR * width)
w = np.linspace(1, -1, k)
x = np.concatenate([np.zeros(4), w, np.zeros(int(SR * 0.02))])
if gap:
x = np.concatenate([x, np.zeros(int(SR * gap)), w * 0.7, np.zeros(int(SR * 0.02))])
return band(x, 30, 9000)
def rumble(d, decay, roll=4.0):
"""Раскаты: тёмный рокот с медленными «перекатами», как гром."""
body = band(brown(d), 15, 180)
body /= np.abs(body).max() + 1e-9
rolls = 0.55 + 0.45 * smooth(d, roll)
return body * rolls * env(d, 0.15, decay)
def fade_end(x, seconds=0.35):
"""Плавный конец: без него эхо и отражения повторяют обрыв щелчком."""
x = x.copy()
n = min(int(SR * seconds), len(x))
x[len(x) - n:] *= np.linspace(1, 0, n) ** 2
return x
def slaps(x, delays=(0.32, 0.71, 1.25, 1.9), gains=(0.5, 0.36, 0.26, 0.17), hi=900):
"""Отражения от рельефа: запоздалые тёмные копии."""
x = fade_end(x)
out = np.concatenate([x, np.zeros(int(SR * (max(delays) + 0.2)))])
dark = band(x, 25, hi)
for dl, g in zip(delays, gains):
k = int(SR * dl)
out[k:k + len(dark)] += dark * g
return out
def reverb(x, seconds, wet, dark=3000, predelay=0.02):
x = fade_end(x)
n = int(SR * seconds)
ir = band(rng.standard_normal(n), hi=dark) * np.exp(-np.arange(n) / (SR * seconds / 6.5))
ir = np.concatenate([np.zeros(int(SR * predelay)), ir])
ir /= np.sqrt(np.sum(ir ** 2)) + 1e-9
size = len(x) + len(ir)
y = np.fft.irfft(np.fft.rfft(x, size) * np.fft.rfft(ir, size), size)
y *= (np.abs(x).max() + 1e-9) / (np.abs(y).max() + 1e-9)
return np.concatenate([x, np.zeros(len(ir))]) * (1 - wet) + y * wet
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 master(x, rms=0.22, ceiling=0.97):
"""Громкость по средней энергии начала (а не по пику) и мягкий потолок: звуки одинаково «плотные»."""
x = x - np.mean(x)
head = x[: max(len(x) // 3, 1)]
loud = np.sqrt(np.mean(head ** 2)) + 1e-9
x = x * (rms / loud)
x = np.tanh(x / ceiling) * ceiling
n = int(SR * 0.004)
x[:n] *= np.linspace(0, 1, n)
m = int(SR * 0.08)
x[-m:] *= np.linspace(1, 0, m)
return x
def far(x, hi=520, extra=2.5):
"""Дальний вариант: верх съеден воздухом, атака размыта, раскатов больше."""
y = band(x, 20, hi)
return reverb(slaps(y, hi=400), extra, 0.55, dark=900, predelay=0.06)
def loop_fade(x, a=0.08, b=0.35):
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 shahed():
"""Двухтактный «мопед»: 2 цилиндра, рваный выхлоп, резонанс глушителя, плавающие обороты."""
d = 2.6
f0 = 68 * (1 + 0.05 * smooth(d, 2.5) + 0.01 * smooth(d, 25))
ph = np.cumsum(2 * np.pi * f0 / SR)
pulse = np.maximum(np.sin(ph), 0) ** 10 + 0.6 * np.maximum(np.sin(ph + 2.4), 0) ** 10
exhaust = band(pulse, 60, 3000)
exhaust = band(exhaust, 120, 260) * 1.6 + band(exhaust, 500, 1100) * 0.8 + exhaust * 0.5
rasp = band(noise(d), 700, 4000) * pulse * 0.7
body = sat(exhaust / (np.abs(exhaust).max() + 1e-9) + rasp / (np.abs(rasp).max() + 1e-9) * 0.4, 2.5)
return loop_fade(master(reverb(body, 0.9, 0.2, dark=2500), rms=0.2))
def geran3():
"""Турбореактивный: ровный рёв с «шелестом» лопаток и мягким свистом турбины."""
d = 2.4
roar = pink(d) * 1.0 + band(brown(d), 30, 220) * 1.4
roar *= 0.85 + 0.15 * smooth(d, 8)
whine = np.sin(2 * np.pi * np.cumsum(2400 * (1 + 0.004 * smooth(d, 1))) / SR) * 0.05
return loop_fade(master(reverb(sat(roar, 1.6) + whine, 1.2, 0.3, dark=3500), rms=0.2))
def lancet():
"""Электромотор: вой, жужжание винта."""
d = 1.8
f = 760 * (1 + 0.03 * smooth(d, 4))
ph = np.cumsum(2 * np.pi * f / SR)
whine = np.sin(ph) * 0.35 + np.sin(2 * ph) * 0.2 + np.sin(3 * ph) * 0.08
prop = band(noise(d), 90, 500)
prop = prop / (np.abs(prop).max() + 1e-9) * (0.5 + 0.5 * np.sin(2 * np.pi * 130 * t(d))) * 0.6
return loop_fade(master(whine + prop, rms=0.18))
def cruise():
"""Крылатая ракета: тяжёлый турбовентиляторный гул, почти без свиста."""
d = 3.0
body = band(brown(d), 20, 260) * 1.8 + band(pink(d), 150, 2500) * 0.7
body *= 0.8 + 0.2 * smooth(d, 3)
return loop_fade(master(reverb(sat(body, 1.8), 1.8, 0.35, dark=2000), rms=0.22))
def rocket_roar(d, low=1.0, crack=1.0, bright=1.0):
"""Ракетный двигатель: рвущийся рёв = суббас + плотный розовый шум + треск струи, всё насыщено."""
sub = band(brown(d), 15, 120)
sub /= np.abs(sub).max() + 1e-9
body = band(pink(d), 60, 1800 * bright)
body /= np.abs(body).max() + 1e-9
flutter = 0.75 + 0.25 * smooth(d, 14)
cr = crackle(d, 260 * crack) * 0.8 * crack
return sat((sub * 1.1 * low + body * 0.8) * flutter + cr, 2.2)
def rocket():
"""Ударная ракета в полёте (Х-22, Циркон): тяжёлый рвущийся рёв."""
return loop_fade(master(reverb(rocket_roar(3.0, low=1.2, crack=0.8), 2.0, 0.35, dark=2500), rms=0.24))
def interceptor():
"""Перехватчик в полёте: рёв жёстче и ярче, быстрые «трепыхания» струи."""
d = 2.6
x = rocket_roar(d, low=0.8, crack=1.4, bright=1.6)
x *= 0.8 + 0.2 * smooth(d, 22)
return loop_fade(master(reverb(x, 1.6, 0.3, dark=3500), rms=0.24))
def ballistic():
"""Падающая баллистика: нарастающий рёв «товарного поезда», рвущийся воздух, без тона."""
d = 4.5
x = t(d)
grow = (x / d) ** 2
tear = band(pink(d), 200, 6500)
tear = band(tear, None, 2500) * (1 - grow) + tear * grow
tear = tear / (np.abs(tear).max() + 1e-9) * (0.25 + 1.0 * grow) * (0.7 + 0.3 * smooth(d, 30))
roar = band(brown(d), 15, 160)
roar = roar / (np.abs(roar).max() + 1e-9) * (0.2 + 1.2 * grow)
return master(reverb(sat(tear + roar, 2.0), 2.5, 0.3, dark=3000), rms=0.2)
def sonic():
"""Двойной хлопок ударной волны и раскаты."""
x = mix(nwave(0.009, 0.12) * 3.0, boom(1.2, 70, 30, 0.08, 0.35) * 0.9)
return master(reverb(slaps(x, hi=1200), 4.0, 0.5, dark=2000), rms=0.2)
# ---------------------------------------------------------------- пуски
def launch_patriot():
"""Зажигание: удар и треск, затем рвущийся рёв, уходящий вверх и в сторону (темнеет, тише)."""
d = 5.0
x = t(d)
hit = mix(nwave(0.006) * 3.0, boom(0.8, 90, 40, 0.05, 0.25) * 1.2)
roar = rocket_roar(d, low=1.3, crack=1.6, bright=1.4)
away = np.exp(-x / 1.5)
roar = roar * away * 0.9 + band(roar, 20, 900) * (1 - away) * 0.6
body = mix(hit, roar * env(d, 0.03, 2.2))
return master(reverb(slaps(body), 3.5, 0.4), rms=0.3)
def eject(depth):
"""Холодный старт: газ выбивает ракету — глухой тяжёлый «ТХУМП» и шипение."""
d = 1.6
thump = boom(d, depth * 2.2, depth, 0.06, 0.28) * 2.0
gas = band(pink(d), 80, 1500) * env(d, 0.005, 0.18)
body = sat(thump + gas, 2.5)
return master(reverb(slaps(body, hi=600), 3.0, 0.4, dark=1500), rms=0.22)
def ignite(depth, d):
"""Маршевый двигатель большой ракеты: нарастающий рёв, суббас, треск, долгие раскаты."""
x = t(d)
swell = np.minimum(x / 0.35, 1.0) * np.exp(-np.maximum(x - 1.0, 0) / (d / 3.0))
sub = boom(d, depth * 1.8, depth, 0.4, d) * 0.8
roar = rocket_roar(d, low=1.6, crack=1.8, bright=1.2)
body = (roar + sub) * swell
return master(reverb(slaps(body, hi=700), 5.0, 0.45, dark=2200), rms=0.32)
def launch_pantsir():
"""Панцирь: резкий хлопок, ракета на огромном ускорении уходит, рёв быстро тает."""
d = 3.2
x = t(d)
hit = nwave(0.005) * 2.5
roar = rocket_roar(d, low=0.8, crack=2.0, bright=2.0) * np.exp(-x / 0.7)
tail = band(rocket_roar(d, low=0.6, crack=1.0), 20, 700) * np.exp(-x / 1.4) * (1 - np.exp(-x / 0.3)) * 0.6
return master(reverb(slaps(mix(hit, roar + tail)), 2.8, 0.35), rms=0.24)
# ---------------------------------------------------------------- пушки
def gun(shots, interval, depth, crack_hi, room):
"""Очередь: каждый выстрел — хлопок + низкий удар; отражение от рельефа."""
d = shots * interval + 0.4
x = np.zeros(int(SR * d))
for i in range(shots):
s = int(SR * (i * interval + rng.uniform(0, 0.004)))
shot = mix(nwave(0.004) * 1.5, boom(0.3, depth * 2.5, depth, 0.015, 0.06) * 1.3,
band(noise(0.08), crack_hi) * env(0.08, 0.0005, 0.012) * 0.25)
x[s:s + len(shot)] += shot[: len(x) - s] * rng.uniform(0.85, 1.0)
return master(reverb(slaps(sat(x, 2.0), delays=(0.18, 0.42), gains=(0.35, 0.2)), room, 0.3), rms=0.28)
# ---------------------------------------------------------------- взрывы
def blast(size):
"""Взрыв: хлопок, суббас с падением тона, огненный рёв, раскаты (size 0..1)."""
d = 3.0 + 4.0 * size
crack = nwave(0.006 + 0.006 * size) * 3.0
sub = boom(d, max(75 - 25 * size, 34), max(26 - 6 * size, 21), 0.06 + 0.06 * size, 0.5 + 0.9 * size) * 1.6
if size > 1.0:
# Тяжёлая боевая часть: второй удар (детонация основного заряда) и давящий инфразвук.
k = int(SR * 0.09)
second = np.concatenate([np.zeros(k), boom(d, 50, 22, 0.1, 1.4 * size) * 1.4])[: len(sub)]
sub = sub + second
fire = band(pink(d), 80, 2200)
fire = fire / (np.abs(fire).max() + 1e-9) * env(d, 0.004, 0.25 + 0.3 * size) * 0.8
roll = rumble(d, 1.2 + 2.0 * size, roll=3.0) * 1.2
debris = crackle(d, 120, 0.002) * env(d, 0.02, 0.5) * 0.12
body = sat(mix(crack, sub, fire, roll, debris), 2.0)
return master(reverb(slaps(body), 3.0 + 3.0 * size, 0.42, dark=2400), rms=0.32)
def blast_air():
"""Перехват в воздухе: резкий разрыв, короткий удар и долгие раскаты сверху."""
d = 4.5
crack = nwave(0.005, 0.0) * 3.5
sub = boom(d, 95, 35, 0.04, 0.4) * 1.3
flash = band(pink(d), 300, 5000)
flash = flash / (np.abs(flash).max() + 1e-9) * env(d, 0.001, 0.06) * 0.7
roll = rumble(d, 2.2, roll=5.0) * 1.1
body = sat(mix(crack, sub, flash, roll), 2.2)
return master(reverb(slaps(body, delays=(0.5, 1.1, 1.8, 2.6), gains=(0.45, 0.35, 0.25, 0.15)), 4.5, 0.5, dark=2000), rms=0.3)
# ---------------------------------------------------------------- запись
SYNTHESIZERS = {
# Only waveform construction belongs here; IDs and distances come from the catalog.
"drone.shahed": shahed,
"drone.geran3": geran3,
"drone.lancet": lancet,
"drone.cruise": cruise,
"drone.rocket": rocket,
"missile.flight": interceptor,
"drone.ballistic": ballistic,
"boom.sonic": sonic,
"launch.patriot": launch_patriot,
"launch.s400_eject": lambda: eject(42),
"launch.s400": lambda: ignite(34, 6.5),
"launch.s500_eject": lambda: eject(30),
"launch.s500": lambda: ignite(26, 8.0),
"launch.pantsir": launch_pantsir,
"gun.gepard": lambda: gun(3, 0.056, 55, 1500, 1.6),
"gun.pantsir": lambda: gun(6, 0.034, 75, 2200, 1.4),
"blast.big": lambda: blast(1.0),
"blast.heavy": lambda: blast(1.35),
"blast.huge": lambda: blast(1.75),
"blast.medium": lambda: blast(0.5),
"blast.air": blast_air,
}
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():
catalog = load_sounds()
if set(SYNTHESIZERS) != {entry["event"] for entry in catalog}:
raise ValueError("sound synthesizers and catalog IDs differ")
events = sound_events(catalog)
out = os.path.join(ROOT, "sounds")
os.makedirs(out, exist_ok=True)
for f in os.listdir(out):
os.remove(os.path.join(out, f))
for entry in catalog:
event, has_far = entry["event"], entry["far"]
fn = SYNTHESIZERS[event]
name = event.replace(".", "_")
x = fn()
write_ogg(os.path.join(out, name + ".ogg"), x)
if has_far:
write_ogg(os.path.join(out, name + "_far.ogg"), master(far(x), rms=0.2))
with open(os.path.join(ROOT, "sounds.json"), "w") as f:
json.dump(events, f, indent=1)
print(f"{len(events)} звуков -> {out}")
if __name__ == "__main__":
main()