Sounds: layered synthesis, far variants, delay by the speed of sound
Rockets roar and crackle instead of whistling; blasts are a crack, a falling sub-bass boom and rolling echoes instead of noise; interceptors get their own flight sound. Far blasts, launches and shots play muffled variants, late by their distance at 343 m/s. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -35,7 +35,15 @@
|
||||
"sounds": [
|
||||
{
|
||||
"name": "airdef:drone_rocket",
|
||||
"attenuation_distance": 420
|
||||
"attenuation_distance": 450
|
||||
}
|
||||
]
|
||||
},
|
||||
"missile.flight": {
|
||||
"sounds": [
|
||||
{
|
||||
"name": "airdef:missile_flight",
|
||||
"attenuation_distance": 380
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -55,11 +63,27 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"boom.sonic_far": {
|
||||
"sounds": [
|
||||
{
|
||||
"name": "airdef:boom_sonic_far",
|
||||
"attenuation_distance": 1440
|
||||
}
|
||||
]
|
||||
},
|
||||
"launch.patriot": {
|
||||
"sounds": [
|
||||
{
|
||||
"name": "airdef:launch_patriot",
|
||||
"attenuation_distance": 600
|
||||
"attenuation_distance": 650
|
||||
}
|
||||
]
|
||||
},
|
||||
"launch.patriot_far": {
|
||||
"sounds": [
|
||||
{
|
||||
"name": "airdef:launch_patriot_far",
|
||||
"attenuation_distance": 1040
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -67,7 +91,15 @@
|
||||
"sounds": [
|
||||
{
|
||||
"name": "airdef:launch_s400_eject",
|
||||
"attenuation_distance": 350
|
||||
"attenuation_distance": 400
|
||||
}
|
||||
]
|
||||
},
|
||||
"launch.s400_eject_far": {
|
||||
"sounds": [
|
||||
{
|
||||
"name": "airdef:launch_s400_eject_far",
|
||||
"attenuation_distance": 640
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -75,7 +107,15 @@
|
||||
"sounds": [
|
||||
{
|
||||
"name": "airdef:launch_s400",
|
||||
"attenuation_distance": 800
|
||||
"attenuation_distance": 900
|
||||
}
|
||||
]
|
||||
},
|
||||
"launch.s400_far": {
|
||||
"sounds": [
|
||||
{
|
||||
"name": "airdef:launch_s400_far",
|
||||
"attenuation_distance": 1440
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -83,7 +123,15 @@
|
||||
"sounds": [
|
||||
{
|
||||
"name": "airdef:launch_s500_eject",
|
||||
"attenuation_distance": 450
|
||||
"attenuation_distance": 500
|
||||
}
|
||||
]
|
||||
},
|
||||
"launch.s500_eject_far": {
|
||||
"sounds": [
|
||||
{
|
||||
"name": "airdef:launch_s500_eject_far",
|
||||
"attenuation_distance": 800
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -91,7 +139,15 @@
|
||||
"sounds": [
|
||||
{
|
||||
"name": "airdef:launch_s500",
|
||||
"attenuation_distance": 1000
|
||||
"attenuation_distance": 1100
|
||||
}
|
||||
]
|
||||
},
|
||||
"launch.s500_far": {
|
||||
"sounds": [
|
||||
{
|
||||
"name": "airdef:launch_s500_far",
|
||||
"attenuation_distance": 1760
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -99,7 +155,15 @@
|
||||
"sounds": [
|
||||
{
|
||||
"name": "airdef:launch_pantsir",
|
||||
"attenuation_distance": 450
|
||||
"attenuation_distance": 500
|
||||
}
|
||||
]
|
||||
},
|
||||
"launch.pantsir_far": {
|
||||
"sounds": [
|
||||
{
|
||||
"name": "airdef:launch_pantsir_far",
|
||||
"attenuation_distance": 800
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -107,7 +171,15 @@
|
||||
"sounds": [
|
||||
{
|
||||
"name": "airdef:gun_gepard",
|
||||
"attenuation_distance": 380
|
||||
"attenuation_distance": 400
|
||||
}
|
||||
]
|
||||
},
|
||||
"gun.gepard_far": {
|
||||
"sounds": [
|
||||
{
|
||||
"name": "airdef:gun_gepard_far",
|
||||
"attenuation_distance": 640
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -115,7 +187,15 @@
|
||||
"sounds": [
|
||||
{
|
||||
"name": "airdef:gun_pantsir",
|
||||
"attenuation_distance": 350
|
||||
"attenuation_distance": 380
|
||||
}
|
||||
]
|
||||
},
|
||||
"gun.pantsir_far": {
|
||||
"sounds": [
|
||||
{
|
||||
"name": "airdef:gun_pantsir_far",
|
||||
"attenuation_distance": 608
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -123,7 +203,15 @@
|
||||
"sounds": [
|
||||
{
|
||||
"name": "airdef:blast_big",
|
||||
"attenuation_distance": 900
|
||||
"attenuation_distance": 1000
|
||||
}
|
||||
]
|
||||
},
|
||||
"blast.big_far": {
|
||||
"sounds": [
|
||||
{
|
||||
"name": "airdef:blast_big_far",
|
||||
"attenuation_distance": 1600
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -131,7 +219,15 @@
|
||||
"sounds": [
|
||||
{
|
||||
"name": "airdef:blast_medium",
|
||||
"attenuation_distance": 600
|
||||
"attenuation_distance": 700
|
||||
}
|
||||
]
|
||||
},
|
||||
"blast.medium_far": {
|
||||
"sounds": [
|
||||
{
|
||||
"name": "airdef:blast_medium_far",
|
||||
"attenuation_distance": 1120
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -139,7 +235,15 @@
|
||||
"sounds": [
|
||||
{
|
||||
"name": "airdef:blast_air",
|
||||
"attenuation_distance": 700
|
||||
"attenuation_distance": 800
|
||||
}
|
||||
]
|
||||
},
|
||||
"blast.air_far": {
|
||||
"sounds": [
|
||||
{
|
||||
"name": "airdef:blast_air_far",
|
||||
"attenuation_distance": 1280
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+40
-1
@@ -47,6 +47,20 @@ struct AirDef {
|
||||
monitor_shown: bool,
|
||||
/// The menu's page while it is open.
|
||||
menu: Option<Page>,
|
||||
/// Where the player is, for the sounds' delay and far variants.
|
||||
listener: DVec3,
|
||||
/// Sounds on their way to the player at the speed of sound: (tick, event, at, pitch).
|
||||
pending: Vec<(u64, String, DVec3, f32)>,
|
||||
}
|
||||
|
||||
/// Blocks a tick the sound travels (343 m/s).
|
||||
const SOUND_SPEED: f64 = 17.15;
|
||||
/// Past this the far variant plays: muffled, more rolling echo.
|
||||
const FAR: f64 = 140.0;
|
||||
|
||||
/// Impulsive sounds (blasts, launches, shots, shock waves) come late from afar and have far variants.
|
||||
fn travels(event: &str) -> bool {
|
||||
["airdef:blast", "airdef:launch", "airdef:gun", "airdef:boom"].iter().any(|p| event.starts_with(p))
|
||||
}
|
||||
|
||||
/// A page of the menu.
|
||||
@@ -238,8 +252,30 @@ impl AirDef {
|
||||
.collect();
|
||||
render::spawn_particles(&spawns);
|
||||
}
|
||||
let now = self.sim.tick;
|
||||
for (event, at, volume, pitch) in out.sounds {
|
||||
render::play_sound(event, at.x, at.y, at.z, volume, pitch);
|
||||
let dist = at.distance(self.listener);
|
||||
if travels(event) {
|
||||
// Seen first, heard later; from afar muffled.
|
||||
let name = if dist > FAR { format!("{event}_far") } else { event.to_string() };
|
||||
let delay = (dist / SOUND_SPEED) as u64;
|
||||
if delay == 0 {
|
||||
render::play_sound(&name, at.x, at.y, at.z, volume, pitch);
|
||||
} else if self.pending.len() < 256 {
|
||||
self.pending.push((now + delay, name, at, pitch));
|
||||
}
|
||||
} else {
|
||||
render::play_sound(event, at.x, at.y, at.z, volume, pitch);
|
||||
}
|
||||
}
|
||||
let mut i = 0;
|
||||
while i < self.pending.len() {
|
||||
if self.pending[i].0 <= now {
|
||||
let (_, name, at, pitch) = self.pending.swap_remove(i);
|
||||
render::play_sound(&name, at.x, at.y, at.z, 1.0, pitch);
|
||||
} else {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
if !out.commands.is_empty() {
|
||||
command::run_quiet(&out.commands);
|
||||
@@ -419,6 +455,8 @@ impl Mod for AirDef {
|
||||
monitor: None,
|
||||
monitor_shown: false,
|
||||
menu: None,
|
||||
listener: DVec3::ZERO,
|
||||
pending: Vec::new(),
|
||||
};
|
||||
let _ = input::register_key("menu", "Меню AirDef", "key.keyboard.n");
|
||||
let _ = input::register_key("monitor", "Монитор ПВО: следующий комплекс", "key.keyboard.k");
|
||||
@@ -442,6 +480,7 @@ impl Mod for AirDef {
|
||||
let now = self.sim.tick;
|
||||
self.ground.answer(render::ground(), now);
|
||||
let mut out = Out::default();
|
||||
self.listener = DVec3::new(p.x, p.y + 1.6, p.z);
|
||||
self.sim.step(&mut self.ground, Some(DVec3::new(p.x, p.y, p.z)), &mut out);
|
||||
let wanted = self.ground.take_wanted();
|
||||
if !wanted.is_empty() {
|
||||
|
||||
@@ -1655,12 +1655,12 @@ impl Sim {
|
||||
if lit {
|
||||
if (m.age - spec.cold) % 46 == 1 {
|
||||
let pitch = match m.kind {
|
||||
MissileKind::Pac3 => 1.15,
|
||||
MissileKind::M57e6 => 1.45,
|
||||
MissileKind::M48n6 => 0.9,
|
||||
MissileKind::M77n6 => 0.75,
|
||||
MissileKind::Pac3 => 1.0,
|
||||
MissileKind::M57e6 => 1.12,
|
||||
MissileKind::M48n6 => 0.88,
|
||||
MissileKind::M77n6 => 0.8,
|
||||
};
|
||||
out.sound("airdef:drone.rocket", m.pos, 1.0, pitch);
|
||||
out.sound("airdef:missile.flight", m.pos, 1.0, pitch);
|
||||
}
|
||||
let back = m.pos - new * 1.4 * spec.scale;
|
||||
out.particles("minecraft:flame", back, 0.05, -new * 0.1, 0.01, 2);
|
||||
|
||||
+275
-157
@@ -1,9 +1,13 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Синтезирует звуки AirDef (airdef-mod/assets/airdef/sounds/*.ogg) и sounds.json.
|
||||
|
||||
Никаких сэмплов: шум, тоны, фильтры и реверберация (синтетический импульсный отклик), всё на numpy;
|
||||
кодирует ffmpeg (libvorbis). Звуки моно, записанные в 2 одинаковых канала (встроенный vorbis в ffmpeg пишет только стерео; Horizon сводит в моно).
|
||||
Дальность слышимости у каждого события своя (attenuation_distance в sounds.json).
|
||||
Никаких сэмплов: шум, тоны, фильтры, насыщение и реверберация на numpy; кодирует ffmpeg
|
||||
(встроенный vorbis пишет только стерео — оба канала одинаковые, Horizon сводит в моно).
|
||||
|
||||
Каждый громкий звук собран слоями: удар (короткий треск ударной волны), тело (суббас с падением
|
||||
тона, рокот), хвост (раскаты: отражения от рельефа, тёмная реверберация). У взрывов и пусков есть
|
||||
дальний вариант `*_far`: верх срезан, атака мягче, раскатов больше — мод выбирает его по расстоянию
|
||||
и задерживает звук по скорости звука. Дальность слышимости — attenuation_distance в sounds.json.
|
||||
|
||||
Запуск: python3 tools/gen_sounds.py
|
||||
"""
|
||||
@@ -29,29 +33,37 @@ 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)
|
||||
"""Фильтр через 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, len(x))
|
||||
return np.fft.irfft(X * g, size)[:n]
|
||||
|
||||
|
||||
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 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):
|
||||
@@ -67,44 +79,83 @@ def env(d, attack, decay):
|
||||
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 sat(x, drive):
|
||||
"""Насыщение: плотность и «злость» без цифрового клиппинга."""
|
||||
x = x / (np.abs(x).max() + 1e-9)
|
||||
return np.tanh(x * drive) / np.tanh(drive)
|
||||
|
||||
|
||||
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)
|
||||
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 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
|
||||
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 norm(x, level=0.9):
|
||||
return x / (np.abs(x).max() + 1e-9) * level
|
||||
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):
|
||||
@@ -115,163 +166,223 @@ def mix(*parts):
|
||||
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():
|
||||
"""Двухтактный мопедный мотор: импульсы ~70 Гц, хриплый выхлоп, «плавающие» обороты."""
|
||||
"""Двухтактный «мопед»: 2 цилиндра, рваный выхлоп, резонанс глушителя, плавающие обороты."""
|
||||
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)
|
||||
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.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)
|
||||
"""Турбореактивный: ровный рёв с «шелестом» лопаток и мягким свистом турбины."""
|
||||
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 = 820 * (1 + 0.03 * smooth(d, 4))
|
||||
f = 760 * (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)
|
||||
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 = 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)
|
||||
"""Крылатая ракета: тяжёлый турбовентиляторный гул, почти без свиста."""
|
||||
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
|
||||
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)
|
||||
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.0
|
||||
"""Падающая баллистика: нарастающий рёв «товарного поезда», рвущийся воздух, без тона."""
|
||||
d = 4.5
|
||||
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)
|
||||
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():
|
||||
"""Двойной хлопок ударной волны с долгим эхом."""
|
||||
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))
|
||||
"""Двойной хлопок ударной волны и раскаты."""
|
||||
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 = 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))
|
||||
"""Зажигание: удар и треск, затем рвущийся рёв, уходящий вверх и в сторону (темнеет, тише)."""
|
||||
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.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))
|
||||
"""Холодный старт: газ выбивает ракету — глухой тяжёлый «ТХУМП» и шипение."""
|
||||
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.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))
|
||||
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 = 2.6
|
||||
"""Панцирь: резкий хлопок, ракета на огромном ускорении уходит, рёв быстро тает."""
|
||||
d = 3.2
|
||||
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))
|
||||
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, thud_hz, crack_hi, room):
|
||||
d = shots * interval + 0.35
|
||||
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)
|
||||
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))
|
||||
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 + 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))
|
||||
"""Взрыв: хлопок, суббас с падением тона, огненный рёв, раскаты (size 0..1)."""
|
||||
d = 3.0 + 4.0 * size
|
||||
crack = nwave(0.006 + 0.006 * size) * 3.0
|
||||
sub = boom(d, 75 - 25 * size, 26 - 6 * size, 0.06 + 0.06 * size, 0.5 + 0.9 * size) * 1.6
|
||||
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)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- запись
|
||||
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),
|
||||
# событие: (звук, дальность слышимости в блоках, есть ли дальний вариант)
|
||||
"drone.shahed": (shahed, 220, False),
|
||||
"drone.geran3": (geran3, 260, False),
|
||||
"drone.lancet": (lancet, 120, False),
|
||||
"drone.cruise": (cruise, 320, False),
|
||||
"drone.rocket": (rocket, 450, False),
|
||||
"missile.flight": (interceptor, 380, False),
|
||||
"drone.ballistic": (ballistic, 700, False),
|
||||
"boom.sonic": (sonic, 900, True),
|
||||
"launch.patriot": (launch_patriot, 650, True),
|
||||
"launch.s400_eject": (lambda: eject(42), 400, True),
|
||||
"launch.s400": (lambda: ignite(34, 6.5), 900, True),
|
||||
"launch.s500_eject": (lambda: eject(30), 500, True),
|
||||
"launch.s500": (lambda: ignite(26, 8.0), 1100, True),
|
||||
"launch.pantsir": (launch_pantsir, 500, True),
|
||||
"gun.gepard": (lambda: gun(3, 0.056, 55, 1500, 1.6), 400, True),
|
||||
"gun.pantsir": (lambda: gun(6, 0.034, 75, 2200, 1.4), 380, True),
|
||||
"blast.big": (lambda: blast(1.0), 1000, True),
|
||||
"blast.medium": (lambda: blast(0.5), 700, True),
|
||||
"blast.air": (blast_air, 800, True),
|
||||
}
|
||||
|
||||
|
||||
@@ -284,21 +395,28 @@ def write_ogg(path, x):
|
||||
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)
|
||||
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)
|
||||
for f in os.listdir(out):
|
||||
os.remove(os.path.join(out, f))
|
||||
events = {}
|
||||
for event, (fn, distance) in SOUNDS.items():
|
||||
for event, (fn, distance, has_far) in SOUNDS.items():
|
||||
name = event.replace(".", "_")
|
||||
write_ogg(os.path.join(out, name + ".ogg"), fn())
|
||||
x = fn()
|
||||
write_ogg(os.path.join(out, name + ".ogg"), x)
|
||||
events[event] = {"sounds": [{"name": f"airdef:{name}", "attenuation_distance": distance}]}
|
||||
if has_far:
|
||||
write_ogg(os.path.join(out, name + "_far.ogg"), master(far(x), rms=0.2))
|
||||
events[event + "_far"] = {"sounds": [{"name": f"airdef:{name}_far", "attenuation_distance": int(distance * 1.6)}]}
|
||||
with open(os.path.join(ROOT, "sounds.json"), "w") as f:
|
||||
json.dump(events, f, indent=1)
|
||||
print(f"{len(SOUNDS)} звуков -> {out}")
|
||||
print(f"{len(events)} звуков -> {out}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
Reference in New Issue
Block a user