436 lines
18 KiB
Python
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()
|