Files
SoundGame/web/rpg/gen/gen_gap_sfx.py
T

130 lines
5.2 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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()