57a5426e65
build / mod (push) Successful in 4m13s
#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>
210 lines
11 KiB
Python
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())
|