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