Files
SoundGame/web/dungeon/gen/gen_sfx.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

332 lines
15 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 程序合成音效(批次 1,16-dungeon.md §9.2 标注 SYN 的条目)。
17 个 id / 19 个文件(side_* 由 mono 源经 ffmpeg pan 派生为预 pan 立体声):
pulse 声波脉冲:180->60Hz 正弦扫频 + 噪声瞬态,0.15s,中频突出(中频段 1-2kHz 补一层)
mat_stone/door/stair/water 材质尾音:闷石敲/空心木咚/金属当啷/水滴湿滑,各 0.2-0.25s
side_wall_l/r 侧向回声·墙(闷哒);side_open_l/r 侧向回声·开口(空旷回灵)
—— 立体声文件能量只在 L 或 R 声道(引擎 play() 无实时 pan,
space whoosh_left/right 同工艺;SA3 立体声分离度不达标时本 SYN 即兜底路径)
step_own/turn bump 单步/拖步转身/撞墙闷响(脚步=低频 100-300Hz,与脉冲中频带分离防糊)
coin/potion/gear 金币叮当/喝药咕咚/换装金属轻响
atk_swing/parry/hurt 挥拳风声/格挡亮叮/受击闷击
levelup 上行钟声琶音(内容音,允许;D 小调五声,与 rpg 的 A 小调区分)
SA3 条目(echo_1..4、stairs_down、mob_step_*、tel_*、atk_hit、enemy_die)本轮跳过,
清单见 research/new8/batch1-assets-report.md。
可复现:本脚本入库,一键出 web/dungeon/audio/sfx/*.{ogg,m4a};中间 wav 在
gen/staging/sfx/(不入库)。规格:ogg libvorbis q4 44.1kHz + m4a aac 128k;
one-shot 真峰值 <= -3dBFS(解码回读法,源归一 -4dBFS 留 MDCT 过冲余量);无 loop 类条目。
用法:.venv-tts/bin/python web/dungeon/gen/gen_sfx.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"
PEAK_SRC = 10 ** (-4.0 / 20) # 源归一 -4dBFS(有损编码过冲余量)
PEAK_LIMIT = 10 ** (-3.0 / 20) # 解码回读上限 -3dBFS
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):
"""简易带通:低通(hi) 减 低通(lo)。k = SR/(pi*fc) 近似窗长。"""
kh, kl = max(1, int(SR / (np.pi * hi))), max(1, int(SR / (np.pi * lo)))
return lowpass(x, kh) - lowpass(x, kl)
def bell(freq, dur, decay=6.0, partials=((1, 1.0), (2.4, 0.35), (3.9, 0.15))):
x = t(dur)
return sum(a * np.sin(2 * np.pi * freq * r * x) * np.exp(-x * decay * (1 + r * 0.4))
for r, a in partials)
def save(name, sig):
"""mono 存(side_* 供 pan 派生)。工艺同 tools/fix_clicks.py:
去直流(减均值)→ 首尾 8ms cos² 淡入淡出(去爆豆,快攻击听感不变)→ 峰值归一 -4dBFS。"""
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(20260901)
# ---- pulse:声波脉冲(0.15s,扫频 + 噪声瞬态 + 中频突出层) ----
d = 0.15
x = t(d)
f = 60 + (180 - 60) * np.exp(-x * 22) # 180->60Hz 下扫
sweep = np.sin(2 * np.pi * np.cumsum(f) / SR) * np.exp(-x * 20)
transient = bandpass(rng.standard_normal(len(x)), 900, 2400) * np.exp(-x * 55) # 1-2kHz 中频瞬态
save("pulse", 0.45 * sweep + 0.8 * transient)
# ---- 材质尾音(0.2-0.25s) ----
# 闷石敲:低频短促敲击
d = 0.22
x = t(d)
save("mat_stone", 0.9 * np.sin(2 * np.pi * 95 * x) * np.exp(-x * 40)
+ 0.3 * lowpass(rng.standard_normal(len(x)), 40) * np.exp(-x * 70))
# 空心木咚:两个衰减正弦 + 木腔共鸣
d = 0.25
x = t(d)
save("mat_door", np.sin(2 * np.pi * 150 * x) * np.exp(-x * 26) + 0.5 * np.sin(2 * np.pi * 230 * x) * np.exp(-x * 38))
# 金属当啷:非谐波金属泛音簇
d = 0.24
x = t(d)
save("mat_stair", sum(a * np.sin(2 * np.pi * f * x) * np.exp(-x * dc)
for f, a, dc in [(1720, 1.0, 16), (2610, 0.6, 22), (3480, 0.4, 28), (860, 0.5, 12)]))
# 水滴湿滑:短促水滴(下滑正弦)+ 高频飞溅
d = 0.22
x = t(d)
f = 900 - 500 * x / d
drop = np.sin(2 * np.pi * np.cumsum(f) / SR) * np.exp(-x * 34)
splash = bandpass(rng.standard_normal(len(x)), 2500, 7000) * np.exp(-x * 60) * 0.35
save("mat_water", drop + splash)
# ---- 侧向回声(mono 源 -> ffmpeg pan 派生预 pan 立体声) ----
# side_wall 闷哒:低频闷冲击(能量集中在 150-600Hz)
d = 0.3
x = t(d)
save("side_wall", lowpass(rng.standard_normal(len(x)), 60) * np.exp(-x * 30)
+ 0.6 * np.sin(2 * np.pi * 130 * x) * np.exp(-x * 36))
# side_open 空旷回灵:中频拍点 + 两记渐弱反射尾(空间感)
y = np.zeros(int(SR * 0.7), dtype=np.float32)
for start, amp, dc in [(0.0, 1.0, 24), (0.13, 0.55, 18), (0.3, 0.3, 13)]:
n = len(y) - int(SR * start)
xx = np.arange(n) / SR
tap = bandpass(np.random.default_rng(int(start * 100) + 5).standard_normal(n), 300, 2600) * np.exp(-xx * dc) * amp
y[int(SR * start):] += tap
save("side_open", y)
# ---- 移动(脚步=低频 100-300Hz) ----
# step_own 单步
d = 0.22
x = t(d)
save("step_own", lowpass(rng.standard_normal(len(x)), 70) * np.exp(-x * 38)
+ 0.4 * np.sin(2 * np.pi * 80 * x) * np.exp(-x * 45))
# step_turn 拖步转身:两段错位的拖擦(慢攻击)
d = 0.5
x = t(d)
drag1 = lowpass(rng.standard_normal(len(x)), 90) * env_exp(d, 0.05, 0.12)
shift = int(0.22 * SR)
drag2 = np.roll(drag1, shift) * 0.7
save("step_turn", 0.9 * drag1 + 0.9 * drag2)
# bump 撞墙闷响:60-110Hz 重击 + 闷噪声
d = 0.35
x = t(d)
save("bump", np.sin(2 * np.pi * 68 * x) * np.exp(-x * 18) + 0.5 * lowpass(rng.standard_normal(len(x)), 55) * np.exp(-x * 28))
# ---- 拾取 ----
# coin 金币叮当:非谐波高频泛音簇,两次碰撞(rpg coin 同族、参数区分)
y = np.zeros(int(SR * 0.45), dtype=np.float32)
for start, dec in [(0.0, 26), (0.11, 34)]:
n = len(y) - int(SR * start)
seg = np.arange(n) / SR
sig = sum(a * np.sin(2 * np.pi * f * seg) * np.exp(-seg * dec) * 0.3
for f, a in [(4700, 1.0), (6300, 0.7), (8300, 0.45), (10500, 0.28)])
y[int(SR * start):] += sig
save("coin", y)
# potion 喝药咕咚:三声下滑「咕」+ 气泡噪声
y = np.zeros(int(SR * 0.8), dtype=np.float32)
for i, start in enumerate([0.0, 0.18, 0.38]):
d_ = 0.16
xx = t(d_)
f0 = 340 - i * 55
f = f0 - 130 * xx / d_
g = np.sin(2 * np.pi * np.cumsum(f) / SR) * env_exp(d_, 0.01, 0.05)
bub = bandpass(rng.standard_normal(len(xx)), 500, 1600) * np.exp(-xx * 30) * 0.25
y[int(SR * start):int(SR * start) + len(g)] += (g + bub) * (1 - i * 0.15)
save("potion", y)
# gear 换装金属轻响:短促金属扣合(低强度)
d = 0.28
x = t(d)
save("gear", 0.7 * sum(a * np.sin(2 * np.pi * f * x) * np.exp(-x * dc)
for f, a, dc in [(1180, 1.0, 20), (1990, 0.5, 30), (520, 0.7, 14)]))
# ---- 战斗 ----
# atk_swing 挥拳风声:带通噪声,幅度先涨后落(频率感觉下坠)
d = 0.32
x = t(d)
env = np.sin(np.pi * np.minimum(x / d, 1.0)) ** 1.5
swoosh = bandpass(rng.standard_normal(len(x)), 400, 3000) * env
save("atk_swing", swoosh)
# parry 格挡亮叮:高频金属亮铃(快攻击、余振)
d = 0.45
x = t(d)
save("parry", sum(a * np.sin(2 * np.pi * f * x) * np.exp(-x * dc)
for f, a, dc in [(2500, 1.0, 9), (3750, 0.55, 13), (5300, 0.35, 17)]))
# hurt 受击闷击:低频闷击 + 平滑噪声团(与 rpg hurt 同族、参数区分)
d = 0.32
x = t(d)
save("hurt", np.sin(2 * np.pi * 55 * x) * np.exp(-x * 16)
+ 0.55 * lowpass(rng.standard_normal(len(x)), 50) * np.exp(-x * 22))
# ---- levelup:上行钟声琶音(内容音,允许;D 小调五声 D4 F4 G4 A4 C5 D5) ----
y = np.zeros(int(SR * 1.8), dtype=np.float32)
for i, f in enumerate([293.66, 349.23, 392.00, 440.00, 523.25, 587.33]):
seg = np.arange(int(SR * 1.0)) / SR
b = sum(a * np.sin(2 * np.pi * f * r * seg) * np.exp(-seg * dcy)
for r, a, dcy in [(1, 1.0, 4), (2.4, 0.35, 7), (3.9, 0.15, 10)])
s = int(SR * i * 0.14)
n = min(len(b), len(y) - s)
y[s:s + n] += b[:n] * 0.4
save("levelup", y)
# ---- turn_tick 战斗回合确认音(R2 D1-6:回合 15s 无操作的合成回执;
# standard 档没有 gd_turn 语音,纯震感像死机——R1 live P2) ----
# 与 guide nudge 同族(880Hz 木鱼质感),0.16s 快衰减 + 高频点缀,可与其他
# 回合音叠放不糊(短、中高频、快衰减)。
d = 0.16
x = t(d)
save("turn_tick", np.sin(2 * np.pi * 880 * x) * np.exp(-x * 38)
+ 0.35 * np.sin(2 * np.pi * 1760 * x) * np.exp(-x * 52))
def save_loop(name, sig):
"""loop 源存(amb_cave 专用):去直流 → 环回交叉淡化(尾 50ms 与头 50ms
加权拼接,接缝连续无爆音)→ **不做首尾淡化**(淡化会让循环每圈塌一次,
规格:去直流不淡化)→ 峰值归一 -4dBFS(同 one-shot 余量口径)。"""
sig = sig - np.mean(sig)
k = int(SR * 0.05)
ramp = np.linspace(0.0, 1.0, k)
head, tail = sig[:k].copy(), sig[-k:].copy()
sig = sig[:-k]
sig[:k] = head * ramp + tail * (1 - ramp)
sig = sig / (np.max(np.abs(sig)) + 1e-9) * PEAK_SRC
sf.write(os.path.join(STAGING, name + ".wav"), sig.astype(np.float32), SR)
# ---- amb_cave 洞穴环境垫(R2 D0-3② 探索呼吸层:静默 >20s 播一段,10s 无缝循环源) ----
# 洞穴空气声=45-320Hz 带通噪(带通天然零直流、无次声漂移——褐噪 cumsum 的能量
# 全在 <30Hz,过编码 highpass 后只剩 -30dB 空气,R2 首版教训)×慢呼吸 LFO
# (4 整周期/10s,接缝连续)+ 两粒远离接缝的远处水滴。真峰值/直流口径与
# one-shot 一致(解码回读 ≤-3dBFS、|直流|≤0.001)。
_n = int(SR * 10.0)
_air = bandpass(rng.standard_normal(_n), 45, 320)
_lfo = 0.7 + 0.3 * np.sin(2 * np.pi * 4 * np.arange(_n) / _n)
_y = np.zeros(_n)
for _start, _f0 in [(2.3, 620.0), (6.7, 540.0)]:
_xx = np.arange(int(SR * 0.3)) / SR
_f = _f0 - 300 * _xx / 0.3
_drop = np.sin(2 * np.pi * np.cumsum(_f) / SR) * np.exp(-_xx * 26) * 0.16
_s0 = int(SR * _start)
_y[_s0:_s0 + len(_drop)] += _drop
save_loop("amb_cave", _air * _lfo * 0.9 + _y)
def pan_derive(name):
"""mono 源 -> 预 pan 立体声(能量只在 L 或 R 声道),ffmpeg pan 派生;
未指定的输出声道默认静音,即 pan=stereo|c0=c0(仅左)/ pan=stereo|c1=c0(仅右)。"""
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)
for _n in ["side_wall", "side_open"]:
pan_derive(_n)
def encode(name, loop=False):
"""staging wav -> ogg q4 + m4a 128k,解码回读校验:
逐声道真峰值 <= -3dBFS(硬 pan 立体声不能按声道均值漏检)、|直流| <= 0.001(与在库资产基线一致)。
工艺:one-shot 源前后垫 60/120ms 静音(编码器软着陆,边界帧伪影落在垫音里);
loop 源(amb_cave)**不垫不淡**(垫音/淡入出会破坏无缝循环),仅 highpass=f=30
(抑制 MDCT/AAC 解码直流再生,可听频段无影响);解码回读超限时按实测值迭代补偿
(真峰值补 volume,直流在源上预减实测偏移;均确定性,最多 4 轮)。"""
src = os.path.join(STAGING, name + ".wav")
base, _sr0 = sf.read(src, always_2d=True)
if not loop:
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/_sfx_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/_sfx_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)
if loop:
y = y[int(SR * 0.02):-int(SR * 0.02)] # QC 掐头去尾(编码器边界帧不计峰)
m = y.mean(axis=1)
tp = 20 * np.log10(np.max(np.abs(y)) + 1e-9) # 逐声道峰值(硬 pan 不被均值稀释)
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 # 超限差值 + 0.25dB 余量
if not ok_dc:
dc_adj -= dcv # 源上预减解码实测直流
else:
raise AssertionError(f"{name}.{ext}: 补偿后仍超限 tp={tp:.2f}dBFS dc={dcv:.4f}")
extra = ""
if loop: # 循环接缝量化:解码回读首尾样本差不超源峰的 5%(听感无咔哒)
joint = float(np.max(np.abs(y[0] - y[-1])))
joint_db = 20 * np.log10(joint + 1e-9) - 20 * np.log10(np.max(np.abs(y)) + 1e-9)
extra = f" joint={joint_db:+.1f}dB rel peak"
print(f"[ok] {name}.{ext} peak={tp:.2f}dBFS dc={dcv:+.4f} ch={y.shape[1]}{extra}")
FILES = ["pulse", "mat_stone", "mat_door", "mat_stair", "mat_water",
"side_wall_l", "side_wall_r", "side_open_l", "side_open_r",
"step_own", "step_turn", "bump", "coin", "potion", "gear",
"atk_swing", "parry", "hurt", "levelup", "turn_tick"]
for _n in FILES:
encode(_n)
encode("amb_cave", loop=True)
# amb_cave 循环规格专项:源级去直流(|均值|<=1e-4)且无首尾淡化(首尾样本非零)
_amb, _ = sf.read(os.path.join(STAGING, "amb_cave.wav"), always_2d=True)
assert abs(float(_amb.mean())) <= 1e-4, "amb_cave: 源直流超标"
assert np.max(np.abs(_amb[0])) > 1e-3 and np.max(np.abs(_amb[-1])) > 1e-3, "amb_cave: 首尾被淡化(loop 规格违例)"
# side_* 极性抽检:能量只在声像侧声道(解码回读)
for _n, _side in [("side_wall_l", 0), ("side_wall_r", 1), ("side_open_l", 0), ("side_open_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}: 目标声道无能量"
print("dungeon SYN sfx done:", len(FILES), "files x2 formats")