#!/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()