Files
SoundGame/tools/gen_hunt_syn.py
T
xsl 005851d01a feat(hunt): 《围猎》2-4人实时联机纯音频协作守卫——纯音频MMORPG第一阶段切片
- 设计:docs/design/23-hunt.md + voice-ids(84 id,166条)+ 13号断言登记,§15记录10+1条设计裁决
- 服务器:零依赖WebSocket(ws.js)+ 房间管理/服务端权威战斗/bot AI(hunt-room.js),断线30s宽限resume回槽、超时转bot;修复package.json模块类型误判的既有破坏
- 客户端:web/hunt/ 全套(教学T0-T4/听觉视野裁剪R1-R8/结算排行榜/guide接入),playtest 211断言
- 引擎:audio.js 后向兼容 opts.pan(StereoPannerNode),web-engine-api.md 同步
- 资产:166条TTS语音(QC无离群)+ 13件合成sfx(真峰值≤-3dBFS)+ 2首Mureka BGM,终检全绿
- 平衡:bot调参至挂机局71%失守/C、3bot局71% A/B(hunt-balance-scan.mjs 24种子扫描)
- 接线:大厅卡片、profiles.js hunt桶、README/roadmap MMO路线条目

验证:hunt-sim 16/16 + api-smoke 12/12 + hunt playtest 211/211 + 既有10游戏与引擎回归零回退 + 真服务器WS端到端冒烟通过
2026-09-13 11:08:01 +08:00

352 lines
13 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 -*-
"""《围猎》程序合成音效(23-hunt.md §10.2,13 件,SYN 全部程序合成兜底)。
落盘:staging wav → tools/gen_hunt_syn_wav/<id>.wav(44.1kHz 立体声);
成品 → web/hunt/audio/sfx/<id>.{ogg,m4a}(tools/bin/ffmpeg 双格式)。
pan 由运行时引擎 opts.pan / 游戏层 panner 承担,资产本体为双声道同信号(gen_rpg_syn 先例)。
one-shot QC 标准(11 件):去直流 + 首尾 8ms cos² 淡化 + 编码后解码回读
真峰值 ≤ -3dBFS(MDCT 过冲坑:以 ebur128 peak=true 实测为准,超限自动降档重编)。
loop QC 标准(2 件,4s 无缝循环):只去直流(首尾禁淡化,事件网格周期化 + 尾部
回卷 overlap-add 保证循环点连续),另做真峰值校验只报告不强制。
频带分离(§8 R8):loop 低频 100-300Hz / howl 中高 1-3kHz / 打击音宽带动感。
时长口径(§10.2/R5):非语音信息全部 <1s——号角 0.9s、哨音 0.6s、chime 0.4s、
击杀 0.5-0.7s;loop 源 4.0s。
用法:.venv-tts/bin/python tools/gen_hunt_syn.py
"""
import os
import subprocess
import sys
import numpy as np
import soundfile as sf
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
FFMPEG = os.path.join(ROOT, "tools", "bin", "ffmpeg")
OUT = os.path.join(ROOT, "tools", "gen_hunt_syn_wav")
DST = os.path.join(ROOT, "web", "hunt", "audio", "sfx")
os.makedirs(OUT, exist_ok=True)
os.makedirs(DST, exist_ok=True)
sr = 44100
ONE_SHOT_TP_CEIL = -3.0 # dBFS,one-shot 真峰值上限
SAMPLE_PEAK = 0.66 # ≈ -3.6dBFS 采样峰值,给 MDCT 过冲留余量
FADE_N = int(sr * 0.008) # 8ms cos²
_rng = np.random.default_rng(23)
def dc(y):
return y - np.mean(y)
def fade(y):
w = np.sin(np.linspace(0, np.pi / 2, FADE_N)) ** 2 # cos² 等价形态
y = y.copy()
y[:FADE_N] *= w
y[-FADE_N:] *= w[::-1]
return y
def norm(y, peak=SAMPLE_PEAK):
m = np.max(np.abs(y))
return (y / m * peak) if m > 0 else y
def stereo(y):
return np.stack([y, y], axis=1).astype(np.float32)
def place(y, start_s, sig):
s = int(start_s * sr)
n = min(len(sig), len(y) - s)
y[s:s + n] += sig[:n]
def bp_noise(n, lo, hi, order=4):
"""简易零相位带通噪声(FFT 矩形窗,合成音效用足够)。"""
x = _rng.standard_normal(n)
X = np.fft.rfft(x)
freqs = np.fft.rfftfreq(n, 1 / sr)
mask = (freqs >= lo) & (freqs <= hi)
# 软边界,降噼啪
soft = np.clip(mask.astype(float), 0, 1)
y = np.fft.irfft(X * soft, n)
return y / (np.max(np.abs(y)) + 1e-9)
def sine_track(n, f0, f1, amp=1.0, vib_hz=0.0, vib_depth=0.0):
"""线性滑音正弦(可选颤音),相位连续。"""
t = np.arange(n) / sr
f = np.linspace(f0, f1, n)
if vib_hz:
f = f * (1 + vib_depth * np.sin(2 * np.pi * vib_hz * t))
ph = 2 * np.pi * np.cumsum(f) / sr
return amp * np.sin(ph)
def harm_stack(n, f0, f1, harms, vib_hz=0.0, vib_depth=0.0):
"""谐波簇滑音:harms=[(ratio, gain), ...]。"""
y = np.zeros(n)
for r, g in harms:
y += g * sine_track(n, f0 * r, f1 * r, 1.0, vib_hz, vib_depth)
return y
# ---------------------------------------------------------------- one-shot 渲染
def r_atk_whoosh(): # 挥击风声 0.3s:下扫带通噪声,快起快落
n = int(sr * 0.30)
t = np.arange(n) / sr
y = bp_noise(n, 400, 2600)
sweep = np.exp(-((t - 0.10) / 0.11) ** 2) # 10ms 处峰顶的粗包络
env = (1 - np.exp(-t * 90)) * np.exp(-t * 14)
return dc(y * env * (0.6 + 0.4 * sweep))
def r_atk_swish(): # 挥空空荡风声 0.3s:更高更空、无低频着落(归因真话:没打到)
n = int(sr * 0.30)
t = np.arange(n) / sr
y = bp_noise(n, 900, 3400)
env = (1 - np.exp(-t * 70)) * np.exp(-t * 11)
hollow = 1 + 0.35 * np.sin(2 * np.pi * 7 * t) # 轻微空洞感 AM
return dc(y * env * hollow * 0.8)
def r_kill_wolf(): # 狼哀嚎倒地 0.5s:短促下滑哀嚎 + 着地闷响
n = int(sr * 0.50)
t = np.arange(n) / sr
yelp = harm_stack(n, 1150, 520, [(1, 1.0), (2, 0.35), (3, 0.12)], vib_hz=9, vib_depth=0.03)
yelp *= (1 - np.exp(-t * 120)) * np.exp(-t * 9)
thud = np.sin(2 * np.pi * 70 * t) * np.exp(-t * 30)
thud += bp_noise(n, 80, 300) * np.exp(-t * 40) * 0.5
return dc(yelp * 0.8 + np.roll(thud, int(0.30 * sr)) * 0.9)
def r_kill_bear(): # 熊闷重倒地 0.7s:低吼爆 + 重落地
n = int(sr * 0.70)
t = np.arange(n) / sr
growl = harm_stack(n, 130, 70, [(1, 1.0), (2, 0.5), (3, 0.2)], vib_hz=22, vib_depth=0.10)
growl *= (1 - np.exp(-t * 60)) * np.exp(-t * 6)
thud = np.sin(2 * np.pi * 52 * t) * np.exp(-t * 16)
thud += bp_noise(n, 60, 220) * np.exp(-t * 20) * 0.7
return dc(growl * 0.5 + np.roll(thud, int(0.32 * sr)))
def r_skill_spin(): # 旋风斩气旋 0.8s(中央,大动态):加速旋转呼啸 + 顶部爆点
n = int(sr * 0.80)
t = np.arange(n) / sr
y = bp_noise(n, 300, 3000)
rate = 3 + 14 * (t / t[-1]) ** 2 # 旋转 AM 加速 3→17Hz
am = 0.5 + 0.5 * np.sin(2 * np.pi * np.cumsum(rate) / sr)
env = (1 - np.exp(-t * 25)) * np.exp(-np.maximum(t - 0.55, 0) * 9)
top = sine_track(n, 900, 1800, 0.25) * np.exp(-np.abs(t - 0.55) * 18) # 气旋顶部哨
return dc(y * am * env * 1.1 + top)
def r_breach_thud(): # 营地被撞闷响 + 木栅裂 0.6s(中央)
n = int(sr * 0.60)
t = np.arange(n) / sr
thud = np.sin(2 * np.pi * 55 * t) * np.exp(-t * 13)
thud += bp_noise(n, 70, 260) * np.exp(-t * 18) * 0.8
# 木栅裂:3 串高频脆响
crack = np.zeros(n)
for i, st in enumerate([0.05, 0.14, 0.26]):
seg_n = n - int(st * sr)
seg_t = np.arange(seg_n) / sr
c = bp_noise(seg_n, 1400, 4200) * np.exp(-seg_t * (60 - i * 10)) * (0.8 - i * 0.2)
place(crack, st, c)
return dc(thud + crack * 0.7)
def r_horn_wave(): # 波次号角 0.9s(中央):铜管状谐波簇,起音膨胀尾音下沉
n = int(sr * 0.90)
t = np.arange(n) / sr
f0, f1 = 220, 208 # A3 微沉
horn = harm_stack(n, f0, f1, [(1, 1.0), (2, 0.55), (3, 0.30), (4, 0.14)], vib_hz=5.5, vib_depth=0.008)
env = (1 - np.exp(-t * 9)) * np.exp(-np.maximum(t - 0.62, 0) * 8)
breath = bp_noise(n, 300, 1200) * env * 0.06
return dc(horn * env + breath)
def r_join_chime(): # 队友加入 0.4s:双音上行(E5→A5 钟声)
n = int(sr * 0.40)
y = np.zeros(n)
for st, f in [(0.0, 659.25), (0.14, 880.0)]:
seg_n = n - int(st * sr)
seg_t = np.arange(seg_n) / sr
bell = sum(g * np.sin(2 * np.pi * f * r * seg_t) * np.exp(-seg_t * d)
for r, g, d in [(1, 1.0, 10), (2.76, 0.25, 16)])
place(y, st, bell * 0.6)
return dc(y)
def r_call_whistle(): # 求援哨音 0.6s(座 3 呼喊用):裁判哨式高频颤音
n = int(sr * 0.60)
t = np.arange(n) / sr
w = sine_track(n, 2350, 2450, 1.0, vib_hz=28, vib_depth=0.012)
w += 0.2 * sine_track(n, 4700, 4900, 1.0, vib_hz=28, vib_depth=0.012)
env = (1 - np.exp(-t * 200)) * np.exp(-np.maximum(t - 0.42, 0) * 12)
return dc(w * env * 0.5)
def r_howl_wolf(): # 生成呼啸 0.8s:起-扬-落狼嚎(1-3kHz 中高带)
n = int(sr * 0.80)
t = np.arange(n) / sr
f = 620 + 520 * np.sin(np.pi * np.clip(t / 0.75, 0, 1)) ** 1.4 # 拱形音高
ph = 2 * np.pi * np.cumsum(f * (1 + 0.02 * np.sin(2 * np.pi * 6 * t))) / sr
howl = np.sin(ph) + 0.3 * np.sin(2 * ph)
env = (1 - np.exp(-t * 12)) * np.exp(-np.maximum(t - 0.5, 0) * 7)
return dc(howl * env * 0.7)
def r_howl_bear(): # 生成低吼 1.0s:低频粗糙吼声,慢 AM
n = int(sr * 1.00)
t = np.arange(n) / sr
g = harm_stack(n, 95, 75, [(1, 1.0), (2, 0.6), (3, 0.3)], vib_hz=17, vib_depth=0.08)
g += bp_noise(n, 150, 500) * 0.25
env = (1 - np.exp(-t * 10)) * np.exp(-np.maximum(t - 0.6, 0) * 6)
return dc(g * env * 0.8)
ONE_SHOTS = {
"atk_whoosh": r_atk_whoosh,
"atk_swish": r_atk_swish,
"kill_wolf": r_kill_wolf,
"kill_bear": r_kill_bear,
"skill_spin": r_skill_spin,
"breach_thud": r_breach_thud,
"horn_wave": r_horn_wave,
"join_chime": r_join_chime,
"call_whistle": r_call_whistle,
"howl_wolf": r_howl_wolf,
"howl_bear": r_howl_bear,
}
# ---------------------------------------------------------------- loop 渲染(4s 无缝)
def wrap_seamless(y, extra):
"""把超出 4s 的尾部回卷叠加到开头,保证循环点连续。"""
n = int(sr * 4.0)
if len(y) > n:
tail = y[n:]
y = y[:n]
y[:len(tail)] += tail
return y
def r_loop_wolf(): # 狼碎步 + 低呜(100-300Hz 低频带;4 步/秒小跑,周期 1s 整除 4s)
n = int(sr * 4.0)
y = np.zeros(n)
step = 0.25 # 4 步/秒,16 步整除 4s,尾 0.09s 不出界无需回卷
for i in range(16):
L = int(sr * 0.09)
tt = np.arange(L) / sr
paw = bp_noise(L, 120, 300) * np.exp(-tt * 70) * (1 - np.exp(-tt * 400))
paw += np.sin(2 * np.pi * (95 + (i % 2) * 12) * tt) * np.exp(-tt * 60) * 0.5
place(y, i * step, paw * (0.55 + 0.15 * ((i * 7) % 3)))
t = np.arange(n) / sr
# 垫底低呜:恒定 200Hz(4s 整 800 周期)+ 0.25Hz AM(1 周期)+ 4.5Hz 颤音
# (18 整周期、积分归零)——所有成分循环点相位对齐,无接缝。
whim = sine_track(n, 200, 200, 0.10, vib_hz=4.5, vib_depth=0.05)
y += whim * (0.5 + 0.5 * np.sin(2 * np.pi * 0.25 * t)) # 0.25Hz 整周期
y = wrap_seamless(y, None)
return dc(y)
def r_loop_bear(): # 熊重步 + 喘息(重低频;约 1.5 步/秒,周期 4/3 s 不整除→用 2s 网格 8 步)
n = int(sr * 4.0)
y = np.zeros(n)
for i in range(8): # 每 0.5s 一步(左右交替),8 步整除 4s,尾 0.16s 不出界
L = int(sr * 0.16)
tt = np.arange(L) / sr
hoof = np.sin(2 * np.pi * (58 + (i % 2) * 7) * tt) * np.exp(-tt * 26)
hoof += bp_noise(L, 70, 240) * np.exp(-tt * 30) * (1 - np.exp(-tt * 300)) * 0.8
place(y, i * 0.5, hoof)
t = np.arange(n) / sr
# 喘息:恒定 80Hz(4s 整 320 周期)+ 0.5Hz AM/颤音(2 整周期)——循环点相位对齐。
breath = sine_track(n, 80, 80, 0.12, vib_hz=0.5, vib_depth=0.06)
y += breath * (0.4 + 0.6 * (0.5 + 0.5 * np.sin(2 * np.pi * 0.5 * t)))
y = wrap_seamless(y, None)
return dc(y)
LOOPS = {
"loop_wolf": r_loop_wolf,
"loop_bear": r_loop_bear,
}
# ---------------------------------------------------------------- 编码 + 真峰值校验
def encode(wav_path, base):
for args, ext in [(["-c:a", "libvorbis", "-q:a", "3"], ".ogg"),
(["-c:a", "aac", "-b:a", "128k"], ".m4a")]:
subprocess.run([FFMPEG, "-y", "-v", "error", "-i", wav_path,
"-ar", "44100", *args, base + ext], check=True)
def true_peak_db(path):
r = subprocess.run([FFMPEG, "-i", path, "-af", "ebur128=peak=true", "-f", "null", "-"],
capture_output=True, text=True)
peaks = [float(line.split("Peak:")[1].strip().split()[0])
for line in r.stderr.splitlines() if "Peak:" in line and "dBFS" in line]
return max(peaks) if peaks else float("nan")
def check_tp(base):
return max(true_peak_db(base + ".ogg"), true_peak_db(base + ".m4a"))
def main():
report = []
problems = []
for fid, fn in ONE_SHOTS.items():
wav_path = os.path.join(OUT, fid + ".wav")
base = os.path.join(DST, fid)
y = fade(norm(dc(fn())))
sf.write(wav_path, stereo(y), sr)
encode(wav_path, base)
tp = check_tp(base)
if tp > ONE_SHOT_TP_CEIL: # MDCT 过冲超限:按超出量降档重编一次再验
gain = 10 ** ((ONE_SHOT_TP_CEIL - tp) / 20) * 0.98
sf.write(wav_path, stereo(y * gain), sr)
encode(wav_path, base)
tp2 = check_tp(base)
report.append((fid, len(y) / sr, tp, f"降 {20 * np.log10(gain):.1f}dB 重编 -> {tp2:.2f}"))
tp = tp2
else:
report.append((fid, len(y) / sr, tp, ""))
if tp > ONE_SHOT_TP_CEIL:
problems.append(f"{fid}: 真峰值 {tp:.2f} dBFS 仍超 {ONE_SHOT_TP_CEIL}")
for fid, fn in LOOPS.items():
wav_path = os.path.join(OUT, fid + ".wav")
base = os.path.join(DST, fid)
y = norm(fn()) # loop:去直流已在渲染内;无首尾淡化(无缝优先)
sf.write(wav_path, stereo(y), sr)
# 无缝断言:循环点首尾采样差应足够小(回卷后连续)
seam = abs(float(y[0]) - float(y[-1]))
encode(wav_path, base)
tp = check_tp(base)
report.append((fid, len(y) / sr, tp, f"loop seam |y0-y1|={seam:.4f}"))
if tp > ONE_SHOT_TP_CEIL:
problems.append(f"{fid}: loop 真峰值 {tp:.2f} dBFS 超 {ONE_SHOT_TP_CEIL}(建议降档)")
print(f"{'id':14s} {'dur(s)':>7s} {'TP(dBFS)':>9s} note")
for fid, dur, tp, note in report:
print(f"{fid:14s} {dur:7.2f} {tp:9.2f} {note}")
if problems:
print("PROBLEMS:")
for p in problems:
print(" ", p)
return 1
print(f"ALL OK: {len(ONE_SHOTS)} one-shot + {len(LOOPS)} loop -> {DST}")
return 0
if __name__ == "__main__":
sys.exit(main())