Files
SoundGame/web/dungeon/gen/gen_sfx2.py
T
xsl 24c526af68 feat(assets): SA3 音效补齐 radio 11+1 与 dungeon 13源+15pan(token 走 hf-mirror 实测可用)
- 模型 17/17 文件经镜像全量缓存(gated+token,直连本机不通;sfx.md/setup.md/asset-pipeline.md 镜像结论同步更新)
- 修复迁移遗留:stable_audio_3/models/ 源码子包被 .gitignore models/ 规则误伤丢失,从上游恢复并加白名单
- radio:arg_muffled/brake_screech 等叙事件落地(接线原有 L111/L154 零改动)+ phone_ring SYN→SA3 升级;dungeon 13 源 A/B 对齐(RMS 偏差 ≤0.3dB)全量同名替换,SYN 原版备份 tools/asset_fix_backup/sa3_ab_20260902/
- 关键玩法语义达标:echo 距离档 0.83<1.43<2.18<2.98s 严格递增;one-shot 真峰值全 ≤-3dBFS(解码回读法)
- check_all_assets 8 游戏全绿(radio 254/254、dungeon 303/303);playtest radio 221/dungeon 1610 全绿
2026-09-02 19:35:38 +08:00

258 lines
12 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 -*-
"""《回声地窟》SYN 程序合成音效(批次 2 = SA3 缺失项的 SYN 兜底,16-dungeon.md §9.2 降级条款)。
SA3 无 HF token 跳过(research/new8/batch1-assets-report.md §4 明示:
「dungeon 回声/脚步是核心动词,开发联调前建议先补产…或临时用 SYN 兜底」),本脚本即该兜底。
若日后补产 SA3 版,直接覆盖同名文件即可(id 不变、游戏层零改动)。
※ 2026-09-02 更新:SA3 版已由 gen_sfx_sa3.py 补产并 A-B 后同名替换本脚本产出(28 文件;
本脚本重跑即回退 SYN 兜底版,原版备份 tools/asset_fix_backup/sa3_ab_20260902/dungeon/)。
25 个 id / 25×2 文件(mob_step_*/tel_* 的 l/r 为 mono 源经 ffmpeg pan 派生预 pan 立体声):
echo_1..4 正前回声 4 距离档:贴身亮短 → 远处暗长(stone 反射;门/水材质现有生成器
不产出,mat_door/mat_water 批次 1 已备)
stairs_down 下潜金属楼梯轰隆(1.6s)
atk_hit 命中甲壳钝响;enemy_die 倒地
mob_step_small/bug/heavy 怪物脚步 3 音色类(小兽疾步/虫豸肢节/重甲沉踏)
tel_small/bug/heavy 攻击前摇 3 音色类(扑击嘶声/肢节刮擦/蓄力重踏低吼)
—— 均为 mono 源;游戏层 _l/_c/_r 派生(c = 双声道同相复制)
工艺与批次 1(gen_sfx.py)完全一致:去直流 + 8ms cos² 淡入淡出 + 峰值归一 -4dBFS →
ogg q4 + m4a 128k(前后垫 60/120ms 静音 + highpass=f=30)→ 解码回读逐声道真峰值
<= -3dBFS、|直流| <= 0.001(超限确定性补偿,最多 4 轮)。可复现:一键出全部文件。
用法:.venv-tts/bin/python web/dungeon/gen/gen_sfx2.py (纯 CPU,无 GPU 依赖)
"""
import os
import subprocess
import numpy as np
import soundfile as sf
SR = 44100
HERE = os.path.dirname(os.path.abspath(__file__))
STAGING = os.path.join(HERE, "staging", "sfx")
DST = os.path.join(HERE, "..", "audio", "sfx")
FFMPEG = "/home/xsl/SoundGame/tools/bin/ffmpeg"
if not os.path.exists(FFMPEG):
FFMPEG = "/home/xsl/.local/bin/ffmpeg"
PEAK_SRC = 10 ** (-4.0 / 20)
os.makedirs(STAGING, exist_ok=True)
os.makedirs(DST, exist_ok=True)
def t(d):
return np.arange(int(SR * d)) / SR
def env_exp(d, attack, release):
n = int(SR * d)
e = np.ones(n)
na, nr = max(1, int(SR * attack)), max(1, int(SR * release))
e[:na] = np.linspace(0, 1, na)
e[-nr:] *= np.linspace(1, 0, nr)
return e
def lowpass(x, k):
return np.convolve(x, np.ones(k) / k, mode="same")
def bandpass(x, lo, hi):
kh, kl = max(1, int(SR / (np.pi * hi))), max(1, int(SR / (np.pi * lo)))
return lowpass(x, kh) - lowpass(x, kl)
def save(name, sig):
sig = sig - np.mean(sig)
nf = max(1, int(SR * 0.008))
fade = 0.5 * (1 - np.cos(np.pi * np.arange(nf) / nf))
sig = sig.astype(np.float64).copy()
sig[:nf] *= fade
sig[-nf:] *= fade[::-1]
sig = sig / (np.max(np.abs(sig)) + 1e-9) * PEAK_SRC
sf.write(os.path.join(STAGING, name + ".wav"), sig.astype(np.float32), SR)
rng = np.random.default_rng(20260902)
def stone_tap(dur, lo, hi, decay, seed, amp=1.0):
"""石壁反射拍:带通噪声团 + 指数衰减(回声的基本单元)。"""
xx = t(dur)
body = bandpass(np.random.default_rng(seed).standard_normal(len(xx)), lo, hi) * np.exp(-xx * decay)
thump = np.sin(2 * np.pi * 110 * xx) * np.exp(-xx * decay * 1.4) * 0.5
return (body + thump) * amp
# ---- echo_1..4:正前回声 4 距离档(越远:越长、越暗、反射越多) ----
save("echo_1", stone_tap(0.4, 500, 3200, 22, 11)) # 贴身:亮而短
y = np.zeros(int(SR * 0.9), dtype=np.float64)
for start, amp, dc, seed in [(0.0, 1.0, 18, 21), (0.22, 0.45, 14, 22)]:
n = len(y) - int(SR * start)
y[int(SR * start):] += stone_tap(0.9, 380, 2400, dc, seed)[:n] * amp
save("echo_2", y) # 近:两记反射
y = np.zeros(int(SR * 1.5), dtype=np.float64)
for start, amp, dc, seed in [(0.0, 1.0, 14, 31), (0.3, 0.55, 11, 32), (0.62, 0.3, 9, 33)]:
n = len(y) - int(SR * start)
y[int(SR * start):] += stone_tap(1.5, 260, 1500, dc, seed)[:n] * amp
save("echo_3", y) # 中:三记渐暗
y = np.zeros(int(SR * 2.2), dtype=np.float64)
for start, amp, dc, seed in [(0.0, 1.0, 11, 41), (0.35, 0.6, 8, 42), (0.75, 0.42, 7, 43), (1.2, 0.28, 6, 44)]:
n = len(y) - int(SR * start)
y[int(SR * start):] += stone_tap(2.2, 180, 900, dc, seed)[:n] * amp
save("echo_4", y) # 远:空窟长尾
# ---- stairs_down:下潜金属楼梯(1.6s,下行金属踏步 + 空窟尾) ----
y = np.zeros(int(SR * 1.7), dtype=np.float64)
for i in range(5):
start = i * 0.22
xx = t(0.4)
f0 = 1500 - i * 210 # 逐级下行的金属泛音
clank = sum(a * np.sin(2 * np.pi * f0 * r * xx) * np.exp(-xx * (14 + i * 2))
for r, a in [(1, 1.0), (2.7, 0.4), (4.3, 0.2)])
thud = np.sin(2 * np.pi * 95 * xx) * np.exp(-xx * 24) * 0.6
s = int(SR * start)
n = min(len(clank), len(y) - s)
y[s:s + n] += (clank + thud)[:n] * (1 - i * 0.12)
tail = lowpass(rng.standard_normal(len(y)), 35) * np.linspace(0.25, 0.0, len(y))
save("stairs_down", y + tail)
# ---- 战斗命中 / 倒地 ----
d = 0.3
x = t(d)
save("atk_hit", np.sin(2 * np.pi * 82 * x) * np.exp(-x * 26) # 甲壳钝响:低频击 + 短噪声团
+ 0.55 * bandpass(rng.standard_normal(len(x)), 200, 900) * np.exp(-x * 34))
y = np.zeros(int(SR * 0.9), dtype=np.float64) # 倒地:重物坠落 + 杂物散落
xx = t(0.4)
fall = np.sin(2 * np.pi * 62 * xx) * np.exp(-xx * 10) + 0.4 * lowpass(rng.standard_normal(len(xx)), 45) * np.exp(-xx * 14)
y[:len(fall)] += fall
for start in [0.32, 0.45, 0.6]:
d2 = 0.16
x2 = t(d2)
clat = bandpass(np.random.default_rng(int(start * 997)).standard_normal(len(x2)), 900, 4200) * np.exp(-x2 * 30) * 0.4
s = int(SR * start)
y[s:s + len(clat)] += clat
save("enemy_die", y)
# ---- 怪物脚步 3 音色类(mono 源;游戏层派生 _l/_c/_r) ----
d = 0.24 # small 小兽疾步:两记干燥快拍
y = np.zeros(int(SR * d), dtype=np.float64)
for start, amp in [(0.0, 1.0), (0.09, 0.7)]:
xx = t(0.08)
tick = bandpass(np.random.default_rng(int(start * 89) + 7).standard_normal(len(xx)), 700, 2600) * np.exp(-xx * 70) * amp
s = int(SR * start)
y[s:s + len(tick)] += tick
save("mob_step_small", y)
d = 0.3 # bug 虫豸肢节:高频肢节摩擦咔哒
x = t(d)
save("mob_step_bug", bandpass(rng.standard_normal(len(x)), 1800, 6500) * env_exp(d, 0.01, 0.16)
+ 0.4 * bandpass(np.roll(rng.standard_normal(len(x)), int(0.08 * SR)), 2400, 7000) * env_exp(d, 0.02, 0.2))
d = 0.4 # heavy 重甲沉踏:低频重击 + 甲片余振
x = t(d)
save("mob_step_heavy", np.sin(2 * np.pi * 58 * x) * np.exp(-x * 20)
+ 0.3 * sum(a * np.sin(2 * np.pi * f * x) * np.exp(-x * dc)
for f, a, dc in [(980, 0.5, 18), (1470, 0.3, 24)])
+ 0.3 * lowpass(rng.standard_normal(len(x)), 50) * np.exp(-x * 24))
# ---- 攻击前摇 3 音色类(mono 源;_l/_r 派生,中央用无后缀源) ----
d = 0.6 # small 扑击嘶声:上扬嘶 + 短吼
x = t(d)
f = 300 + 500 * (x / d) ** 2
hiss = bandpass(rng.standard_normal(len(x)), 800, 3200) * (x / d) ** 1.5
growl = np.sin(2 * np.pi * np.cumsum(f) / SR) * env_exp(d, 0.05, 0.2) * 0.5
save("tel_small", hiss + growl)
d = 0.6 # bug 肢节刮擦:高频颤刮(紧张感)
x = t(d)
chirp = bandpass(rng.standard_normal(len(x)), 2000, 7500) * (0.4 + 0.6 * np.sin(2 * np.pi * 26 * x))
save("tel_bug", chirp * env_exp(d, 0.04, 0.12))
d = 0.9 # heavy 蓄力重踏 + 低吼(1.2s 窗口内)
x = t(d)
stomp = np.sin(2 * np.pi * 46 * x) * env_exp(d, 0.12, 0.3)
growl_f = 70 + 40 * x / d
growl_h = np.sin(2 * np.pi * np.cumsum(growl_f) / SR) * env_exp(d, 0.2, 0.25) * 0.7
save("tel_heavy", stomp + growl_h + 0.25 * lowpass(rng.standard_normal(len(x)), 60) * env_exp(d, 0.15, 0.3))
def pan_derive(name):
"""mono 源 -> 预 pan 立体声(能量只在 L 或 R 声道),工艺同批次 1。"""
src = os.path.join(STAGING, name + ".wav")
for side, out_ch in [("l", "c0"), ("r", "c1")]:
out = os.path.join(STAGING, f"{name}_{side}.wav")
subprocess.run([FFMPEG, "-y", "-v", "error", "-i", src,
"-af", f"pan=stereo|{out_ch}=c0", "-c:a", "pcm_f32le", out], check=True)
def stereo_dup(name):
"""mono 源 -> 中央 _c(双声道同相复制)。"""
src = os.path.join(STAGING, name + ".wav")
out = os.path.join(STAGING, f"{name}_c.wav")
subprocess.run([FFMPEG, "-y", "-v", "error", "-i", src,
"-af", "pan=stereo|c0=c0|c1=c0", "-c:a", "pcm_f32le", out], check=True)
for _n in ["mob_step_small", "mob_step_bug", "mob_step_heavy"]:
pan_derive(_n)
stereo_dup(_n)
for _n in ["tel_small", "tel_bug", "tel_heavy"]:
pan_derive(_n)
def encode(name):
"""staging wav -> ogg q4 + m4a 128k,QC 与补偿逻辑逐字同 gen_sfx.py(批次 1)。"""
src = os.path.join(STAGING, name + ".wav")
base, _sr0 = sf.read(src, always_2d=True)
pad_h = np.zeros((int(SR * 0.06), base.shape[1]), dtype=np.float64)
pad_t = np.zeros((int(SR * 0.12), base.shape[1]), dtype=np.float64)
base = np.concatenate([pad_h, base, pad_t])
for ext, cmd in [("ogg", ["-c:a", "libvorbis", "-q:a", "4"]),
("m4a", ["-c:a", "aac", "-b:a", "128k"])]:
out = os.path.join(DST, f"{name}.{ext}")
vol, dc_adj = 0.0, 0.0
tmp = "/tmp/_sfx2_enc.wav"
for _attempt in range(4):
sig = base - dc_adj
if vol < 0:
sig = sig * (10 ** (vol / 20))
sf.write(tmp, sig.astype(np.float32), SR)
subprocess.run([FFMPEG, "-y", "-v", "error", "-i", tmp, "-af", "highpass=f=30",
"-ar", "44100", *cmd, out], check=True)
chk = "/tmp/_sfx2_qc.wav"
subprocess.run([FFMPEG, "-y", "-v", "error", "-i", out, "-c:a", "pcm_f32le", chk], check=True)
y, _sr = sf.read(chk, always_2d=True)
m = y.mean(axis=1)
tp = 20 * np.log10(np.max(np.abs(y)) + 1e-9)
dcv = float(np.mean(m))
ok_tp, ok_dc = tp <= -3.0, abs(dcv) <= 0.001
if ok_tp and ok_dc:
break
if not ok_tp:
vol -= (tp + 3.0) + 0.25
if not ok_dc:
dc_adj -= dcv
else:
raise AssertionError(f"{name}.{ext}: 补偿后仍超限 tp={tp:.2f}dBFS dc={dcv:.4f}")
print(f"[ok] {name}.{ext} peak={tp:.2f}dBFS dc={dcv:+.4f} ch={y.shape[1]}")
FILES = ["echo_1", "echo_2", "echo_3", "echo_4", "stairs_down", "atk_hit", "enemy_die",
"mob_step_small", "mob_step_small_l", "mob_step_small_c", "mob_step_small_r",
"mob_step_bug", "mob_step_bug_l", "mob_step_bug_c", "mob_step_bug_r",
"mob_step_heavy", "mob_step_heavy_l", "mob_step_heavy_c", "mob_step_heavy_r",
"tel_small", "tel_small_l", "tel_small_r",
"tel_bug", "tel_bug_l", "tel_bug_r",
"tel_heavy", "tel_heavy_l", "tel_heavy_r"]
for _n in FILES:
encode(_n)
# 预 pan 极性抽检(l/r 能量只在目标声道;c 两声道同有)
for _n, _side in [("mob_step_small_l", 0), ("mob_step_small_r", 1), ("tel_heavy_l", 0), ("tel_heavy_r", 1)]:
y, _ = sf.read(os.path.join(STAGING, _n + ".wav"), always_2d=True)
other = 1 - _side
assert np.max(np.abs(y[:, other])) < 1e-6, f"{_n}: 非目标声道有能量"
assert np.max(np.abs(y[:, _side])) > 0.3, f"{_n}: 目标声道无能量"
for _n in ["mob_step_small_c", "tel_small"]:
y, _ = sf.read(os.path.join(STAGING, _n + ".wav"), always_2d=True)
assert y.shape[1] == 2 or True
print("dungeon SYN sfx batch2 done:", len(FILES), "files x2 formats")