- 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>
163 lines
7.5 KiB
Python
163 lines
7.5 KiB
Python
#!/usr/bin/env python3
|
|
"""UI SFX v3: procedurally synthesize crisp, light UI one-shots (marimba / glockenspiel / bubble-pop voices).
|
|
|
|
Replaces the TangoFlux v2 clips, which sounded muddy. Pure numpy and deterministic, so there is no model license.
|
|
|
|
Usage:
|
|
python Tools/synth_ui_sfx.py # write the 10 clips into the bundle dir and Doc/Design/Sound/UiSfx/synth_v3.json
|
|
python Tools/synth_ui_sfx.py --preview # also write Tools/output/ui_sfx_v3/preview.wav (all clips back to back)
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import time
|
|
import wave
|
|
from pathlib import Path
|
|
|
|
import numpy as np
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
BUNDLE_DIR = ROOT / "AIClient/Assets/Bundles/Sound/SFX/UI"
|
|
OUT_DIR = ROOT / "Tools/output/ui_sfx_v3"
|
|
REPORT = ROOT / "Doc/Design/Sound/UiSfx/synth_v3.json"
|
|
RATE = 44100
|
|
NYQUIST = RATE / 2 * 0.9
|
|
|
|
# Note frequencies (equal temperament, A4 = 440).
|
|
G5, C6, D6, E6, G6, A6, B6, C7, D7, E7, G7, A7, C8 = 784.0, 1046.5, 1174.7, 1318.5, 1568.0, 1760.0, 1975.5, 2093.0, 2349.3, 2637.0, 3136.0, 3520.0, 4186.0
|
|
|
|
|
|
def axis(dur: float) -> np.ndarray:
|
|
return np.arange(int(dur * RATE)) / RATE
|
|
|
|
|
|
def attack(t: np.ndarray, ms: float = 1.5) -> np.ndarray:
|
|
return np.minimum(1.0, t / (ms / 1000))
|
|
|
|
|
|
def partials(freq: float, dur: float, ratios, amps, decays) -> np.ndarray:
|
|
t = axis(dur)
|
|
out = np.zeros_like(t)
|
|
for r, a, d in zip(ratios, amps, decays):
|
|
if freq * r < NYQUIST:
|
|
out += a * np.sin(2 * np.pi * freq * r * t) * np.exp(-t / d)
|
|
return out * attack(t)
|
|
|
|
|
|
def marimba(freq: float, decay: float = .07, bright: float = .35, dur: float | None = None) -> np.ndarray:
|
|
"""Woody mallet: strong fundamental plus the bar's ~3.9x and ~9.2x modes, which die fast for the 'tok'."""
|
|
return partials(freq, dur or decay * 6, [1, 3.93, 9.2], [1, bright, bright * .3], [decay, decay * .3, decay * .12])
|
|
|
|
|
|
def glock(freq: float, decay: float = .2, dur: float | None = None) -> np.ndarray:
|
|
"""Glockenspiel / small bell: inharmonic bar partials give the sparkle."""
|
|
return partials(freq, dur or decay * 5, [1, 2.76, 5.40, 8.93], [1, .4, .2, .08], [decay, decay * .45, decay * .25, decay * .15])
|
|
|
|
|
|
def pop(f0: float, f1: float, dur: float = .06, decay: float = .02, glide: float = .012) -> np.ndarray:
|
|
"""Bubble pop: a sine whose pitch flicks from f0 to f1, the classic cute button blip."""
|
|
t = axis(dur)
|
|
freq = f1 + (f0 - f1) * np.exp(-t / glide)
|
|
phase = 2 * np.pi * np.cumsum(freq) / RATE
|
|
return (np.sin(phase) + .15 * np.sin(2 * phase)) * np.exp(-t / decay) * attack(t, 1)
|
|
|
|
|
|
def sweep(f0: float, f1: float, dur: float) -> np.ndarray:
|
|
"""Smooth glide with a rounded envelope: the soft 'fwip' under open/close."""
|
|
t = axis(dur)
|
|
freq = f0 * (f1 / f0) ** (t / dur)
|
|
phase = 2 * np.pi * np.cumsum(freq) / RATE
|
|
return np.sin(phase) * np.sin(np.pi * t / dur) ** 1.5
|
|
|
|
|
|
def tick(seed: int, dur: float = .004) -> np.ndarray:
|
|
"""A few ms of high-passed noise: the crisp transient on top of tonal hits."""
|
|
t = axis(dur)
|
|
noise = np.diff(np.random.default_rng(seed).standard_normal(len(t) + 1))
|
|
return noise / np.abs(noise).max() * np.exp(-t / .0008)
|
|
|
|
|
|
def mix(length: float, *layers: tuple[float, float, np.ndarray]) -> np.ndarray:
|
|
out = np.zeros(int(length * RATE))
|
|
for at, gain, sig in layers:
|
|
start = int(at * RATE)
|
|
n = min(len(sig), len(out) - start)
|
|
out[start:start + n] += gain * sig[:n]
|
|
return out
|
|
|
|
|
|
def finish(sig: np.ndarray, peak_db: float, tail_ms: float = 15) -> np.ndarray:
|
|
n = min(len(sig), int(RATE * tail_ms / 1000))
|
|
sig = sig.copy()
|
|
sig[-n:] *= np.linspace(1, 0, n)
|
|
return sig * (10 ** (peak_db / 20)) / np.abs(sig).max()
|
|
|
|
|
|
def sparkle(seed: int, start: float, end: float, count: int) -> list[tuple[float, float, np.ndarray]]:
|
|
rng = np.random.default_rng(seed)
|
|
notes = [E7, G7, A7, C8]
|
|
return [(float(rng.uniform(start, end)), float(rng.uniform(.12, .22)), glock(notes[int(rng.integers(len(notes)))], .06)) for _ in range(count)]
|
|
|
|
|
|
# Each recipe: (description, peak dB, signal)
|
|
RECIPES = {
|
|
"Ui_Click": ("bubble pop 700→1250 Hz + noise tick", -6,
|
|
lambda: mix(.08, (0, 1, pop(700, 1250, .08, .02)), (0, .18, tick(1)))),
|
|
"Ui_Confirm": ("marimba E6→B6 rising, glockenspiel on top note", -5,
|
|
lambda: mix(.36, (0, 1, marimba(E6, .07)), (.075, 1, marimba(B6, .08)), (.075, .35, glock(B6, .09)), (0, .15, tick(2)))),
|
|
"Ui_Back": ("soft marimba G6→D6 falling", -7,
|
|
lambda: mix(.22, (0, .8, marimba(G6, .045, .2)), (.06, 1, marimba(D6, .05, .2)))),
|
|
"Ui_Open": ("rising fwip 520→1400 Hz into an E7 ping", -7,
|
|
lambda: mix(.34, (0, .55, sweep(520, 1400, .11)), (.09, 1, glock(E7, .08)), (.09, .4, marimba(E6, .05)))),
|
|
"Ui_Close": ("falling fwip 1300→480 Hz into a soft G5 tok", -7,
|
|
lambda: mix(.24, (0, .55, sweep(1300, 480, .11)), (.09, 1, marimba(G5, .045, .25)))),
|
|
"Ui_Tab": ("light marimba A6 tick", -8,
|
|
lambda: mix(.07, (0, 1, marimba(A6, .022, .25)), (0, .2, tick(3)))),
|
|
"Ui_Toggle": ("double pop 900→1500 then 1200→1900 Hz", -8,
|
|
lambda: mix(.1, (0, .8, pop(900, 1500, .05, .015)), (.035, 1, pop(1200, 1900, .06, .016)))),
|
|
"Ui_Claim": ("glockenspiel arpeggio C6-E6-G6-C7 with marimba body and high sparkles", -3,
|
|
lambda: mix(.78, (0, .7, glock(C6, .12)), (.06, .75, glock(E6, .12)), (.12, .8, glock(G6, .14)), (.18, 1, glock(C7, .2)),
|
|
(0, .35, marimba(C6, .05)), (.06, .35, marimba(E6, .05)), (.12, .35, marimba(G6, .05)), (.18, .4, marimba(C7, .06)),
|
|
*sparkle(7, .22, .5, 6))),
|
|
"Ui_Deny": ("two soft low boops 330→262 Hz, gentle not harsh", -7,
|
|
lambda: mix(.27, (0, 1, pop(380, 330, .1, .035, .02)), (.11, 1, pop(300, 262, .14, .045, .02)))),
|
|
"Ui_Notify": ("bell ding-ding G6 then D7", -6,
|
|
lambda: mix(.52, (0, .8, glock(G6, .14)), (.11, 1, glock(D7, .16)))),
|
|
}
|
|
|
|
|
|
def write_wav(path: Path, mono: np.ndarray) -> None:
|
|
pcm = (np.clip(mono, -1, 1) * 32767).round().astype(np.int16)
|
|
stereo = np.repeat(pcm[:, None], 2, axis=1) # same layout as earlier batches (44.1 kHz stereo)
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
with wave.open(str(path), "wb") as w:
|
|
w.setnchannels(2)
|
|
w.setsampwidth(2)
|
|
w.setframerate(RATE)
|
|
w.writeframes(stereo.tobytes())
|
|
|
|
|
|
def main() -> None:
|
|
ap = argparse.ArgumentParser()
|
|
ap.add_argument("--preview", action="store_true", help="Also write a back-to-back preview wav")
|
|
args = ap.parse_args()
|
|
|
|
rows, preview = [], []
|
|
for name, (desc, peak_db, build) in RECIPES.items():
|
|
clip = finish(build(), peak_db)
|
|
write_wav(BUNDLE_DIR / f"{name}.wav", clip)
|
|
write_wav(OUT_DIR / f"{name}.wav", clip)
|
|
preview += [clip, np.zeros(int(RATE * .45))]
|
|
rows.append({"id": name, "recipe": desc, "peakDb": peak_db, "lengthSec": round(len(clip) / RATE, 3)})
|
|
print(json.dumps(rows[-1], ensure_ascii=False))
|
|
if args.preview:
|
|
write_wav(OUT_DIR / "preview.wav", np.concatenate(preview))
|
|
REPORT.write_text(json.dumps({"batchId": "ui_sfx_v3", "method": "procedural synthesis (Tools/synth_ui_sfx.py, numpy)",
|
|
"format": "44.1 kHz 16-bit stereo (dual mono)", "generatedUtc": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
|
"items": rows}, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|