diff --git a/airdef-mod/assets/airdef/sounds.json b/airdef-mod/assets/airdef/sounds.json index 7bcfa36..04c51d3 100644 --- a/airdef-mod/assets/airdef/sounds.json +++ b/airdef-mod/assets/airdef/sounds.json @@ -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 } ] } diff --git a/airdef-mod/assets/airdef/sounds/blast_air.ogg b/airdef-mod/assets/airdef/sounds/blast_air.ogg index e4db9f2..22d5862 100644 Binary files a/airdef-mod/assets/airdef/sounds/blast_air.ogg and b/airdef-mod/assets/airdef/sounds/blast_air.ogg differ diff --git a/airdef-mod/assets/airdef/sounds/blast_air_far.ogg b/airdef-mod/assets/airdef/sounds/blast_air_far.ogg new file mode 100644 index 0000000..ce3fa42 Binary files /dev/null and b/airdef-mod/assets/airdef/sounds/blast_air_far.ogg differ diff --git a/airdef-mod/assets/airdef/sounds/blast_big.ogg b/airdef-mod/assets/airdef/sounds/blast_big.ogg index 0a0f419..c30f02e 100644 Binary files a/airdef-mod/assets/airdef/sounds/blast_big.ogg and b/airdef-mod/assets/airdef/sounds/blast_big.ogg differ diff --git a/airdef-mod/assets/airdef/sounds/blast_big_far.ogg b/airdef-mod/assets/airdef/sounds/blast_big_far.ogg new file mode 100644 index 0000000..d2dee38 Binary files /dev/null and b/airdef-mod/assets/airdef/sounds/blast_big_far.ogg differ diff --git a/airdef-mod/assets/airdef/sounds/blast_medium.ogg b/airdef-mod/assets/airdef/sounds/blast_medium.ogg index 8d014af..787d59b 100644 Binary files a/airdef-mod/assets/airdef/sounds/blast_medium.ogg and b/airdef-mod/assets/airdef/sounds/blast_medium.ogg differ diff --git a/airdef-mod/assets/airdef/sounds/blast_medium_far.ogg b/airdef-mod/assets/airdef/sounds/blast_medium_far.ogg new file mode 100644 index 0000000..d79cfc1 Binary files /dev/null and b/airdef-mod/assets/airdef/sounds/blast_medium_far.ogg differ diff --git a/airdef-mod/assets/airdef/sounds/boom_sonic.ogg b/airdef-mod/assets/airdef/sounds/boom_sonic.ogg index 77aa7c0..1e11ddc 100644 Binary files a/airdef-mod/assets/airdef/sounds/boom_sonic.ogg and b/airdef-mod/assets/airdef/sounds/boom_sonic.ogg differ diff --git a/airdef-mod/assets/airdef/sounds/boom_sonic_far.ogg b/airdef-mod/assets/airdef/sounds/boom_sonic_far.ogg new file mode 100644 index 0000000..c531bbd Binary files /dev/null and b/airdef-mod/assets/airdef/sounds/boom_sonic_far.ogg differ diff --git a/airdef-mod/assets/airdef/sounds/drone_ballistic.ogg b/airdef-mod/assets/airdef/sounds/drone_ballistic.ogg index 89b6c11..7034d78 100644 Binary files a/airdef-mod/assets/airdef/sounds/drone_ballistic.ogg and b/airdef-mod/assets/airdef/sounds/drone_ballistic.ogg differ diff --git a/airdef-mod/assets/airdef/sounds/drone_cruise.ogg b/airdef-mod/assets/airdef/sounds/drone_cruise.ogg index 9aa290d..795fe9c 100644 Binary files a/airdef-mod/assets/airdef/sounds/drone_cruise.ogg and b/airdef-mod/assets/airdef/sounds/drone_cruise.ogg differ diff --git a/airdef-mod/assets/airdef/sounds/drone_geran3.ogg b/airdef-mod/assets/airdef/sounds/drone_geran3.ogg index 63868d0..013cafe 100644 Binary files a/airdef-mod/assets/airdef/sounds/drone_geran3.ogg and b/airdef-mod/assets/airdef/sounds/drone_geran3.ogg differ diff --git a/airdef-mod/assets/airdef/sounds/drone_lancet.ogg b/airdef-mod/assets/airdef/sounds/drone_lancet.ogg index a10287b..413b8ba 100644 Binary files a/airdef-mod/assets/airdef/sounds/drone_lancet.ogg and b/airdef-mod/assets/airdef/sounds/drone_lancet.ogg differ diff --git a/airdef-mod/assets/airdef/sounds/drone_rocket.ogg b/airdef-mod/assets/airdef/sounds/drone_rocket.ogg index 4000fa3..584bbf4 100644 Binary files a/airdef-mod/assets/airdef/sounds/drone_rocket.ogg and b/airdef-mod/assets/airdef/sounds/drone_rocket.ogg differ diff --git a/airdef-mod/assets/airdef/sounds/drone_shahed.ogg b/airdef-mod/assets/airdef/sounds/drone_shahed.ogg index 899d726..f8193fe 100644 Binary files a/airdef-mod/assets/airdef/sounds/drone_shahed.ogg and b/airdef-mod/assets/airdef/sounds/drone_shahed.ogg differ diff --git a/airdef-mod/assets/airdef/sounds/gun_gepard.ogg b/airdef-mod/assets/airdef/sounds/gun_gepard.ogg index 3706ff2..3a877fb 100644 Binary files a/airdef-mod/assets/airdef/sounds/gun_gepard.ogg and b/airdef-mod/assets/airdef/sounds/gun_gepard.ogg differ diff --git a/airdef-mod/assets/airdef/sounds/gun_gepard_far.ogg b/airdef-mod/assets/airdef/sounds/gun_gepard_far.ogg new file mode 100644 index 0000000..cbf4a4f Binary files /dev/null and b/airdef-mod/assets/airdef/sounds/gun_gepard_far.ogg differ diff --git a/airdef-mod/assets/airdef/sounds/gun_pantsir.ogg b/airdef-mod/assets/airdef/sounds/gun_pantsir.ogg index 1fa64e2..eaeabd1 100644 Binary files a/airdef-mod/assets/airdef/sounds/gun_pantsir.ogg and b/airdef-mod/assets/airdef/sounds/gun_pantsir.ogg differ diff --git a/airdef-mod/assets/airdef/sounds/gun_pantsir_far.ogg b/airdef-mod/assets/airdef/sounds/gun_pantsir_far.ogg new file mode 100644 index 0000000..5120d93 Binary files /dev/null and b/airdef-mod/assets/airdef/sounds/gun_pantsir_far.ogg differ diff --git a/airdef-mod/assets/airdef/sounds/launch_pantsir.ogg b/airdef-mod/assets/airdef/sounds/launch_pantsir.ogg index e77af92..0a132bf 100644 Binary files a/airdef-mod/assets/airdef/sounds/launch_pantsir.ogg and b/airdef-mod/assets/airdef/sounds/launch_pantsir.ogg differ diff --git a/airdef-mod/assets/airdef/sounds/launch_pantsir_far.ogg b/airdef-mod/assets/airdef/sounds/launch_pantsir_far.ogg new file mode 100644 index 0000000..bde586c Binary files /dev/null and b/airdef-mod/assets/airdef/sounds/launch_pantsir_far.ogg differ diff --git a/airdef-mod/assets/airdef/sounds/launch_patriot.ogg b/airdef-mod/assets/airdef/sounds/launch_patriot.ogg index 26abba2..aa38313 100644 Binary files a/airdef-mod/assets/airdef/sounds/launch_patriot.ogg and b/airdef-mod/assets/airdef/sounds/launch_patriot.ogg differ diff --git a/airdef-mod/assets/airdef/sounds/launch_patriot_far.ogg b/airdef-mod/assets/airdef/sounds/launch_patriot_far.ogg new file mode 100644 index 0000000..2a40e97 Binary files /dev/null and b/airdef-mod/assets/airdef/sounds/launch_patriot_far.ogg differ diff --git a/airdef-mod/assets/airdef/sounds/launch_s400.ogg b/airdef-mod/assets/airdef/sounds/launch_s400.ogg index d686805..8cda957 100644 Binary files a/airdef-mod/assets/airdef/sounds/launch_s400.ogg and b/airdef-mod/assets/airdef/sounds/launch_s400.ogg differ diff --git a/airdef-mod/assets/airdef/sounds/launch_s400_eject.ogg b/airdef-mod/assets/airdef/sounds/launch_s400_eject.ogg index 5b8fb7a..f768196 100644 Binary files a/airdef-mod/assets/airdef/sounds/launch_s400_eject.ogg and b/airdef-mod/assets/airdef/sounds/launch_s400_eject.ogg differ diff --git a/airdef-mod/assets/airdef/sounds/launch_s400_eject_far.ogg b/airdef-mod/assets/airdef/sounds/launch_s400_eject_far.ogg new file mode 100644 index 0000000..dc6d324 Binary files /dev/null and b/airdef-mod/assets/airdef/sounds/launch_s400_eject_far.ogg differ diff --git a/airdef-mod/assets/airdef/sounds/launch_s400_far.ogg b/airdef-mod/assets/airdef/sounds/launch_s400_far.ogg new file mode 100644 index 0000000..4e89e0c Binary files /dev/null and b/airdef-mod/assets/airdef/sounds/launch_s400_far.ogg differ diff --git a/airdef-mod/assets/airdef/sounds/launch_s500.ogg b/airdef-mod/assets/airdef/sounds/launch_s500.ogg index 90cc3c5..66083bf 100644 Binary files a/airdef-mod/assets/airdef/sounds/launch_s500.ogg and b/airdef-mod/assets/airdef/sounds/launch_s500.ogg differ diff --git a/airdef-mod/assets/airdef/sounds/launch_s500_eject.ogg b/airdef-mod/assets/airdef/sounds/launch_s500_eject.ogg index e4d3305..56d7d7a 100644 Binary files a/airdef-mod/assets/airdef/sounds/launch_s500_eject.ogg and b/airdef-mod/assets/airdef/sounds/launch_s500_eject.ogg differ diff --git a/airdef-mod/assets/airdef/sounds/launch_s500_eject_far.ogg b/airdef-mod/assets/airdef/sounds/launch_s500_eject_far.ogg new file mode 100644 index 0000000..94a0f4b Binary files /dev/null and b/airdef-mod/assets/airdef/sounds/launch_s500_eject_far.ogg differ diff --git a/airdef-mod/assets/airdef/sounds/launch_s500_far.ogg b/airdef-mod/assets/airdef/sounds/launch_s500_far.ogg new file mode 100644 index 0000000..9cb55c2 Binary files /dev/null and b/airdef-mod/assets/airdef/sounds/launch_s500_far.ogg differ diff --git a/airdef-mod/assets/airdef/sounds/missile_flight.ogg b/airdef-mod/assets/airdef/sounds/missile_flight.ogg new file mode 100644 index 0000000..02c7b69 Binary files /dev/null and b/airdef-mod/assets/airdef/sounds/missile_flight.ogg differ diff --git a/airdef-mod/src/lib.rs b/airdef-mod/src/lib.rs index 43accf4..565dcda 100644 --- a/airdef-mod/src/lib.rs +++ b/airdef-mod/src/lib.rs @@ -47,6 +47,20 @@ struct AirDef { monitor_shown: bool, /// The menu's page while it is open. menu: Option, + /// 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() { diff --git a/airdef-mod/src/sim.rs b/airdef-mod/src/sim.rs index a698314..e9446fb 100644 --- a/airdef-mod/src/sim.rs +++ b/airdef-mod/src/sim.rs @@ -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); diff --git a/tools/gen_sounds.py b/tools/gen_sounds.py index 0150ea9..7cac7f4 100644 --- a/tools/gen_sounds.py +++ b/tools/gen_sounds.py @@ -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__":