Files
AIGame/Tools/generate_ui_sfx_v2.py
T
udxandClaude Opus 5.5 89e0fa246f Add formal enemies, remaining formal partners and synthesized UI SFX
- UI SFX v3: procedurally synthesized one-shots (Tools/synth_ui_sfx.py) wired to
  buttons, rewards, errors and red dots via UiSfx, gated by the SFX setting.
- Five formal enemies (Hornlet, Dashlet, Shellhorn, HornCaptain, GreatHorn):
  Hunyuan 50k -> 2000-tri XPbr, DGXA TokenRig, Idle/Walk/Attack, nested per type
  in PrototypeArena; PrototypeBattle.Enemy gains presentation-only Moving/Strikes.
- Formal partners for all ten pets in the arena and portraits, including new
  VineSprite, ThunderLeopard and TideWhale.
- Pipeline fixes: reject local TokenRig fallback, drop loose mesh islands,
  configurable Unity run_script timeout, companion static stage.
- Also includes concurrent work from another tool in this working tree
  (FlameLion, enemy combat FX, challenge mode, home explorer, server updates).
- Resources registered in Doc/ResDesc_*.md; verified on isolated :18081.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 12:23:55 +08:00

136 lines
6.4 KiB
Python

#!/usr/bin/env python3
"""UI SFX v2: generate 1s TangoFlux one-shots per seed, trim to button-length, auto-pick a candidate.
Usage:
python Tools/generate_ui_sfx_v2.py # generate missing candidates, process, install picks
python Tools/generate_ui_sfx_v2.py --process # reprocess existing candidates only (no service needed)
python Tools/generate_ui_sfx_v2.py --pick Ui_Click=52003 # override the automatic pick
"""
from __future__ import annotations
import argparse
import json
import shutil
import time
import wave
from pathlib import Path
import numpy as np
from generate_ui_sfx import download, submit, wait_job, wait_ready
ROOT = Path(__file__).resolve().parents[1]
DEFAULT_MANIFEST = ROOT / "Doc/Design/Sound/UiSfx/manifest_v2.json"
def read_wav(path: Path) -> tuple[np.ndarray, int]:
with wave.open(str(path)) as w:
if w.getsampwidth() != 2:
raise ValueError(f"{path}: expected 16-bit PCM")
data = np.frombuffer(w.readframes(w.getnframes()), dtype=np.int16).reshape(-1, w.getnchannels())
return data.astype(np.float64) / 32768.0, w.getframerate()
def write_wav(path: Path, data: np.ndarray, rate: int) -> None:
pcm = (np.clip(data, -1, 1) * 32767).round().astype(np.int16)
path.parent.mkdir(parents=True, exist_ok=True)
with wave.open(str(path), "wb") as w:
w.setnchannels(pcm.shape[1])
w.setsampwidth(2)
w.setframerate(rate)
w.writeframes(pcm.tobytes())
def process(data: np.ndarray, rate: int, target: float, peak_db: float, fade_in_ms: float) -> tuple[np.ndarray, dict]:
"""Trim leading silence, cap at target with an exponential-ish fade, normalize peak. Returns clip and score metrics."""
level = np.abs(data).max(axis=1)
peak = level.max()
if peak < 0.1:
# Near-silent generations would only amplify the noise floor once normalized.
return data[:1], {"onsetSec": 0.0, "kept": 0.0, "score": -1.0, "rawPeak": round(float(peak), 4), "silent": True}
onset = int(np.argmax(level > peak * 0.08))
onset = max(0, onset - int(rate * 0.003))
energy = level ** 2
n = int(target * rate)
kept = float(energy[onset:onset + n].sum() / energy.sum())
clip = data[onset:onset + n].copy()
fade_in = min(len(clip), max(1, int(rate * fade_in_ms / 1000)))
clip[:fade_in] *= np.linspace(0, 1, fade_in)[:, None]
# Fade the last 40% to zero so the cap never clicks; squared curve keeps the attack intact.
tail = max(1, int(len(clip) * 0.4))
clip[-tail:] *= (np.linspace(1, 0, tail) ** 2)[:, None]
clip *= (10 ** (peak_db / 20)) / max(1e-6, np.abs(clip).max())
onset_sec = onset / rate
# Prefer energy already inside the window (less truncation) and an immediate attack.
score = kept - 2.0 * max(0.0, onset_sec - 0.02)
return clip, {"onsetSec": round(onset_sec, 4), "kept": round(kept, 4), "score": round(score, 4), "rawPeak": round(float(peak), 4), "silent": False}
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("--manifest", type=Path, default=DEFAULT_MANIFEST)
ap.add_argument("--process", action="store_true", help="Skip generation; reprocess existing candidates")
ap.add_argument("--pick", action="append", default=[], help="Override pick, e.g. Ui_Click=52003")
ap.add_argument("--max-inflight", type=int, default=3)
args = ap.parse_args()
manifest = json.loads(args.manifest.read_text(encoding="utf-8"))
defaults = manifest["defaults"]
out_dir = ROOT / manifest["outputDir"]
bundle_dir = ROOT / manifest["bundleDir"]
design_dir = ROOT / manifest["designDir"]
picks = dict(p.split("=", 1) for p in args.pick)
base = manifest["service"].rstrip("/")
if not args.process:
wait_ready(base)
pending: list[tuple[Path, dict, dict]] = []
def flush_one() -> None:
path, body, job = pending.pop(0)
done = wait_job(base, job["id"])
download(base, done, path)
path.with_suffix(".json").write_text(json.dumps({**body, "serverJob": done}, ensure_ascii=False, indent=2), encoding="utf-8")
print(json.dumps({"done": path.name}))
for item in manifest["items"]:
for seed in item.get("seeds", defaults["seeds"]):
path = out_dir / "raw" / f"{item['id']}_{seed}.wav"
if path.exists():
continue
while len(pending) >= args.max_inflight:
flush_one()
body = {"prompt": item["prompt"] + defaults["suffix"], "duration": defaults["duration"], "seed": seed}
job = submit(base, body, defaults)
pending.append((path, body, job))
while pending:
flush_one()
report = []
for item in manifest["items"]:
candidates = []
for seed in item.get("seeds", defaults["seeds"]):
raw = out_dir / "raw" / f"{item['id']}_{seed}.wav"
data, rate = read_wav(raw)
clip, metrics = process(data, rate, item["targetSec"], item["peakDb"], defaults["fadeInMs"])
trimmed = out_dir / "trimmed" / f"{item['id']}_{seed}.wav"
write_wav(trimmed, clip, rate)
candidates.append({"seed": seed, "raw": str(raw.relative_to(ROOT)).replace("\\", "/"),
"trimmed": str(trimmed.relative_to(ROOT)).replace("\\", "/"),
"lengthSec": round(len(clip) / rate, 4), **metrics})
auto = max(candidates, key=lambda c: c["score"])
chosen = next((c for c in candidates if str(c["seed"]) == picks.get(item["id"])), auto)
shutil.copy2(ROOT / chosen["trimmed"], bundle_dir / f"{item['id']}.wav")
report.append({"id": item["id"], "targetSec": item["targetSec"], "peakDb": item["peakDb"],
"prompt": item["prompt"] + defaults["suffix"], "pickedSeed": chosen["seed"],
"pickedBy": "manual" if chosen is not auto else "auto", "candidates": candidates})
print(json.dumps({"id": item["id"], "seed": chosen["seed"], "kept": chosen["kept"], "onset": chosen["onsetSec"]}))
result = {"batchId": manifest["batchId"], "generatedUtc": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), "items": report}
(out_dir / "batch_report.json").write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8")
(design_dir / "generation_v2.json").write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8")
if __name__ == "__main__":
main()