Files
AIGame/Tools/build_audio_mix_review.py

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()