Ship the WeChat hot-update catalogs and the camp, TideWhale T2, UI atlas, and animation-lab work.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,215 @@
|
||||
"""Render review WAVs from installed clips and a Pipeline mix snapshot.
|
||||
|
||||
This is an offline reconstruction, not a device/Unity loopback recording.
|
||||
No normalization or limiter masks the installed gain balance. Production audio
|
||||
and account data are read-only. Outputs remain outside Assets/Bundles.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import imageio_ffmpeg
|
||||
import numpy as np
|
||||
from scipy.io import wavfile
|
||||
from scipy.signal import resample_poly
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
OUT = ROOT / "Doc/Design/Sound/MixReview"
|
||||
RATE = 48000
|
||||
SOUND = ROOT / "AIClient/Assets/Bundles/Sound"
|
||||
|
||||
|
||||
def load(path):
|
||||
return json.loads(path.read_text(encoding="utf-8-sig"))
|
||||
|
||||
|
||||
def sha(path):
|
||||
return hashlib.sha256(path.read_bytes()).hexdigest()
|
||||
|
||||
|
||||
class Mixer:
|
||||
def __init__(self, seconds, snapshot):
|
||||
self.data = np.zeros((round(seconds * RATE), 2), dtype=np.float64)
|
||||
self.events = []
|
||||
self.cache = {}
|
||||
self.sources = {}
|
||||
self.voices = {"skill": [None] * 8, "enemy": [None] * 8}
|
||||
self.next = {"skill": 0, "enemy": 0}
|
||||
self.seen = set()
|
||||
self.last_enemy = {}
|
||||
self.snapshot = snapshot
|
||||
self.now = 0
|
||||
|
||||
def clip(self, path):
|
||||
path = path.resolve()
|
||||
if path not in self.cache:
|
||||
if path.suffix == ".wav":
|
||||
rate, pcm = wavfile.read(path)
|
||||
x = pcm.astype(np.float64) / (32768 if pcm.dtype == np.int16 else 1)
|
||||
if x.ndim == 1:
|
||||
x = np.repeat(x[:, None], 2, axis=1)
|
||||
assert x.shape[1] == 2 and np.isfinite(x).all()
|
||||
if rate != RATE:
|
||||
from math import gcd
|
||||
divisor = gcd(rate, RATE)
|
||||
x = resample_poly(x, RATE // divisor, rate // divisor)
|
||||
else:
|
||||
decoded = subprocess.run([imageio_ffmpeg.get_ffmpeg_exe(), "-v", "error", "-i", str(path),
|
||||
"-f", "f32le", "-ar", str(RATE), "-ac", "2", "-"], capture_output=True, check=True)
|
||||
x = np.frombuffer(decoded.stdout, dtype="<f4").reshape(-1, 2).astype(np.float64)
|
||||
self.cache[path] = x
|
||||
self.sources[path.relative_to(ROOT).as_posix()] = sha(path)
|
||||
return self.cache[path]
|
||||
|
||||
def advance(self, seconds):
|
||||
end = min(len(self.data), round(seconds * RATE))
|
||||
assert end >= self.now
|
||||
for bank in self.voices.values():
|
||||
for voice in bank:
|
||||
if voice:
|
||||
a, clip, gain, _, _ = voice
|
||||
count = min(end - self.now, len(clip) - (self.now - a))
|
||||
if count > 0:
|
||||
self.data[self.now:self.now + count] += clip[self.now - a:self.now - a + count] * gain
|
||||
self.now = end
|
||||
|
||||
def play(self, bank, path, seconds, cast=0, sound=-1):
|
||||
self.advance(seconds)
|
||||
if bank == "skill" and cast > 0:
|
||||
if (cast, sound) in self.seen:
|
||||
return
|
||||
self.seen.add((cast, sound))
|
||||
if bank == "enemy":
|
||||
if seconds - self.last_enemy.get(sound, -100) < .075:
|
||||
return
|
||||
self.last_enemy[sound] = seconds
|
||||
index = self.next[bank] % len(self.voices[bank])
|
||||
self.next[bank] += 1
|
||||
pcm = self.clip(path)
|
||||
gain = self.snapshot["skill"][index]["volume"] if bank == "skill" else self.snapshot["enemyVoiceVolume"]
|
||||
self.voices[bank][index] = [self.now, pcm, gain, cast, sound]
|
||||
if bank == "enemy":
|
||||
active = [v for v in self.voices[bank] if v and self.now - v[0] < len(v[1])]
|
||||
gain = min(self.snapshot["enemyVoiceVolume"], self.snapshot["enemyMixBudget"] / len(active))
|
||||
for voice in active:
|
||||
voice[2] = gain
|
||||
self.events.append(dict(time=round(seconds, 4), bank=bank, id=path.stem, voice=index, gain=gain, cast=cast))
|
||||
|
||||
def stop_charge(self, cast, seconds):
|
||||
self.advance(seconds)
|
||||
for i, voice in enumerate(self.voices["skill"]):
|
||||
if voice and voice[3:] == [cast, 0]:
|
||||
self.voices["skill"][i] = None
|
||||
|
||||
def one_shot(self, path, seconds, gain, bank="ui"):
|
||||
pcm = self.clip(path)
|
||||
start = round(seconds * RATE)
|
||||
count = min(len(pcm), len(self.data) - start)
|
||||
if count > 0:
|
||||
self.data[start:start + count] += pcm[:count] * gain
|
||||
self.events.append(dict(time=seconds, bank=bank, id=path.stem, gain=gain))
|
||||
|
||||
def save(self, name):
|
||||
self.advance(len(self.data) / RATE)
|
||||
peak = float(np.abs(self.data).max())
|
||||
true_peak = float(np.abs(resample_poly(self.data, 4, 1)).max())
|
||||
assert np.isfinite(self.data).all() and peak > .001, name + ": empty or invalid audio"
|
||||
# Keep the raw float master so clipping cannot be hidden by the PCM conversion.
|
||||
raw = OUT / (name + "_float.wav")
|
||||
wavfile.write(raw, RATE, self.data.astype(np.float32))
|
||||
path = OUT / (name + ".wav")
|
||||
wavfile.write(path, RATE, np.round(np.clip(self.data, -1, 32767 / 32768) * 32768).astype(np.int16))
|
||||
row = dict(file=path.name, floatMaster=raw.name, seconds=len(self.data) / RATE,
|
||||
samplePeakDbfs=20 * np.log10(max(peak, 1e-12)), truePeak4xDbfs=20 * np.log10(max(true_peak, 1e-12)),
|
||||
rmsDbfs=20 * np.log10(max(float(np.sqrt(np.mean(self.data ** 2))), 1e-12)),
|
||||
overRangeSamples=int(np.count_nonzero(np.abs(self.data) >= 1)),
|
||||
eventCount=len(self.events), maxSkillVoices=8, maxEnemyVoices=8, sha256=sha(path))
|
||||
(OUT / (name + ".json")).write_text(json.dumps(dict(metrics=row, events=self.events, sources=self.sources), indent=2) + "\n", encoding="utf-8")
|
||||
return row
|
||||
|
||||
|
||||
def main():
|
||||
OUT.mkdir(parents=True, exist_ok=True)
|
||||
snapshot = load(OUT / "unity_snapshot.json")
|
||||
plan = load(ROOT / "Config/Combat/partner_presentations_v1.json")
|
||||
skills = [SOUND / ("SFX/BattleSkills/Sfx_" + s + ".wav") for s in plan["soundIds"]]
|
||||
enemy_names = ["HornletAttack", "DashletAttack", "ShellhornAttack", "HornCaptainAttack", "GreatHornAttack", "HitSoft", "HitArmor", "HitShield"]
|
||||
enemies = [SOUND / ("SFX/EnemyCombat/Enemy_" + s + ".wav") for s in enemy_names]
|
||||
ui_names = ["Click", "Confirm", "Back", "Open", "Close", "Tab", "Claim", "Deny", "Toggle", "Notify"]
|
||||
ui = [SOUND / ("SFX/UI/Ui_" + s + ".wav") for s in ui_names]
|
||||
rows = []
|
||||
mix = Mixer(17, snapshot)
|
||||
mix.one_shot(ROOT / "AIClient" / snapshot["music"][0]["clip"], 0, snapshot["music"][0]["volume"], "music")
|
||||
for i, path in enumerate(ui):
|
||||
mix.one_shot(path, 1 + i * 1.4, snapshot["ui"][0]["volume"])
|
||||
rows.append(mix.save("menu_ui"))
|
||||
# Nine skill sounds in manifest order, separated at the exact installed per-voice gain.
|
||||
mix = Mixer(16, snapshot)
|
||||
for i, path in enumerate(skills):
|
||||
mix.play("skill", path, .75 + i * 1.6, cast=i + 1, sound=i)
|
||||
rows.append(mix.save("skill_bank"))
|
||||
trace_path = ROOT / "Tools/output/combat_runtime/traces/stage_20_t2_f0.json"
|
||||
trace = load(trace_path)
|
||||
for speed in (1, 2):
|
||||
mix = Mixer(26, snapshot)
|
||||
art = {u["id"]: u["art"] for u in trace["manifest"]["initial"] if u["team"] == 1}
|
||||
# PrototypeFlow drains enemy cues before skill cues on each 50ms simulation tick.
|
||||
ticks = {}
|
||||
for event in trace["events"]:
|
||||
ticks.setdefault(event["tick"], []).append(event)
|
||||
for tick, events in ticks.items():
|
||||
seconds = .5 + tick * .05 / speed
|
||||
if seconds >= 24:
|
||||
break
|
||||
mix.advance(seconds)
|
||||
for e in events:
|
||||
if e["kind"] == "spawn":
|
||||
art[e["id"]] = e["art"]
|
||||
if e["kind"] != "hit":
|
||||
continue
|
||||
source, target = e.get("source", -1), e.get("target", -1)
|
||||
if source >= 100 and target < 100:
|
||||
index = art[source]
|
||||
mix.play("enemy", enemies[index], seconds, sound=index)
|
||||
index = 7 if e.get("amount", 0) == 0 else 5
|
||||
mix.play("enemy", enemies[index], seconds, sound=index)
|
||||
elif source < 100 and target >= 100 and art[target] >= 2:
|
||||
mix.play("enemy", enemies[6], seconds, sound=6)
|
||||
for e in events:
|
||||
source, cast, kind = e.get("source", -1), e.get("cast", 0), e["kind"]
|
||||
if kind in ("release", "cancel", "cast_end"):
|
||||
mix.stop_charge(cast, seconds)
|
||||
if 0 <= source < 10:
|
||||
p = plan["profiles"][source]
|
||||
key = {"cast_start": "chargeSound", "release": "launchSound", "hit": "hitSound", "shield": "abilitySound", "heal": "abilitySound", "slow": "abilitySound"}.get(kind)
|
||||
sound = p[key] if key else -1
|
||||
if sound >= 0:
|
||||
mix.play("skill", skills[sound], seconds, cast=cast, sound=sound)
|
||||
if kind == "battle_end":
|
||||
mix.voices = {"skill": [None] * 8, "enemy": [None] * 8}
|
||||
if tick == 200:
|
||||
mix.one_shot(ui[8], seconds, snapshot["ui"][0]["volume"])
|
||||
rows.append(mix.save("battle_" + str(speed) + "x"))
|
||||
mix = Mixer(8, snapshot)
|
||||
for i in range(16):
|
||||
seconds = 1 + i * .25
|
||||
for sound in range(9):
|
||||
mix.play("skill", skills[sound], seconds, cast=i * 10 + sound + 1, sound=sound)
|
||||
for sound in range(5):
|
||||
mix.play("enemy", enemies[sound], seconds, sound=sound)
|
||||
mix.one_shot(ui[i % 10], seconds, snapshot["ui"][0]["volume"])
|
||||
rows.append(mix.save("overlap_stress"))
|
||||
result = dict(status="pass" if all(r["overRangeSamples"] == 0 and r["truePeak4xDbfs"] < 0 for r in rows) else "needs_adjustment",
|
||||
method="offline reconstruction from installed clips, Pipeline snapshot and authoritative trace; not a loopback recording",
|
||||
rendererAttempt="Unity AudioRenderer timed out; not used as acceptance evidence",
|
||||
rate=RATE, channels=2, normalized=False, limiter=False, subjectivelyApproved=False,
|
||||
snapshotSha256=sha(OUT / "unity_snapshot.json"), traceSha256=sha(trace_path), files=rows)
|
||||
(OUT / "validation.json").write_text(json.dumps(result, indent=2) + "\n", encoding="utf-8")
|
||||
print(json.dumps(result, indent=2))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user