第二批反馈:引擎改造(限幅/SoundTouch不变调变速/长按快进/跳句/iOS闪光降级) + 6游戏状态BGM与试玩测试全绿
This commit is contained in:
@@ -0,0 +1,129 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""R6 缺口 sfx(docs/design/11-rpg-battle-rebalance.md §5):turn_player / victory。
|
||||
|
||||
- turn_player:程序合成(numpy)。上行双音(五声 E5→A5 拨弦质感),≤0.4s,
|
||||
"轮到你"的听觉标点,每回合出现,必须极短不烦。
|
||||
- victory:Stable Audio 3 生成(见 gen_gap_sfx.sh,英文 prompt 照录 11 文档:
|
||||
"triumphant short brass fanfare, 3 rising notes, wuxia style, under 1.5 seconds"),
|
||||
本脚本负责后处理:去头静音 + 定长 1.2s。
|
||||
|
||||
用法:
|
||||
bash web/rpg/gen/gen_gap_sfx.sh # SA3 生成 victory staging(flock GPU)
|
||||
.venv-tts/bin/python web/rpg/gen/gen_gap_sfx.py # 合成 turn_player + 两者后处理落盘
|
||||
|
||||
后处理标准(docs/audio/asset-manifest.md R6 起):one-shot 去直流 → 首尾 8ms cos²
|
||||
淡入淡出 → 峰值 -3dBFS(ogg 解码回读真峰值校验,超 -3dB 降增益重编,首轮附带
|
||||
18kHz 低通,最多 3 次迭代)。落盘 web/rpg/audio/sfx/<id>.{ogg,m4a}(q3 / aac 128k)。
|
||||
"""
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
import numpy as np
|
||||
import soundfile as sf
|
||||
|
||||
ROOT = "/home/ubuntu/SoundGame"
|
||||
STAGING = os.path.join(ROOT, "web", "rpg", "gen", "staging", "sfx")
|
||||
OUT = os.path.join(ROOT, "web", "rpg", "audio", "sfx")
|
||||
FFMPEG = os.path.join(ROOT, "tools", "bin", "ffmpeg")
|
||||
|
||||
FADE_MS = 8
|
||||
PEAK_TARGET = 10 ** (-3.0 / 20) # -3dBFS
|
||||
TRIM = {"victory": 1.2} # 去头后定长(秒);turn_player 合成即定长不裁
|
||||
|
||||
|
||||
def synth_turn_player(path, sr=44100):
|
||||
"""上行双音拨弦:E5(659.25) → A5(880),各 0.16s,总 0.36s。
|
||||
基波 + 2/3 次谐波指数衰减模拟拨弦亮度,避免纯正弦的"提示音"廉价感。"""
|
||||
def pluck(freq, dur, sr):
|
||||
t = np.arange(int(sr * dur)) / sr
|
||||
env = np.exp(-t * 18)
|
||||
y = (np.sin(2 * np.pi * freq * t)
|
||||
+ 0.45 * np.sin(2 * np.pi * freq * 2 * t)
|
||||
+ 0.18 * np.sin(2 * np.pi * freq * 3 * t)) * env
|
||||
return y
|
||||
n1 = pluck(659.25, 0.16, sr)
|
||||
n2 = pluck(880.00, 0.20, sr)
|
||||
y = np.concatenate([n1, n2]).astype(np.float32)
|
||||
sf.write(path, y, sr)
|
||||
print(f"[synth] turn_player {len(y) / sr:.2f}s")
|
||||
|
||||
|
||||
def head_trim(y, sr, thresh_db=-45):
|
||||
"""去头静音(5ms 包络,阈值 -45dB,同 R4/R6 工艺)。"""
|
||||
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 ** (thresh_db / 20)))
|
||||
return y[start:]
|
||||
|
||||
|
||||
def fix_array(y, sr):
|
||||
"""one-shot 标准:首尾 8ms cos² 淡入淡出 → 峰值归一 -3dBFS → 去直流。"""
|
||||
y = y.astype(np.float64)
|
||||
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]
|
||||
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 过冲根因见 round6-report §1),
|
||||
过冲则降增益重编(首轮附带 18kHz 低通),最多 3 次。返回解码实测峰值 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():
|
||||
os.makedirs(STAGING, exist_ok=True)
|
||||
os.makedirs(OUT, exist_ok=True)
|
||||
|
||||
tp_staging = os.path.join(STAGING, "turn_player.wav")
|
||||
if not os.path.exists(tp_staging):
|
||||
synth_turn_player(tp_staging)
|
||||
|
||||
for fid in ("turn_player", "victory"):
|
||||
src = os.path.join(STAGING, fid + ".wav")
|
||||
if not os.path.exists(src):
|
||||
print(f"[WARN] 缺 staging {fid}.wav(victory 需先跑 gen_gap_sfx.sh 生成)")
|
||||
continue
|
||||
y, sr = sf.read(src, always_2d=True)
|
||||
y = head_trim(y, sr)
|
||||
if fid in TRIM:
|
||||
y = y[:int(sr * TRIM[fid])]
|
||||
y = fix_array(y, sr)
|
||||
fixed = os.path.join(STAGING, fid + ".fixed.wav")
|
||||
sf.write(fixed, y, sr)
|
||||
base = os.path.join(OUT, fid)
|
||||
peak_db = encode(fixed, base)
|
||||
dur = len(y) / sr
|
||||
mono = y.mean(axis=1)
|
||||
print(f"[ok] {fid} dur={dur:.2f}s true_peak={peak_db:.2f}dBFS "
|
||||
f"dc={float(np.mean(mono)):.5f} -> {base}.{{ogg,m4a}}")
|
||||
assert dur <= 0.4 if fid == "turn_player" else dur <= 1.2, f"{fid} 超长 {dur:.2f}s"
|
||||
assert peak_db <= -2.7, f"{fid} 真峰值超标 {peak_db:.2f}dB"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user