- 模型 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 全绿
258 lines
12 KiB
Python
258 lines
12 KiB
Python
#!/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")
|