第二批反馈:引擎改造(限幅/SoundTouch不变调变速/长按快进/跳句/iOS闪光降级) + 6游戏状态BGM与试玩测试全绿
This commit is contained in:
@@ -0,0 +1,162 @@
|
||||
#!/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/ubuntu/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()
|
||||
Reference in New Issue
Block a user