216 lines
10 KiB
Python
216 lines
10 KiB
Python
"""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()
|