Files
SoundGame/web/dungeon/gen/gen_sfx2.py
T
xsl d870a75a26 feat(new8/batch1): 两款新 RPG《不眠频率》radio +《回声地窟》dungeon 全套(多agent流水线:策划→资产→开发→测试→玩家R1→修复→R2)
- 程序章程 research/new8/00-program.md(8款概念/回合协议/验收门/进度账)
- 设计:docs/design/15-radio.md+voice-ids(132句,6状态9抉择4结局,电话滤波资产侧带通)/16-dungeon.md+voice-ids(125句,脉冲回声/7层seed生成/三档战斗/元进度遗产)
- 资产:TTS 970文件(GPU flock串行)+ Mureka BGM 8首 $0.24(台账 mureka_log.md)+ 程序合成 sfx(含预pan侧墙/开口、phone_ring铃声、amb_cave呼吸层、turn_tick)+ 终检 check_all_assets 8游戏全绿
- radio:game.js+playtest 221断言(4结局矩阵/pendingChoice盲玩公平/RING两级看门狗/菜单循环/存档schema)
- dungeon:game.js+playtest 1610断言(seed全网格回声一致性/学期零死/格挡80%/死亡归因真话/元进度/菜单冻结/上滑5段状态链)
- 引擎修复:audio.js _playVoice 加载竞态补杀 token(R1真机71分钟语音风暴根因)+ 同id 3s窗口≥5次熔断保险丝 + gesture.js 长按boost事件门(+66行,9套件回归全绿)
- 大厅:新增 radio/dungeon 卡片 + GAMES 数组
- harness:sessions/radio.mjs+dungeon.mjs(主线+压力双剧本)+ transcribe 注册 + voice_text.json 刷新(8游戏704句)+ 13号文档 §20 登记
- R1 存档:4人设评审×2游戏 + 真机试玩报告×2(radio 5/5愿意玩;dungeon 0/4→确诊风暴)+ synthesis×2 + 转写8份
- R2 修复:radio F1-F5(看门狗补挂/rep_low改词重录/真铃声/ASK续玩语义/链末跳过保护)+ dungeon D0-1..D1-6(风暴根治/回声真实性/密度下限+呼吸层/导航最短路/菜单关闭反馈/金币档同账本)+ 实现核对登记
2026-09-02 18:52:37 +08:00

256 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 不变、游戏层零改动)。
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")