#!/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()