- 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>
136 lines
6.4 KiB
Python
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()
|