"""PoC (работа A, этап A0): пост-обработка сгенерированного WAV под формат игры.
Вход: review/<id>_<seed>.wav — AudioLDM2, 16000 Гц, моно, 16-бит.
Выход: review/<id>_<seed>_loop.wav — 22050 Гц, моно 16-бит, нормализованный,
бесшовный луп (хвост кроссфейдом вплетается в начало — стык без щелчка).
Запуск: ~/.cache/rpg-ai/venv-aiaudio/bin/python tools/aiaudio/post.py review/amb_ponds_1.wav
Флаги: --check — сверить стык с фоном записи; --noloop — SFX без лупа
(ресэмпл + нормализация, хвост не вплетается).
"""
import sys
import wave
from pathlib import Path
import numpy as np
from scipy.signal import resample_poly
HERE = Path(__file__).resolve().parent
TARGET_RATE = 22050 # формат игры: моно, 22050 Гц, 16-бит (см. audio/gen.mjs)
FADE = 1.0 # длительность кроссфейда лупа, с
PEAK = 0.85 # целевой пик нормализации
def read_wav(path: Path) -> tuple[np.ndarray, int]:
with wave.open(str(path), "rb") as w:
rate = w.getframerate()
data = np.frombuffer(w.readframes(w.getnframes()), dtype=np.int16)
return data.astype(np.float64) / 32768.0, rate
def write_wav(path: Path, samples: np.ndarray) -> None:
pcm = np.clip(samples * 32767.0, -32768, 32767).astype(np.int16)
with wave.open(str(path), "wb") as w:
w.setnchannels(1)
w.setsampwidth(2)
w.setframerate(TARGET_RATE)
w.writeframes(pcm.tobytes())
def loopify(s: np.ndarray, rate: int, fade: float) -> np.ndarray:
"""Хвост (последние fade с) кроссфейдом вплетается в начало — луп без шва.
B[0..fade] = tail·(1-w) + head·w (хвост затухает, голова нарастает),
дальше — средина записи; шов (конец -> начало) соседние сэмплы A.
Длина выхода = len - fade с.
"""
n = int(fade * rate)
w = np.linspace(0.0, 1.0, n)
tail, head, body = s[-n:], s[:n], s[n:-n]
return np.concatenate([tail * (1 - w) + head * w, body])
def main() -> None:
# Разбор аргументов вручную: --peak <значение> — цель нормализации
# (манифест может задавать тише для слоёв; по умолчанию PEAK).
peak_target = PEAK
args: list[str] = []
raw = sys.argv[1:]
i = 0
while i < len(raw):
if raw[i] == "--peak":
peak_target = float(raw[i + 1])
i += 2
elif not raw[i].startswith("--"):
args.append(raw[i])
i += 1
else:
i += 1
check = "--check" in raw
noloop = "--noloop" in raw
if len(args) != 1:
print("использование: post.py <review/xxx.wav> [--check] [--noloop] [--peak 0.85]")
sys.exit(1)
src = Path(args[0]).resolve()
s, rate = read_wav(src)
s = resample_poly(s, TARGET_RATE, rate) # 16000 -> 22050 (целые множители: 441/320)
s = s if noloop else loopify(s, TARGET_RATE, FADE)
# Нормализация ПОСЛЕ loopify: пик модели часто в фейдах головы/хвоста,
# которые луп вплетает/выбрасывает — иначе фактический пик ниже цели.
peak = np.abs(s).max()
if peak > 1e-9:
s = s * (peak_target / peak)
out = src.with_name(src.stem + ("_fx.wav" if noloop else "_loop.wav"))
write_wav(out, s)
dur = s.shape[0] / TARGET_RATE
print(f"готово: {out.relative_to(HERE)} ({dur:.1f} с, {TARGET_RATE} Гц, пик {peak_target})")
if check:
# Стык без щелчка: максимальный межсэмпловый скачок на шве против
# такого же скачка в середине записи — шов не должен выделяться.
mid = np.abs(np.diff(s[len(s) // 4 : len(s) // 4 + TARGET_RATE])).max()
seam = np.abs(s[-1] - s[0])
print(f"стык: скачок на шве {seam:.4f}, максимум в середине {mid:.4f}")
assert seam <= max(mid, 0.01), "шов лупа подозрительно громкий"
if __name__ == "__main__":
main()