From 1553976484aa680ac049eced130b38b162b1c001 Mon Sep 17 00:00:00 2001 From: chexit Date: Wed, 4 Nov 2026 15:56:58 +0300 Subject: [PATCH] Share sound identifiers and metadata with resource generation Co-Authored-By: Codex --- .gitea/workflows/build.yml | 1 + airdef-mod/src/sim/catalog.rs | 5 ++ airdef-mod/src/sim/catalog_sound_ids.rs | 22 +++++ airdef-mod/src/sim/outcome.rs | 6 +- airdef-mod/src/sim/strike.rs | 4 +- airdef-mod/src/sim/voices.rs | 4 +- catalog/sounds.json | 107 ++++++++++++++++++++++++ docs/REPO-MAP.md | 2 +- tools/airdef_assets/catalog.py | 34 ++++++++ tools/gen_catalog.py | 25 ++++-- tools/gen_sounds.py | 59 +++++++------ tools/test_sound_catalog.py | 35 ++++++++ 12 files changed, 262 insertions(+), 42 deletions(-) create mode 100644 airdef-mod/src/sim/catalog_sound_ids.rs create mode 100644 catalog/sounds.json create mode 100644 tools/test_sound_catalog.py diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml index c74315c..8572125 100644 --- a/.gitea/workflows/build.yml +++ b/.gitea/workflows/build.yml @@ -36,6 +36,7 @@ jobs: run: | python3 -m pip install --user -r tools/requirements.txt python3 tools/gen_catalog.py --check + python3 -m unittest discover -s tools -p test_sound_catalog.py python3 tools/gen_models.py --check python3 -m unittest discover -s tools -p test_model_bounds.py - name: Тесты ядра без SDK diff --git a/airdef-mod/src/sim/catalog.rs b/airdef-mod/src/sim/catalog.rs index 61313ae..eea435e 100644 --- a/airdef-mod/src/sim/catalog.rs +++ b/airdef-mod/src/sim/catalog.rs @@ -52,6 +52,11 @@ impl Unit { } use DroneKind::*; +/// Stable sound IDs shared with the audio resource generator. +pub mod sound { + include!("catalog_sound_ids.rs"); +} + // Generated entries keep the historical raid and unit ordering. include!("catalog_entries.rs"); diff --git a/airdef-mod/src/sim/catalog_sound_ids.rs b/airdef-mod/src/sim/catalog_sound_ids.rs new file mode 100644 index 0000000..b0e2cd6 --- /dev/null +++ b/airdef-mod/src/sim/catalog_sound_ids.rs @@ -0,0 +1,22 @@ +// Generated by tools/gen_catalog.py from catalog/sounds.json. Do not edit. +pub const DRONE_SHAHED: &str = "airdef:drone.shahed"; +pub const DRONE_GERAN3: &str = "airdef:drone.geran3"; +pub const DRONE_LANCET: &str = "airdef:drone.lancet"; +pub const DRONE_CRUISE: &str = "airdef:drone.cruise"; +pub const DRONE_ROCKET: &str = "airdef:drone.rocket"; +pub const MISSILE_FLIGHT: &str = "airdef:missile.flight"; +pub const DRONE_BALLISTIC: &str = "airdef:drone.ballistic"; +pub const BOOM_SONIC: &str = "airdef:boom.sonic"; +pub const LAUNCH_PATRIOT: &str = "airdef:launch.patriot"; +pub const LAUNCH_S400_EJECT: &str = "airdef:launch.s400_eject"; +pub const LAUNCH_S400: &str = "airdef:launch.s400"; +pub const LAUNCH_S500_EJECT: &str = "airdef:launch.s500_eject"; +pub const LAUNCH_S500: &str = "airdef:launch.s500"; +pub const LAUNCH_PANTSIR: &str = "airdef:launch.pantsir"; +pub const GUN_GEPARD: &str = "airdef:gun.gepard"; +pub const GUN_PANTSIR: &str = "airdef:gun.pantsir"; +pub const BLAST_BIG: &str = "airdef:blast.big"; +pub const BLAST_HEAVY: &str = "airdef:blast.heavy"; +pub const BLAST_HUGE: &str = "airdef:blast.huge"; +pub const BLAST_MEDIUM: &str = "airdef:blast.medium"; +pub const BLAST_AIR: &str = "airdef:blast.air"; diff --git a/airdef-mod/src/sim/outcome.rs b/airdef-mod/src/sim/outcome.rs index 7559658..1adc13e 100644 --- a/airdef-mod/src/sim/outcome.rs +++ b/airdef-mod/src/sim/outcome.rs @@ -123,9 +123,9 @@ pub fn look(kind: DroneKind, outcome: Outcome) -> (f32, u32, f32, &'static str) let life = (8 + (w as u32) / 2) * 9; let gain = 1.0 + 0.06 * w; match outcome { - Outcome::DisabledFalling => (size, life, gain, "airdef:blast.air"), - Outcome::Breakup => (size * 0.9, life, gain * 0.9, "airdef:blast.medium"), - Outcome::AirDetonation => (size * 1.9, life * 8 / 5, gain * 1.5, "airdef:blast.big"), + Outcome::DisabledFalling => (size, life, gain, super::catalog::sound::BLAST_AIR), + Outcome::Breakup => (size * 0.9, life, gain * 0.9, super::catalog::sound::BLAST_MEDIUM), + Outcome::AirDetonation => (size * 1.9, life * 8 / 5, gain * 1.5, super::catalog::sound::BLAST_BIG), } } diff --git a/airdef-mod/src/sim/strike.rs b/airdef-mod/src/sim/strike.rs index 2c1ae76..7329cda 100644 --- a/airdef-mod/src/sim/strike.rs +++ b/airdef-mod/src/sim/strike.rs @@ -660,7 +660,7 @@ impl Sim { out.model(id + 1, "airdef:geran3_flame", pos + rot * DVec3::new(0.0, up * spec.scale, back * spec.scale), rot, spec.scale * size * flicker, true); } if f.carrier.is_some() { - if age == EJECT_TICKS { out.sound("airdef:launch.s400", pos, 1.0, 1.0); } + if age == EJECT_TICKS { out.sound(super::catalog::sound::LAUNCH_S400, pos, 1.0, 1.0); } if age >= EJECT_TICKS { let (event, gain, pitch) = MissileKind::M48n6.engine_voice(); out.engine(id, event, pos, pos - from, gain, pitch, false); @@ -759,7 +759,7 @@ impl Sim { land: p.aim + DVec2::new(gx, gz) * TUNE.cep, start: self.tick, arrive: self.tick + ticks, snap, hold: 0 }); if let Some(k) = self.strikes.known.iter_mut().find(|k| k.site == p.site) { k.aimed = true; } self.record_strike_launch(self.strikes.next, p.carrier, start); - out.sound("airdef:launch.s400_eject", start, 1.0, 1.0); + out.sound(super::catalog::sound::LAUNCH_S400_EJECT, start, 1.0, 1.0); out.particles("minecraft:cloud", start, 0.6, DVec3::Y * 0.05, 0.05, 30); } } diff --git a/airdef-mod/src/sim/voices.rs b/airdef-mod/src/sim/voices.rs index cd10dba..735c9bc 100644 --- a/airdef-mod/src/sim/voices.rs +++ b/airdef-mod/src/sim/voices.rs @@ -19,7 +19,7 @@ impl MissileKind { /// a motor without a flame. pub fn engine_voice(self) -> (&'static str, f32, f32) { if self.spec().flame == 0.0 { - return ("airdef:drone.lancet", 0.8, 1.6); + return (super::catalog::sound::DRONE_LANCET, 0.8, 1.6); } let pitch = match self { MissileKind::Pac3 => 1.0, @@ -31,7 +31,7 @@ impl MissileKind { MissileKind::M9m338 => 1.15, MissileKind::P1sun => 1.7, }; - ("airdef:missile.flight", 1.0, pitch) + (super::catalog::sound::MISSILE_FLIGHT, 1.0, pitch) } /// Ticks from the launch until the motor lights (a cold launch throws it out of the tube first). pub fn lit_after(self) -> u32 { diff --git a/catalog/sounds.json b/catalog/sounds.json new file mode 100644 index 0000000..c3da9ae --- /dev/null +++ b/catalog/sounds.json @@ -0,0 +1,107 @@ +[ + { + "event": "drone.shahed", + "distance": 220, + "far": false + }, + { + "event": "drone.geran3", + "distance": 260, + "far": false + }, + { + "event": "drone.lancet", + "distance": 120, + "far": false + }, + { + "event": "drone.cruise", + "distance": 320, + "far": false + }, + { + "event": "drone.rocket", + "distance": 450, + "far": false + }, + { + "event": "missile.flight", + "distance": 380, + "far": false + }, + { + "event": "drone.ballistic", + "distance": 700, + "far": false + }, + { + "event": "boom.sonic", + "distance": 900, + "far": true + }, + { + "event": "launch.patriot", + "distance": 650, + "far": true + }, + { + "event": "launch.s400_eject", + "distance": 400, + "far": true + }, + { + "event": "launch.s400", + "distance": 900, + "far": true + }, + { + "event": "launch.s500_eject", + "distance": 500, + "far": true + }, + { + "event": "launch.s500", + "distance": 1100, + "far": true + }, + { + "event": "launch.pantsir", + "distance": 500, + "far": true + }, + { + "event": "gun.gepard", + "distance": 400, + "far": true + }, + { + "event": "gun.pantsir", + "distance": 380, + "far": true + }, + { + "event": "blast.big", + "distance": 1000, + "far": true + }, + { + "event": "blast.heavy", + "distance": 1300, + "far": true + }, + { + "event": "blast.huge", + "distance": 1700, + "far": true + }, + { + "event": "blast.medium", + "distance": 700, + "far": true + }, + { + "event": "blast.air", + "distance": 800, + "far": true + } +] diff --git a/docs/REPO-MAP.md b/docs/REPO-MAP.md index 762cd91..ab22097 100644 --- a/docs/REPO-MAP.md +++ b/docs/REPO-MAP.md @@ -38,7 +38,7 @@ - `python3 tools/gen_models.py` — перегенерировать. - `python3 tools/gen_models.py --check` — только сравнить, выход 1 при различиях. -`tools/gen_sounds.py` создаёт `airdef-mod/assets/airdef/sounds/*.ogg` и `sounds.json`. Этот генератор не использует manifest и не входит в проверку `gen_models.py`. +`catalog/sounds.json` хранит звуковые ID, дальности и наличие дальнего варианта; `tools/gen_catalog.py` экспортирует ID для Rust, а `tools/gen_sounds.py` создаёт `airdef-mod/assets/airdef/sounds/*.ogg` и `sounds.json`. Этот генератор не использует manifest и не входит в проверку `gen_models.py`. ## Архив: `legacy/` diff --git a/tools/airdef_assets/catalog.py b/tools/airdef_assets/catalog.py index 208a02f..4094f91 100644 --- a/tools/airdef_assets/catalog.py +++ b/tools/airdef_assets/catalog.py @@ -23,3 +23,37 @@ def load(): if len(item["color"]) != 3 or any(not isinstance(c, int) or not 0 <= c <= 255 for c in item["color"]): raise ValueError(f"invalid icon color for {item['id']}") return data + + +def load_sounds(): + """Load stable event IDs and engine attenuation metadata.""" + import re + + entries = json.loads((PATH.parent / "sounds.json").read_text(encoding="utf-8")) + seen = set() + for entry in entries: + event, distance, far = entry["event"], entry["distance"], entry["far"] + if not re.fullmatch(r"[a-z0-9_]+\.[a-z0-9_]+", event) or event in seen: + raise ValueError(f"invalid or duplicate sound event {event!r}") + if type(distance) is not int or distance <= 0 or type(far) is not bool: + raise ValueError(f"invalid sound metadata for {event}") + seen.add(event) + names = [entry["event"].replace(".", "_") for entry in entries] + if len(names) != len(set(names)): + raise ValueError("sound event IDs collide as resource names") + registered = [name for entry in entries for name in ([entry["event"], entry["event"] + "_far"] if entry["far"] else [entry["event"]])] + if len(registered) != len(set(registered)): + raise ValueError("sound event IDs collide with far variants") + return entries + + +def sound_events(entries): + """Build the Minecraft sound event registry without generating audio.""" + events = {} + for entry in entries: + event, distance = entry["event"], entry["distance"] + name = event.replace(".", "_") + events[event] = {"sounds": [{"name": f"airdef:{name}", "attenuation_distance": distance}]} + if entry["far"]: + events[event + "_far"] = {"sounds": [{"name": f"airdef:{name}_far", "attenuation_distance": int(distance * 1.6)}]} + return events diff --git a/tools/gen_catalog.py b/tools/gen_catalog.py index d2bd779..22434cd 100644 --- a/tools/gen_catalog.py +++ b/tools/gen_catalog.py @@ -4,7 +4,7 @@ import argparse from pathlib import Path import re -from airdef_assets.catalog import load +from airdef_assets.catalog import load, load_sounds OUTPUT = Path(__file__).resolve().parents[1] / "airdef-mod/src/sim/catalog_entries.rs" @@ -29,16 +29,27 @@ def generate(data): return "\n".join(lines + ["];", ""]) +def generate_sound_ids(entries): + lines = ["// Generated by tools/gen_catalog.py from catalog/sounds.json. Do not edit."] + for entry in entries: + event = entry["event"] + name = event.replace(".", "_").upper() + lines.append(f'pub const {name}: &str = "airdef:{event}";') + return "\n".join(lines + [""]) + + def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--check", action="store_true") args = parser.parse_args() - generated = generate(load()) - if args.check: - if not OUTPUT.exists() or OUTPUT.read_text(encoding="utf-8") != generated: - parser.exit(1, f"{OUTPUT} is stale; run python3 tools/gen_catalog.py\n") - else: - OUTPUT.write_text(generated, encoding="utf-8") + sound_output = OUTPUT.with_name("catalog_sound_ids.rs") + outputs = ((OUTPUT, generate(load())), (sound_output, generate_sound_ids(load_sounds()))) + for path, generated in outputs: + if args.check: + if not path.exists() or path.read_text(encoding="utf-8") != generated: + parser.exit(1, f"{path} is stale; run python3 tools/gen_catalog.py\n") + else: + path.write_text(generated, encoding="utf-8") if __name__ == "__main__": diff --git a/tools/gen_sounds.py b/tools/gen_sounds.py index 1f0da43..87a7b4f 100644 --- a/tools/gen_sounds.py +++ b/tools/gen_sounds.py @@ -19,6 +19,8 @@ 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) @@ -367,29 +369,29 @@ def blast_air(): # ---------------------------------------------------------------- запись -SOUNDS = { - # событие: (звук, дальность слышимости в блоках, есть ли дальний вариант) - "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.heavy": (lambda: blast(1.35), 1300, True), - "blast.huge": (lambda: blast(1.75), 1700, True), - "blast.medium": (lambda: blast(0.5), 700, True), - "blast.air": (blast_air, 800, True), +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, } @@ -408,19 +410,22 @@ def write_ogg(path, x): 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)) - events = {} - for event, (fn, distance, has_far) in SOUNDS.items(): + 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) - 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(events)} звуков -> {out}") diff --git a/tools/test_sound_catalog.py b/tools/test_sound_catalog.py new file mode 100644 index 0000000..cf6fa6d --- /dev/null +++ b/tools/test_sound_catalog.py @@ -0,0 +1,35 @@ +"""Check sound metadata against shipped resources without regenerating audio.""" +import ast +import json +from pathlib import Path +import unittest + +from airdef_assets.catalog import load_sounds, sound_events + +ROOT = Path(__file__).resolve().parents[1] + + +class SoundCatalogTests(unittest.TestCase): + def test_registry_and_audio_files_match_catalog(self): + assets = ROOT / "airdef-mod/assets/airdef" + expected = sound_events(load_sounds()) + actual = json.loads((assets / "sounds.json").read_text(encoding="utf-8")) + self.assertEqual(actual, expected) + files = {sound["name"].split(":", 1)[1] + ".ogg" + for event in expected.values() for sound in event["sounds"]} + self.assertEqual(files, {path.name for path in (assets / "sounds").glob("*.ogg")}) + + def test_every_catalog_event_has_one_synthesizer(self): + source = (ROOT / "tools/gen_sounds.py").read_text(encoding="utf-8") + tree = ast.parse(source) + mapping = next(node.value for node in tree.body if isinstance(node, ast.Assign) + and any(isinstance(target, ast.Name) and target.id == "SYNTHESIZERS" + for target in node.targets)) + keys = [ast.literal_eval(key) for key in mapping.keys] + self.assertEqual(len(keys), len(set(keys))) + # Synthesis uses a shared random stream, so changing order changes the audio. + self.assertEqual(keys, [entry["event"] for entry in load_sounds()]) + + +if __name__ == "__main__": + unittest.main()