Files

163 lines
6.7 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""爆音修复(R6):处理 scan_clicks.py 标记的 sfx/bgm 资产。
修复策略(只修 sfx/bgm,voice 不动):
- one-shot sfx:去直流(逐声道减均值)→ 首尾 8ms cos² 淡入淡出 → 峰值归一 -3dBFS
- loop(bgm/ 与 sfx/amb_*):只去直流(首尾交叉淡化工艺保证边界连续,加淡入淡出会破坏循环)
- rpg one-shot 优先从 staging(tools/gen_rpg_sfx_wav/ 原始 SA3 wav / gen_rpg_syn.py 重出的
/tmp/rpg_syn)重做,避免二次有损编码;其余从现有 ogg 解码修复。
处理前原件备份到 tools/asset_fix_backup/。处理前后指标记录到 /tmp/fix_report.jsonl。
"""
import json
import os
import shutil
import subprocess
import sys
import numpy as np
import soundfile as sf
ROOT = "/home/xsl/SoundGame"
WEB = os.path.join(ROOT, "web")
BACKUP = os.path.join(ROOT, "tools", "asset_fix_backup")
FADE_MS = 8
PEAK_TARGET = 10 ** (-3.0 / 20) # -3dBFS
RPG_SA3 = os.path.join(ROOT, "tools", "gen_rpg_sfx_wav")
RPG_SYN = "/tmp/rpg_syn"
# rpg one-shot 的定长裁剪参数(同 R4 工艺)
RPG_TRIM = {
"tel_wolf": 1.0, "tel_bandit": 1.0, "tel_spider": 1.0, "tel_elite": 1.0,
"tel_boss1": 1.0, "tel_croc": 1.0, "tel_shaman": 1.0, "tel_puppet": 1.0,
"tel_boss2": 1.0, "atk_swing": 0.5, "atk_hit": 0.4, "parry": 0.5,
"skill_slash": 0.8, "enemy_die": 1.0, "potion": 1.2, "chest": 1.5,
"drink_water": 1.5, "rest": 2.0, "door_heavy": 3.0,
}
def is_loop(rel):
return "/bgm/" in "/" + rel or os.path.basename(rel).startswith("amb_")
def metrics(y, sr):
mono = y.mean(axis=1) if y.ndim > 1 else y
n5 = max(1, int(sr * 5 / 1000))
peak = float(np.max(np.abs(mono)))
return {
"peak_db": round(20 * np.log10(max(peak, 1e-9)), 2),
"dc": round(float(np.mean(mono)), 5),
"head5ms_db": round(20 * np.log10(max(float(np.max(np.abs(mono[:n5]))), 1e-9)), 2),
"tail5ms_db": round(20 * np.log10(max(float(np.max(np.abs(mono[-n5:]))), 1e-9)), 2),
}
def fix_array(y, sr, loop):
y = y.astype(np.float64)
if not loop:
n = int(sr * FADE_MS / 1000)
ramp = np.sin(np.linspace(0, np.pi / 2, n)) ** 2
y[:n] *= ramp[:, None] if y.ndim > 1 else ramp
y[-n:] *= ramp[::-1][:, None] if y.ndim > 1 else ramp[::-1]
# 峰值归一 -3dBFS
peak = np.max(np.abs(y))
if peak > 0:
y = y / peak * PEAK_TARGET
# 去直流放最后(淡入淡出/归一会重新引入微小偏置;常数平移不影响循环连续性)
y = y - y.mean(axis=0)
return y.astype(np.float32)
def encode(wav_path, base):
"""编码 ogg+m4a;对 ogg 做解码回读真峰值校验——MDCT 重建对高密度/近 Nyquist
内容会过冲数 dB(R6 爆音根因),过冲则按实测降增益重编(最多 3 次迭代,
首轮附带 18kHz 低通消振铃)。返回解码后实测峰值 dB。"""
subprocess.run(["ffmpeg", "-y", "-v", "error", "-i", wav_path, "-ar", "44100",
"-c:a", "aac", "-b:a", "128k", base + ".m4a"], check=True)
cur = wav_path
peak_db = None
for attempt in range(3):
subprocess.run(["ffmpeg", "-y", "-v", "error", "-i", cur, "-ar", "44100",
"-c:a", "libvorbis", "-q:a", "3", base + ".ogg"], check=True)
dec, _sr = sf.read(base + ".ogg", always_2d=True)
peak_db = 20 * np.log10(max(float(np.max(np.abs(dec))), 1e-9))
if peak_db <= -2.7:
break
gain_db = -3.2 - peak_db
nxt = wav_path + f".tp{attempt}.wav"
af = ("lowpass=f=18000," if attempt == 0 else "") + f"volume={gain_db:.2f}dB"
subprocess.run(["ffmpeg", "-y", "-v", "error", "-i", cur,
"-af", af, "-c:a", "pcm_f32le", nxt], check=True)
cur = nxt
return peak_db
def main():
flagged = []
for line in open("/tmp/scan_all.jsonl"):
r = json.loads(line)
if "/voice/" in r["path"]:
continue # 语音响度策略不动
if r["hot_peak"] or r["dc_bad"] or ((r["click_head"] or r["click_tail"]) and not r["loop"]):
flagged.append(r["path"])
print(f"flagged sfx/bgm: {len(flagged)}")
report = []
for rel in flagged:
game = rel.split("/")[0]
name = os.path.basename(rel)[:-4]
ogg = os.path.join(WEB, rel)
base = ogg[:-4]
loop = is_loop(rel)
# 选源:rpg one-shot 用 staging 重做;其余用现有 ogg
src = None
trim = None
if game == "rpg" and not loop:
if name in RPG_TRIM and os.path.exists(os.path.join(RPG_SA3, name + ".wav")):
src, trim = os.path.join(RPG_SA3, name + ".wav"), RPG_TRIM[name]
elif os.path.exists(os.path.join(RPG_SYN, name + ".wav")):
src = os.path.join(RPG_SYN, name + ".wav")
if src:
y, sr = sf.read(src, always_2d=True)
y = y.astype(np.float64)
if trim: # 去头静音 + 定长(ffmpeg silenceremove 的 python 等价:阈值 -45dB)
mono = np.abs(y.mean(axis=1))
win = int(sr * 0.005)
env = np.convolve(mono, np.ones(win) / win, mode="same")
start = int(np.argmax(env > 10 ** (-45 / 20)))
y = y[start:start + int(sr * trim)]
else:
# 有备份读备份(初代无损源),避免多次有损转码
bak = os.path.join(BACKUP, rel)
y, sr = sf.read(bak if os.path.exists(bak) else ogg, always_2d=True)
before = metrics(y, sr)
# 备份原件(ogg+m4a)
for ext in (".ogg", ".m4a"):
dst = os.path.join(BACKUP, rel[:-4] + ext)
os.makedirs(os.path.dirname(dst), exist_ok=True)
if os.path.exists(base + ext):
shutil.copy2(base + ext, dst)
y2 = fix_array(y, sr, loop)
after = metrics(y2, sr)
tmp = f"/tmp/fix_{game}_{name}.wav"
sf.write(tmp, y2, sr)
dec_peak = encode(tmp, base) # ogg 解码回读真峰值(含过冲迭代修正)
after["decoded_peak_db"] = round(dec_peak, 2)
report.append({"path": rel, "loop": loop, "src": "staging" if src else "ogg",
"before": before, "after": after})
print(f"[fix] {rel} ({'loop' if loop else 'shot'}, src={'staging' if src else 'ogg'}) "
f"peak {before['peak_db']}→{after['peak_db']} (decoded {round(dec_peak, 2)}) "
f"dc {before['dc']}→{after['dc']} head {before['head5ms_db']}→{after['head5ms_db']}")
with open("/tmp/fix_report.jsonl", "w") as f:
for r in report:
f.write(json.dumps(r, ensure_ascii=False) + "\n")
print(f"fixed {len(report)} files, backup in {BACKUP}")
if __name__ == "__main__":
main()