Files
airdef/tools/acoustics_check.py
T
chexit 57a5426e65
build / mod (push) Successful in 4m13s
Strikes on launch sites, command map and placement editor, fire doctrines, ground routes, acoustics switch and reverb
#101: radars locate sites from launches, /airdef sites, /airdef strike; sites launch slower (15/25/50 ticks) so a strike can come in time. #48: M opens the map on the network's picture, with an editor. #47: guard, sector, reserve and salvo per system. #94: vehicles route round slopes and water and say when they cannot get through. #96: reverb, and a menu switch for the acoustics. #92: generated files are validated before the swap, repo map.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 00:59:03 +03:00

210 lines
11 KiB
Python

#!/usr/bin/env python3
"""Проверка акустики без слуха (#96): энергия по полосам в дорожке с преградой и без неё.
python3 tools/acoustics_check.py open.raw wall.raw # s16le, 48000 Гц, стерео
python3 tools/acoustics_check.py open.raw wall.raw --rate 48000 --channels 2
Дорожки берутся из `Audio::film_mix` (экспорт видео пишет `<имя>.audio.raw`) или из теста
`cargo test -p horizon-client --lib audio::tests::export_wall_tracks -- --ignored` (HORIZON_ACOUSTICS_OUT=папка).
Для каждой дорожки считается энергия левого канала в четырёх полосах (до 300 Гц, 300-1500, 1500-5000, выше
5000) и разница в дБ между дорожками. Преграда должна срезать верхи (выше 5 кГц не меньше чем на 15 дБ) и
оставить низы (до 300 Гц теряют не больше 8 дБ). Коды выхода: 0 — в порогах, 1 — нет, 2 — ошибка аргументов.
Режим `--echo` (стадия 2): в дорожке с одним щелчком ищутся пики огибающей после прямого звука, их задержки
(мс от прямого) сравниваются с ожидаемыми от геометрии:
python3 tools/acoustics_check.py --echo gorge.raw --expect 187.3,254.1 [--tol 1.0]
python3 tools/acoustics_check.py --echo field.raw --expect none
python3 tools/acoustics_check.py --echo-dir папка # gorge/field/room и expected.txt из export_echo_tracks
Дорожки пишет `audio::tests::export_echo_tracks` (HORIZON_ACOUSTICS_OUT=папка). Коды выхода те же.
Режим `--reverb-dir` (стадия 3): папка из `audio::tests::export_reverb_tracks` (щелчок в комнате, тоннеле, ущелье
и поле; `expected_reverb.txt`: предсказанный спад и уровень из замера пространства). По хвосту после прямого звука
считаются RT60 (обратное интегрирование Шрёдера, спад с -5 до -35 дБ, удвоенный) и энергия хвоста к энергии щелчка:
python3 tools/acoustics_check.py --reverb-dir папка [--rt-tol 0.35]
Проверяется: в поле хвоста нет (энергия хвоста меньше -90 дБ от щелчка), спад упорядочен поле < ущелье < тоннель <
комната, у комнаты и тоннеля измеренный RT60 в пределах `--rt-tol` от того, что дают их посылки в слоты (колонка
`RT60 expected`: сумма экспонент слотов, спад -5...-35 дБ; слой из двух слотов читается длиннее короткого).
Только стандартная библиотека.
"""
import argparse
import array
import cmath
import math
import sys
BANDS = [("<300", 0, 300), ("300-1500", 300, 1500), ("1500-5000", 1500, 5000), (">5000", 5000, 1e9)]
def fft(a):
n = len(a)
if n == 1:
return a
even, odd = fft(a[0::2]), fft(a[1::2])
t = [cmath.exp(-2j * math.pi * k / n) * odd[k] for k in range(n // 2)]
return [even[k] + t[k] for k in range(n // 2)] + [even[k] - t[k] for k in range(n // 2)]
def bands(path, rate, channels):
raw = array.array("h")
with open(path, "rb") as f:
data = f.read()
raw.frombytes(data[: len(data) // 2 * 2])
left = [s / 32768.0 for s in raw[0::channels]]
n = 1
while n * 2 <= len(left):
n *= 2
n = min(n, 1 << 16)
if n < 1024:
sys.exit(f"{path}: too short ({len(left)} frames)")
# A Hann window over the loudest n frames' start: the burst is at the start of the track.
w = [left[i] * (0.5 - 0.5 * math.cos(2 * math.pi * i / (n - 1))) for i in range(n)]
spec = fft([complex(v) for v in w])
out = [0.0] * len(BANDS)
for k in range(1, n // 2):
hz = k * rate / n
for i, (_, lo, hi) in enumerate(BANDS):
if lo <= hz < hi:
out[i] += abs(spec[k]) ** 2
return out
def echo_peaks(path, rate, channels, min_gap_ms=10.0, rel=0.02):
"""Delays (ms from the first peak) of the peaks of the smoothed envelope of a track."""
raw = array.array("h")
with open(path, "rb") as f:
data = f.read()
raw.frombytes(data[: len(data) // 2 * 2])
frames = [sum(abs(raw[i + c]) for c in range(channels)) / 32768.0 for i in range(0, len(raw) - channels + 1, channels)]
win = max(1, rate // 1000) # 1 ms
env = []
acc = 0.0
for i, v in enumerate(frames):
acc += v
if i >= win:
acc -= frames[i - win]
env.append(acc / win)
first = max(range(min(len(env), rate // 20)), key=env.__getitem__) # the direct sound: loudest in the first 50 ms
top = env[first]
gap = int(min_gap_ms * rate / 1000)
peaks, i = [], first + int(0.02 * rate)
while i < len(env):
if env[i] >= rel * top:
j = max(range(i, min(len(env), i + gap)), key=env.__getitem__)
peaks.append((j - first) * 1000.0 / rate)
i = j + gap
else:
i += 1
return peaks
def check_echo(path, expect, tol, rate, channels):
peaks = echo_peaks(path, rate, channels)
print(f"{path}: peaks at {', '.join(f'{p:.1f}' for p in peaks) or 'none'} ms; expected {', '.join(f'{e:.1f}' for e in expect) or 'none'} ms")
ok = len(peaks) == len(expect) and all(abs(p - e) <= tol for p, e in zip(peaks, expect))
print(f" {'ok' if ok else 'FAIL'} (tolerance {tol} ms)")
return ok
def reverb_track(path, rate, channels, dry_ms=60.0):
"""(tail energy relative to the click in dB or None, RT60 in s or None) of a click's track."""
raw = array.array("h")
with open(path, "rb") as f:
data = f.read()
raw.frombytes(data[: len(data) // 2 * 2])
left = [s / 32768.0 for s in raw[0::channels]]
cut = int(dry_ms * rate / 1000)
dry = sum(v * v for v in left[:cut])
tail = left[cut:]
energy = sum(v * v for v in tail)
if dry <= 0 or energy <= 0 or 10 * math.log10(energy / dry) < -90:
return None, None
edc, acc = [0.0] * len(tail), 0.0
for i in range(len(tail) - 1, -1, -1):
acc += tail[i] * tail[i]
edc[i] = acc
level = lambda i: 10 * math.log10(max(edc[i], 1e-30) / edc[0])
a = next((i for i in range(len(tail)) if level(i) <= -5), None)
b = next((i for i in range(len(tail)) if level(i) <= -35), None)
rt = 2 * (b - a) / rate if a is not None and b is not None else None
return 10 * math.log10(energy / dry), rt
def check_reverb(folder, rate, channels, tol):
import os
want = {}
with open(os.path.join(folder, "expected_reverb.txt")) as f:
for line in f:
name, decay, wet, effective = line.split()
want[name] = (float(decay), float(wet), float(effective))
got = {}
print(f"{'scene':>8} {'decay of space':>16} {'RT60 expected':>14} {'measured RT60 s':>16} {'tail/click dB':>14}")
for name in ("field", "gorge", "tunnel", "room"):
level, rt = reverb_track(os.path.join(folder, name + ".raw"), rate, channels)
got[name] = (level, rt)
print(f"{name:>8} {want[name][0]:16.2f} {want[name][2]:14.2f} {('none' if rt is None else f'{rt:.2f}'):>16} {('none' if level is None else f'{level:.1f}'):>14}")
ok = got["field"][1] is None
print(f" field has no tail: {'ok' if ok else 'FAIL'}")
order = [got[n][1] for n in ("gorge", "tunnel", "room")]
ordered = all(v is not None for v in order) and order[0] < order[1] < order[2]
print(f" gorge < tunnel < room: {'ok' if ordered else 'FAIL'}")
close = True
for n in ("tunnel", "room"):
rt = got[n][1]
good = rt is not None and abs(rt - want[n][2]) <= tol * want[n][2]
close &= good
print(f" {n} within {tol:.0%} of the RT60 its sends make: {'ok' if good else 'FAIL'}")
return ok and ordered and close
def db(a, b):
return 10 * math.log10(max(a, 1e-20) / max(b, 1e-20))
def main():
p = argparse.ArgumentParser(description=__doc__.split("\n")[0])
p.add_argument("open", nargs="?")
p.add_argument("wall", nargs="?")
p.add_argument("--echo", help="track with one click: check the delays of its echoes")
p.add_argument("--reverb-dir", help="folder written by export_reverb_tracks: check the decay of room, tunnel, gorge, field")
p.add_argument("--rt-tol", type=float, default=0.35, help="how far a measured RT60 may be from the predicted, as a fraction")
p.add_argument("--echo-dir", help="folder written by export_echo_tracks: check gorge, field and room")
p.add_argument("--expect", default="none", help="expected delays in ms, comma separated, or none")
p.add_argument("--tol", type=float, default=1.0, help="ms a peak may be off")
p.add_argument("--rate", type=int, default=48000)
p.add_argument("--channels", type=int, default=2)
p.add_argument("--min-high-cut", type=float, default=15.0, help="dB the band above 5 kHz must drop")
p.add_argument("--max-low-loss", type=float, default=8.0, help="dB the band below 300 Hz may drop")
a = p.parse_args()
if a.reverb_dir:
return 0 if check_reverb(a.reverb_dir, a.rate, a.channels, a.rt_tol) else 1
if a.echo_dir:
import os
ok = True
with open(os.path.join(a.echo_dir, "expected.txt")) as f:
for line in f:
name, _, vals = line.strip().partition(" ")
want = [float(v) for v in vals.split(",") if v]
# The ms in the room are for the walls the geometry says; only the first peak is compared there.
ok &= check_echo(os.path.join(a.echo_dir, name + ".raw"), want, a.tol, a.rate, a.channels)
return 0 if ok else 1
if a.echo:
want = [] if a.expect == "none" else [float(v) for v in a.expect.split(",")]
return 0 if check_echo(a.echo, want, a.tol, a.rate, a.channels) else 1
if not a.open or not a.wall:
p.error("give open.raw and wall.raw, or --echo / --echo-dir")
o, w = bands(a.open, a.rate, a.channels), bands(a.wall, a.rate, a.channels)
print(f"{'band, Hz':>10} {'open dB':>9} {'wall dB':>9} {'change dB':>10}")
ref = max(o)
for (name, _, _), x, y in zip(BANDS, o, w):
print(f"{name:>10} {db(x, ref):9.1f} {db(y, ref):9.1f} {db(y, x):10.1f}")
low, high = db(w[0], o[0]), db(w[-1], o[-1])
ok = high <= -a.min_high_cut and low >= -a.max_low_loss
print(f"highs {high:.1f} dB (need <= -{a.min_high_cut}), lows {low:.1f} dB (need >= -{a.max_low_loss}): {'ok' if ok else 'FAIL'}")
return 0 if ok else 1
if __name__ == "__main__":
sys.exit(main())