130 lines
5.2 KiB
Python
130 lines
5.2 KiB
Python
#!/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()
|