第二批反馈:引擎改造(限幅/SoundTouch不变调变速/长按快进/跳句/iOS闪光降级) + 6游戏状态BGM与试玩测试全绿

This commit is contained in:
SoundGame Dev
2026-08-30 16:45:42 +08:00
parent cc0f8c008d
commit 9a915d62c0
1163 changed files with 6346 additions and 208 deletions
+162
View File
@@ -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()