"""Численный отбор AI-клипов (работа A1): метрики вместо слушания.
Для review/<id>_<seed>_loop.wav считает: RMS, диапазон огибающей (дыхание
лупа — std скользящего RMS), долю высокочастотной энергии (резкость/жёсткость),
центроид спектра, число клиппингов. Кандидаты против эталона сравниваются
по этим цифрам; финальный отбор — таблицей в консоль.
Запуск: ~/.cache/rpg-ai/venv-aiaudio/bin/python tools/aiaudio/analyze.py <glob...>
"""
import sys
import wave
from pathlib import Path
import numpy as np
HERE = Path(__file__).resolve().parent
def read_wav(path: Path) -> np.ndarray:
with wave.open(str(path), "rb") as w:
data = np.frombuffer(w.readframes(w.getnframes()), dtype=np.int16)
return data.astype(np.float64) / 32768.0
def metrics(s: np.ndarray, rate: int) -> dict:
win = rate // 10 # окно огибающей 100 мс
n = len(s) // win
env = np.sqrt((s[: n * win].reshape(n, win) ** 2).mean(axis=1))
spec = np.abs(np.fft.rfft(s * np.hanning(len(s))))
freqs = np.fft.rfftfreq(len(s), 1 / rate)
total = spec.sum() + 1e-12
hi = spec[freqs > 4000].sum() / total # резкость: доля >4 кГц
centroid = (spec * freqs).sum() / total
clips = int((np.abs(s) > 0.99).sum())
return {
"rms": float(np.sqrt((s**2).mean())),
"dyn": float(env.std() / (env.mean() + 1e-12)), # дыхание: неровность огибающей
"hi": float(hi),
"cent": float(centroid),
"clip": clips,
}
def main() -> None:
import glob
files: list[Path] = []
for pat in sys.argv[1:]:
# Шаблон относительный: сначала от cwd, при пустом результате — от tools/aiaudio.
for base in (Path.cwd(), HERE):
found = sorted(glob.glob(str(base / pat)))
if found:
files.extend(Path(p) for p in found)
break
if not files:
print("нет файлов под шаблонами")
sys.exit(1)
print(f"{'файл':44} {'rms':>6} {'dyn':>6} {'hi':>6} {'cent':>6} {'clip':>5}")
for f in files:
m = metrics(*((lambda r, rate: (r, rate))(read_wav(f), 22050)))
print(
f"{f.name:44} {m['rms']:6.3f} {m['dyn']:6.3f} {m['hi']:6.3f} "
f"{m['cent']:6.0f} {m['clip']:5d}"
)
if __name__ == "__main__":
main()